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:
parent
0129f5623c
commit
0e7c1aeb15
@ -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
|
||||
);
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user