Activity
Purpose
The aggregate root of the bookable catalogue — a published offering of a Company. Session and Time slot hang off it; Contributor rows credit the people involved; Pass entitlement template references it to declare what a pass covers. The type (SLOT_BASED / SERVICE) drives downstream UX and capacity defaults; the orthogonal hasSeatSelection boolean flags activities whose bookings populate seat rows. Pre-869dpxbj6 the type axis carried SHOW and MOVIE as separate values — they were collapsed into SLOT_BASED with hasSeatSelection=false (SHOW) and hasSeatSelection=true (MOVIE).
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). slug(text, UNIQUE, NOT NULL) — public URL slug, e.g./activity/dune-part-2.companyId(uuid, NOT NULL — no DB-level FK declared, schema-validated only).type(enumactivity_type:SLOT_BASED,SERVICE).DININGwas removed by migration0048_clear_tattoo.sql— see ADR sphere-targetapp-enum-cleanup-drop-dining-add-services.SHOWandMOVIEwere collapsed intoSLOT_BASED(with the seating axis moving tohasSeatSelection) by migration0049_worried_rattler.sql— see ADR activity-model-simplification.hasSeatSelection(boolean, NOT NULL, defaultfalse) — orthogonal flag introduced by869dpxbj6. Valid only whentype = 'SLOT_BASED'; enforced by DB CHECK constraintactivities_has_seat_selection_check CHECK (type != 'SERVICE' OR has_seat_selection = false). Drives whetherbooking_seatsrows are populated at booking time (the join table is not yet implemented — the flag is forward-compatible).status(enumactivity_status:DRAFT,PUBLISHED,ARCHIVED,CANCELLED).sphereId(uuid, NOT NULL, FK →activities.spheres.id) — denormalized from primary category for fast filtering.metadata(jsonb, default{}),variants(jsonb, nullable) — for tier/membership configurations.price,currency,allowedPaymentMethods(text[], default['ON_SITE']) — pricing & payment config.cancellationWindowHours(default 2, null = cancellation forbidden),bookingWindowHours,reminderHoursBefore(default 24).refundable(boolean, NOT NULL, defaulttrue) — organizer-configurable policy flag. Whenfalse, client UI hides the cancel CTA on customer bookings; canonical signal exposed to the client surface viaBookingActivityDto.refundable. The cancel endpoint itself does not yet enforce this flag — see Known gotchas. See ADR activity-refundable-and-cancellation-window.tags(text[], default[]).
status and type together drive admin-side and client-side behaviour. allowedPaymentMethods is the gate on what payment options customers see at checkout. sphereId is denormalised — it MUST equal the sphere of the activity’s primary Category and is sync-enforced by the service.
Invariants
slugis UNIQUE (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).companyIdNOT NULL — no DB-level FK (cross-schema reference:activities.activities→companies.companies). Application-level integrity only. See ADR cross-schema-references-without-fk.sphereIdNOT NULL and FK →activities.spheres.id(no on-delete cascade specified; defaults to NO ACTION) (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).sphereIdmust equalprimaryCategory.sphereId— enforced byActivitiesServiceon create: it loads the primary category and compares (csid !== dto.sphereIdraises a conflict) (enforced in tktspace-backend/libs/features/activities/src/lib/services/activities.service.ts:42-56).
Business invariants:
- Every activity has exactly one sphere (NOT NULL FK) and at least one category (via
activity_categoriesjoin). The sphere must match the primary category’s sphere. allowedPaymentMethodsdefaults to['ON_SITE']— addingCARD/WALLET/BONUS/PASSis an explicit admin decision per activity.- For
type=SERVICE, sessions are forced tocapacityOverride=1— enforced inSessionsService(sessions.service.ts). cancellationWindowHours = NULLmeans cancellation is forbidden (default is 2 hours).refundabledefaults totrueon insert — applied at the service layer (activities.service.ts#create:refundable: dto.refundable ?? true) so that omitting the field on create yields a refundable activity.updatepasses the value through unchanged (enforced in tktspace-backend/libs/features/activities/src/lib/services/activities.service.ts).refundablecolumn isNOT NULL DEFAULT true(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration0047_rapid_molly_hayes.sql).hasSeatSelectioncolumn isNOT NULL DEFAULT false(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration0049_worried_rattler.sql).- DB CHECK constraint
activities_has_seat_selection_check—type != 'SERVICE' OR has_seat_selection = false. Postgres rejects with error23514if aSERVICEactivity is written withhas_seat_selection=true; the backend exception filter maps23514to HTTP 400 (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration0049_worried_rattler.sql). - DTO-level invariant
hasSeatSelectionrequirestype = 'SLOT_BASED'— class-validator decorator onCreateActivityDto/UpdateActivityDtoand service-layer re-validation on PATCH (loaded row merged with partial body); see ADR activity-model-simplification §D4. statusdefaults toDRAFT;ageRatingdefaults to0;pricedefaults to'0';currencydefaults to'UAH'(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).- Index on
sphere_idfor fast sphere-filtered listing (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts). - GIN trigram index
activities_title_trgm_idxontitleusinggin_trgm_ops— enables two-tierpg_trgmsearch via theqparam (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration0043_pg_trgm_search_indexes.sql).
business invariants: TBD by human
Lifecycle
Status enum values: DRAFT, PUBLISHED, ARCHIVED, CANCELLED.
created → DRAFT (default on create)DRAFT|PUBLISHED|CANCELLED → ARCHIVED (only via DELETE handler — soft-archive, activities.service.ts:308)DRAFT ↔ PUBLISHED, * → CANCELLED (admin via generic PATCH /api/business/activities/{id})Only → ARCHIVED has a dedicated code path (the soft-delete handler). All other transitions ride the generic update PATCH — there are no /publish or /cancel endpoints today, so the state machine is implicit and unenforced.
Relationships
- Company (ENT-016) —
companyId(NOT NULL, no FK at DB level). N:1. - Sphere (ENT-037) —
sphereId→activities.spheres.id. N:1, denormalized from primary category. - Category (ENT-009) — N:M via the join table
activity_categories(pure join; not documented separately). - Session (ENT-012) — 1:N, scheduled occurrences.
- Time slot (ENT-013) — 1:N, recurrence rules.
- Contributor (ENT-010) — 1:N, people credited on the activity.
- Activity extra (ENT-006) — 1:N.
- Activity link (ENT-007) — 1:N.
- Activity partner (ENT-008) — 1:N.
- User activity favorite (ENT-015) — N:M via favorites table.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — /activities, /activities/{id}, /activities/{activityId}/sessions, /companies/{companyId}/activities, etc. (ActivityBriefDto, ActivityDetailClientResponseDto, ActivityPreviewClientResponseDto). Search extensions (Phase C): GET /activities and GET /companies/{companyId}/activities now accept q (string, min 2 / max 64 — two-tier pg_trgm search: Tier A matches activities.title, Tier B matches contributor users.full_name via EXISTS join) and contributorUserId (UUID — restricts to activities where that user is a contributor via companyMember.userId). When q is present, ActivityPreviewClientResponseDto gains matchedByContributor?: boolean (true iff Tier B fired). search param remains functional but is marked deprecated — q supersedes it. Precedence: contributorUserId overrides q when both are passed. See ADR contributor-aware-search-and-session-filter. Sessions filter: GET /activities/{activityId}/sessions and the company-scoped sibling accept contributorUserId (UUID) — filters sessions by coach via session_coaches → companyMember → users.id; falls back to activity-level contributor check when the activity has zero session_coaches rows. Refundable (my-bookings): BookingActivityDto.refundable: boolean (required) is now included on GET /companies/{companyId}/my-bookings and GET /companies/{companyId}/activities/{activityId}/my-bookings so the gym_app can gate the cancel CTA. See ADR activity-refundable-and-cancellation-window. hasSeatSelection (Phase C, 869dpxbj6): ActivityPreviewClientResponseDto.hasSeatSelection: boolean (required); ActivityDetailClientResponseDto inherits the field via OmitType(ActivityPreviewClientResponseDto, ['matchedByContributor']). FavoriteActivityDto.type is now a named-enum ActivityType ref (was bare-string); enum domain is [SLOT_BASED, SERVICE]. See ADR activity-model-simplification. BookingActivityDto.sphereCode (Phase C, 869dr5gj6): the nested activity slice on every CustomerBookingClientResponseDto (returned by GET /api/client/me/bookings, GET /companies/{companyId}/my-bookings) now carries sphereCode: string (required) — projected via a spheres join on activities.sphereId. Drives client-side sphere icon lookup; not an enum on the DTO (sphere codes are seeded rows, not a TS enum). See Booking and ADR cross-company-me-bookings. | Swagger UI |
| business | yes — GET /activities (ActivityAdminListItemDto, CreateActivityAdminDto, /activities/{activityId}/contributors, etc.). Trainer filter (Phase D): GET /api/business/activities gained contributorUserId (UUID, optional) — restricts to activities where that user is a contributor via companyMember.userId, scoped to the active company. Driven by the business trainer autocomplete combobox calling GET /api/business/contributors. See ADR activities-trainer-autocomplete. Refundable trio: ActivityResponseDto.refundable: boolean (required); CreateActivityDto.refundable?: boolean (optional, service defaults to true); UpdateActivityDto.refundable?: boolean (optional, omit leaves current value). Activity form renders the cancellationWindowHours number input alongside the new refundable toggle (the number input is gated behind refundable === true). See ADR activity-refundable-and-cancellation-window. hasSeatSelection trio (Phase C, 869dpxbj6): ActivityResponseDto.hasSeatSelection: boolean (required); CreateActivityDto.hasSeatSelection?: boolean (optional, defaults to false); UpdateActivityDto.hasSeatSelection?: boolean. ActivityType enum narrows to [SLOT_BASED, SERVICE]. Activity form replaces the 4-option type dropdown with a 2-option dropdown plus a “Has seat selection” toggle (hidden when type === 'SERVICE'). See ADR activity-model-simplification. | Swagger UI |
| super-admin | no | — |
Per-surface shape differs: client surface returns curated public fields, business surface returns admin-list / create / update DTOs.
Known gotchas / open questions
companyIdhas NO database FK — intentional cross-schema design. See ADR cross-schema-references-without-fk. Same applies tolocations.company_idand several others.sphereIdis denormalized fromprimaryCategory.sphereId. Sync is enforced inactivities.service.ts:42-56on create; on category re-link the service must re-validate (verify on update path).- The NestJS module path is
libs/features/activities/but at the routing level the business mount is/api/business/*(the historical naming is inapps/api/src/app/modules/admin-api/— see_workflow/CLAUDE.md). - Application-level invariant (currently NOT enforced): when linking an activity to a category,
category.companyIdmust be eitherNULL(platform-wide category) or equal toactivity.companyId. Linking to another company’s category is a multi-tenancy violation. OPEN: add this assertion to the activity-category service before insert; today nothing prevents the bad link. - Status transitions are implicit. All status changes except
→ ARCHIVEDride the generic update PATCH — there is no/publishor/cancelendpoint, no transition log, and no DB CHECK on allowed transitions. - Bookings live in their own context but their NestJS feature module is co-located in
libs/features/activities/— see Bookings. refundableis currently a client-UI gate only. The cancel endpoint (bookingsClientDelete/bookingsClientCancel) does not yet read the flag — a customer that reaches the endpoint by other means still cancels. The enforcement guard is deferred to a follow-up ticket. See ADR activity-refundable-and-cancellation-window “Open follow-ups”.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Dedicated state-transition endpoints —
POST /activities/{id}/publish,POST /activities/{id}/cancel,POST /activities/{id}/archive. Replaces the generic PATCH and gives each transition explicit validation + audit logging. - DB CHECK constraint on the legal status transitions (or a typed state machine on the service layer that validates source status before write).
- Assert
activity.companyId == category.companyId(orcategory.companyId IS NULL) in the activity-category linking service. Today nothing prevents a multi-tenancy violation. - Re-validate
sphereId == primaryCategory.sphereIdon category re-link — the sync is enforced on create, but the update path may not cover swapping the primary category. - First-class lifecycle audit log for activity publication / archival — currently no record of when an activity was published or by whom.