Skip to content

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, default gen_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 (enum activity_type: SLOT_BASED, SERVICE). DINING was removed by migration 0048_clear_tattoo.sql — see ADR sphere-targetapp-enum-cleanup-drop-dining-add-services. SHOW and MOVIE were collapsed into SLOT_BASED (with the seating axis moving to hasSeatSelection) by migration 0049_worried_rattler.sql — see ADR activity-model-simplification.
  • hasSeatSelection (boolean, NOT NULL, default false) — orthogonal flag introduced by 869dpxbj6. Valid only when type = 'SLOT_BASED'; enforced by DB CHECK constraint activities_has_seat_selection_check CHECK (type != 'SERVICE' OR has_seat_selection = false). Drives whether booking_seats rows are populated at booking time (the join table is not yet implemented — the flag is forward-compatible).
  • status (enum activity_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, default true) — organizer-configurable policy flag. When false, client UI hides the cancel CTA on customer bookings; canonical signal exposed to the client surface via BookingActivityDto.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

  • slug is UNIQUE (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).
  • companyId NOT NULL — no DB-level FK (cross-schema reference: activities.activitiescompanies.companies). Application-level integrity only. See ADR cross-schema-references-without-fk.
  • sphereId NOT 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).
  • sphereId must equal primaryCategory.sphereId — enforced by ActivitiesService on create: it loads the primary category and compares (csid !== dto.sphereId raises 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_categories join). The sphere must match the primary category’s sphere.
  • allowedPaymentMethods defaults to ['ON_SITE'] — adding CARD/WALLET/BONUS/PASS is an explicit admin decision per activity.
  • For type=SERVICE, sessions are forced to capacityOverride=1 — enforced in SessionsService (sessions.service.ts).
  • cancellationWindowHours = NULL means cancellation is forbidden (default is 2 hours).
  • refundable defaults to true on 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. update passes the value through unchanged (enforced in tktspace-backend/libs/features/activities/src/lib/services/activities.service.ts).
  • refundable column is NOT NULL DEFAULT true (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration 0047_rapid_molly_hayes.sql).
  • hasSeatSelection column is NOT NULL DEFAULT false (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration 0049_worried_rattler.sql).
  • DB CHECK constraint activities_has_seat_selection_checktype != 'SERVICE' OR has_seat_selection = false. Postgres rejects with error 23514 if a SERVICE activity is written with has_seat_selection=true; the backend exception filter maps 23514 to HTTP 400 (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration 0049_worried_rattler.sql).
  • DTO-level invariant hasSeatSelection requires type = 'SLOT_BASED' — class-validator decorator on CreateActivityDto / UpdateActivityDto and service-layer re-validation on PATCH (loaded row merged with partial body); see ADR activity-model-simplification §D4.
  • status defaults to DRAFT; ageRating defaults to 0; price defaults to '0'; currency defaults to 'UAH' (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts).
  • Index on sphere_id for 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_idx on title using gin_trgm_ops — enables two-tier pg_trgm search via the q param (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts, migration 0043_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) — sphereIdactivities.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

SurfaceExposedNotes
clientyes — /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
businessyes — 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-adminno

Per-surface shape differs: client surface returns curated public fields, business surface returns admin-list / create / update DTOs.

Known gotchas / open questions

  • companyId has NO database FK — intentional cross-schema design. See ADR cross-schema-references-without-fk. Same applies to locations.company_id and several others.
  • sphereId is denormalized from primaryCategory.sphereId. Sync is enforced in activities.service.ts:42-56 on 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 in apps/api/src/app/modules/admin-api/ — see _workflow/CLAUDE.md).
  • Application-level invariant (currently NOT enforced): when linking an activity to a category, category.companyId must be either NULL (platform-wide category) or equal to activity.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 → ARCHIVED ride the generic update PATCH — there is no /publish or /cancel endpoint, 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.
  • refundable is 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 endpointsPOST /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 (or category.companyId IS NULL) in the activity-category linking service. Today nothing prevents a multi-tenancy violation.
  • Re-validate sphereId == primaryCategory.sphereId on 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.