diff --git a/apps/api/src/features/routines/routine.errors.ts b/apps/api/src/features/routines/routine.errors.ts new file mode 100644 index 0000000..0c2c11a --- /dev/null +++ b/apps/api/src/features/routines/routine.errors.ts @@ -0,0 +1,7 @@ +/** Defines expected business errors raised by routine operations. */ +export class InvalidRoutineExerciseSelectionError extends Error { + constructor() { + super('One or more exercises are not available to this user.'); + this.name = 'InvalidRoutineExerciseSelectionError'; + } +} diff --git a/apps/api/src/features/routines/routine.routes.test.ts b/apps/api/src/features/routines/routine.routes.test.ts index e05058a..68c2d7f 100644 --- a/apps/api/src/features/routines/routine.routes.test.ts +++ b/apps/api/src/features/routines/routine.routes.test.ts @@ -7,25 +7,40 @@ import { describe, expect, it, vi } from 'vitest'; import { createSessionMiddleware } from '../auth/session/session.middleware.js'; import { establishAuthenticatedTestSession } from '../../test/authenticated-session.test-helper.js'; import { testSessionConfiguration } from '../../test/fixtures/session-configuration.fixture.js'; +import { InvalidRoutineExerciseSelectionError } from './routine.errors.js'; import { createRoutineRouter } from './routine.routes.js'; const userId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; -const routineList = [ - { - id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120', - name: 'Upper Body', - description: null, - exerciseCount: 3, - scheduledDays: [1, 3, 5], - createdAt: '2026-08-24T18:00:00.000Z', - updatedAt: '2026-08-24T19:00:00.000Z', - }, -]; +const routineSummary = { + id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120', + name: 'Upper Body', + description: null, + exerciseCount: 3, + scheduledDays: [1, 3, 5], + createdAt: '2026-08-24T18:00:00.000Z', + updatedAt: '2026-08-24T19:00:00.000Z', +}; +const routineList = [routineSummary]; +const createRoutineInput = { + name: 'Upper Body', + exercises: [ + { + exerciseId: '9f4b5a8e-2c3d-4f10-8a11-000000000001', + position: 0, + sets: 3, + targetReps: 10, + weight: 25, + weightUnit: 'lb', + }, + ], +}; function createTestApp( + createRoutine = vi.fn(() => Promise.resolve(routineSummary)), listRoutines = vi.fn(() => Promise.resolve(routineList)), ) { const app = express(); + app.use(express.json()); app.use( createSessionMiddleware( new session.MemoryStore(), @@ -33,11 +48,68 @@ function createTestApp( ), ); app.use(establishAuthenticatedTestSession()); - app.use('/routines', createRoutineRouter({ listRoutines })); + app.use('/routines', createRoutineRouter({ createRoutine, listRoutines })); - return { app, listRoutines }; + return { app, createRoutine, listRoutines }; } +describe('POST /routines', () => { + it('rejects anonymous and invalid requests', async () => { + const { app, createRoutine } = createTestApp(); + + const anonymous = await request(app) + .post('/routines') + .send(createRoutineInput); + const invalid = await request(app) + .post('/routines') + .set('x-test-authenticated-user-id', userId) + .send({ name: 'Upper Body', exercises: [] }); + + expect(anonymous.status).toBe(401); + expect(invalid.status).toBe(400); + expect(invalid.body).toEqual({ error: 'invalid_request' }); + expect(createRoutine).not.toHaveBeenCalled(); + }); + + it('creates a routine for the authenticated user', async () => { + const { app, createRoutine } = createTestApp(); + + const response = await request(app) + .post('/routines') + .set('x-test-authenticated-user-id', userId) + .send(createRoutineInput); + + expect(response.status).toBe(201); + expect(response.body).toEqual(routineSummary); + expect(createRoutine).toHaveBeenCalledWith(userId, { + ...createRoutineInput, + exercises: [ + { + ...createRoutineInput.exercises[0], + restBetweenSetsSeconds: 0, + restAfterExerciseSeconds: 0, + }, + ], + schedules: [], + }); + }); + + it('rejects exercises unavailable to the authenticated user', async () => { + const createRoutine = vi.fn(() => + Promise.reject(new InvalidRoutineExerciseSelectionError()), + ); + const { app } = createTestApp(createRoutine); + + const response = await request(app) + .post('/routines') + .set('x-test-authenticated-user-id', userId) + .send(createRoutineInput); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'invalid_exercise_selection' }); + }); +}); + describe('GET /routines', () => { it('rejects anonymous requests', async () => { const { app, listRoutines } = createTestApp(); @@ -62,7 +134,10 @@ describe('GET /routines', () => { }); it('returns an empty list for a user without routines', async () => { - const { app } = createTestApp(vi.fn(() => Promise.resolve([]))); + const { app } = createTestApp( + undefined, + vi.fn(() => Promise.resolve([])), + ); const response = await request(app) .get('/routines') diff --git a/apps/api/src/features/routines/routine.routes.ts b/apps/api/src/features/routines/routine.routes.ts index fa0460a..d026a61 100644 --- a/apps/api/src/features/routines/routine.routes.ts +++ b/apps/api/src/features/routines/routine.routes.ts @@ -1,6 +1,9 @@ /** Defines authenticated routine-list API routes. */ import { + createRoutineInputSchema, routineListResponseSchema, + routineSummarySchema, + type CreateRoutineInput, type RoutineSummary, } from '@goforlift/contracts'; import { Router } from 'express'; @@ -9,16 +12,45 @@ import { getAuthenticatedUserId, requireAuthentication, } from '../auth/guards/authentication.middleware.js'; +import { InvalidRoutineExerciseSelectionError } from './routine.errors.js'; type RoutineRouterDependencies = { + createRoutine: ( + userId: string, + input: CreateRoutineInput, + ) => Promise; listRoutines: (userId: string) => Promise; }; export function createRoutineRouter({ + createRoutine, listRoutines, }: RoutineRouterDependencies) { const router = Router(); + router.post('/', requireAuthentication, async (request, response, next) => { + const inputResult = createRoutineInputSchema.safeParse(request.body); + + if (!inputResult.success) { + response.status(400).json({ error: 'invalid_request' }); + return; + } + + try { + const userId = getAuthenticatedUserId(request); + const routine = await createRoutine(userId, inputResult.data); + + response.status(201).json(routineSummarySchema.parse(routine)); + } catch (error) { + if (error instanceof InvalidRoutineExerciseSelectionError) { + response.status(400).json({ error: 'invalid_exercise_selection' }); + return; + } + + next(error); + } + }); + router.get('/', requireAuthentication, async (request, response, next) => { try { const userId = getAuthenticatedUserId(request); diff --git a/apps/api/src/features/routines/routine.service.test.ts b/apps/api/src/features/routines/routine.service.test.ts index 02d477c..a6c5e66 100644 --- a/apps/api/src/features/routines/routine.service.test.ts +++ b/apps/api/src/features/routines/routine.service.test.ts @@ -2,8 +2,140 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { describe, expect, it, vi } from 'vitest'; -import { routines } from '../../db/schema/index.js'; -import { listRoutinesForUser } from './routine.service.js'; +import { + routineExercises, + routineSchedules, + routines, +} from '../../db/schema/index.js'; +import { InvalidRoutineExerciseSelectionError } from './routine.errors.js'; +import { + createRoutineForUser, + listRoutinesForUser, +} from './routine.service.js'; + +const userId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; +const exerciseId = '9f4b5a8e-2c3d-4f10-8a11-000000000001'; + +describe('createRoutineForUser', () => { + it('creates the routine, exercises, and schedules in one transaction', async () => { + const createdAt = new Date('2026-08-24T18:00:00.000Z'); + const updatedAt = new Date('2026-08-24T19:00:00.000Z'); + const createdRoutine = { + id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120', + name: 'Upper Body', + description: null, + createdAt, + updatedAt, + }; + const returning = vi.fn().mockResolvedValue([createdRoutine]); + const routineValues = vi.fn(() => ({ returning })); + const exerciseValues = vi.fn().mockResolvedValue(undefined); + const scheduleValues = vi.fn().mockResolvedValue(undefined); + const insert = vi.fn((table) => { + if (table === routines) return { values: routineValues }; + if (table === routineExercises) return { values: exerciseValues }; + if (table === routineSchedules) return { values: scheduleValues }; + throw new Error('Unexpected table.'); + }); + const where = vi.fn().mockResolvedValue([{ id: exerciseId }]); + const transaction = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ where })), + })), + insert, + }; + const database = { + transaction: vi.fn( + async ( + callback: (activeTransaction: typeof transaction) => Promise, + ) => callback(transaction), + ), + } as unknown as NodePgDatabase; + + const result = await createRoutineForUser(database, userId, { + name: createdRoutine.name, + exercises: [ + { + exerciseId, + position: 0, + sets: 3, + targetReps: 10, + weight: 25, + weightUnit: 'lb', + restBetweenSetsSeconds: 60, + restAfterExerciseSeconds: 90, + }, + ], + schedules: [{ dayOfWeek: 3, localTime: '18:30:00' }], + }); + + expect(routineValues).toHaveBeenCalledWith({ + userId, + name: createdRoutine.name, + description: null, + }); + expect(exerciseValues).toHaveBeenCalledWith([ + expect.objectContaining({ + routineId: createdRoutine.id, + exerciseId, + weight: '25', + }), + ]); + expect(scheduleValues).toHaveBeenCalledWith([ + { + routineId: createdRoutine.id, + dayOfWeek: 3, + localTime: '18:30:00', + }, + ]); + expect(result).toEqual({ + id: createdRoutine.id, + name: createdRoutine.name, + description: null, + exerciseCount: 1, + scheduledDays: [3], + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }); + }); + + it('rejects unavailable exercises before inserting a routine', async () => { + const insert = vi.fn(); + const transaction = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })), + })), + insert, + }; + const database = { + transaction: vi.fn( + async ( + callback: (activeTransaction: typeof transaction) => Promise, + ) => callback(transaction), + ), + } as unknown as NodePgDatabase; + + await expect( + createRoutineForUser(database, userId, { + name: 'Upper Body', + exercises: [ + { + exerciseId, + position: 0, + sets: 3, + targetReps: 10, + weight: 25, + weightUnit: 'lb', + restBetweenSetsSeconds: 0, + restAfterExerciseSeconds: 0, + }, + ], + schedules: [], + }), + ).rejects.toBeInstanceOf(InvalidRoutineExerciseSelectionError); + expect(insert).not.toHaveBeenCalled(); + }); +}); describe('listRoutinesForUser', () => { it('returns serialized routine summaries from the owner-scoped query', async () => { @@ -28,8 +160,6 @@ describe('listRoutinesForUser', () => { const from = vi.fn(() => ({ leftJoin: firstLeftJoin })); const select = vi.fn(() => ({ from })); const database = { select } as unknown as NodePgDatabase; - const userId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; - const result = await listRoutinesForUser(database, userId); expect(select).toHaveBeenCalledOnce(); diff --git a/apps/api/src/features/routines/routine.service.ts b/apps/api/src/features/routines/routine.service.ts index a45f979..1cda77f 100644 --- a/apps/api/src/features/routines/routine.service.ts +++ b/apps/api/src/features/routines/routine.service.ts @@ -1,13 +1,109 @@ /** Lists browser-safe routine summaries owned by the authenticated user. */ -import type { RoutineSummary } from '@goforlift/contracts'; -import { asc, countDistinct, desc, eq, sql } from 'drizzle-orm'; +import type { CreateRoutineInput, RoutineSummary } from '@goforlift/contracts'; +import { + and, + asc, + countDistinct, + desc, + eq, + inArray, + isNull, + or, + sql, +} from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { + exercises, routineExercises, routineSchedules, routines, } from '../../db/schema/index.js'; +import { InvalidRoutineExerciseSelectionError } from './routine.errors.js'; + +export async function createRoutineForUser( + database: NodePgDatabase, + userId: string, + input: CreateRoutineInput, +): Promise { + return database.transaction(async (currentTransaction) => { + const requestedExerciseIds = [ + ...new Set(input.exercises.map((exercise) => exercise.exerciseId)), + ]; + const availableExercises = await currentTransaction + .select({ id: exercises.id }) + .from(exercises) + .where( + and( + inArray(exercises.id, requestedExerciseIds), + or(isNull(exercises.ownerUserId), eq(exercises.ownerUserId, userId)), + ), + ); + + // Ensure every requested exercise was available + // length check is safe because we compared requestedExerciseIds, aggainst availableExercises + // The API deliberately does not reveal which case occurred. It throws one safe business error. + if (availableExercises.length !== requestedExerciseIds.length) { + throw new InvalidRoutineExerciseSelectionError(); + } + + const [createdRoutine] = await currentTransaction + .insert(routines) + .values({ + userId, + name: input.name, + description: input.description ?? null, + }) + .returning({ + id: routines.id, + name: routines.name, + description: routines.description, + createdAt: routines.createdAt, + updatedAt: routines.updatedAt, + }); + + if (!createdRoutine) { + throw new Error('Routine creation did not return a row.'); + } + + await currentTransaction.insert(routineExercises).values( + input.exercises.map((exercise) => ({ + routineId: createdRoutine.id, + exerciseId: exercise.exerciseId, + position: exercise.position, + sets: exercise.sets, + targetReps: exercise.targetReps, + weight: exercise.weight.toString(), + weightUnit: exercise.weightUnit, + restBetweenSetsSeconds: exercise.restBetweenSetsSeconds, + restAfterExerciseSeconds: exercise.restAfterExerciseSeconds, + notes: exercise.notes ?? null, + })), + ); + + if (input.schedules.length > 0) { + await currentTransaction.insert(routineSchedules).values( + input.schedules.map((schedule) => ({ + routineId: createdRoutine.id, + dayOfWeek: schedule.dayOfWeek, + localTime: schedule.localTime, + })), + ); + } + + return { + id: createdRoutine.id, + name: createdRoutine.name, + description: createdRoutine.description, + exerciseCount: input.exercises.length, + scheduledDays: [ + ...new Set(input.schedules.map((schedule) => schedule.dayOfWeek)), + ].sort((first, second) => first - second), + createdAt: createdRoutine.createdAt.toISOString(), + updatedAt: createdRoutine.updatedAt.toISOString(), + }; + }); +} export async function listRoutinesForUser( database: NodePgDatabase, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 0a41d18..9b5a17d 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -23,7 +23,10 @@ import { listExercisesForUser, } from './features/exercises/exercise.service.js'; import { createRoutineRouter } from './features/routines/routine.routes.js'; -import { listRoutinesForUser } from './features/routines/routine.service.js'; +import { + createRoutineForUser, + listRoutinesForUser, +} from './features/routines/routine.service.js'; const { db, pool } = createDatabase(env.DATABASE_URL); const { middleware: sessionMiddleware, store: sessionStore } = @@ -53,6 +56,7 @@ const exerciseRouter = createExerciseRouter({ }); const routineRouter = createRoutineRouter({ + createRoutine: (userId, input) => createRoutineForUser(db, userId, input), listRoutines: (userId) => listRoutinesForUser(db, userId), }); diff --git a/packages/contracts/src/routines/routine.contract.test.ts b/packages/contracts/src/routines/routine.contract.test.ts index d8b70e9..b698422 100644 --- a/packages/contracts/src/routines/routine.contract.test.ts +++ b/packages/contracts/src/routines/routine.contract.test.ts @@ -2,10 +2,101 @@ import { describe, expect, it } from 'vitest'; import { + createRoutineInputSchema, routineListResponseSchema, routineSummarySchema, } from './routine.contract.js'; +const validCreateRoutineInput = { + name: ' Upper Body ', + description: ' Monday training plan. ', + exercises: [ + { + exerciseId: '9f4b5a8e-2c3d-4f10-8a11-000000000001', + position: 0, + sets: 3, + targetReps: 10, + weight: 25, + weightUnit: 'lb' as const, + }, + ], + schedules: [{ dayOfWeek: 1, localTime: '18:30:00' }], +}; + +describe('createRoutineInputSchema', () => { + it('normalizes a valid routine and applies rest defaults', () => { + expect(createRoutineInputSchema.parse(validCreateRoutineInput)).toEqual({ + ...validCreateRoutineInput, + name: 'Upper Body', + description: 'Monday training plan.', + exercises: [ + { + ...validCreateRoutineInput.exercises[0], + restBetweenSetsSeconds: 0, + restAfterExerciseSeconds: 0, + }, + ], + }); + }); + + it('rejects invalid exercise configuration and schedules', () => { + expect(() => + createRoutineInputSchema.parse({ + ...validCreateRoutineInput, + exercises: [], + }), + ).toThrow(); + expect(() => + createRoutineInputSchema.parse({ + ...validCreateRoutineInput, + exercises: [ + { + ...validCreateRoutineInput.exercises[0], + sets: 0, + weightUnit: 'stone', + }, + ], + }), + ).toThrow(); + expect(() => + createRoutineInputSchema.parse({ + ...validCreateRoutineInput, + schedules: [{ dayOfWeek: 7, localTime: '25:00:00' }], + }), + ).toThrow(); + }); + + it('rejects duplicate positions, schedules, and client-controlled fields', () => { + expect(() => + createRoutineInputSchema.parse({ + ...validCreateRoutineInput, + exercises: [ + validCreateRoutineInput.exercises[0], + { + ...validCreateRoutineInput.exercises[0], + exerciseId: '9f4b5a8e-2c3d-4f10-8a11-000000000002', + }, + ], + }), + ).toThrow(); + expect(() => + createRoutineInputSchema.parse({ + ...validCreateRoutineInput, + schedules: [ + validCreateRoutineInput.schedules[0], + validCreateRoutineInput.schedules[0], + ], + }), + ).toThrow(); + expect(() => + createRoutineInputSchema.parse({ + ...validCreateRoutineInput, + userId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + }), + ).toThrow(); + }); +}); + const routineSummary = { id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120', name: 'Upper Body', diff --git a/packages/contracts/src/routines/routine.contract.ts b/packages/contracts/src/routines/routine.contract.ts index c662d3b..f41cb2f 100644 --- a/packages/contracts/src/routines/routine.contract.ts +++ b/packages/contracts/src/routines/routine.contract.ts @@ -1,6 +1,74 @@ /** Defines browser-safe routine-list contracts shared across applications. */ import { z } from 'zod'; +export const ROUTINE_NAME_MAX_LENGTH = 100; + +export const createRoutineExerciseInputSchema = z + .object({ + exerciseId: z.uuid(), + position: z.number().int().nonnegative(), + sets: z.number().int().positive(), + targetReps: z.number().int().positive(), + weight: z.number().nonnegative(), + weightUnit: z.enum(['lb', 'kg']), + restBetweenSetsSeconds: z.number().int().nonnegative().default(0), + restAfterExerciseSeconds: z.number().int().nonnegative().default(0), + notes: z.string().trim().nullable().optional(), + }) + .strict(); + +export const createRoutineScheduleInputSchema = z + .object({ + dayOfWeek: z.number().int().min(0).max(6), + localTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/), + }) + .strict(); + +export const createRoutineInputSchema = z + .object({ + name: z.string().trim().min(1).max(ROUTINE_NAME_MAX_LENGTH), + description: z.string().trim().nullable().optional(), + exercises: z.array(createRoutineExerciseInputSchema).min(1), + schedules: z.array(createRoutineScheduleInputSchema).default([]), + }) + .strict() + .superRefine((input, context) => { + // Field-level schemas validate each item independently. These checks compare + // items across the full request so malformed or modified clients cannot send + // values that would conflict with the database's uniqueness rules. + const positions = new Set(); + + input.exercises.forEach((exercise, index) => { + // Two exercises cannot occupy the same ordered position in one routine. + if (positions.has(exercise.position)) { + context.addIssue({ + code: 'custom', + message: 'Exercise positions must be unique.', + path: ['exercises', index, 'position'], + }); + } + + positions.add(exercise.position); + }); + + const schedules = new Set(); + + input.schedules.forEach((schedule, index) => { + // The day-and-time pair matches the schedule table's uniqueness boundary. + const scheduleKey = `${schedule.dayOfWeek}-${schedule.localTime}`; + + if (schedules.has(scheduleKey)) { + context.addIssue({ + code: 'custom', + message: 'Routine schedules must be unique.', + path: ['schedules', index], + }); + } + + schedules.add(scheduleKey); + }); + }); + export const routineSummarySchema = z.object({ id: z.uuid(), name: z.string().min(1), @@ -17,3 +85,4 @@ export const routineListResponseSchema = z.object({ export type RoutineSummary = z.infer; export type RoutineListResponse = z.infer; +export type CreateRoutineInput = z.infer;