learning_ai_clock/backend/src/modules/timers/routes.ts

187 lines
6.1 KiB
TypeScript

/**
* 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;
});
}