187 lines
6.1 KiB
TypeScript
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;
|
|
});
|
|
}
|