diff --git a/backend/src/modules/routines/routes.ts b/backend/src/modules/routines/routes.ts index 2673942..aa93990 100644 --- a/backend/src/modules/routines/routes.ts +++ b/backend/src/modules/routines/routes.ts @@ -10,6 +10,7 @@ * 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'; @@ -111,6 +112,9 @@ export async function routineRoutes(app: FastifyInstance) { 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); @@ -121,6 +125,11 @@ export async function routineRoutes(app: FastifyInstance) { } 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; }); @@ -155,15 +164,39 @@ export async function routineRoutes(app: FastifyInstance) { } const now = new Date().toISOString(); - // TODO-004: Clone template instead of mutating in-place - // Priority: medium | Phase: A.1 - // When routine.isTemplate is true: - // 1. Create a NEW RoutineDoc (crypto.randomUUID() for id) with isTemplate=false - // 2. Copy all fields from the template into the clone - // 3. Set the clone's status to 'active', startedAt to now, first step to 'active' - // 4. Leave the original template unchanged (status stays 'template') - // 5. Return the new clone, not the template - // This lets users reuse templates multiple times without losing the original. + 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, @@ -171,11 +204,7 @@ export async function routineRoutes(app: FastifyInstance) { status: 'active' as const, currentStepIndex: 0, startedAt: now, - steps: routine.steps.map((step, i) => ({ - ...step, - status: i === 0 ? 'active' as const : 'pending' as const, - startedAt: i === 0 ? now : undefined, - })), + steps: activeSteps, }, routine.syncVersion ); diff --git a/backend/src/modules/timers/routes.ts b/backend/src/modules/timers/routes.ts index d8fb6bf..72cb6a1 100644 --- a/backend/src/modules/timers/routes.ts +++ b/backend/src/modules/timers/routes.ts @@ -118,6 +118,9 @@ export async function timerRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } + // Snapshot old state for event detection + const oldTimer = await repo.getTimer(id, auth.sub); + const { syncVersion, ...updates } = parsed.data; const result = await repo.updateTimer(id, auth.sub, updates, syncVersion); @@ -128,6 +131,16 @@ export async function timerRoutes(app: FastifyInstance) { } if (!result.doc) throw new NotFoundError('Timer not found'); + // Emit domain events on state transitions + if (oldTimer && result.doc.state !== oldTimer.state) { + const payload = { timerId: id, userId: auth.sub, label: result.doc.label }; + if (result.doc.state === 'fired') { + getEventBus().emit('timer.fired', payload); + } else if (result.doc.state === 'completed') { + getEventBus().emit('timer.completed', payload); + } + } + req.log.info({ timerId: id, syncVersion }, 'Updated timer'); return result.doc; });