From cd3edf02cd4aa9c929247fa8d172379336751197 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Mon, 24 Aug 2026 15:05:41 -0700 Subject: [PATCH] feat(api): add routine exercises table --- apps/api/drizzle/0004_huge_veda.sql | 27 ++ apps/api/drizzle/meta/0004_snapshot.json | 489 ++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema/index.ts | 1 + apps/api/src/db/schema/routine-exercises.ts | 101 ++++ docs/database.md | 7 +- 6 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 apps/api/drizzle/0004_huge_veda.sql create mode 100644 apps/api/drizzle/meta/0004_snapshot.json create mode 100644 apps/api/src/db/schema/routine-exercises.ts diff --git a/apps/api/drizzle/0004_huge_veda.sql b/apps/api/drizzle/0004_huge_veda.sql new file mode 100644 index 0000000..f8571a3 --- /dev/null +++ b/apps/api/drizzle/0004_huge_veda.sql @@ -0,0 +1,27 @@ +CREATE TABLE "routine_exercises" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "routine_id" uuid NOT NULL, + "exercise_id" uuid NOT NULL, + "position" integer NOT NULL, + "sets" integer NOT NULL, + "target_reps" integer NOT NULL, + "weight" numeric(8, 2) NOT NULL, + "weight_unit" text NOT NULL, + "rest_between_sets_seconds" integer DEFAULT 0 NOT NULL, + "rest_after_exercise_seconds" integer DEFAULT 0 NOT NULL, + "notes" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "routine_exercises_position_nonnegative" CHECK ("routine_exercises"."position" >= 0), + CONSTRAINT "routine_exercises_sets_positive" CHECK ("routine_exercises"."sets" > 0), + CONSTRAINT "routine_exercises_target_reps_positive" CHECK ("routine_exercises"."target_reps" > 0), + CONSTRAINT "routine_exercises_weight_nonnegative" CHECK ("routine_exercises"."weight" >= 0), + CONSTRAINT "routine_exercises_weight_unit_supported" CHECK ("routine_exercises"."weight_unit" IN ('lb', 'kg')), + CONSTRAINT "routine_exercises_rest_between_sets_nonnegative" CHECK ("routine_exercises"."rest_between_sets_seconds" >= 0), + CONSTRAINT "routine_exercises_rest_after_exercise_nonnegative" CHECK ("routine_exercises"."rest_after_exercise_seconds" >= 0) +); +--> statement-breakpoint +ALTER TABLE "routine_exercises" ADD CONSTRAINT "routine_exercises_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "routine_exercises" ADD CONSTRAINT "routine_exercises_exercise_id_exercises_id_fk" FOREIGN KEY ("exercise_id") REFERENCES "public"."exercises"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "routine_exercises_routine_position_unique" ON "routine_exercises" USING btree ("routine_id","position");--> statement-breakpoint +CREATE INDEX "routine_exercises_exercise_id_idx" ON "routine_exercises" USING btree ("exercise_id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0004_snapshot.json b/apps/api/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..52ead4d --- /dev/null +++ b/apps/api/drizzle/meta/0004_snapshot.json @@ -0,0 +1,489 @@ +{ + "id": "4b0ccca9-4c6f-4809-9675-3abccf9aeb34", + "prevId": "72d07b04-09c7-43fa-a79a-4ea70a6a75ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.exercises": { + "name": "exercises", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "exercises_owner_user_id_idx": { + "name": "exercises_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercises_owner_user_id_users_id_fk": { + "name": "exercises_owner_user_id_users_id_fk", + "tableFrom": "exercises", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_exercises": { + "name": "routine_exercises", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "exercise_id": { + "name": "exercise_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sets": { + "name": "sets", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_reps": { + "name": "target_reps", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rest_between_sets_seconds": { + "name": "rest_between_sets_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rest_after_exercise_seconds": { + "name": "rest_after_exercise_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_exercises_routine_position_unique": { + "name": "routine_exercises_routine_position_unique", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_exercises_exercise_id_idx": { + "name": "routine_exercises_exercise_id_idx", + "columns": [ + { + "expression": "exercise_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_exercises_routine_id_routines_id_fk": { + "name": "routine_exercises_routine_id_routines_id_fk", + "tableFrom": "routine_exercises", + "tableTo": "routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_exercises_exercise_id_exercises_id_fk": { + "name": "routine_exercises_exercise_id_exercises_id_fk", + "tableFrom": "routine_exercises", + "tableTo": "exercises", + "columnsFrom": ["exercise_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "routine_exercises_position_nonnegative": { + "name": "routine_exercises_position_nonnegative", + "value": "\"routine_exercises\".\"position\" >= 0" + }, + "routine_exercises_sets_positive": { + "name": "routine_exercises_sets_positive", + "value": "\"routine_exercises\".\"sets\" > 0" + }, + "routine_exercises_target_reps_positive": { + "name": "routine_exercises_target_reps_positive", + "value": "\"routine_exercises\".\"target_reps\" > 0" + }, + "routine_exercises_weight_nonnegative": { + "name": "routine_exercises_weight_nonnegative", + "value": "\"routine_exercises\".\"weight\" >= 0" + }, + "routine_exercises_weight_unit_supported": { + "name": "routine_exercises_weight_unit_supported", + "value": "\"routine_exercises\".\"weight_unit\" IN ('lb', 'kg')" + }, + "routine_exercises_rest_between_sets_nonnegative": { + "name": "routine_exercises_rest_between_sets_nonnegative", + "value": "\"routine_exercises\".\"rest_between_sets_seconds\" >= 0" + }, + "routine_exercises_rest_after_exercise_nonnegative": { + "name": "routine_exercises_rest_after_exercise_nonnegative", + "value": "\"routine_exercises\".\"rest_after_exercise_seconds\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_user_id_idx": { + "name": "routines_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_user_id_users_id_fk": { + "name": "routines_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "sid": { + "name": "sid", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "sess": { + "name": "sess", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expire": { + "name": "expire", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_expire_idx": { + "name": "sessions_expire_idx", + "columns": [ + { + "expression": "expire", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_provider_subject": { + "name": "auth_provider_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_auth_provider_subject_unique": { + "name": "users_auth_provider_subject_unique", + "columns": [ + { + "expression": "auth_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auth_provider_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index f834cac..f514a65 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1787608216413, "tag": "0003_overrated_nightshade", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1787608832607, + "tag": "0004_huge_veda", + "breakpoints": true } ] } diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index 1651bc4..f620e3b 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -1,5 +1,6 @@ /** Exposes the complete Drizzle schema from feature-focused table modules. */ export * from './exercises.js'; +export * from './routine-exercises.js'; export * from './routines.js'; export * from './sessions.js'; export * from './users.js'; diff --git a/apps/api/src/db/schema/routine-exercises.ts b/apps/api/src/db/schema/routine-exercises.ts new file mode 100644 index 0000000..24cf07f --- /dev/null +++ b/apps/api/src/db/schema/routine-exercises.ts @@ -0,0 +1,101 @@ +/** Defines ordered exercise plans and configuration within reusable routines. */ +import { sql } from 'drizzle-orm'; +import { + check, + index, + integer, + numeric, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; + +import { exercises } from './exercises.js'; +import { routines } from './routines.js'; + +export const routineExercises = pgTable( + 'routine_exercises', + { + /** Primary identifier for this routine exercise configuration. */ + id: uuid('id').defaultRandom().primaryKey(), + + /** Routine containing the configured exercise. */ + routineId: uuid('routine_id') + .notNull() + .references(() => routines.id, { onDelete: 'cascade' }), + + /** Exercise selected for this position in the routine. */ + exerciseId: uuid('exercise_id') + .notNull() + .references(() => exercises.id, { onDelete: 'no action' }), + + /** Zero-based position determining exercise order within the routine. */ + position: integer('position').notNull(), + + /** Number of planned sets for the exercise. */ + sets: integer('sets').notNull(), + + /** Planned repetitions for each set in the MVP. */ + targetReps: integer('target_reps').notNull(), + + /** Planned weight shared by all sets, stored with two decimal places. */ + weight: numeric('weight', { precision: 8, scale: 2 }).notNull(), + + /** Unit used by the planned weight; restricted to lb or kg for the MVP. */ + weightUnit: text('weight_unit').notNull(), + + /** Rest duration after a set before continuing the exercise. */ + restBetweenSetsSeconds: integer('rest_between_sets_seconds') + .default(0) + .notNull(), + + /** Additional rest duration before beginning the next exercise. */ + restAfterExerciseSeconds: integer('rest_after_exercise_seconds') + .default(0) + .notNull(), + + /** Optional notes specific to this exercise within the routine. */ + notes: text('notes'), + + /** Time when this routine exercise configuration was created. */ + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + + /** Time when this routine exercise configuration was last changed. */ + updatedAt: timestamp('updated_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex('routine_exercises_routine_position_unique').on( + table.routineId, + table.position, + ), + index('routine_exercises_exercise_id_idx').on(table.exerciseId), + check( + 'routine_exercises_position_nonnegative', + sql`${table.position} >= 0`, + ), + check('routine_exercises_sets_positive', sql`${table.sets} > 0`), + check( + 'routine_exercises_target_reps_positive', + sql`${table.targetReps} > 0`, + ), + check('routine_exercises_weight_nonnegative', sql`${table.weight} >= 0`), + check( + 'routine_exercises_weight_unit_supported', + sql`${table.weightUnit} IN ('lb', 'kg')`, + ), + check( + 'routine_exercises_rest_between_sets_nonnegative', + sql`${table.restBetweenSetsSeconds} >= 0`, + ), + check( + 'routine_exercises_rest_after_exercise_nonnegative', + sql`${table.restAfterExerciseSeconds} >= 0`, + ), + ], +); diff --git a/docs/database.md b/docs/database.md index 27a552b..8a1415a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -677,10 +677,9 @@ Do not expose sequential database implementation details merely for convenience. Weight must not be stored using floating-point types that introduce unnecessary binary floating-point precision behavior. -Use an appropriate PostgreSQL numeric representation. - -The exact precision/scale should be chosen during Drizzle schema implementation -based on supported weight increments. +Use `NUMERIC(8,2)` for planned and historical weight. This supports two decimal +places without floating-point rounding behavior and provides substantially more +range than the MVP requires. The API should expose weight through a clearly defined contract.