/** * Routine REST endpoints — ChronoMind cloud sync. * * GET /routines — list user's routines (filterable, paginated) * GET /routines/sync — delta sync (routines modified since timestamp) * GET /routines/:id — single routine * POST /routines — create routine * PUT /routines/:id — update routine (with syncVersion conflict check) * DELETE /routines/:id — delete routine * POST /routines/batch — batch upsert */ import crypto from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors'; import { extractAuth } from '../../lib/auth.js'; import { getEventBus } from '../../lib/event-bus.js'; import { isFeatureEnabled } from '../../lib/feature-flags.js'; import * as repo from './repository.js'; import { CreateRoutineSchema, UpdateRoutineSchema, RoutineQuerySchema, RoutineSyncQuerySchema, BatchUpsertRoutinesSchema, type RoutineDoc, } from './types.js'; import { PRODUCT_ID } from '../../lib/product-config.js'; export async function routineRoutes(app: FastifyInstance) { // Sync — must be before :id param route app.get('/routines/sync', async req => { const auth = await extractAuth(req); const parsed = RoutineSyncQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const routines = await repo.getRoutinesSince( auth.sub, PRODUCT_ID, parsed.data.since, parsed.data.limit ); return { routines, count: routines.length }; }); // List routines app.get('/routines', async req => { const auth = await extractAuth(req); const parsed = RoutineQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { items, total } = await repo.listRoutines(auth.sub, PRODUCT_ID, parsed.data); return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; }); // Get single routine app.get('/routines/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const routine = await repo.getRoutine(id, auth.sub); if (!routine) throw new NotFoundError('Routine not found'); if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found'); return routine; }); // Create routine app.post('/routines', async (req, reply) => { const auth = await extractAuth(req); const parsed = CreateRoutineSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const input = parsed.data; const now = new Date().toISOString(); const doc: RoutineDoc = { id: input.id, userId: auth.sub, productId: PRODUCT_ID, name: input.name, description: input.description, steps: input.steps, totalDurationMinutes: input.totalDurationMinutes, status: input.status, currentStepIndex: input.currentStepIndex, isTemplate: input.isTemplate, category: input.category, createdAt: now, startedAt: input.startedAt, elapsedBeforePause: input.elapsedBeforePause, deviceId: input.deviceId, lastSyncedAt: now, syncVersion: input.syncVersion, }; req.log.info({ routineId: doc.id, isTemplate: doc.isTemplate }, 'Creating routine'); const created = await repo.createRoutine(doc); reply.code(201); return created; }); // Update routine (with syncVersion conflict check) app.put('/routines/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const parsed = UpdateRoutineSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } // Snapshot old state for event detection const oldRoutine = await repo.getRoutine(id, auth.sub); const { syncVersion, ...updates } = parsed.data; const result = await repo.updateRoutine(id, auth.sub, updates, syncVersion); if (result.conflict) { throw new ConflictError( `Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}` ); } if (!result.doc) throw new NotFoundError('Routine not found'); // Emit domain event on completed transition if (oldRoutine && result.doc.status === 'completed' && oldRoutine.status !== 'completed') { getEventBus().emit('routine.completed', { routineId: id, userId: auth.sub, name: result.doc.name }); } req.log.info({ routineId: id, syncVersion }, 'Updated routine'); return result.doc; }); // Delete routine app.delete('/routines/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const success = await repo.deleteRoutine(id, auth.sub); if (!success) throw new NotFoundError('Routine not found'); req.log.info({ routineId: id }, 'Deleted routine'); return { success: true }; }); // ── Phase A.1: Start routine ──────────────────────────────── // POST /routines/:id/start — transition a routine from ready/template → active app.post('/routines/:id/start', async req => { if (!isFeatureEnabled('mcp.enabled')) { throw new BadRequestError('Start routine is not enabled'); } const auth = await extractAuth(req); const { id } = req.params as { id: string }; const routine = await repo.getRoutine(id, auth.sub); if (!routine) throw new NotFoundError('Routine not found'); if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found'); if (routine.status !== 'ready' && routine.status !== 'template') { throw new BadRequestError( `Cannot start routine in status "${routine.status}" — must be "ready" or "template"` ); } const now = new Date().toISOString(); const activeSteps = routine.steps.map((step, i) => ({ ...step, status: i === 0 ? 'active' as const : 'pending' as const, startedAt: i === 0 ? now : undefined, completedAt: undefined, })); if (routine.isTemplate) { // Clone template into a new routine — leave original unchanged const cloneId = crypto.randomUUID(); const clone: RoutineDoc = { ...routine, id: cloneId, isTemplate: false, status: 'active', currentStepIndex: 0, startedAt: now, elapsedBeforePause: 0, steps: activeSteps, createdAt: now, lastSyncedAt: now, syncVersion: 1, }; delete clone._ts; delete clone._etag; const created = await repo.createRoutine(clone); getEventBus().emit('routine.started', { routineId: cloneId, userId: auth.sub, name: routine.name }); req.log.info({ routineId: cloneId, templateId: id }, 'Started routine (cloned from template)'); return created; } // Non-template: update in place const result = await repo.updateRoutine( id, auth.sub, { status: 'active' as const, currentStepIndex: 0, startedAt: now, steps: activeSteps, }, routine.syncVersion ); if (result.conflict) { throw new ConflictError( `Sync conflict: server version is ${result.serverVersion}, received ${routine.syncVersion}` ); } if (!result.doc) throw new NotFoundError('Routine not found'); getEventBus().emit('routine.started', { routineId: id, userId: auth.sub, name: routine.name }); req.log.info({ routineId: id }, 'Started routine'); return result.doc; }); // Batch upsert app.post('/routines/batch', async req => { const auth = await extractAuth(req); const parsed = BatchUpsertRoutinesSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const now = new Date().toISOString(); const enriched = parsed.data.routines.map(r => ({ ...r, createdAt: now, lastSyncedAt: now, })); req.log.info({ count: enriched.length }, 'Batch upsert routines'); const result = await repo.batchUpsertRoutines(auth.sub, PRODUCT_ID, enriched); return result; }); }