feat(backend): state-change event detection + clone template on start

- PUT /timers/🆔 detect state→fired and state→completed transitions,
  emit timer.fired / timer.completed domain events
- PUT /routines/🆔 detect status→completed transition,
  emit routine.completed domain event
- POST /routines/:id/start (TODO-004): when isTemplate=true, clone the
  template into a new RoutineDoc instead of mutating in-place. Original
  template stays reusable. Non-templates still update in place.

All 6 ChronoMind event types are now fully wired end-to-end.
This commit is contained in:
saravanakumardb1 2026-04-13 17:00:05 -07:00
parent 0129f5623c
commit 0e7c1aeb15
2 changed files with 56 additions and 14 deletions

View File

@ -10,6 +10,7 @@
* POST /routines/batch batch upsert * POST /routines/batch batch upsert
*/ */
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors'; import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors';
import { extractAuth } from '../../lib/auth.js'; 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('; ')); 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 { syncVersion, ...updates } = parsed.data;
const result = await repo.updateRoutine(id, auth.sub, updates, syncVersion); 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'); 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'); req.log.info({ routineId: id, syncVersion }, 'Updated routine');
return result.doc; return result.doc;
}); });
@ -155,15 +164,39 @@ export async function routineRoutes(app: FastifyInstance) {
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
// TODO-004: Clone template instead of mutating in-place const activeSteps = routine.steps.map((step, i) => ({
// Priority: medium | Phase: A.1 ...step,
// When routine.isTemplate is true: status: i === 0 ? 'active' as const : 'pending' as const,
// 1. Create a NEW RoutineDoc (crypto.randomUUID() for id) with isTemplate=false startedAt: i === 0 ? now : undefined,
// 2. Copy all fields from the template into the clone completedAt: undefined,
// 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 if (routine.isTemplate) {
// This lets users reuse templates multiple times without losing the original. // 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( const result = await repo.updateRoutine(
id, id,
auth.sub, auth.sub,
@ -171,11 +204,7 @@ export async function routineRoutes(app: FastifyInstance) {
status: 'active' as const, status: 'active' as const,
currentStepIndex: 0, currentStepIndex: 0,
startedAt: now, startedAt: now,
steps: routine.steps.map((step, i) => ({ steps: activeSteps,
...step,
status: i === 0 ? 'active' as const : 'pending' as const,
startedAt: i === 0 ? now : undefined,
})),
}, },
routine.syncVersion routine.syncVersion
); );

View File

@ -118,6 +118,9 @@ export async function timerRoutes(app: FastifyInstance) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); 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 { syncVersion, ...updates } = parsed.data;
const result = await repo.updateTimer(id, auth.sub, updates, syncVersion); 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'); 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'); req.log.info({ timerId: id, syncVersion }, 'Updated timer');
return result.doc; return result.doc;
}); });