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
*/
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
);

View File

@ -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;
});