diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 183146f..89654b3 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -18,6 +18,7 @@ describe('GET /health', () => { createApp({ authRouter: Router(), exerciseRouter: Router(), + routineRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', @@ -36,6 +37,7 @@ describe('GET /health', () => { createApp({ authRouter: Router(), exerciseRouter: Router(), + routineRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', @@ -59,6 +61,7 @@ describe('GET /health', () => { createApp({ authRouter: Router(), exerciseRouter: Router(), + routineRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', @@ -82,6 +85,7 @@ describe('GET /health', () => { createApp({ authRouter: Router(), exerciseRouter: Router(), + routineRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', @@ -104,6 +108,7 @@ describe('JSON request parsing', () => { return createApp({ authRouter: Router(), exerciseRouter: Router(), + routineRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e152b7b..36d74b7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,6 +19,7 @@ type AppDependencies = { csrfErrorHandler: express.ErrorRequestHandler; csrfProtection: RequestHandler; exerciseRouter: Router; + routineRouter: Router; sessionMiddleware: RequestHandler; trustProxyHops: number; webOrigin: string; @@ -30,6 +31,7 @@ export function createApp({ csrfErrorHandler, csrfProtection, exerciseRouter, + routineRouter, sessionMiddleware, trustProxyHops, webOrigin, @@ -46,6 +48,7 @@ export function createApp({ app.use(csrfProtection); app.use('/auth', authRouter); app.use('/exercises', exerciseRouter); + app.use('/routines', routineRouter); app.get('/health', async (_request, response) => { try { diff --git a/apps/api/src/features/routines/routine.routes.test.ts b/apps/api/src/features/routines/routine.routes.test.ts new file mode 100644 index 0000000..e05058a --- /dev/null +++ b/apps/api/src/features/routines/routine.routes.test.ts @@ -0,0 +1,74 @@ +/** Verifies authentication and response behavior for the routine list route. */ +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +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 { 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', + }, +]; + +function createTestApp( + listRoutines = vi.fn(() => Promise.resolve(routineList)), +) { + const app = express(); + app.use( + createSessionMiddleware( + new session.MemoryStore(), + testSessionConfiguration, + ), + ); + app.use(establishAuthenticatedTestSession()); + app.use('/routines', createRoutineRouter({ listRoutines })); + + return { app, listRoutines }; +} + +describe('GET /routines', () => { + it('rejects anonymous requests', async () => { + const { app, listRoutines } = createTestApp(); + + const response = await request(app).get('/routines'); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'authentication_required' }); + expect(listRoutines).not.toHaveBeenCalled(); + }); + + it('returns routine summaries for the authenticated user', async () => { + const { app, listRoutines } = createTestApp(); + + const response = await request(app) + .get('/routines') + .set('x-test-authenticated-user-id', userId); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ routines: routineList }); + expect(listRoutines).toHaveBeenCalledWith(userId); + }); + + it('returns an empty list for a user without routines', async () => { + const { app } = createTestApp(vi.fn(() => Promise.resolve([]))); + + const response = await request(app) + .get('/routines') + .set('x-test-authenticated-user-id', userId); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ routines: [] }); + }); +}); diff --git a/apps/api/src/features/routines/routine.routes.ts b/apps/api/src/features/routines/routine.routes.ts new file mode 100644 index 0000000..fa0460a --- /dev/null +++ b/apps/api/src/features/routines/routine.routes.ts @@ -0,0 +1,34 @@ +/** Defines authenticated routine-list API routes. */ +import { + routineListResponseSchema, + type RoutineSummary, +} from '@goforlift/contracts'; +import { Router } from 'express'; + +import { + getAuthenticatedUserId, + requireAuthentication, +} from '../auth/guards/authentication.middleware.js'; + +type RoutineRouterDependencies = { + listRoutines: (userId: string) => Promise; +}; + +export function createRoutineRouter({ + listRoutines, +}: RoutineRouterDependencies) { + const router = Router(); + + router.get('/', requireAuthentication, async (request, response, next) => { + try { + const userId = getAuthenticatedUserId(request); + const routines = await listRoutines(userId); + + response.status(200).json(routineListResponseSchema.parse({ routines })); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/apps/api/src/features/routines/routine.service.test.ts b/apps/api/src/features/routines/routine.service.test.ts new file mode 100644 index 0000000..02d477c --- /dev/null +++ b/apps/api/src/features/routines/routine.service.test.ts @@ -0,0 +1,48 @@ +/** Verifies owner-scoped routine summary selection and serialization. */ +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'; + +describe('listRoutinesForUser', () => { + it('returns serialized routine summaries from the owner-scoped query', async () => { + const createdAt = new Date('2026-08-24T18:00:00.000Z'); + const updatedAt = new Date('2026-08-24T19:00:00.000Z'); + const rows = [ + { + id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120', + name: 'Upper Body', + description: null, + exerciseCount: 3, + scheduledDays: [5, 1, 3], + createdAt, + updatedAt, + }, + ]; + const orderBy = vi.fn().mockResolvedValue(rows); + const groupBy = vi.fn(() => ({ orderBy })); + const where = vi.fn(() => ({ groupBy })); + const secondLeftJoin = vi.fn(() => ({ where })); + const firstLeftJoin = vi.fn(() => ({ leftJoin: secondLeftJoin })); + 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(); + expect(where).toHaveBeenCalledOnce(); + expect(result).toEqual([ + { + ...rows[0], + scheduledDays: [1, 3, 5], + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }, + ]); + expect(result[0]?.id).toBe(rows[0]?.id); + expect(routines.userId).toBeDefined(); + }); +}); diff --git a/apps/api/src/features/routines/routine.service.ts b/apps/api/src/features/routines/routine.service.ts new file mode 100644 index 0000000..a45f979 --- /dev/null +++ b/apps/api/src/features/routines/routine.service.ts @@ -0,0 +1,53 @@ +/** 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 { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { + routineExercises, + routineSchedules, + routines, +} from '../../db/schema/index.js'; + +export async function listRoutinesForUser( + database: NodePgDatabase, + userId: string, +): Promise { + const ownedRoutines = await database + .select({ + id: routines.id, + name: routines.name, + description: routines.description, + exerciseCount: countDistinct(routineExercises.id), + scheduledDays: sql` + coalesce( + array_agg(distinct ${routineSchedules.dayOfWeek}) + filter (where ${routineSchedules.dayOfWeek} is not null), + array[]::smallint[] + ) + `, + createdAt: routines.createdAt, + updatedAt: routines.updatedAt, + }) + .from(routines) + .leftJoin(routineExercises, eq(routineExercises.routineId, routines.id)) + .leftJoin(routineSchedules, eq(routineSchedules.routineId, routines.id)) + .where(eq(routines.userId, userId)) + .groupBy( + routines.id, + routines.name, + routines.description, + routines.createdAt, + routines.updatedAt, + ) + .orderBy(desc(routines.updatedAt), asc(routines.name)); + + return ownedRoutines.map((routine) => ({ + ...routine, + scheduledDays: [...routine.scheduledDays].sort( + (first, second) => first - second, + ), + createdAt: routine.createdAt.toISOString(), + updatedAt: routine.updatedAt.toISOString(), + })); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b425b3c..0a41d18 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -22,6 +22,8 @@ import { createExerciseForUser, listExercisesForUser, } from './features/exercises/exercise.service.js'; +import { createRoutineRouter } from './features/routines/routine.routes.js'; +import { listRoutinesForUser } from './features/routines/routine.service.js'; const { db, pool } = createDatabase(env.DATABASE_URL); const { middleware: sessionMiddleware, store: sessionStore } = @@ -50,11 +52,16 @@ const exerciseRouter = createExerciseRouter({ listExercises: (userId) => listExercisesForUser(db, userId), }); +const routineRouter = createRoutineRouter({ + listRoutines: (userId) => listRoutinesForUser(db, userId), +}); + const app = createApp({ authRouter, csrfErrorHandler, csrfProtection, exerciseRouter, + routineRouter, webOrigin: env.WEB_ORIGIN, trustProxyHops: env.TRUST_PROXY_HOPS, sessionMiddleware, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5435c08..5ea07c2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,3 +2,4 @@ export * from './auth/auth.contract.js'; export * from './exercises/exercise.contract.js'; export * from './health/health.contract.js'; +export * from './routines/routine.contract.js'; diff --git a/packages/contracts/src/routines/routine.contract.test.ts b/packages/contracts/src/routines/routine.contract.test.ts new file mode 100644 index 0000000..d8b70e9 --- /dev/null +++ b/packages/contracts/src/routines/routine.contract.test.ts @@ -0,0 +1,50 @@ +/** Verifies routine-list contracts accept public data and reject malformed payloads. */ +import { describe, expect, it } from 'vitest'; + +import { + routineListResponseSchema, + routineSummarySchema, +} from './routine.contract.js'; + +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', +}; + +describe('routine contracts', () => { + it('accepts populated and empty routine lists', () => { + expect( + routineListResponseSchema.safeParse({ routines: [routineSummary] }) + .success, + ).toBe(true); + expect(routineListResponseSchema.safeParse({ routines: [] }).success).toBe( + true, + ); + }); + + it('rejects malformed routine summaries', () => { + expect( + routineSummarySchema.safeParse({ + ...routineSummary, + exerciseCount: -1, + }).success, + ).toBe(false); + expect( + routineSummarySchema.safeParse({ + ...routineSummary, + scheduledDays: [7], + }).success, + ).toBe(false); + expect( + routineSummarySchema.safeParse({ + ...routineSummary, + createdAt: 'not-a-date', + }).success, + ).toBe(false); + }); +}); diff --git a/packages/contracts/src/routines/routine.contract.ts b/packages/contracts/src/routines/routine.contract.ts new file mode 100644 index 0000000..c662d3b --- /dev/null +++ b/packages/contracts/src/routines/routine.contract.ts @@ -0,0 +1,19 @@ +/** Defines browser-safe routine-list contracts shared across applications. */ +import { z } from 'zod'; + +export const routineSummarySchema = z.object({ + id: z.uuid(), + name: z.string().min(1), + description: z.string().nullable(), + exerciseCount: z.number().int().nonnegative(), + scheduledDays: z.array(z.number().int().min(0).max(6)), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}); + +export const routineListResponseSchema = z.object({ + routines: z.array(routineSummarySchema), +}); + +export type RoutineSummary = z.infer; +export type RoutineListResponse = z.infer;