From 72a3d7ffba8016c15b4899e5e93859b1a1664e7a Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 16 Apr 2026 17:43:08 +0200 Subject: [PATCH 1/8] feat: add superadmin trial controls --- .../1776353491107_invitation_trial_days.ts | 13 +++++ .../CreateInvitationModal.tsx | 41 ++++++++++++++ .../(dashboards)/superadmin/page.tsx | 39 +++++++++++++- src/models/Invitation.ts | 1 + src/server/models/Invitation.ts | 1 + src/server/models/User.ts | 6 ++- src/server/repositories/User.ts | 9 ++++ src/server/services/database/schema.ts | 1 + src/server/trpc/routers/auth.ts | 5 +- src/server/trpc/routers/invitation.ts | 12 ++++- src/server/trpc/routers/user.ts | 6 +++ .../server/trpc/routers/confirmInvite.test.ts | 38 +++++++++++++ .../server/trpc/routers/invitation.test.ts | 53 ++++++++++++++++++- 13 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 migrations/1776353491107_invitation_trial_days.ts diff --git a/migrations/1776353491107_invitation_trial_days.ts b/migrations/1776353491107_invitation_trial_days.ts new file mode 100644 index 000000000..ce9484454 --- /dev/null +++ b/migrations/1776353491107_invitation_trial_days.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("invitation") + .addColumn("trial_days", "integer") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("invitation").dropColumn("trial_days").execute(); +} diff --git a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx index c606c1175..d7c55e1af 100644 --- a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx +++ b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx @@ -5,7 +5,10 @@ import { PlusIcon } from "lucide-react"; import { type FormEvent, useMemo, useState } from "react"; import { toast } from "sonner"; import FormFieldWrapper from "@/components/forms/FormFieldWrapper"; +import { DEFAULT_TRIAL_PERIOD_DAYS } from "@/constants"; +import { useCurrentUser } from "@/hooks"; import { useOrganisations } from "@/hooks/useOrganisations"; +import { UserRole } from "@/models/User"; import { useTRPC } from "@/services/trpc/react"; import { Button } from "@/shadcn/ui/button"; import { Checkbox } from "@/shadcn/ui/checkbox"; @@ -43,7 +46,11 @@ export default function CreateInvitationModal() { const [selectedDataSourceIds, setSelectedDataSourceIds] = useState< Set >(new Set()); + const [isTrial, setIsTrial] = useState(false); + const [trialDays, setTrialDays] = useState(DEFAULT_TRIAL_PERIOD_DAYS); + const { currentUser } = useCurrentUser(); + const isSuperadmin = currentUser?.role === UserRole.Superadmin; const { organisationId: senderOrganisationId } = useOrganisations(); const trpc = useTRPC(); const client = useQueryClient(); @@ -82,6 +89,8 @@ export default function CreateInvitationModal() { setIsCreatingNewOrg(true); setSelectedMapIds(new Set()); setSelectedDataSourceIds(new Set()); + setIsTrial(false); + setTrialDays(DEFAULT_TRIAL_PERIOD_DAYS); }; // Collect data source IDs for each map @@ -175,6 +184,9 @@ export default function CreateInvitationModal() { email, name, mapSelections: mapSelections.length > 0 ? mapSelections : undefined, + ...(isSuperadmin + ? { isTrial, trialDays: isTrial ? trialDays : undefined } + : {}), }); }; @@ -291,6 +303,35 @@ export default function CreateInvitationModal() { )} + {isSuperadmin && ( +
+
+ setIsTrial(Boolean(checked))} + /> + +
+ {isTrial && ( + + setTrialDays(Number(e.target.value))} + /> + + )} +
+ )} + {mapData && mapsByOrg.length > 0 && (
diff --git a/src/app/(private)/(dashboards)/superadmin/page.tsx b/src/app/(private)/(dashboards)/superadmin/page.tsx index abcc25af1..603cbb633 100644 --- a/src/app/(private)/(dashboards)/superadmin/page.tsx +++ b/src/app/(private)/(dashboards)/superadmin/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; import { Settings } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -8,6 +9,7 @@ import { toast } from "sonner"; import { useCurrentUser } from "@/hooks"; import { UserRole } from "@/models/User"; import { useTRPC } from "@/services/trpc/react"; +import { Button } from "@/shadcn/ui/button"; import { Select, SelectContent, @@ -49,6 +51,17 @@ export default function SuperadminPage() { }, }), ); + const { mutate: clearTrial } = useMutation( + trpc.user.clearTrial.mutationOptions({ + onSuccess: () => { + client.invalidateQueries({ queryKey: trpc.user.list.queryKey() }); + toast.success("Trial cleared"); + }, + onError: (error) => { + toast.error("Failed to clear trial.", { description: error.message }); + }, + }), + ); if (currentUser?.role !== UserRole.Superadmin) redirect("/"); @@ -78,8 +91,9 @@ export default function SuperadminPage() { Email Name - Organisation + Organisation Role + Trial @@ -87,7 +101,9 @@ export default function SuperadminPage() { {u.email} {u.name} - {u.organisations.join(", ")} + + {u.organisations.join(", ")} + + + {u.trialEndsAt ? ( +
+ + Expires{" "} + {format(new Date(u.trialEndsAt), "d MMM yyyy")} + + +
+ ) : ( + + )} +
))}
diff --git a/src/models/Invitation.ts b/src/models/Invitation.ts index 04d327d1c..afe912103 100644 --- a/src/models/Invitation.ts +++ b/src/models/Invitation.ts @@ -11,6 +11,7 @@ export const invitationSchema = z.object({ updatedAt: z.date(), used: z.boolean(), isTrial: z.boolean(), + trialDays: z.number().nullish(), }); export type Invitation = z.infer; diff --git a/src/server/models/Invitation.ts b/src/server/models/Invitation.ts index 7d2f33f93..4835a7f55 100644 --- a/src/server/models/Invitation.ts +++ b/src/server/models/Invitation.ts @@ -11,6 +11,7 @@ export type InvitationTable = Invitation & { id: GeneratedAlways; used: Generated; isTrial: Generated; + trialDays: Generated; createdAt: ColumnType; updatedAt: ColumnType; }; diff --git a/src/server/models/User.ts b/src/server/models/User.ts index f989b5e06..411806dae 100644 --- a/src/server/models/User.ts +++ b/src/server/models/User.ts @@ -9,7 +9,11 @@ import type { export type UserTable = User & { id: GeneratedAlways; createdAt: ColumnType; - trialEndsAt: ColumnType; + trialEndsAt: ColumnType< + Date | null, + string | undefined, + string | null | undefined + >; }; export type NewUser = Insertable; export type UserUpdate = Updateable; diff --git a/src/server/repositories/User.ts b/src/server/repositories/User.ts index 057e15c6b..3b912e2e1 100644 --- a/src/server/repositories/User.ts +++ b/src/server/repositories/User.ts @@ -92,6 +92,15 @@ export async function updateUserTrialEndsAt(id: string, trialEndsAt: Date) { .executeTakeFirstOrThrow(); } +export async function clearUserTrial(id: string) { + return db + .updateTable("user") + .where("id", "=", id) + .set({ trialEndsAt: null }) + .returningAll() + .executeTakeFirstOrThrow(); +} + export async function updateUserRole(id: string, role: UserRole | null) { return db .updateTable("user") diff --git a/src/server/services/database/schema.ts b/src/server/services/database/schema.ts index cc804b4cb..a32ff6e88 100644 --- a/src/server/services/database/schema.ts +++ b/src/server/services/database/schema.ts @@ -109,6 +109,7 @@ export interface Invitation { userId: string | null; // uuid, NULL used: boolean; // boolean, NOT NULL, DEFAULT false isTrial: boolean; // boolean, NOT NULL, DEFAULT false + trialDays: number | null; // integer, NULL createdAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL updatedAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL diff --git a/src/server/trpc/routers/auth.ts b/src/server/trpc/routers/auth.ts index 137a7c619..9d75a7a9d 100644 --- a/src/server/trpc/routers/auth.ts +++ b/src/server/trpc/routers/auth.ts @@ -4,7 +4,6 @@ import { JWTExpired } from "jose/errors"; import { NoResultError } from "kysely"; import z from "zod"; import { setJWT } from "@/auth/jwt"; -import { DEFAULT_TRIAL_PERIOD_DAYS } from "@/constants"; import { passwordSchema } from "@/models/User"; import ForgotPassword from "@/server/emails/ForgotPassword"; import { @@ -49,9 +48,9 @@ export const authRouter = router({ }); // Set trial end date for trial invitations - if (invitation.isTrial && !user.trialEndsAt) { + if (invitation.trialDays && !user.trialEndsAt) { const trialEndsAt = new Date( - Date.now() + DEFAULT_TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000, + Date.now() + invitation.trialDays * 24 * 60 * 60 * 1000, ); user = await updateUserTrialEndsAt(user.id, trialEndsAt); } diff --git a/src/server/trpc/routers/invitation.ts b/src/server/trpc/routers/invitation.ts index 05c9ac5de..7039f88ce 100644 --- a/src/server/trpc/routers/invitation.ts +++ b/src/server/trpc/routers/invitation.ts @@ -1,6 +1,7 @@ import { TRPCError } from "@trpc/server"; import { SignJWT } from "jose"; import z from "zod"; +import { DEFAULT_TRIAL_PERIOD_DAYS } from "@/constants"; import { UserRole } from "@/models/User"; import copyMapsToOrganisation from "@/server/commands/copyMapsToOrganisation"; import ensureOrganisationMap from "@/server/commands/ensureOrganisationMap"; @@ -37,6 +38,8 @@ export const invitationRouter = router({ }), ) .optional(), + isTrial: z.boolean().optional(), + trialDays: z.number().int().min(1).optional(), }) .refine((data) => data.organisationId || data.organisationName, { message: "Either organisationId or organisationName must be provided", @@ -76,12 +79,19 @@ export const invitationRouter = router({ await ensureOrganisationMap(org.id); } + const isSuperadmin = ctx.user.role === UserRole.Superadmin; + const isTrial = isSuperadmin ? Boolean(input.isTrial) : true; + const trialDays = isTrial + ? (input.trialDays ?? DEFAULT_TRIAL_PERIOD_DAYS) + : null; + const invitation = await createInvitation({ email: input.email.toLowerCase().trim(), name: input.name, organisationId: org.id, senderOrganisationId: senderOrg.id, - isTrial: ctx.user.role !== UserRole.Superadmin, + isTrial, + trialDays, }); const secret = new TextEncoder().encode(process.env.JWT_SECRET || ""); diff --git a/src/server/trpc/routers/user.ts b/src/server/trpc/routers/user.ts index 2db07431f..fca92ee71 100644 --- a/src/server/trpc/routers/user.ts +++ b/src/server/trpc/routers/user.ts @@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"; import z from "zod"; import { UserRole, passwordSchema, userSchema } from "@/models/User"; import { + clearUserTrial, listUsers, updateUser, updateUserRole, @@ -11,6 +12,11 @@ import { protectedProcedure, router, superadminProcedure } from "../index"; export const userRouter = router({ list: superadminProcedure.query(() => listUsers()), + clearTrial: superadminProcedure + .input(z.object({ userId: z.string() })) + .mutation(async ({ input }) => { + return clearUserTrial(input.userId); + }), updateRole: superadminProcedure .input( z.object({ diff --git a/tests/unit/server/trpc/routers/confirmInvite.test.ts b/tests/unit/server/trpc/routers/confirmInvite.test.ts index 396790e01..fcf10ab18 100644 --- a/tests/unit/server/trpc/routers/confirmInvite.test.ts +++ b/tests/unit/server/trpc/routers/confirmInvite.test.ts @@ -43,6 +43,7 @@ describe("auth.confirmInvite", () => { organisationId: org.id, senderOrganisationId: org.id, isTrial: true, + trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); const token = await createInviteToken(invitation.id); @@ -88,6 +89,41 @@ describe("auth.confirmInvite", () => { expect(result.trialEndsAt).toBeNull(); }); + test("trial invitation with custom days sets correct trialEndsAt", async () => { + const org = await upsertOrganisation({ name: `Org ${uuidv4()}` }); + const email = `custom-trial-${uuidv4()}@example.com`; + userEmails.push(email); + + const customDays = 14; + const invitation = await createInvitation({ + email, + name: "Custom Trial User", + organisationId: org.id, + senderOrganisationId: org.id, + isTrial: true, + trialDays: customDays, + }); + + const token = await createInviteToken(invitation.id); + const caller = makeCaller(); + const result = await caller.confirmInvite({ + token, + password: "test-password-123", + }); + + expect(result.trialEndsAt).toBeTruthy(); + if (!result.trialEndsAt) return; + const trialEndsAt = new Date(result.trialEndsAt); + const expectedMin = new Date( + Date.now() + (customDays - 1) * 24 * 60 * 60 * 1000, + ); + const expectedMax = new Date( + Date.now() + (customDays + 1) * 24 * 60 * 60 * 1000, + ); + expect(trialEndsAt.getTime()).toBeGreaterThan(expectedMin.getTime()); + expect(trialEndsAt.getTime()).toBeLessThan(expectedMax.getTime()); + }); + test("trial invitation does not overwrite existing trialEndsAt", async () => { const org = await upsertOrganisation({ name: `Org ${uuidv4()}` }); const email = `existing-trial-${uuidv4()}@example.com`; @@ -100,6 +136,7 @@ describe("auth.confirmInvite", () => { organisationId: org.id, senderOrganisationId: org.id, isTrial: true, + trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); const token1 = await createInviteToken(invitation1.id); @@ -118,6 +155,7 @@ describe("auth.confirmInvite", () => { organisationId: org.id, senderOrganisationId: org.id, isTrial: true, + trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); const token2 = await createInviteToken(invitation2.id); diff --git a/tests/unit/server/trpc/routers/invitation.test.ts b/tests/unit/server/trpc/routers/invitation.test.ts index ce01e81a5..0ceba9ee5 100644 --- a/tests/unit/server/trpc/routers/invitation.test.ts +++ b/tests/unit/server/trpc/routers/invitation.test.ts @@ -298,7 +298,7 @@ describe("invitation.create", () => { }); describe("invitation.create isTrial", () => { - test("advocate invitation is marked as trial", async () => { + test("advocate invitation is marked as trial with default trial days", async () => { const senderOrg = await createSenderOrg(); const advocate = await createTestUser(UserRole.Advocate, senderOrg.id); const caller = makeCaller(advocate); @@ -317,9 +317,10 @@ describe("invitation.create isTrial", () => { .selectAll() .executeTakeFirstOrThrow(); expect(invitation.isTrial).toBe(true); + expect(invitation.trialDays).toBe(30); }); - test("superadmin invitation is not marked as trial", async () => { + test("superadmin invitation is not marked as trial by default", async () => { const senderOrg = await createSenderOrg(); const superadmin = await createTestUser(UserRole.Superadmin, senderOrg.id); const caller = makeCaller(superadmin); @@ -338,6 +339,54 @@ describe("invitation.create isTrial", () => { .selectAll() .executeTakeFirstOrThrow(); expect(invitation.isTrial).toBe(false); + expect(invitation.trialDays).toBeNull(); + }); + + test("superadmin can create a trial invitation with custom days", async () => { + const senderOrg = await createSenderOrg(); + const superadmin = await createTestUser(UserRole.Superadmin, senderOrg.id); + const caller = makeCaller(superadmin); + + const email = `invitee-${uuidv4()}@example.com`; + await caller.create({ + name: "Invitee", + email, + senderOrganisationId: senderOrg.id, + organisationName: `New Org ${uuidv4()}`, + isTrial: true, + trialDays: 14, + }); + + const invitation = await db + .selectFrom("invitation") + .where("email", "=", email) + .selectAll() + .executeTakeFirstOrThrow(); + expect(invitation.isTrial).toBe(true); + expect(invitation.trialDays).toBe(14); + }); + + test("superadmin trial invitation defaults to 30 days", async () => { + const senderOrg = await createSenderOrg(); + const superadmin = await createTestUser(UserRole.Superadmin, senderOrg.id); + const caller = makeCaller(superadmin); + + const email = `invitee-${uuidv4()}@example.com`; + await caller.create({ + name: "Invitee", + email, + senderOrganisationId: senderOrg.id, + organisationName: `New Org ${uuidv4()}`, + isTrial: true, + }); + + const invitation = await db + .selectFrom("invitation") + .where("email", "=", email) + .selectAll() + .executeTakeFirstOrThrow(); + expect(invitation.isTrial).toBe(true); + expect(invitation.trialDays).toBe(30); }); }); From da3d5055316b71828280d568d98144bbe0359df5 Mon Sep 17 00:00:00 2001 From: joaquimds Date: Thu, 16 Apr 2026 18:02:35 +0200 Subject: [PATCH 2/8] Update src/server/trpc/routers/invitation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/server/trpc/routers/invitation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/trpc/routers/invitation.ts b/src/server/trpc/routers/invitation.ts index 7039f88ce..86783778b 100644 --- a/src/server/trpc/routers/invitation.ts +++ b/src/server/trpc/routers/invitation.ts @@ -82,7 +82,9 @@ export const invitationRouter = router({ const isSuperadmin = ctx.user.role === UserRole.Superadmin; const isTrial = isSuperadmin ? Boolean(input.isTrial) : true; const trialDays = isTrial - ? (input.trialDays ?? DEFAULT_TRIAL_PERIOD_DAYS) + ? isSuperadmin + ? (input.trialDays ?? DEFAULT_TRIAL_PERIOD_DAYS) + : DEFAULT_TRIAL_PERIOD_DAYS : null; const invitation = await createInvitation({ From 9d02efa990ebab5507b1770ee7745cdf397b6a21 Mon Sep 17 00:00:00 2001 From: joaquimds Date: Thu, 16 Apr 2026 18:02:44 +0200 Subject: [PATCH 3/8] Update migrations/1776353491107_invitation_trial_days.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- migrations/1776353491107_invitation_trial_days.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/1776353491107_invitation_trial_days.ts b/migrations/1776353491107_invitation_trial_days.ts index ce9484454..7179fbce8 100644 --- a/migrations/1776353491107_invitation_trial_days.ts +++ b/migrations/1776353491107_invitation_trial_days.ts @@ -4,10 +4,10 @@ import type { Kysely } from "kysely"; export async function up(db: Kysely): Promise { await db.schema .alterTable("invitation") - .addColumn("trial_days", "integer") + .addColumn("trialDays", "integer") .execute(); } export async function down(db: Kysely): Promise { - await db.schema.alterTable("invitation").dropColumn("trial_days").execute(); + await db.schema.alterTable("invitation").dropColumn("trialDays").execute(); } From 1bf7dd7bbe203c6eb60851e890a59010b31c3c39 Mon Sep 17 00:00:00 2001 From: joaquimds Date: Thu, 16 Apr 2026 18:02:57 +0200 Subject: [PATCH 4/8] Update src/server/trpc/routers/user.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/server/trpc/routers/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/trpc/routers/user.ts b/src/server/trpc/routers/user.ts index fca92ee71..cb00069fd 100644 --- a/src/server/trpc/routers/user.ts +++ b/src/server/trpc/routers/user.ts @@ -13,7 +13,7 @@ import { protectedProcedure, router, superadminProcedure } from "../index"; export const userRouter = router({ list: superadminProcedure.query(() => listUsers()), clearTrial: superadminProcedure - .input(z.object({ userId: z.string() })) + .input(z.object({ userId: z.string().uuid() })) .mutation(async ({ input }) => { return clearUserTrial(input.userId); }), From bdcaaff755c167394cc571d1254ec22a8bf0b3c5 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 16 Apr 2026 18:03:21 +0200 Subject: [PATCH 5/8] fix: missing data sources in invitations --- .../invite-organisation/CreateInvitationModal.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx index d7c55e1af..3d770e8d8 100644 --- a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx +++ b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx @@ -113,6 +113,12 @@ export default function CreateInvitationModal() { dsIds.add(dsv.dataSourceId); } } + // Only include data sources that actually exist + for (const id of dsIds) { + if (!mapData.dataSourceNames[id]) { + dsIds.delete(id); + } + } result.set(map.id, dsIds); } return result; From 599d1c4a8df43e499564e2163a09bae521166798 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Fri, 17 Apr 2026 13:17:00 +0200 Subject: [PATCH 6/8] fix: remove superfluous invite.isTrial property --- .../1776353491107_invitation_trial_days.ts | 25 ++++++++++++++++++- src/models/Invitation.ts | 1 - src/server/models/Invitation.ts | 1 - src/server/services/database/schema.ts | 1 - src/server/trpc/routers/invitation.ts | 5 +--- .../server/trpc/routers/confirmInvite.test.ts | 5 ---- .../server/trpc/routers/invitation.test.ts | 10 +++----- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/migrations/1776353491107_invitation_trial_days.ts b/migrations/1776353491107_invitation_trial_days.ts index 7179fbce8..a2013eabf 100644 --- a/migrations/1776353491107_invitation_trial_days.ts +++ b/migrations/1776353491107_invitation_trial_days.ts @@ -1,13 +1,36 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Kysely } from "kysely"; +import { sql, type Kysely } from "kysely"; export async function up(db: Kysely): Promise { await db.schema .alterTable("invitation") .addColumn("trialDays", "integer") .execute(); + + // Backfill existing trial invitations with the default trial period + await db + .updateTable("invitation") + .where("isTrial", "=", true) + .set({ trialDays: 30 }) + .execute(); + + await db.schema.alterTable("invitation").dropColumn("isTrial").execute(); } export async function down(db: Kysely): Promise { + await db.schema + .alterTable("invitation") + .addColumn("isTrial", "boolean", (col) => + col.notNull().defaultTo(sql`false`), + ) + .execute(); + + // Restore isTrial from trialDays + await db + .updateTable("invitation") + .where("trialDays", "is not", null) + .set({ isTrial: true }) + .execute(); + await db.schema.alterTable("invitation").dropColumn("trialDays").execute(); } diff --git a/src/models/Invitation.ts b/src/models/Invitation.ts index afe912103..4aa8b7f11 100644 --- a/src/models/Invitation.ts +++ b/src/models/Invitation.ts @@ -10,7 +10,6 @@ export const invitationSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), used: z.boolean(), - isTrial: z.boolean(), trialDays: z.number().nullish(), }); diff --git a/src/server/models/Invitation.ts b/src/server/models/Invitation.ts index 4835a7f55..f6f13f322 100644 --- a/src/server/models/Invitation.ts +++ b/src/server/models/Invitation.ts @@ -10,7 +10,6 @@ import type { export type InvitationTable = Invitation & { id: GeneratedAlways; used: Generated; - isTrial: Generated; trialDays: Generated; createdAt: ColumnType; updatedAt: ColumnType; diff --git a/src/server/services/database/schema.ts b/src/server/services/database/schema.ts index a32ff6e88..340e4ac3f 100644 --- a/src/server/services/database/schema.ts +++ b/src/server/services/database/schema.ts @@ -108,7 +108,6 @@ export interface Invitation { organisationId: string; // uuid, NOT NULL userId: string | null; // uuid, NULL used: boolean; // boolean, NOT NULL, DEFAULT false - isTrial: boolean; // boolean, NOT NULL, DEFAULT false trialDays: number | null; // integer, NULL createdAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL updatedAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL diff --git a/src/server/trpc/routers/invitation.ts b/src/server/trpc/routers/invitation.ts index 86783778b..8d44868d6 100644 --- a/src/server/trpc/routers/invitation.ts +++ b/src/server/trpc/routers/invitation.ts @@ -82,9 +82,7 @@ export const invitationRouter = router({ const isSuperadmin = ctx.user.role === UserRole.Superadmin; const isTrial = isSuperadmin ? Boolean(input.isTrial) : true; const trialDays = isTrial - ? isSuperadmin - ? (input.trialDays ?? DEFAULT_TRIAL_PERIOD_DAYS) - : DEFAULT_TRIAL_PERIOD_DAYS + ? (input.trialDays ?? DEFAULT_TRIAL_PERIOD_DAYS) : null; const invitation = await createInvitation({ @@ -92,7 +90,6 @@ export const invitationRouter = router({ name: input.name, organisationId: org.id, senderOrganisationId: senderOrg.id, - isTrial, trialDays, }); diff --git a/tests/unit/server/trpc/routers/confirmInvite.test.ts b/tests/unit/server/trpc/routers/confirmInvite.test.ts index fcf10ab18..99dd1bc4d 100644 --- a/tests/unit/server/trpc/routers/confirmInvite.test.ts +++ b/tests/unit/server/trpc/routers/confirmInvite.test.ts @@ -42,7 +42,6 @@ describe("auth.confirmInvite", () => { name: "Trial User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: true, trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); @@ -76,7 +75,6 @@ describe("auth.confirmInvite", () => { name: "Regular User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: false, }); const token = await createInviteToken(invitation.id); @@ -100,7 +98,6 @@ describe("auth.confirmInvite", () => { name: "Custom Trial User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: true, trialDays: customDays, }); @@ -135,7 +132,6 @@ describe("auth.confirmInvite", () => { name: "Existing Trial User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: true, trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); @@ -154,7 +150,6 @@ describe("auth.confirmInvite", () => { name: "Existing Trial User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: true, trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); diff --git a/tests/unit/server/trpc/routers/invitation.test.ts b/tests/unit/server/trpc/routers/invitation.test.ts index 0ceba9ee5..89ca51aa2 100644 --- a/tests/unit/server/trpc/routers/invitation.test.ts +++ b/tests/unit/server/trpc/routers/invitation.test.ts @@ -297,8 +297,8 @@ describe("invitation.create", () => { }); }); -describe("invitation.create isTrial", () => { - test("advocate invitation is marked as trial with default trial days", async () => { +describe("invitation.create trialDays", () => { + test("advocate invitation has default trial days", async () => { const senderOrg = await createSenderOrg(); const advocate = await createTestUser(UserRole.Advocate, senderOrg.id); const caller = makeCaller(advocate); @@ -316,11 +316,10 @@ describe("invitation.create isTrial", () => { .where("email", "=", email) .selectAll() .executeTakeFirstOrThrow(); - expect(invitation.isTrial).toBe(true); expect(invitation.trialDays).toBe(30); }); - test("superadmin invitation is not marked as trial by default", async () => { + test("superadmin invitation has no trial days by default", async () => { const senderOrg = await createSenderOrg(); const superadmin = await createTestUser(UserRole.Superadmin, senderOrg.id); const caller = makeCaller(superadmin); @@ -338,7 +337,6 @@ describe("invitation.create isTrial", () => { .where("email", "=", email) .selectAll() .executeTakeFirstOrThrow(); - expect(invitation.isTrial).toBe(false); expect(invitation.trialDays).toBeNull(); }); @@ -362,7 +360,6 @@ describe("invitation.create isTrial", () => { .where("email", "=", email) .selectAll() .executeTakeFirstOrThrow(); - expect(invitation.isTrial).toBe(true); expect(invitation.trialDays).toBe(14); }); @@ -385,7 +382,6 @@ describe("invitation.create isTrial", () => { .where("email", "=", email) .selectAll() .executeTakeFirstOrThrow(); - expect(invitation.isTrial).toBe(true); expect(invitation.trialDays).toBe(30); }); }); From 3a1ba5b2cb0322be77973677c0e290232d4d2cca Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Fri, 17 Apr 2026 13:31:16 +0200 Subject: [PATCH 7/8] fix: minor security patch --- migrations/1776353491107_invitation_trial_days.ts | 2 +- package.json | 4 ++-- src/server/trpc/routers/invitation.ts | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/migrations/1776353491107_invitation_trial_days.ts b/migrations/1776353491107_invitation_trial_days.ts index a2013eabf..44136620b 100644 --- a/migrations/1776353491107_invitation_trial_days.ts +++ b/migrations/1776353491107_invitation_trial_days.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { sql, type Kysely } from "kysely"; +import { type Kysely, sql } from "kysely"; export async function up(db: Kysely): Promise { await db.schema diff --git a/package.json b/package.json index 1812687f9..10bb5a315 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "migrate": "kysely migrate:latest", "typecheck": "tsc --noEmit", "prettier": "prettier --log-level warn --write .", - "lint:fix": "eslint --fix bin src tests", - "madge": "madge --circular --extensions ts,tsx --ts-config tsconfig.json bin src tests", + "lint:fix": "eslint --fix bin migrations src tests", + "madge": "madge --circular --extensions ts,tsx --ts-config tsconfig.json bin migrations src tests", "lint": "run-p prettier lint:fix typecheck madge", "lint:ci": "eslint && tsc --noEmit && prettier --log-level warn --check . && madge --circular --extensions ts,tsx --ts-config tsconfig.json bin src", "start": "next start", diff --git a/src/server/trpc/routers/invitation.ts b/src/server/trpc/routers/invitation.ts index 8d44868d6..ca1cb1684 100644 --- a/src/server/trpc/routers/invitation.ts +++ b/src/server/trpc/routers/invitation.ts @@ -81,8 +81,11 @@ export const invitationRouter = router({ const isSuperadmin = ctx.user.role === UserRole.Superadmin; const isTrial = isSuperadmin ? Boolean(input.isTrial) : true; + const requestedTrialDays = isSuperadmin + ? input.trialDays + : DEFAULT_TRIAL_PERIOD_DAYS; const trialDays = isTrial - ? (input.trialDays ?? DEFAULT_TRIAL_PERIOD_DAYS) + ? (requestedTrialDays ?? DEFAULT_TRIAL_PERIOD_DAYS) : null; const invitation = await createInvitation({ From ebff6135c5f6df7500e3f0fee5c753784eb3cdea Mon Sep 17 00:00:00 2001 From: joaquimds Date: Fri, 17 Apr 2026 13:37:31 +0200 Subject: [PATCH 8/8] Update package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 10bb5a315..84327dd43 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "lint:fix": "eslint --fix bin migrations src tests", "madge": "madge --circular --extensions ts,tsx --ts-config tsconfig.json bin migrations src tests", "lint": "run-p prettier lint:fix typecheck madge", - "lint:ci": "eslint && tsc --noEmit && prettier --log-level warn --check . && madge --circular --extensions ts,tsx --ts-config tsconfig.json bin src", + "lint:ci": "eslint && tsc --noEmit && prettier --log-level warn --check . && madge --circular --extensions ts,tsx --ts-config tsconfig.json bin migrations src tests", "start": "next start", "test": "./bin/prepare-test-env.sh && NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=.env.testing ./node_modules/.bin/vitest" },