Client is a trainee, a Product is a plan the studio sells, and a ClientSubscription is one purchase of a plan by a trainee. None of this touches a payment provider. It is the coach’s bookkeeping, and it also drives app access.
Client
Table clients. The largest model in the schema. Fields are grouped below.
Identity and contact
Body and targets
Plan mirror
These columns mirror the trainee’s current subscription.ClientSubscription is the source of truth. The service in apps/core-api/src/modules/client-subscriptions/client-subscriptions.service.ts recomputes the mirror whenever subscription rows change.
endsOn being null means “this studio does not track subscriptions for this trainee”, and the app stays open. That is why a client whose only subscription was cancelled before it started still gets a dated endsOn: leaving it null would hand app access back.
Check-in schedule
When the per-trainee form columns are null, the form comes from the trainee’s
Product (Product.onboardingFormId, Product.updateFormId).
App activity and preferences
Bookkeeping
Indexes and constraints
- Unique
(studioId, externalUserId). Import identity is per studio, so the same AutoFit client can be imported into two studios. - B-tree:
studioId,coachId,productId,onboardingFormId,updateFormId,(studioId, status),deletedAt,(studioId, createdAt),(studioId, deletedAt). - Trigram GIN:
name,phone,email. These back the server-side trainee search.
ClientStatus
Gender
MALE, FEMALE, OTHER. The gender form field is separate: its answers are lowercase male or female, and that field type is not in CLIENT_SAVE_TYPES, so a form answer does not write this column.
Trainee account deletion
deleteTraineeAccount in apps/core-api/src/modules/trainee/trainee.repository.ts is a soft delete with a scrub. In one transaction it deletes the trainee’s photos, technique videos, health samples, meal logs, nutrition day logs, favourite meals, shopping list, form responses, form assignments, inbox items, push tokens and WhatsApp messages, detaches calendar events, then sets deletedAt, status: 'ARCHIVED', an anonymized name, and nulls every personal field. Push tokens are deleted explicitly because a soft delete never fires the cascade.
Product
Table products. A plan the studio sells to trainees.
Indexes:
studioId, onboardingFormId, updateFormId.
Relations: clients (the mirror column), subscriptions, and the two form relations named ProductOnboardingForm and ProductUpdateForm.
ClientSubscription
Table client_subscriptions. One row per plan a trainee holds, held or will hold. A trainee can have several, queued back to back.
Indexes:
clientId, (studioId, status), (clientId, status), (clientId, startedOn), (studioId, status, startedOn).
SubscriptionStatus
LIVE_SUBSCRIPTION_STATUSES in client-subscriptions/coverage.ts is SCHEDULED, ACTIVE, FROZEN. Live rows are the ones that block overlapping dates.
SubscriptionStartTrigger
Chosen by the studio setting subscriptionStartMode.
Rules the service enforces
These come fromclient-subscriptions.service.ts and are worth knowing before writing rows by hand:
- Dates are civil days stored as UTC midnight. Comparisons use the day key, never the millisecond.
- No row may end before it starts. Equal dates are allowed, because
endsOn === startedOnis the legacy shape for “no end date recorded”. - Live rows of one trainee may not overlap. Overlap is a strict comparison on whole days, so a new plan may start on the day the previous one ends. That is the back-to-back renewal.
- When a plan is assigned without a start date, it starts when the trainee’s last live plan runs out, not today.
- An hourly reconciler moves dated rows along: an
ACTIVErow past its end becomesEXPIRED, and the first dueSCHEDULEDrow becomesACTIVE. Waiting rows are never touched by it. - A cancellation can only shorten coverage. It is capped at the row’s own
endsOn, and a row cancelled before it started covers nothing. - Some imported rows have
startedOnafterendsOn. The overlap check squares the endpoints up so a broken row is flagged, never silently ignored.
DurationUnit
DAYS or MONTHS.