Workouts — data model
All tables defined in libs/db/src/lib/schema/workouts.ts. Comments at libs/db/src/lib/schema/comments.ts. Attachments at libs/db/src/lib/schema/attachments.ts.
workouts
| Column | Type | Meaning |
|---|---|---|
id | uuid PK | |
organization_id | uuid FK → organizations.id, not null | Org scope. |
program_id | uuid FK → programs.id, nullable | Optional binding to a program (track). |
author_id | uuid FK → users.id, not null | Creator. |
title | varchar(255) nullable | |
description | text nullable | Freeform body or freeform-mode workout text. |
scoring | enum workout_scoring | time / reps / rounds_reps / weight / distance / calories / points / none |
mode | enum workout_mode | structured or freeform. Default structured. |
time_cap | integer nullable | Minutes. |
is_snapshot | boolean not null default false | True iff this is a per-assignment frozen copy (FIT-152). |
forked_from_id | uuid self-FK nullable | Snapshot rows point at their library source. CHECK requires this set when is_snapshot=true. |
embedding | vector(1024) nullable | Voyage 1024-dim semantic vector. Populated by enrichment queue. |
embedding_hash | varchar(64) nullable | Gate re-embedding when unrelated fields change. |
created_at / updated_at / deleted_at | timestamps | Soft delete. |
Indexes:
workouts_library_idxon(organization_id, program_id)WHEREis_snapshot=false AND deleted_at IS NULL— library list hot path.workouts_embedding_idxHNSW onembedding(vector_cosine_ops).workouts_forked_from_id_idxonforked_from_idWHERE not null — reverse fork lookup.
CHECK constraints:
workouts_snapshot_immutable_chk—is_snapshot=false OR deleted_at IS NULL. Snapshots never soft-deleted.workouts_snapshot_provenance_chk—is_snapshot=false OR forked_from_id IS NOT NULL. Snapshots carry a back-pointer.
workout_sections
| Column | Type | Meaning |
|---|---|---|
id | uuid PK | |
workout_id | uuid FK → workouts.id ON DELETE CASCADE, not null | |
type | varchar(100) default 'main' | Section type (warmup / strength / conditioning / skill / main / cooldown / accessory). |
title, description | nullable | Coach-supplied labels. |
sort_order | integer | |
shape | varchar(50) nullable | Container shape (linear / amrap / emom / for_time / tabata / rep_scheme / rounds / intervals). Null = legacy/linear default. |
config | jsonb nullable | Shape-specific config (e.g. AMRAP cap, EMOM round count). |
timestamps + deleted_at | Soft-deleted alongside parent via service logic; CASCADE on hard delete of workout. |
Index: workout_sections_workout_id_idx on (workout_id, sort_order) WHERE deleted_at IS NULL.
workout_movements
| Column | Type | Meaning |
|---|---|---|
id | uuid PK | |
section_id | uuid FK → workout_sections.id ON DELETE CASCADE | |
exercise_id | uuid FK → exercises.id | Canonical or org-custom exercise. |
sort_order | integer | |
prescription | jsonb nullable | Canonical PrescriptionSchema shape (sets/reps/load/rest/tempo/notes/label/superset_group). See libs/shared/src/lib/schemas/prescription.ts. |
notes | text nullable | Coach notes shown beside prescription. |
label | varchar(10) nullable | Letter label A/B/C. |
superset_group | varchar(10) nullable | Superset id (B1, B2). |
timestamps + deleted_at |
Indexes:
workout_movements_section_id_idxon(section_id, sort_order)WHEREdeleted_at IS NULL.workout_movements_exercise_id_idxonexercise_idWHEREdeleted_at IS NULL— “every workout that uses Back Squat”.
exercise_comments
libs/db/src/lib/schema/comments.ts. One row per comment on a movement.
| Column | Type | Meaning |
|---|---|---|
id | uuid PK | |
workout_movement_id | uuid FK → workout_movements.id | Comment anchors to a single movement on a single workout (library or snapshot — comments are duplicated on fork). |
author_id | uuid FK → users.id | |
body | text | |
parent_comment_id | uuid self-FK nullable | Reply threading (single level deep in UI). |
timestamps + deleted_at |
Indexes: exercise_comments_movement_id_idx, exercise_comments_author_id_idx.
attachments (filtered to comments)
Polymorphic. Rows with owner_type='exercise_comment' belong to a comment; owner_id = comment id. Carries url (R2 key), mime, kind (image / video), thumb_url (R2 key for the 400x400 JPEG), uploaded_by, timestamps + deleted_at. Attachments are served via presigned R2 URLs (1h TTL, cached).
Row lifecycle
| Event | DB effect |
|---|---|
POST /workouts | Insert workouts (+ workout_sections, workout_movements in one txn if sections[] supplied). Enrichment job enqueued. |
PATCH /workouts/:id (no assignmentId) | Update library row in place. Enrichment re-enqueued. |
PATCH /workouts/:id?assignmentId=X (first edit) | forkSnapshotIfNeeded deep-copies → new workouts (is_snapshot=true), sections, movements, comments. Assignment’s snapshot_workout_id flipped to the new row id. Then the patch applies to the snapshot. |
PUT /workouts/:id/sections | Same txn pattern: delete existing sections+movements, insert new tree. With assignmentId, redirects to snapshot. |
DELETE /workouts/:id | Soft delete (deleted_at=now()) on library row. 400 if is_snapshot=true. Sections/movements not touched directly — service filters them by deleted_at. |
| Comment create | Insert exercise_comments row + optional attachments rows (consume from staged uploads). |
| Lazy-fork comment duplication | deepCopyWorkout re-inserts each non-deleted comment on the mirrored snapshot movement, with parent_comment_id remapped through a per-fork commentMap. |
Soft-delete behavior
workouts,workout_sections,workout_movements,exercise_comments,attachmentsall carrydeleted_at. All hot-path queries filterIS NULL.DELETEon a workout marksdeleted_at. Sub-rows are NOT cascaded; they are filtered on read. (FK ON DELETE CASCADE only triggers on physical delete.)setSectionsphysically deletes existing rows inside its transaction before re-inserting — by design, a section reorder/replace fully replaces the tree.- Snapshot rows can never be deleted (CHECK).
Multi-org isolation
workouts.organization_idis required and the column that scopes every list/read query.- Movements are scoped via
section → workout → organization_id.WorkoutsService.updateMovementPrescriptionre-joins through the chain and re-checks the org id on UPDATE for defense in depth (apps/api/src/workouts/workouts.service.ts:739). exercisesreferenced by a movement may be canonical (no org) or this org’s customs — see exercises.- Comments inherit org scope through their movement → section → workout chain. The
requireWorkoutAccessgate validates membership before any comment list / create.