learning_ai_clock/backend/src/modules/routines/routes.ts
saravanakumardb1 0e7c1aeb15 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.
2026-04-13 17:00:05 -07:00

244 lines
8.4 KiB
TypeScript

/**
* Routine REST endpoints — ChronoMind cloud sync.
*
* GET /routines — list user's routines (filterable, paginated)
* GET /routines/sync — delta sync (routines modified since timestamp)
* GET /routines/:id — single routine
* POST /routines — create routine
* PUT /routines/:id — update routine (with syncVersion conflict check)
* DELETE /routines/:id — delete routine
* 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';
import { getEventBus } from '../../lib/event-bus.js';
import { isFeatureEnabled } from '../../lib/feature-flags.js';
import * as repo from './repository.js';
import {
CreateRoutineSchema,
UpdateRoutineSchema,
RoutineQuerySchema,
RoutineSyncQuerySchema,
BatchUpsertRoutinesSchema,
type RoutineDoc,
} from './types.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
export async function routineRoutes(app: FastifyInstance) {
// Sync — must be before :id param route
app.get('/routines/sync', async req => {
const auth = await extractAuth(req);
const parsed = RoutineSyncQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const routines = await repo.getRoutinesSince(
auth.sub,
PRODUCT_ID,
parsed.data.since,
parsed.data.limit
);
return { routines, count: routines.length };
});
// List routines
app.get('/routines', async req => {
const auth = await extractAuth(req);
const parsed = RoutineQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listRoutines(auth.sub, PRODUCT_ID, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Get single routine
app.get('/routines/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const routine = await repo.getRoutine(id, auth.sub);
if (!routine) throw new NotFoundError('Routine not found');
if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found');
return routine;
});
// Create routine
app.post('/routines', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateRoutineSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: RoutineDoc = {
id: input.id,
userId: auth.sub,
productId: PRODUCT_ID,
name: input.name,
description: input.description,
steps: input.steps,
totalDurationMinutes: input.totalDurationMinutes,
status: input.status,
currentStepIndex: input.currentStepIndex,
isTemplate: input.isTemplate,
category: input.category,
createdAt: now,
startedAt: input.startedAt,
elapsedBeforePause: input.elapsedBeforePause,
deviceId: input.deviceId,
lastSyncedAt: now,
syncVersion: input.syncVersion,
};
req.log.info({ routineId: doc.id, isTemplate: doc.isTemplate }, 'Creating routine');
const created = await repo.createRoutine(doc);
reply.code(201);
return created;
});
// Update routine (with syncVersion conflict check)
app.put('/routines/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = UpdateRoutineSchema.safeParse(req.body);
if (!parsed.success) {
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);
if (result.conflict) {
throw new ConflictError(
`Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}`
);
}
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;
});
// Delete routine
app.delete('/routines/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const success = await repo.deleteRoutine(id, auth.sub);
if (!success) throw new NotFoundError('Routine not found');
req.log.info({ routineId: id }, 'Deleted routine');
return { success: true };
});
// ── Phase A.1: Start routine ────────────────────────────────
// POST /routines/:id/start — transition a routine from ready/template → active
app.post('/routines/:id/start', async req => {
if (!isFeatureEnabled('mcp.enabled')) {
throw new BadRequestError('Start routine is not enabled');
}
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const routine = await repo.getRoutine(id, auth.sub);
if (!routine) throw new NotFoundError('Routine not found');
if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found');
if (routine.status !== 'ready' && routine.status !== 'template') {
throw new BadRequestError(
`Cannot start routine in status "${routine.status}" — must be "ready" or "template"`
);
}
const now = new Date().toISOString();
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,
{
status: 'active' as const,
currentStepIndex: 0,
startedAt: now,
steps: activeSteps,
},
routine.syncVersion
);
if (result.conflict) {
throw new ConflictError(
`Sync conflict: server version is ${result.serverVersion}, received ${routine.syncVersion}`
);
}
if (!result.doc) throw new NotFoundError('Routine not found');
getEventBus().emit('routine.started', { routineId: id, userId: auth.sub, name: routine.name });
req.log.info({ routineId: id }, 'Started routine');
return result.doc;
});
// Batch upsert
app.post('/routines/batch', async req => {
const auth = await extractAuth(req);
const parsed = BatchUpsertRoutinesSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const now = new Date().toISOString();
const enriched = parsed.data.routines.map(r => ({
...r,
createdAt: now,
lastSyncedAt: now,
}));
req.log.info({ count: enriched.length }, 'Batch upsert routines');
const result = await repo.batchUpsertRoutines(auth.sub, PRODUCT_ID, enriched);
return result;
});
}