/** * Timer REST endpoints — ChronoMind cloud sync. * * GET /timers — list user's timers (filterable, paginated) * GET /timers/sync — delta sync (timers modified since timestamp) * GET /timers/:id — single timer * POST /timers — create timer * PUT /timers/:id — update timer (with syncVersion conflict check) * DELETE /timers/:id — delete timer * POST /timers/batch — batch upsert (offline queue flush / initial sync) */ import type { FastifyInstance } from 'fastify'; import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors'; import { extractAuth } from '../../lib/auth.js'; import * as repo from './repository.js'; import { CreateTimerSchema, UpdateTimerSchema, TimerQuerySchema, TimerSyncQuerySchema, BatchUpsertSchema, type TimerDoc, } from './types.js'; const PRODUCT_ID = 'chronomind'; export async function timerRoutes(app: FastifyInstance) { // Sync — must be before :id param route app.get('/timers/sync', async req => { const auth = await extractAuth(req); const parsed = TimerSyncQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const timers = await repo.getTimersSince( auth.sub, PRODUCT_ID, parsed.data.since, parsed.data.limit ); return { timers, count: timers.length }; }); // List timers app.get('/timers', async req => { const auth = await extractAuth(req); const parsed = TimerQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { items, total } = await repo.listTimers(auth.sub, PRODUCT_ID, parsed.data); return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; }); // Get single timer app.get('/timers/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const timer = await repo.getTimer(id, auth.sub); if (!timer) throw new NotFoundError('Timer not found'); if (timer.productId !== PRODUCT_ID) throw new NotFoundError('Timer not found'); return timer; }); // Create timer app.post('/timers', async (req, reply) => { const auth = await extractAuth(req); const parsed = CreateTimerSchema.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: TimerDoc = { id: input.id, userId: auth.sub, productId: PRODUCT_ID, label: input.label, description: input.description, type: input.type, state: input.state, urgency: input.urgency, duration: input.duration, targetTime: input.targetTime, createdAt: now, startedAt: input.startedAt, cascade: input.cascade, pomodoro: input.pomodoro, isCalendarSync: input.isCalendarSync, calendarEventId: input.calendarEventId, category: input.category, deviceId: input.deviceId, lastSyncedAt: now, syncVersion: input.syncVersion, }; req.log.info({ timerId: doc.id, type: doc.type }, 'Creating timer'); const created = await repo.createTimer(doc); reply.code(201); return created; }); // Update timer (with syncVersion conflict check) app.put('/timers/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const parsed = UpdateTimerSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { syncVersion, ...updates } = parsed.data; const result = await repo.updateTimer(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('Timer not found'); req.log.info({ timerId: id, syncVersion }, 'Updated timer'); return result.doc; }); // Delete timer app.delete('/timers/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const success = await repo.deleteTimer(id, auth.sub); if (!success) throw new NotFoundError('Timer not found'); req.log.info({ timerId: id }, 'Deleted timer'); return { success: true }; }); // Sync status — returns timer + routine counts and latest sync timestamp. // MCP tool: chronomind.syncStatus(userId) — instant ops visibility without querying raw data. app.get('/timers/sync-status', async req => { const auth = await extractAuth(req); const { items: timers } = await repo.listTimers(auth.sub, PRODUCT_ID, { limit: 1000, offset: 0, sortBy: 'createdAt', sortOrder: 'desc', }); const active = timers.filter(t => t.state === 'active' || t.state === 'warning').length; const pending = timers.filter(t => t.state === 'paused').length; const lastSyncedAt = timers[0]?.lastSyncedAt ?? null; const unsyncedCount = timers.filter(t => t.lastSyncedAt == null).length; return { userId: auth.sub, productId: PRODUCT_ID, totalTimers: timers.length, active, pending, unsyncedCount, lastSyncedAt, generatedAt: new Date().toISOString(), }; }); // Batch upsert (initial sync / offline queue flush) app.post('/timers/batch', async req => { const auth = await extractAuth(req); const parsed = BatchUpsertSchema.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.timers.map(t => ({ ...t, createdAt: now, lastSyncedAt: now, })); req.log.info({ count: enriched.length }, 'Batch upsert timers'); const result = await repo.batchUpsert(auth.sub, PRODUCT_ID, enriched); return result; }); }