Trials — Data Model
One migration: libs/db/drizzle/0184_trials.sql.
staff_notes (new)
Schema: libs/db/src/lib/schema/staff-notes.ts.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | gen_random_uuid() |
organization_id | uuid NOT NULL → organizations ON DELETE CASCADE | |
membership_id | uuid → memberships ON DELETE CASCADE | Note on a member |
organization_lead_id | uuid → organization_leads ON DELETE CASCADE | Note on a lead; kept after conversion |
body | text NOT NULL | 1..2000 chars (API-validated, trimmed) |
important | boolean NOT NULL DEFAULT false | Shown on the roster and in the coach brief |
author_user_id | uuid → users ON DELETE SET NULL | |
created_at, updated_at | timestamptz NOT NULL DEFAULT now() | |
deleted_at | timestamptz | Soft delete |
- CHECK
staff_notes_subject_chk:membership_id IS NOT NULL OR organization_lead_id IS NOT NULL. - Indexes:
staff_notes_membership_idx (membership_id),staff_notes_organization_lead_idx (organization_lead_id). - Carry-over is a read rule, not a copy: member reads include notes whose
organization_lead_idis the lead withorganization_leads.converted_membership_id= that membership.
lead_trial_appointments (new columns)
| Column | Type | Notes |
|---|---|---|
class_session_id | uuid → class_sessions ON DELETE SET NULL | The class the trial sits in; null for a trial without a class |
checked_in_at | timestamptz | Coach check-in evidence; never an outcome |
checked_in_by_user_id | uuid → users ON DELETE SET NULL | Who checked them in |
- Partial index
lead_trial_appointments_session_idx (class_session_id) WHERE superseded_at IS NULL. - Seat rule: a row holds a seat of
class_session_idwhileoutcome IN ('pending', 'attended') AND superseded_at IS NULL(holdsTrialSeat,SEAT_HOLDING_TRIAL_OUTCOMES). outcome = 'converted': the lead converted before the class and the seat became their booking (bookings.trial_appointment_id). Not superseded, holds no seat.- A checked-in trial ended by a lead path or a class cancel gets
outcome = 'attended'and stays unsuperseded; other ended trials getcancelled/rescheduledandsuperseded_at. starts_atequals the session’s start for a session trial and follows it when the session moves.- Check-in and undo bump
version, like every appointment write.
bookings (new columns)
| Column | Type | Notes |
|---|---|---|
trial_marked_by_user_id | uuid → users ON DELETE SET NULL | A staff member marked the booking a trial; null = not marked |
trial_appointment_id | uuid → lead_trial_appointments ON DELETE SET NULL | The lead trial seat this booking took over on conversion. Never revoked by the entitlement sweep |
plans (new columns)
| Column | Type | Notes |
|---|---|---|
is_trial | boolean NOT NULL DEFAULT false | Bookings paid with the plan show as trials. Drop-in plans only: CHECK plans_trial_drop_in_chk (NOT is_trial OR type = 'drop_in') |
trial_once_per_person | boolean NOT NULL DEFAULT true | Trial plans only (meaningless when is_trial is false): a member self-serve purchase is refused (409 trial_already_used) when the person already held any trial plan of the org. Staff enrollment is never refused. |
expiresInDays (existing plans.expires_in_days) now also applies to trial drop-ins — the trial’s “use within” window — besides class packs.
class_sessions (new column)
| Column | Type | Notes |
|---|---|---|
trial_brief_sent_for | timestamptz | The starts_at the coach brief went out for. Brief is due when it IS DISTINCT FROM starts_at, so a moved class is re-armed. |
Not stored
- A roster entry’s trial reason — derived on every read from
bookings.trial_appointment_id,bookings.trial_marked_by_user_idand the booking’s planis_trial, in that precedence. API-internal: no response carries it. - Health-declaration state — derived from the org’s
health_declarationcompliance form and its submissions.
Shared schemas
libs/shared/src/lib/schemas/trials.schema.ts: staffNoteSchema, createStaffNoteSchema, updateStaffNoteSchema, ROSTER_ENTRY_KINDS (booking, lead_trial), TRIAL_APPOINTMENT_OUTCOMES (incl. converted), rosterTrialSchema ({ lead }, no reason), rosterEntrySchema (canMarkTrial, canUnmarkTrial), sessionRosterResponseSchema, markBookingTrialSchema, trialsTodaySessionSchema, trialSessionOptionSchema, createMemberTrialBookingSchema, memberTrialBookingSchema. The trial error codes (trial_session_full, trial_session_unavailable, trial_not_pending, trial_not_markable) are API-side.
plan.schema.ts: isTrial on createPlanSchema (default false), updatePlanSchema, planResponseSchema; trialOncePerPerson on the same three (create: optional, server default true; refused on non-trial plans; reset on un-flag); expiresInDays refined to class packs and trial plans (isExpiryAllowedForPlan). Helpers: isTrialAllowedForType, isExpiryAllowedForPlan, consumableValidityEnd (the window stamped on activation), planKind / PlanKind ('trial' for a trial drop-in — how staff surfaces label a plan). Codes: TRIAL_REQUIRES_DROP_IN, TRIAL_PLAN_IN_USE.
payment-error-codes.ts: PaymentErrorCodes.TRIAL_ALREADY_USED = trial_already_used.
sales-workspace.schema.ts: salesResultInputSchema.trial is { startsAt?, classSessionId?, type } with exactly one of startsAt / classSessionId.
notification-prefs.ts: category trials (opt-in by default, like every category).