Session
Purpose
A concrete date+time occurrence of an Activity — the thing a customer actually books. A session can be created automatically by materialising a Time slot recurrence rule, or directly by an admin via the sessions endpoint. Every Booking points at exactly one session.
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). activityId(uuid, FK →activities.activities.id, on-delete cascade).locationId(nullable uuid, FK →activities.locations.id).timeSlotId(nullable uuid, FK →activities.time_slots.id, on-delete set null) — link back to the recurrence rule that generated this session.startsAt,endsAt(timestamps, NOT NULL).price(nullable decimal 10,2) — override over activity price.capacityOverride(nullable integer).status(enumsession_status:AVAILABLE,BOOKED,CANCELLED, defaultAVAILABLE).
capacityOverride is the gate on auto-status: when set, status auto-syncs between AVAILABLE and BOOKED based on active-booking count. When NULL (unlimited), the session never moves to BOOKED even if it has many bookings — BOOKED is a “no more slots” flag, not “has at least one booking”.
Invariants
activityIdON DELETE CASCADE (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).timeSlotIdON DELETE SET NULL — deleting the recurrence rule does not erase scheduled occurrences (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).- UNIQUE on
(timeSlotId, startsAt)— prevents duplicate sessions for the same recurrence rule at the same time (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts). statusdefaults toAVAILABLE;startsAt,endsAtNOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).
Business invariants:
startsAt < endsAt— application-level only, no DB CHECK.capacityOverride IS NULL→ status NEVER auto-transitions toBOOKED. The session is effectively unlimited.capacityOverride IS NOT NULL→ status is auto-synced bySessionsService.syncStatus:BOOKEDwhen active-booking count (excludingCANCELLED/REFUNDED) reaches capacity,AVAILABLEotherwise.- For Activity with
type=SERVICE, sessions getcapacityOverride=1forced (sessions.service.ts).
Lifecycle
Status enum values: AVAILABLE, BOOKED, CANCELLED.
created → AVAILABLE (default — both from time-slot materialisation and admin create)AVAILABLE ↔ BOOKED (auto, via SessionsService.syncStatus — only if capacityOverride IS NOT NULL)* → CANCELLED (admin via PATCH /api/business/sessions/{id} with status: 'CANCELLED')syncStatus is invoked from every booking mutation path:
bookings.service.ts:83, 93, 267(admin booking flows)bookings-client.service.ts:112, 151, 259(client booking flows)
Materialisation sources (verified against code)
Sessions are created from three sources:
| Source | When | Notes |
|---|---|---|
TimeSlotsService.generateSessions | On time-slot create / update (sync) | Generates sessions for N days ahead |
BullMQ scheduler — generateSessionsForSlot | Per-slot job that returns nextRunInMs for re-queueing | Steady-state materialisation |
ActivitiesScheduler.handleSessionGenerationSafetyNet (cron EVERY_DAY_AT_MIDNIGHT) | Daily backstop | Backfills any active slots that were missed |
Admin POST /api/business/sessions | Ad-hoc one-off sessions | Inserted with timeSlotId=NULL |
Files: libs/features/activities/src/lib/services/time-slots.service.ts, libs/features/activities/src/lib/services/activities-scheduler.service.ts.
Relationships
- Activity (ENT-005) —
activityId→activities.activities.id, on-delete cascade. N:1. - Location (ENT-011) —
locationId→activities.locations.id. N:1, optional. - Time slot (ENT-013) —
timeSlotId→activities.time_slots.id, on-delete set null. N:1, optional. - Company member (coach) (ENT-019) — N:M via
session_coaches(pure join, PK = (sessionId, memberId), on-delete cascade both sides). - Booking (ENT-003) — referenced by
bookings.bookings.session_id. 1:N.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — /activities/{activityId}/sessions, /companies/{companyId}/activities/{activityId}/sessions (SessionAvailableClientResponseDto, BookingSessionDto, UpcomingSessionDto, SessionCoachClientDto) | Swagger UI |
| business | yes — /sessions, /sessions/{id} (SessionResponseDto, CreateSessionDto, UpdateSessionDto, SessionStatus, SessionAttendeePreviewDto, SessionBookingDto) | Swagger UI |
| super-admin | no | — |
Per-surface fields diverge: client gets aggregated availability + coach previews; business gets the editable shape with attendee and booking lists.
Known gotchas / open questions
- Sessions can outlive their generating
time_slot(on-delete set null). Reports filtered by slot must handle null. - The UNIQUE on
(timeSlotId, startsAt)allows multiple ad-hoc sessions at the samestartsAtonly iftimeSlotIdis NULL — i.e. duplicate detection is per-rule. BOOKEDis NOT “has bookings”. It means “active bookings ≥ capacity”. Sessions withcapacityOverride IS NULL(unlimited) never reachBOOKEDregardless of booking count. This often confuses product folks reading the enum name.syncStatusis not transactionally isolated with booking create/cancel — there is noSELECT FOR UPDATEon the session row. Under concurrent booking writes the count read may race the status write, leaving staleAVAILABLEon a full session momentarily.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- DB CHECK
starts_at < ends_at— promote this app-level invariant to the database. - Lock the session row in
syncStatus(SELECT ... FOR UPDATEor PostgreSQL advisory lock) to remove the race window between count-read and status-update under concurrent bookings. - Rename
SessionStatusEnumvalues to be unambiguous — e.g.OPEN | FULL | CANCELLEDinstead ofAVAILABLE | BOOKED | CANCELLED. “BOOKED” wrongly implies “this session has bookings”. - Document BullMQ jobs (queue names, payload shapes, retry/dedup config) for session generation in Catalog context.
- Add
cancelledAt/cancelledReasoncolumns to capture why and when a session was cancelled — useful for refund flows and admin reporting.