Trials — Behavior
All routes are under /organizations/:orgId, return { data: T }, and take the response shapes in libs/shared/src/lib/schemas/trials.schema.ts.
Who is a trial
There is no first-workout detection: nothing about attendance history makes someone a trial. A roster entry is a trial for one of three reasons, in precedence order. The reason is API-internal (TrialSource in session-roster.service.ts, used for the unmark rules and trial_not_markable): no response carries it, and no coach is told why someone is a trial.
| Reason | When | trial carries |
|---|---|---|
| lead | A lead’s trial seat in this class (entry kind lead_trial), or the member booking that seat became when the lead converted before the class (bookings.trial_appointment_id, entry kind booking). | lead { organizationLeadId, appointmentId, appointmentVersion, outcome, leadNote, checkedInBy } |
| staff | A staff member marked this booking a trial (bookings.trial_marked_by_user_id). | { lead: null } |
| plan | The booking’s subscription’s plan has is_trial = true (an owner-flagged plan such as a “Trial class” drop-in). A plain drop-in is not a trial. | { lead: null } |
Otherwise trial is null. A live trial is a trial entry whose status is confirmed or attended; for the day card and the brief that is a lead trial seat holding its seat (pending or attended, not superseded) or a confirmed/attended booking with a trial (SessionRosterService.trialsFor).
The trial columns (trial_marked_by_user_id, trial_appointment_id) name staff and Sales records, so member-facing booking responses never carry them: the booking a member makes (BookingsService.book / bookForSubject) and their own bookings list (getBookingsForUser).
Session roster
GET /sessions/:sessionId/roster — staff only (owner/admin/coach; 403 otherwise; 404 for a session outside the org).
- Entries: every non-deleted booking (all statuses) and every lead trial appointment of the session that is not superseded and not
converted(a converted one is represented by the member booking it became). - A lead trial maps onto the booking vocabulary: pending →
confirmed, pending and checked in →attended, a confirmed outcome as itself (rescheduled/cancelled→cancelled). canMarkTrial= a booking that is not a trial yet;canUnmarkTrial= a staff-marked trial. Acancelledorno_showbooking offers neither, and lead and plan trials never offer Unmark.- Coaches see a lead trial’s intake note even though they have no Sales access. This is intentional: the note often says why the person came, which is what a coach needs at the door. The roster exposes nothing else of the Sales record (lead source, conversation, tasks, assignee, pipeline).
- Order: live trials first, then status group (attended/confirmed, waitlisted, no-show, cancelled), then name.
trialCountcounts live trials. person.phoneis E.164 (normalizePhone),nullwhen missing or invalid, so clients can dial orwa.meit as-is.importantNotesare the person’s important notes;noteCountcounts all of them (see Staff notes).- Every per-person fact (trial reason, health, notes) is read in batched queries, never per entry.
GET /sessions/:id/bookings stays: the check-in display and other callers still use it.
Marking a booking a trial
PUT /sessions/:sessionId/bookings/:bookingId/trial, body markBookingTrialSchema ({ isTrial: boolean }) — staff only.
- The booking must belong to the session and the org and not be deleted (404 otherwise).
isTrial: truesetstrial_marked_by_user_idto the caller; marking an already marked booking keeps who marked it first.falseclears it.- 409
trial_not_markablewhen the booking is a trial through Sales (trial_appointment_idset) or through its plan without a staff mark, or when it iscancelledorno_show(a missed or cancelled class is history). - A member who cancels and books the class again gets a plain booking: reviving their row clears the staff mark and the Sales seat link (
trial_appointment_id), so it is not a trial and the entitlement sweep may revoke it like any booking. The same holds when a converted lead’s seat revives the member’s old row (moveIntoBookingclears a stale staff mark). - Returns the updated
RosterEntry.
Booking a member as a trial
Staff (owner, admin, coach) can book an existing member — one with a paid plan included — into a class as a trial, from the member details page (overview tab, Trial classes card) and from the Sales lead sheet of a converted lead (the same card, titled with the member’s name). Open leads keep the Sales trial flow; saveResult still refuses a closed lead (Lead is closed).
POST /organizations/:orgId/members/:membershipId/trial-bookings, body createMemberTrialBookingSchema ({ classSessionId, subscriptionId? }) — staff only (403 otherwise; 404 for a member or class outside the org).
- The booking is the member’s own, through
BookingsService.bookForMemberexactly like a staff booking for a member: plan selection, credits, caps and quotas, booking policy, forms and debt gates, capacity and waitlist all apply, and the member’s notifications go out. No booking rule is added or relaxed. The staff mark (trial_marked_by_user_id= caller) is written in the booking’s own transaction;booked_by_user_idis the caller too. - More than one plan can pay → the engine’s 409
plan_selection_requiredwithplans(the candidates in eligibility order); the client resubmits withsubscriptionId. Every other refusal is the engine’s (no_credits,not_covered_by_plan,plan_required_to_book,registration_closed,session_started,Session is full, …). - A full class with an open waitlist → the member is waitlisted and the booking is marked.
- The member already holds a non-cancelled booking in the class → it is only marked, under its row lock (idempotent; a first mark keeps who marked it; a booking already a trial through Sales or its plan stays as it is). A booking created concurrently between the check and the write is marked the same way.
- Response:
MemberTrialBooking(memberTrialBookingSchema).
GET /organizations/:orgId/members/:membershipId/trial-bookings — staff only. MemberTrialBooking[]: the member’s bookings whose class has not ended (coalesce(ends_at, starts_at) > now), not cancelled and not deleted, that are trials for any reason (lead, staff, plan); sorted by class start. Each is { bookingId, status, session: { id, name, startsAt, endsAt, coachName }, canUnmark } with canUnmark = staff-marked, not a Sales seat, and not no_show. Unmark uses the mark route with { isTrial: false }.
GET /organizations/:orgId/members/:membershipId/trial-bookings/session-options — staff only. The class picker: TrialSessionOption[] from the same listing as the Sales picker (published, next 14 days, spots left after bookings, held external seats and trial seats). Full classes are offered too (the booking waitlists).
The booking dialog asks for the paying plan only after a plan_selection_required; other refusals read through the existing booking copy, naming the member (payments.errorCodes.*_child, booking-refusal-copy), else trials.memberTrials.errors.*.
Lead detail (GET …/sales-workspace/leads/:leadId) exposes convertedMember: { membershipId, name } | null from organization_leads.converted_membership_id (the member must be in the org and not deleted).
Trial plans
plans.is_trial (isTrial on createPlanSchema, default false; updatePlanSchema; planResponseSchema). Every booking paid with such a plan shows as a plan trial. Changing the flag changes how existing bookings read; nothing is copied onto bookings.
A trial is a single class, so only drop-in plans can be trial plans. createPlanSchema refines isTrial to type === 'drop_in'; the plans service refuses create and update (merged with the stored type) with 400 { code: 'trial_requires_drop_in' }; the database enforces plans_trial_drop_in_chk. There is no trial plan type: a trial plan is stored as type: 'drop_in' + isTrial: true, and the booking engine treats it as any drop-in (one credit).
Trial workout in the plan editor
The Membership Plans sheet offers Trial workout as a fourth kind, right after Drop-in and drawn as nested under it (“A single trial class for newcomers”). Picking it saves type: 'drop_in', isTrial: true; planKind() (shared) reads a plan back as trial. Like every kind it is fixed once the plan exists: editing shows it locked.
A Trial workout shows only: name (pre-filled with the localized “Trial workout” when empty), description, price (“Set 0 for a free trial”), payment link, Use within (days or weeks → expiresInDays), Show in shop, One per person, and the quota list when booking-policy quotas are on (class eligibility, as for any drop-in). Interval, credits, intro offer, purchase cap (maxPurchases), plan group, booking limits and overlapping bookings are hidden and not sent; an edit sends maxPurchases: null and planGroupId: null, and never re-sends isTrial.
The plans list type cell and the plan detail header badge read “Trial workout” for a trial plan instead of “Drop-in”.
Trial fields
trialOncePerPerson(default true) — see below. Accepted only on trial plans: create refuses it unlessisTrial, update refuses it on a plan that stays non-trial (400). An update that un-flags the plan (isTrial: false) ignores it and resets the column to its default, so a plan that is no longer a trial carries no once-per-person rule.expiresInDays— “use within N days”. Accepted on class packs and trial drop-ins (isExpiryAllowedForPlan), refused on any other plan (400expiresInDays only applies to class pack and trial plans); un-flagging a trial that still carries a window is refused until the window is cleared (expiresInDays: null) in the same or an earlier update, with a 400 that says so (… clear expiresInDays (set it to null) …). The window is stamped as the subscription’scurrentPeriodEndwhen it activates (consumableValidityEnd: free purchase, webhook activation, presale/debt settlement, cash start); a drop-in past it is no longer a credit source (isExpiredConsumable) and the lifecycle sweep ends it asexpired, as for a punch card.- Un-flagging a sold trial — a trial plan with any subscription (not deleted, any status, an abandoned checkout included) stays a trial:
isTrial: falseis refused with 400{ code: 'trial_plan_in_use' }(TRIAL_PLAN_IN_USE), since it would rewrite how those bookings read and what the once-per-person rule counts. The owner creates a new plan instead.
One per person
When a trial plan has trialOncePerPerson, a member self-serve purchase of it is refused with 409 { code: 'trial_already_used' } (PaymentErrorCodes.TRIAL_ALREADY_USED) if the person being enrolled has already held a subscription to any trial plan of the organization.
- Checked in
PlansService.purchasebyassertTrialNotUsed(plans/trial-once-per-person.util.ts), before the plan’s form gate. Every member self-serve path goes through it: the shop, the join link’s automatic purchase (join-continue.tsx), the minisite pricing CTA (which lands in the join flow), a guardian buying for a child (onBehalfOfUserId— the child is checked), and resume-checkout. - “Held” means a trial subscription that was sold or ever activated, in any status (active, used up, expired, cancelled after it started, presale
scheduled), under any of the person’s memberships in the org. “In progress” means an unpaidpendingcheckout of another trial plan of the org: it counts too, so two unpaid checkouts can never become two trials. Not counted: apendingcheckout of the same plan (so resuming it works), a checkout cancelled before it ever activated (abandoned or declined), and rows staff deleted. - Staff are never refused: the exemption is decided by the actor, so a desk sale (
onBehalfOfMembershipId,POST members/:id/checkout) goes through, and staff enrollment/assignment (enroll, cash enrollment) never calls the check. - Turning the setting off makes the plan purchasable again by anyone; non-trial plans are never checked.
- Two concurrent purchases of different trial plans by one person are not serialized (the check runs before the subscription’s own transaction); once either checkout exists as
pending, the other is refused.
Web maps the code to trials.alreadyUsed: a toast in the shop and the add-child plan step; on the join page a “You’ve already had a trial” screen with a link to the shop, and no failed-checkout report. Mobile maps it in shop checkout (use-payment-error-strings.ts); the family setup wizard’s plan step still shows the server message.
Health declaration
healthDeclaration on a roster entry (FormsService.healthDeclarationStates):
nullwhen the org has no published, non-archived compliance form withtype_key = 'health_declaration'.nullfor lead trial seats: a lead cannot sign forms before converting.- Otherwise
signedwhen the member’s user holds an accepted submission of a live declaration family, elsenot_signed.
Only the state is exposed. Specifics (an injury, a condition) belong in a staff note.
Trial seat lifecycle
A lead trial appointment with class_session_id = S, outcome pending or attended (SEAT_HOLDING_TRIAL_OUTCOMES) and superseded_at IS NULL holds one seat of S (holdsTrialSeat in apps/api/src/bookings/trial-seats.ts), mirroring confirmed + attended bookings. A coach’s check-in leaves it pending, and the rep confirming attended keeps it too: a trial that happened never hands its seat to the waitlist mid-class. A no_show or cancelled outcome, or superseding, frees it. A converted trial holds nothing: its member booking holds the seat.
Where it counts
sessionOccupancy (booking-enforcement.util.ts) adds trial seats to confirmed + attended bookings, so every capacity decision built on it — book(), waitlist fillFreeSeats, the entitlement sweep — sees them. Display counts add them through trialSeatCounts: getBookingCounts (bookingCount/capacityRemaining on the week list and session detail) and findUpcomingToday.
Picking a session
GET /sales-workspace/trial-sessions?from=&to= — Sales access. Published, non-deleted sessions starting after now in [from, to); default now → +14 days; a range over 31 days or with to <= from is 400. Each option has name (title or class type), coachName, capacity, trialSeats and spotsLeft = capacity − confirmed/attended bookings − trial seats − Arbox-held − Fizikal-held (null = unlimited). A failing Arbox/Fizikal read counts as none.
Claim
POST /sales-workspace/leads/:leadId/results with outcome: 'trial_scheduled' and trial: { classSessionId, type } (exactly one of classSessionId / startsAt):
- The lead’s pending trials are ended first (
endPendingTrials(…, 'rescheduled'), see Release), so moving a trial within one full class does not count its own seat against it. A pending trial the coach had already checked the lead into endsattendedinstead and keeps its seat. TrialSeatsService.claimSeatlocks the session row (FOR UPDATE) and checks: in the org,published, not deleted, starts in the future — else 400trial_session_unavailable; with a capacity, seats taken (sessionOccupancy) + Arbox + Fizikal held < capacity — else 409trial_session_full.- The appointment is inserted with
starts_at= the session’s start andtrial_typeas sent (the web sends the class name).
The write runs in the result’s serializable transaction, like book(): a member taking the last seat at the same moment makes one of the two retry. The session row lock queues the claim behind a waitlist fill.
A trial without a class (startsAt) holds no seat and behaves as before.
Lead conversion
Every path that converts a lead or links it to a member — Sales convert and link (SalesWorkspaceService.closeAcquisitionAsConverted), the leads CRM convert (OrganizationLeadsService.convertLead) and link (linkLeadToMember), and a self-signup matched to the lead (convertLeadOnSelfSignup) — moves the seat in the conversion’s own transaction (the CRM paths through OrganizationLeadsService.closeTrialsOnConversion), so a lead is never left converted with its trial still holding a lead’s seat. TrialSeatsService.moveIntoBooking takes the lead’s pending, not superseded trial in a class that has not started and that no coach checked in:
- The appointment ends
outcome = 'converted'withoutcome_at(version bumped, not superseded). - The member gets a
confirmedbooking in that class withtrial_appointment_id= the appointment andbooked_by_user_id= the converting staff member (null on a self-signup). Their existing row for the session is revived (one row per session and member); a row that already holds a seat is kept, and the trial’s seat then frees and is offered to the waitlist after commit. - The seat moves, it is not freed: no capacity check and no waitlist promotion.
- Credit: the member’s live plans (
active/past_due/debt, not course) are judged as for a fresh booking (isCreditSource, thenpromotionVerdict: expired pack, horizon, overlap, caps, quotas). Exactly one that can pay is attached and its credit spent asbook()would (deductCreditAtomic, in a savepoint); with several, a single trial plan among them is chosen; otherwise, or when the credit runs out, the booking carries no plan. - A
sales_triallead activity recordsaction: 'converted_to_booking'with the appointment, session and booking.
The entitlement sweep never revokes a booking with trial_appointment_id set (BookingEntitlementSweepService.loadCandidates). Any other pending trial of the lead (a class that already started without a check-in, a free-standing tour) ends in the same transaction (endPendingTrials(…, 'cancelled'), see Release); a checked-in one ends attended. Freed seats are offered to the waitlist after commit. The lead sheet shows a converted trial as “Converted — booked as member”, and the rep’s outcome editor refuses it (no longer pending).
Release
Ending a lead’s pending trials (endPendingTrials, built on endTrials in trial-seats.ts) treats each pending, non-superseded appointment by its check-in:
- Checked in (
checked_in_atset): the trial happened. It endsoutcome = 'attended'(version bumped), is not superseded, stays on the roster and keeps its seat. - Not checked in: it ends with the path’s outcome and is superseded, which frees the seat. After the transaction commits, the caller offers the freed seats to the waitlist (
WaitlistPromotionService.fillSessions, triggertrial_released).
| Path | Outcome for a trial not checked in |
|---|---|
Rescheduled to another time or session (result trial_scheduled again) | rescheduled (the new session’s seat is excluded from the refill) |
Result not_relevant (lead goes lost) | cancelled |
| Any conversion or member linking (Sales, leads CRM, self-signup), for a trial not moved into a booking (class already started, or no class) | cancelled |
Archive / bulk archive (OrganizationLeadsService.archiveLead, bulk archive) | cancelled |
The rep’s own outcome (POST …/trial-outcomes) does not supersede: no_show and cancelled free the seat (refilled with trial_released), attended keeps it.
endPendingTrials returns only the sessions whose seats freed; callers refill only those. The refill follows the normal promotion rules (published, not within the 15-minute cutoff, registration open).
Class cancelled or deleted
ClassSessionsService.cancel, deleteSession and bulkDelete, a schedule template apply with overwrite (ProgramTemplatesService.applyToSchedule) and the Arbox schedule import cancelling a class gone from Arbox (ArboxScheduleImportService.cancelDisappeared) call endSessionTrials inside their transaction. It ends the session’s pending trials by the same rule (checked in → attended, kept; otherwise cancelled and superseded), writes a sales_trial lead activity per trial (action: 'session_cancelled', no actor, with the appointment, session and outcome), and cancels the open follow_up_trial task of each lead whose trial was cancelled, since there is no outcome left to confirm. No waitlist runs: the class is gone. The lead surfaces in the Sales queue without a next action, for the rep to rebook.
A lead’s trial seat is a person on the class: a session holding only trials is not empty. Bulk delete without includeBooked (resolveBulkFilter) and the template overwrite (loadExistingSessions, also used by copy-week) count trial seats with bookings (trialSeatCounts), so they skip such a session (overwrite reports it blocked) rather than deleting it; endSessionTrials in the overwrite covers a trial claimed between the conflict check and the write.
Session time change
On SESSION_TIME_CHANGED, TrialSessionSyncListener moves the session’s pending trial appointments to the new start, sets the lead’s trial_date to it, and moves the open follow_up_trial task to one hour after the new start (org timezone). Failures are logged (trial_session_sync_failed) and never block the session edit.
Check-in evidence vs outcome
A coach’s check-in proves the trialist walked in; it is never an outcome by itself. The rep confirms the outcome, except when the lead’s trial is ended by another path (reschedule, Not relevant, conversion, archive, class cancelled): then a checked-in trial ends attended rather than cancelled (see Release).
POST /sessions/:sessionId/trials/:appointmentId/check-in— staff. The appointment must belong to the session and the org (404), not be superseded, bependingand not already checked in (409trial_not_pending). Setschecked_in_at = now,checked_in_by_user_id, bumpsversion, writes asales_triallead activity (action: 'checked_in', actor = the coach). Returns the updatedRosterEntry. No time-window check, as with staff booking check-in.DELETE …/check-in— undo while still pending and checked in (409trial_not_pendingotherwise): clears both columns, bumpsversion, activitycheck_in_undone.- The outcome stays
pending; the seat stays held. - A booking that came from a converted trial checks in like any booking (
POST|DELETE /sessions/:id/check-in/:bookingId). - Sales lead detail returns per appointment
classSession { id, name, startsAt, status, coachName } | null,checkedInAt,checkedInBy { userId, firstName }. The lead sheet shows “Checked in HH:MM by”, and the outcome editor preselects attended when checkedInAtis set. - The auto no-show job works on
bookingsonly; it never touches trial appointments. See ADR-0025.
Staff notes
staff_notes rows on a member (membership_id), a lead (organization_lead_id), or both. Body 1..2000 chars (trimmed), important defaults to false. Soft-deleted.
| Route | Who reads/writes | Who edits/deletes |
|---|---|---|
GET|POST /members/:membershipId/notes, PATCH|DELETE …/:noteId | Staff (owner/admin/coach) | Author, or owner/admin |
GET|POST /sales-workspace/leads/:leadId/notes, PATCH|DELETE …/:noteId | Sales access to the lead (assertLead) | Author, or a Sales manager |
POST /sessions/:sessionId/trials/:appointmentId/notes | Staff; lands on the trialist’s lead | — |
- Carry-over: nothing is copied at conversion. A member’s notes include the notes on the lead that converted into it (
organization_leads.converted_membership_id), and those can be edited through the member route. canEditon each note tells the client whether to offer edit/delete.PATCHneedsbodyorimportant.- Important notes appear on the roster, on the trials today card (as a marker) and in the coach brief (the first one, previewed).
Coach brief push
TrialBriefService.sendDueBriefs, a scheduled job every 5 minutes.
- Window: sessions
published, not deleted, with a coach whose membership is active, starting in(now, now + 60 min], andtrial_brief_sent_for IS DISTINCT FROM starts_at, with at least one live trial. - Attempted once per start time: a conditional update claims the marker (
trial_brief_sent_for = starts_at) before the push is enqueued, so overlapping ticks cannot both send. The push service enqueues the delivery job; an Expo delivery failure after that is not retried. - Re-arm: a moved class has a marker that no longer equals its start, so it is briefed again at the new time. Only an enqueue that throws (
notifyUserfailing) clears the marker, so the next tick retries before the class. A session with no trial keeps no marker, so a trial added later is still announced. - Push: to the coach’s user, category
trials,orgId, route/(tabs)/schedule/[id], data{ id, organizationId }. Title “N trials at HH:MM ·” (pluralized), body one line per trial with their name and their first important note — never why they are a trial. Localized per recipient ( pushCopy.trialBrief, dictionarytrialsPush). The due query joins the coach’s membership in the session’s own org (memberships.organization_id = programs.organization_id). - Prefs: the push service drops recipients who turned
trialspush off.
Trials today
GET /sessions/trials-today — staff. The org-timezone day’s published, non-deleted sessions with at least one live trial, by start. A coach sees only the sessions they coach; owner/admin see all, isMine marking their own. Each trial has kind, id, displayName, imageUrl and hasImportantNotes (no reason).
GET /sessions/upcoming-today rows carry trialCount (live trials).
Clients
Web
- Sales result editor: session picker fed by
trial-sessions(full sessions disabled) plus “Other (no class)”; default trial type = class name; handlestrial_session_full/trial_session_unavailable. - Lead sheet: trial rows with class, time, coach and the check-in line; “Converted — booked as member” for a converted trial;
StaffNotessection. - Plan editor: Trial workout kind (see Trial workout in the plan editor); the plans list and plan detail header read “Trial workout” for trial plans.
- Shop, add-child plan step, join page:
trial_already_usedmapped totrials.alreadyUsed. - Member detail (overview tab):
StaffNotes. - Session detail sheet: roster with
SessionTrials(“Trials · N”) on top, a Trial badge (no reason shown), “Mark as trial” on booking rows wherecanMarkTrialand “Unmark” wherecanUnmarkTrial; lead trial check-in / undo via the trial routes, bookings unchanged. - Today panel: “N trials” chip per session from
trialCount.
Mobile
- Staff = owner/admin/coach of the active org, and not acting for a child (
useIsStaff). - Home: “Trials today” card, hidden when empty or not staff; tap opens the session.
- Session screen: staff-only Roster with a Trial badge (no reason shown), “Trials · N” first and “Everyone else” after, Mark as trial on booking rows when
canMarkTrialand Unmark whencanUnmarkTrial, check-in / undo (bookings → booking check-in route, lead trials → trial route) and add note (members → member notes, lead trials → session trial notes). - Roster and trial data are kept in memory only, never in the persisted query cache. Writes are online-only, never queued.
- Tapping a push whose
data.organizationIdis another org the user is an active member of (the brief carries it) switches the active org before navigating. The notifications settings screen hidestrialsfrom non-staff.
Trial definition for reports (apps/api/src/trials/trial-records.ts)
One definition, read by the Trials report, person trial history, the Action Center row and TRIAL_CONVERTED:
- A trial = a lead trial appointment not superseded and not
converted(class trials and tours), or a non-deleted booking that is a trial —trial_appointment_id(sourcelead),trial_marked_by_user_id(staff) or a trial plan (plan), in that precedence. Trial date = class start (the appointment’s own time for a tour). - Status (
TRIAL_STATUSES): lead trial — the rep’s outcome; pending + checked in =attended; pending in a cancelled class =cancelled; elseupcomingbefore the start,awaiting_outcomeafter. Booking —attended/no_show/cancelledas stored; a confirmed booking past its start =awaiting_outcome; waitlisted past its start =cancelled. - checkedIn = attended with a staff check-in (booking
checkin_method = 'manual', or the roster check-in). - Converted (attended trials only) = the person’s first activated (
current_period_startset) non-trial subscription whose start (starts_on, elsecreated_at) lies between booking the trial andTRIAL_CONVERSION_WINDOW_DAYS(30) after it; a plan bought before the class counts at 0 days. A lead’s person is the membership it converted into (converted_membership_id, ormemberships.source_lead_id). - Rep: lead → the lead’s current owner; staff → who marked it; plan → none. Coach: the class’s coach membership (or Arbox external coach name). Both are reported by their membership id in the org; a rep who no longer has a membership there keeps their row by name with
id: null.
Trials report
GET /analytics/trials?from&to — analytics:view (owner/admin; coaches 403). Dates are class dates in the org timezone, to inclusive. Returns TrialsReportResponse: totals, bySource (lead, staff, plan), byClassType (classTypeId null = tour), bySlot (weekday 0 = Sunday, hour, org timezone), byCoach, byRep, and attendedNotConverted (newest first, max 200, with leadId for Sales links and E.164 phone).
Person trial history
GET /members/:membershipId/trial-history (staff) and GET /sales-workspace/leads/:leadId/trial-history (Sales access to the lead). PersonTrial[], newest first, no source. A member and the lead it converted from are one person: both routes list the trials of both.
Action Center
trials.attended_not_converted (owner/admin): people who attended a trial in the last 14 days and have not converted; one row per person, warning from 5. drilldownHref /dashboard/analytics?tab=trials; meta { days: 14, sample: [{ name, membershipId, leadId, href }] }.
Analytics events (TrialEvents)
Captured after commit through trackTrial (trial-telemetry.ts): keyed on the acting user (org key org:<id> for jobs and imports), organization group, never throwing; ids, enums and counts only.
| Event | Where |
|---|---|
trial_scheduled | SalesWorkspaceService.saveResult (trial_scheduled) |
trial_released | reschedule (rescheduled), Not relevant (lost), rep outcome cancelled (cancelled), archive / bulk archive (archived), every conversion path — Sales convert/link, CRM convert/link, self-signup (converted), class cancel / delete / bulk delete / template overwrite / Arbox cancel (session_cancelled). A checked-in trial that ends attended is not released. |
trial_outcome_confirmed | SalesWorkspaceService.trialOutcome |
trial_checked_in / trial_check_in_undone | roster lead check-in/undo; booking check-in/undo (ClassSessionsService.checkIn/undoCheckIn → SessionRosterService.trackBookingCheckIn) when the booking is a trial |
booking_trial_marked | markBookingTrial, only when the mark changed |
member_trial_booked | MemberTrialBookingsService.book (planType trial for a trial plan) |
staff_note_added | member and lead note create, roster trial note |
trial_brief_sent | per briefed session (withImportantNotes = how many trialists carry one) |
trial_plan_purchased | PlansService.purchase, a non-staff actor’s new checkout of a trial plan (free = active now; paid = checkout opened) |
trial_purchase_refused | assertTrialNotUsed refusal in PlansService.purchase |
trial_converted | TrialConversionListener on subscription.started, when the subscription is an attended trial’s conversion. Sent with a uuid derived from the subscription id, so a redelivered start counts once |
Observability
trial_brief_run(info, every run):due,briefed,skipped,failed,durationMs.trial_refused(info):reasontrial_session_full(claim),trial_already_used(purchase),trial_plan_in_use(plan update),trial_not_markable(mark), with ids.trial_seat_move_failed(moveIntoBooking) andtrial_session_end_failed(endSessionTrials): error log + Sentry (checktag) and rethrown; serialization failures/deadlocks are skipped (the caller retries).- Existing:
trial_brief_failed,trial_session_sync_failed,trial_held_seats_unavailable.
The Sales report’s trial_attended cohort cell counts an appointment with outcome attended, a pending one checked in, or a converted one whose member booking was attended.