diff --git a/apps/api/drizzle/0005_freezing_siren.sql b/apps/api/drizzle/0005_freezing_siren.sql new file mode 100644 index 0000000..03c4464 --- /dev/null +++ b/apps/api/drizzle/0005_freezing_siren.sql @@ -0,0 +1,12 @@ +CREATE TABLE "routine_schedules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "routine_id" uuid NOT NULL, + "day_of_week" smallint NOT NULL, + "local_time" time(0) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "routine_schedules_day_of_week_supported" CHECK ("routine_schedules"."day_of_week" BETWEEN 0 AND 6) +); +--> statement-breakpoint +ALTER TABLE "routine_schedules" ADD CONSTRAINT "routine_schedules_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "routine_schedules_routine_day_time_unique" ON "routine_schedules" USING btree ("routine_id","day_of_week","local_time"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..16428d3 --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,584 @@ +{ + "id": "8e09e649-8bf9-41e8-92d1-b9d3cd08d4d0", + "prevId": "4b0ccca9-4c6f-4809-9675-3abccf9aeb34", + "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.routine_schedules": { + "name": "routine_schedules", + "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 + }, + "day_of_week": { + "name": "day_of_week", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "local_time": { + "name": "local_time", + "type": "time(0)", + "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": { + "routine_schedules_routine_day_time_unique": { + "name": "routine_schedules_routine_day_time_unique", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day_of_week", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "local_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_schedules_routine_id_routines_id_fk": { + "name": "routine_schedules_routine_id_routines_id_fk", + "tableFrom": "routine_schedules", + "tableTo": "routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "routine_schedules_day_of_week_supported": { + "name": "routine_schedules_day_of_week_supported", + "value": "\"routine_schedules\".\"day_of_week\" BETWEEN 0 AND 6" + } + }, + "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 f514a65..dd1ba9c 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1787608832607, "tag": "0004_huge_veda", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787609439724, + "tag": "0005_freezing_siren", + "breakpoints": true } ] } diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index f620e3b..374d594 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -1,6 +1,7 @@ /** Exposes the complete Drizzle schema from feature-focused table modules. */ export * from './exercises.js'; export * from './routine-exercises.js'; +export * from './routine-schedules.js'; export * from './routines.js'; export * from './sessions.js'; export * from './users.js'; diff --git a/apps/api/src/db/schema/routine-schedules.ts b/apps/api/src/db/schema/routine-schedules.ts new file mode 100644 index 0000000..e50e1f0 --- /dev/null +++ b/apps/api/src/db/schema/routine-schedules.ts @@ -0,0 +1,53 @@ +/** Defines optional recurring local schedule entries for workout routines. */ +import { sql } from 'drizzle-orm'; +import { + check, + pgTable, + smallint, + time, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; + +import { routines } from './routines.js'; + +export const routineSchedules = pgTable( + 'routine_schedules', + { + /** Primary identifier for the planned schedule entry. */ + id: uuid('id').defaultRandom().primaryKey(), + + /** Routine planned for this recurring day and local time. */ + routineId: uuid('routine_id') + .notNull() + .references(() => routines.id, { onDelete: 'cascade' }), + + /** Day of week using Sunday 0 through Saturday 6. */ + dayOfWeek: smallint('day_of_week').notNull(), + + /** User-local planned workout time without a time-zone offset. */ + localTime: time('local_time', { precision: 0 }).notNull(), + + /** Time when the schedule entry was created. */ + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + + /** Time when the schedule entry was last changed. */ + updatedAt: timestamp('updated_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex('routine_schedules_routine_day_time_unique').on( + table.routineId, + table.dayOfWeek, + table.localTime, + ), + check( + 'routine_schedules_day_of_week_supported', + sql`${table.dayOfWeek} BETWEEN 0 AND 6`, + ), + ], +); diff --git a/docs/database.md b/docs/database.md index 8a1415a..a6d210a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -288,7 +288,7 @@ Example: |---|---|---| | `id` | UUID | Primary identifier for the scheduled occurrence. | | `routine_id` | UUID, FK → `routines.id` | Identifies the routine being scheduled. | -| `day_of_week` | SMALLINT | Day on which the routine is planned. Use a clearly documented convention such as 0–6. | +| `day_of_week` | SMALLINT | Day on which the routine is planned: Sunday is 0 through Saturday as 6, matching JavaScript's day convention. | | `local_time` | TIME | User-local planned workout time. | | `created_at` | TIMESTAMPTZ | When the schedule was created. | | `updated_at` | TIMESTAMPTZ | When the schedule was last modified. |