Skip to content

feat: add trial accounts for Advocate invited users#421

Merged
joaquimds merged 5 commits intomainfrom
feat/trial-account
Apr 16, 2026
Merged

feat: add trial accounts for Advocate invited users#421
joaquimds merged 5 commits intomainfrom
feat/trial-account

Conversation

@joaquimds
Copy link
Copy Markdown
Member

No description provided.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.trialEndsAt and invitation.isTrial (models, schema typings, and migrations).
  • Sets trialEndsAt when 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.

Comment thread src/server/trpc/index.ts
Comment on lines +73 to +78
if (ctx.user.trialEndsAt && new Date(ctx.user.trialEndsAt) < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Your trial has expired.",
});
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread migrations/1776346034221_user_trial_ends_at.ts Outdated
Comment thread migrations/1776346034220_invitation_is_trial.ts Outdated
Comment thread src/components/TrialBanner.tsx Outdated
Comment on lines +23 to +25
const [daysRemaining] = useState(() =>
getDaysRemaining(currentUser?.trialEndsAt),
);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +74
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;
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +57
// 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);
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@joaquimds joaquimds requested a review from Copilot April 16, 2026 14:45
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +2 to +9
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`),
)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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))

Copilot uses AI. Check for mistakes.
Comment on lines 83 to 85
senderOrganisationId: senderOrg.id,
isTrial: ctx.user.role !== UserRole.Superadmin,
});
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +57
// 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);
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/server/trpc/index.ts
Comment on lines +74 to +79
if (ctx.user.trialEndsAt && new Date(ctx.user.trialEndsAt) < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: TRIAL_EXPIRED_MESSAGE,
});
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/app/HTMLBody.tsx
@@ -0,0 +1,29 @@
import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
import "./global.css";
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
import "./global.css";

Copilot uses AI. Check for mistakes.
Comment thread src/app/global-error.tsx
import { TRIAL_EXPIRED_MESSAGE } from "@/constants";
import { Button } from "@/shadcn/ui/button";
import { Card, CardContent, CardTitle } from "@/shadcn/ui/card";
import HTMLBody from "./HTMLBody";
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@joaquimds joaquimds merged commit ee59e18 into main Apr 16, 2026
1 check passed
@joaquimds joaquimds deleted the feat/trial-account branch April 16, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants