Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions migrations/1776353491107_invitation_trial_days.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type Kysely, sql } from "kysely";

export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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();
}
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,7 +46,11 @@ export default function CreateInvitationModal() {
const [selectedDataSourceIds, setSelectedDataSourceIds] = useState<
Set<string>
>(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();
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -175,6 +190,9 @@ export default function CreateInvitationModal() {
email,
name,
mapSelections: mapSelections.length > 0 ? mapSelections : undefined,
...(isSuperadmin
? { isTrial, trialDays: isTrial ? trialDays : undefined }
: {}),
});
};

Expand Down Expand Up @@ -291,6 +309,35 @@ export default function CreateInvitationModal() {
)}
</FormFieldWrapper>

{isSuperadmin && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="is-trial"
checked={isTrial}
onCheckedChange={(checked) => setIsTrial(Boolean(checked))}
/>
<Label
htmlFor="is-trial"
className="font-normal cursor-pointer"
>
Trial account
</Label>
</div>
{isTrial && (
<FormFieldWrapper id="trial-days" label="Trial duration (days)">
<Input
id="trial-days"
type="number"
min={1}
value={trialDays}
onChange={(e) => setTrialDays(Number(e.target.value))}
Comment thread
joaquimds marked this conversation as resolved.
/>
Comment thread
joaquimds marked this conversation as resolved.
</FormFieldWrapper>
)}
</div>
)}

{mapData && mapsByOrg.length > 0 && (
<div className="flex flex-col gap-2 mt-4">
<Label>Maps to copy</Label>
Expand Down
39 changes: 37 additions & 2 deletions src/app/(private)/(dashboards)/superadmin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"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";
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,
Expand Down Expand Up @@ -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("/");

Expand Down Expand Up @@ -78,16 +91,19 @@ export default function SuperadminPage() {
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Organisation</TableHead>
<TableHead className="max-w-xs">Organisation</TableHead>
<TableHead>Role</TableHead>
<TableHead>Trial</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users?.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.email}</TableCell>
<TableCell>{u.name}</TableCell>
<TableCell>{u.organisations.join(", ")}</TableCell>
<TableCell className="max-w-xs">
{u.organisations.join(", ")}
</TableCell>
<TableCell>
<Select
value={u.role ?? "none"}
Expand All @@ -112,6 +128,25 @@ export default function SuperadminPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
{u.trialEndsAt ? (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Expires{" "}
{format(new Date(u.trialEndsAt), "d MMM yyyy")}
</span>
<Button
variant="outline"
size="sm"
onClick={() => clearTrial({ userId: u.id })}
>
Upgrade
</Button>
</div>
) : (
<span className="text-sm text-muted-foreground">—</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
Expand Down
2 changes: 1 addition & 1 deletion src/models/Invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof invitationSchema>;
2 changes: 1 addition & 1 deletion src/server/models/Invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
export type InvitationTable = Invitation & {
id: GeneratedAlways<string>;
used: Generated<boolean>;
isTrial: Generated<boolean>;
trialDays: Generated<number | null>;
createdAt: ColumnType<Date, string | undefined, never>;
updatedAt: ColumnType<Date, string | undefined, string>;
};
Expand Down
6 changes: 5 additions & 1 deletion src/server/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import type {
export type UserTable = User & {
id: GeneratedAlways<string>;
createdAt: ColumnType<Date, string | undefined, never>;
trialEndsAt: ColumnType<Date | null, string | undefined, string | undefined>;
trialEndsAt: ColumnType<
Date | null,
string | undefined,
string | null | undefined
>;
};
export type NewUser = Insertable<UserTable>;
export type UserUpdate = Updateable<UserTable>;
9 changes: 9 additions & 0 deletions src/server/repositories/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearUserTrial() uses returningAll(). Even though password hashes are stripped by the SuperJSON serializer, returning the entire row is still more data than needed for this action. Consider returning only the fields needed by callers (or returning nothing) to minimise accidental data exposure and reduce payload size.

Suggested change
.returningAll()
.returning(["id", "trialEndsAt"])

Copilot uses AI. Check for mistakes.
.executeTakeFirstOrThrow();
}

export async function updateUserRole(id: string, role: UserRole | null) {
return db
.updateTable("user")
Expand Down
2 changes: 1 addition & 1 deletion src/server/services/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/server/trpc/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Comment thread
joaquimds marked this conversation as resolved.
Expand Down
14 changes: 13 additions & 1 deletion src/server/trpc/routers/invitation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -37,6 +38,8 @@ export const invitationRouter = router({
}),
)
.optional(),
isTrial: z.boolean().optional(),
trialDays: z.number().int().min(1).optional(),
})
Comment on lines +41 to 43
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trialDays can be provided while isTrial is false/omitted (superadmin default), and it will be silently ignored. To prevent client mistakes and make the API easier to use, consider a zod refinement that rejects trialDays unless isTrial === true (and possibly rejects isTrial === true with trialDays missing if you want it explicit).

Copilot uses AI. Check for mistakes.
.refine((data) => data.organisationId || data.organisationName, {
message: "Either organisationId or organisationName must be provided",
Expand Down Expand Up @@ -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;
Comment thread
joaquimds marked this conversation as resolved.

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 || "");
Expand Down
6 changes: 6 additions & 0 deletions src/server/trpc/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearTrial currently returns the full updated user record from the repository, but the caller (UI) doesn’t use the response. Consider returning void (or a minimal { id, trialEndsAt }) to keep the API surface small and avoid unnecessary data transfer.

Suggested change
return clearUserTrial(input.userId);
await clearUserTrial(input.userId);

Copilot uses AI. Check for mistakes.
}),
updateRole: superadminProcedure
.input(
z.object({
Expand Down
Loading
Loading