diff --git a/migrations/1776353491107_invitation_trial_days.ts b/migrations/1776353491107_invitation_trial_days.ts new file mode 100644 index 000000000..44136620b --- /dev/null +++ b/migrations/1776353491107_invitation_trial_days.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { type Kysely, sql } 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/package.json b/package.json index 1812687f9..84327dd43 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "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", + "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" }, diff --git a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx index c606c1175..3d770e8d8 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 @@ -104,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; @@ -175,6 +190,9 @@ export default function CreateInvitationModal() { email, name, mapSelections: mapSelections.length > 0 ? mapSelections : undefined, + ...(isSuperadmin + ? { isTrial, trialDays: isTrial ? trialDays : undefined } + : {}), }); }; @@ -291,6 +309,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..4aa8b7f11 100644 --- a/src/models/Invitation.ts +++ b/src/models/Invitation.ts @@ -10,7 +10,7 @@ export const invitationSchema = z.object({ createdAt: z.date(), 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..f6f13f322 100644 --- a/src/server/models/Invitation.ts +++ b/src/server/models/Invitation.ts @@ -10,7 +10,7 @@ import type { 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..340e4ac3f 100644 --- a/src/server/services/database/schema.ts +++ b/src/server/services/database/schema.ts @@ -108,7 +108,7 @@ 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/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..ca1cb1684 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,21 @@ export const invitationRouter = router({ await ensureOrganisationMap(org.id); } + 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 + ? (requestedTrialDays ?? 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, + 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..cb00069fd 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().uuid() })) + .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..99dd1bc4d 100644 --- a/tests/unit/server/trpc/routers/confirmInvite.test.ts +++ b/tests/unit/server/trpc/routers/confirmInvite.test.ts @@ -42,7 +42,7 @@ describe("auth.confirmInvite", () => { name: "Trial User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: true, + trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); const token = await createInviteToken(invitation.id); @@ -75,7 +75,6 @@ describe("auth.confirmInvite", () => { name: "Regular User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: false, }); const token = await createInviteToken(invitation.id); @@ -88,6 +87,40 @@ 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, + 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`; @@ -99,7 +132,7 @@ describe("auth.confirmInvite", () => { name: "Existing Trial User", organisationId: org.id, senderOrganisationId: org.id, - isTrial: true, + trialDays: DEFAULT_TRIAL_PERIOD_DAYS, }); const token1 = await createInviteToken(invitation1.id); @@ -117,7 +150,7 @@ describe("auth.confirmInvite", () => { name: "Existing Trial User", 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..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", 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,10 +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", 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); @@ -337,7 +337,52 @@ describe("invitation.create isTrial", () => { .where("email", "=", email) .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.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.trialDays).toBe(30); }); });