- 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.
244 lines
8.4 KiB
TypeScript
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;
|
|
});
|
|
}
|