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
|
* 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
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user