feat: add trial accounts for Advocate invited users#421
Conversation
There was a problem hiding this comment.
Pull request overview
Adds trial-account support for users invited by Advocates by recording a per-user trial end date, setting it on invite confirmation, and restricting access once the trial expires (with UI messaging).
Changes:
- Introduces
user.trialEndsAtandinvitation.isTrial(models, schema typings, and migrations). - Sets
trialEndsAtwhen confirming a trial invitation; enforces expiry in tRPC auth middleware and the private app layout. - Adds trial UI components (
TrialBanner,TrialExpired) and updates client-side auth error handling.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/app/api/login/route.test.ts | Updates login test fixture to include trialEndsAt. |
| src/services/trpc/react.tsx | Adds client redirect handling for FORBIDDEN errors. |
| src/server/trpc/routers/invitation.ts | Marks created invitations as trial/non-trial based on inviter role. |
| src/server/trpc/routers/auth.ts | Sets trialEndsAt when confirming a trial invite. |
| src/server/trpc/index.ts | Blocks protected tRPC procedures when a trial has expired. |
| src/server/services/database/schema.ts | Extends DB schema typings/docs with trialEndsAt and isTrial. |
| src/server/repositories/User.ts | Adds repository helper to update trialEndsAt. |
| src/server/models/User.ts | Adds trialEndsAt to Kysely user table typing. |
| src/server/models/Invitation.ts | Adds isTrial to Kysely invitation table typing. |
| src/models/User.ts | Extends shared User model with trialEndsAt. |
| src/models/Invitation.ts | Extends shared Invitation model with isTrial. |
| src/constants/index.ts | Adds DEFAULT_TRIAL_PERIOD_DAYS. |
| src/components/TrialBanner.tsx | New banner showing days remaining for trials. |
| src/authTypes.ts | Exposes trialEndsAt on CurrentUser. |
| src/auth/index.ts | Adds trialEndsAt to server session payload. |
| src/app/(private)/layout.tsx | Renders trial banner; shows trial-expired view when expired. |
| src/app/(private)/TrialExpired.tsx | New trial-expired UI content. |
| migrations/1776346034221_user_trial_ends_at.ts | Adds trial_ends_at column. |
| migrations/1776346034220_invitation_is_trial.ts | Adds is_trial column. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (ctx.user.trialEndsAt && new Date(ctx.user.trialEndsAt) < new Date()) { | ||
| throw new TRPCError({ | ||
| code: "FORBIDDEN", | ||
| message: "Your trial has expired.", | ||
| }); | ||
| } |
There was a problem hiding this comment.
The trial-expiry guard applies to every authenticated user, including superadmins. If a user is ever promoted to Superadmin after receiving a trial invite (or if a superadmin account ends up with trialEndsAt set), they’ll be locked out by this middleware. Consider exempting UserRole.Superadmin here, and/or clearing trialEndsAt when assigning elevated roles.
| const [daysRemaining] = useState(() => | ||
| getDaysRemaining(currentUser?.trialEndsAt), | ||
| ); |
There was a problem hiding this comment.
daysRemaining is initialised from currentUser only once (via useState initializer). Since currentUserAtom starts as null and is typically populated after hydration, this will often lock daysRemaining to null and the banner will never appear. Compute daysRemaining from currentUser?.trialEndsAt on each render (or via useMemo with currentUser?.trialEndsAt as a dependency) instead of storing it in state.
| if (err instanceof TRPCClientError && typeof window !== "undefined") { | ||
| if (err.data?.code === "UNAUTHORIZED") { | ||
| const redirectTo = encodeURIComponent( | ||
| window.location.pathname + window.location.search, | ||
| ); | ||
| window.location.href = `/login?redirectTo=${redirectTo}`; | ||
| return; | ||
| } | ||
| if (err.data?.code === "FORBIDDEN") { | ||
| window.location.href = DEFAULT_AUTH_REDIRECT; | ||
| return; | ||
| } |
There was a problem hiding this comment.
The global tRPC errorLink redirect on any FORBIDDEN error will also catch normal authorisation/validation flows (e.g. invalid current password, missing feature flags, org access checks) and unexpectedly navigate the user away instead of surfacing the error. Narrow this redirect to the specific trial-expired condition (e.g. add a dedicated flag/code in the error formatter for trial expiry, or match on a dedicated cause/metadata) rather than all FORBIDDEN errors.
| // Set trial end date for trial invitations | ||
| if (invitation.isTrial && !user.trialEndsAt) { | ||
| const trialEndsAt = new Date( | ||
| Date.now() + DEFAULT_TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000, | ||
| ); | ||
| await updateUserTrialEndsAt(user.id, trialEndsAt); | ||
| } |
There was a problem hiding this comment.
updateUserTrialEndsAt() is awaited but its result isn’t used, and the mutation returns the original user object from upsertUser. This means the caller won’t see trialEndsAt set immediately after confirming a trial invite. Consider returning the updated user record (from updateUserTrialEndsAt or a re-fetch) so the mutation response is consistent with the persisted state.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { type Kysely, sql } from "kysely"; | ||
|
|
||
| export async function up(db: Kysely<any>): Promise<void> { | ||
| await db.schema | ||
| .alterTable("invitation") | ||
| .addColumn("isTrial", "boolean", (col) => | ||
| col.notNull().defaultTo(sql`false`), | ||
| ) |
There was a problem hiding this comment.
This migration uses a snake_case column name (is_trial) with the schema builder. Repo convention is to use camelCase identifiers with Kysely’s schema/query builder (CamelCasePlugin maps to snake_case in Postgres). Rename the column to isTrial here and use defaultTo(false) (no sql tag needed) to match existing migrations like 1761056533021_invitation_used.ts.
| import { type Kysely, sql } from "kysely"; | |
| export async function up(db: Kysely<any>): Promise<void> { | |
| await db.schema | |
| .alterTable("invitation") | |
| .addColumn("isTrial", "boolean", (col) => | |
| col.notNull().defaultTo(sql`false`), | |
| ) | |
| import { type Kysely } from "kysely"; | |
| export async function up(db: Kysely<any>): Promise<void> { | |
| await db.schema | |
| .alterTable("invitation") | |
| .addColumn("isTrial", "boolean", (col) => col.notNull().defaultTo(false)) |
| senderOrganisationId: senderOrg.id, | ||
| isTrial: ctx.user.role !== UserRole.Superadmin, | ||
| }); |
There was a problem hiding this comment.
New behaviour: invitations created by non-superadmins are marked as trial (isTrial). There are existing unit tests for invitation.create, but none asserting the isTrial flag for advocate vs superadmin. Add coverage to ensure the persisted invitation has isTrial=true for advocates and false for superadmins.
| // Set trial end date for trial invitations | ||
| if (invitation.isTrial && !user.trialEndsAt) { | ||
| const trialEndsAt = new Date( | ||
| Date.now() + DEFAULT_TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000, | ||
| ); | ||
| await updateUserTrialEndsAt(user.id, trialEndsAt); | ||
| } |
There was a problem hiding this comment.
New behaviour: confirmInvite now sets trialEndsAt when invitation.isTrial is true. There is unit test coverage for other authRouter procedures, but no tests for this mutation. Add tests to verify (1) trial invitations set trialEndsAt once and (2) non-trial invitations do not set it.
| if (ctx.user.trialEndsAt && new Date(ctx.user.trialEndsAt) < new Date()) { | ||
| throw new TRPCError({ | ||
| code: "FORBIDDEN", | ||
| message: TRIAL_EXPIRED_MESSAGE, | ||
| }); | ||
| } |
There was a problem hiding this comment.
New behaviour: protectedProcedure blocks users whose trialEndsAt is in the past with FORBIDDEN + TRIAL_EXPIRED_MESSAGE. Add unit tests around this middleware (e.g. calling a protected procedure with a user whose trialEndsAt is before/after now) to ensure the intended access control and error message are enforced.
| @@ -0,0 +1,29 @@ | |||
| import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; | |||
| import "./global.css"; | |||
There was a problem hiding this comment.
HTMLBody imports ./global.css. In Next.js (App Router), global CSS can only be imported from the root app/layout.tsx (or an equivalent top-level entry). Importing it from a shared component will cause a build-time error. Move the global.css import back to src/app/layout.tsx (or convert it to a CSS module if it must be imported here).
| import "./global.css"; |
| import { TRIAL_EXPIRED_MESSAGE } from "@/constants"; | ||
| import { Button } from "@/shadcn/ui/button"; | ||
| import { Card, CardContent, CardTitle } from "@/shadcn/ui/card"; | ||
| import HTMLBody from "./HTMLBody"; |
There was a problem hiding this comment.
GlobalError is a client component, but it imports HTMLBody (a server component by default). Client components cannot import server components in the App Router, so this will fail compilation. Inline the <html>/<body> wrapper in global-error.tsx, or refactor HTMLBody so it is client-compatible and does not import server-only modules.
No description provided.