From c1a1f86cd60e1c962fa12051f4783db1387b1692 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 23:48:31 -0800 Subject: [PATCH] feat(timers): add timer CRUD endpoints for ChronoMind cloud sync (42 new tests, 759 total) --- .../platform-service/src/lib/cosmos-init.ts | 2 + .../src/modules/timers/repository.ts | 191 ++++++++ .../src/modules/timers/routes.ts | 158 +++++++ .../src/modules/timers/timers.test.ts | 408 ++++++++++++++++++ .../src/modules/timers/types.ts | 175 ++++++++ services/platform-service/src/server.ts | 3 + 6 files changed, 937 insertions(+) create mode 100644 services/platform-service/src/modules/timers/repository.ts create mode 100644 services/platform-service/src/modules/timers/routes.ts create mode 100644 services/platform-service/src/modules/timers/timers.test.ts create mode 100644 services/platform-service/src/modules/timers/types.ts diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 9bd3e8bc..708b3144 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -37,6 +37,8 @@ const CONTAINER_DEFS: Record = { // NomGap fasting modules fasting_sessions: { partitionKeyPath: '/userId' }, fasting_protocols: { partitionKeyPath: '/userId' }, + // ChronoMind timers + timers: { partitionKeyPath: '/userId' }, // Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md) telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 }, telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 }, diff --git a/services/platform-service/src/modules/timers/repository.ts b/services/platform-service/src/modules/timers/repository.ts new file mode 100644 index 00000000..197ffd9e --- /dev/null +++ b/services/platform-service/src/modules/timers/repository.ts @@ -0,0 +1,191 @@ +/** + * Timers repository — Cosmos DB CRUD + sync + batch upsert. + * + * Container: timers (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { TimerDoc, TimerQuery, BatchUpsertResult } from './types.js'; + +function container() { + return getContainer('timers'); +} + +export async function listTimers( + userId: string, + productId: string, + query: TimerQuery +): Promise<{ items: TimerDoc[]; total: number }> { + const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId']; + const params: { name: string; value: string | number }[] = [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ]; + + if (query.state) { + conditions.push('c.state = @state'); + params.push({ name: '@state', value: query.state }); + } + if (query.type) { + conditions.push('c.type = @type'); + params.push({ name: '@type', value: query.type }); + } + if (query.urgency) { + conditions.push('c.urgency = @urgency'); + params.push({ name: '@urgency', value: query.urgency }); + } + if (query.category) { + conditions.push('c.category = @category'); + params.push({ name: '@category', value: query.category }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const sortField = `c.${query.sortBy}`; + const orderDir = query.sortOrder.toUpperCase(); + + // Count query + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + // Data query with pagination + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getTimer(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createTimer(doc: TimerDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as TimerDoc; +} + +export async function updateTimer( + id: string, + userId: string, + updates: Partial, + expectedSyncVersion: number +): Promise<{ doc: TimerDoc | null; conflict: boolean; serverVersion?: number }> { + try { + const { resource: existing } = await container().item(id, userId).read(); + if (!existing) return { doc: null, conflict: false }; + + // Optimistic concurrency: reject stale writes + if (expectedSyncVersion <= existing.syncVersion) { + return { doc: null, conflict: true, serverVersion: existing.syncVersion }; + } + + const now = new Date().toISOString(); + const merged: TimerDoc = { + ...existing, + ...updates, + syncVersion: expectedSyncVersion, + lastSyncedAt: now, + }; + const { resource } = await container().item(id, userId).replace(merged); + return { doc: resource as TimerDoc, conflict: false }; + } catch { + return { doc: null, conflict: false }; + } +} + +export async function deleteTimer(id: string, userId: string): Promise { + try { + const { resource: existing } = await container().item(id, userId).read(); + if (!existing) return false; + await container().item(id, userId).delete(); + return true; + } catch { + return false; + } +} + +export async function getTimersSince( + userId: string, + productId: string, + sinceTimestamp: string, + limit: number +): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@since', value: sinceTimestamp }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +export async function batchUpsert( + userId: string, + productId: string, + timers: Array & { id: string; syncVersion: number }> +): Promise { + const synced: string[] = []; + const conflicts: Array<{ id: string; serverVersion: number }> = []; + const errors: Array<{ id: string; error: string }> = []; + + for (const timer of timers) { + try { + const existing = await getTimer(timer.id, userId); + const now = new Date().toISOString(); + + if (existing) { + // Upsert: accept if incoming syncVersion >= existing + if (timer.syncVersion >= existing.syncVersion) { + const merged: TimerDoc = { + ...existing, + ...timer, + userId, + productId, + lastSyncedAt: now, + }; + await container().item(timer.id, userId).replace(merged); + synced.push(timer.id); + } else { + conflicts.push({ id: timer.id, serverVersion: existing.syncVersion }); + } + } else { + // New document + const doc: TimerDoc = { + ...timer, + userId, + productId, + lastSyncedAt: now, + } as TimerDoc; + await container().items.create(doc); + synced.push(timer.id); + } + } catch (err) { + errors.push({ id: timer.id, error: err instanceof Error ? err.message : 'Unknown error' }); + } + } + + return { synced, conflicts, errors }; +} diff --git a/services/platform-service/src/modules/timers/routes.ts b/services/platform-service/src/modules/timers/routes.ts new file mode 100644 index 00000000..37fbb48f --- /dev/null +++ b/services/platform-service/src/modules/timers/routes.ts @@ -0,0 +1,158 @@ +/** + * 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 '../../lib/errors.js'; +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 }; + }); + + // 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; + }); +} diff --git a/services/platform-service/src/modules/timers/timers.test.ts b/services/platform-service/src/modules/timers/timers.test.ts new file mode 100644 index 00000000..7242a522 --- /dev/null +++ b/services/platform-service/src/modules/timers/timers.test.ts @@ -0,0 +1,408 @@ +/** + * Timers module unit tests — validates schemas, constants, and type guards. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateTimerSchema, + UpdateTimerSchema, + TimerQuerySchema, + TimerSyncQuerySchema, + BatchUpsertSchema, + TIMER_TYPES, + TIMER_STATES, + URGENCY_LEVELS, + CASCADE_PRESETS, +} from './types.js'; + +// ── Constants ── + +describe('type constants', () => { + it('has 3 timer types', () => { + expect(TIMER_TYPES).toEqual(['countdown', 'alarm', 'pomodoro']); + }); + + it('has 7 timer states', () => { + expect(TIMER_STATES).toEqual([ + 'active', + 'paused', + 'fired', + 'snoozed', + 'dismissed', + 'completed', + 'warning', + ]); + expect(TIMER_STATES).toHaveLength(7); + }); + + it('has 5 urgency levels', () => { + expect(URGENCY_LEVELS).toEqual(['critical', 'important', 'standard', 'gentle', 'passive']); + expect(URGENCY_LEVELS).toHaveLength(5); + }); + + it('has 4 cascade presets', () => { + expect(CASCADE_PRESETS).toEqual(['minimal', 'standard', 'aggressive', 'custom']); + }); +}); + +// ── CreateTimerSchema ── + +describe('CreateTimerSchema', () => { + const validMinimal = { + id: 'timer_001', + label: 'Morning alarm', + type: 'alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }; + + it('accepts minimal valid input with defaults', () => { + const result = CreateTimerSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.id).toBe('timer_001'); + expect(result.data.label).toBe('Morning alarm'); + expect(result.data.type).toBe('alarm'); + expect(result.data.state).toBe('active'); + expect(result.data.urgency).toBe('standard'); + expect(result.data.syncVersion).toBe(1); + } + }); + + it('accepts full countdown timer with cascade', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + type: 'countdown', + state: 'active', + urgency: 'critical', + duration: 300, + description: 'Important meeting prep', + cascade: { + preset: 'aggressive', + intervals: [30, 15, 5, 1], + }, + deviceId: 'iphone-14-pro', + category: 'work', + syncVersion: 3, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.cascade?.preset).toBe('aggressive'); + expect(result.data.cascade?.intervals).toEqual([30, 15, 5, 1]); + expect(result.data.urgency).toBe('critical'); + expect(result.data.syncVersion).toBe(3); + } + }); + + it('accepts pomodoro timer with full config', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + type: 'pomodoro', + pomodoro: { + focusMinutes: 25, + shortBreakMinutes: 5, + longBreakMinutes: 15, + roundsBeforeLong: 4, + currentRound: 1, + isBreak: false, + totalRoundsCompleted: 0, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.pomodoro?.focusMinutes).toBe(25); + expect(result.data.pomodoro?.roundsBeforeLong).toBe(4); + } + }); + + it('accepts timer with calendar sync', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + isCalendarSync: true, + calendarEventId: 'cal_abc123', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing id', () => { + const result = CreateTimerSchema.safeParse({ + label: 'Morning alarm', + type: 'alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }); + expect(result.success).toBe(false); + }); + + it('rejects missing label', () => { + const result = CreateTimerSchema.safeParse({ + id: 'timer_001', + type: 'alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }); + expect(result.success).toBe(false); + }); + + it('rejects missing type', () => { + const result = CreateTimerSchema.safeParse({ + id: 'timer_001', + label: 'Morning alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid type', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, type: 'stopwatch' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid state', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, state: 'deleted' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid urgency', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, urgency: 'extreme' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid targetTime format', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, targetTime: 'not-a-date' }); + expect(result.success).toBe(false); + }); + + it('rejects negative duration', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, duration: -10 }); + expect(result.success).toBe(false); + }); + + it('rejects label > 500 chars', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, label: 'x'.repeat(501) }); + expect(result.success).toBe(false); + }); + + it('rejects pomodoro focusMinutes > 120', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + pomodoro: { + focusMinutes: 150, + shortBreakMinutes: 5, + longBreakMinutes: 15, + roundsBeforeLong: 4, + currentRound: 0, + isBreak: false, + totalRoundsCompleted: 0, + }, + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateTimerSchema ── + +describe('UpdateTimerSchema', () => { + it('accepts state update with syncVersion', () => { + const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 2 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.state).toBe('paused'); + expect(result.data.syncVersion).toBe(2); + } + }); + + it('accepts complete update with timestamps', () => { + const result = UpdateTimerSchema.safeParse({ + state: 'completed', + completedAt: '2026-03-01T08:00:00.000Z', + syncVersion: 5, + }); + expect(result.success).toBe(true); + }); + + it('accepts pomodoro round update', () => { + const result = UpdateTimerSchema.safeParse({ + pomodoro: { + focusMinutes: 25, + shortBreakMinutes: 5, + longBreakMinutes: 15, + roundsBeforeLong: 4, + currentRound: 3, + isBreak: true, + totalRoundsCompleted: 2, + }, + syncVersion: 4, + }); + expect(result.success).toBe(true); + }); + + it('requires syncVersion', () => { + const result = UpdateTimerSchema.safeParse({ state: 'paused' }); + expect(result.success).toBe(false); + }); + + it('rejects syncVersion < 1', () => { + const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 0 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid state', () => { + const result = UpdateTimerSchema.safeParse({ state: 'deleted', syncVersion: 2 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid completedAt format', () => { + const result = UpdateTimerSchema.safeParse({ + completedAt: 'yesterday', + syncVersion: 2, + }); + expect(result.success).toBe(false); + }); +}); + +// ── TimerQuerySchema ── + +describe('TimerQuerySchema', () => { + it('provides defaults for empty query', () => { + const result = TimerQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('createdAt'); + expect(result.data.sortOrder).toBe('desc'); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('coerces string numbers for limit and offset', () => { + const result = TimerQuerySchema.safeParse({ limit: '25', offset: '10' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + expect(result.data.offset).toBe(10); + } + }); + + it('accepts all filter combinations', () => { + const result = TimerQuerySchema.safeParse({ + state: 'active', + type: 'pomodoro', + urgency: 'critical', + category: 'work', + sortBy: 'targetTime', + sortOrder: 'asc', + }); + expect(result.success).toBe(true); + }); + + it('rejects limit > 100', () => { + const result = TimerQuerySchema.safeParse({ limit: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects negative offset', () => { + const result = TimerQuerySchema.safeParse({ offset: -1 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid sortBy', () => { + const result = TimerQuerySchema.safeParse({ sortBy: 'random' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid state filter', () => { + const result = TimerQuerySchema.safeParse({ state: 'deleted' }); + expect(result.success).toBe(false); + }); +}); + +// ── TimerSyncQuerySchema ── + +describe('TimerSyncQuerySchema', () => { + it('accepts valid since timestamp', () => { + const result = TimerSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(100); + } + }); + + it('accepts custom limit', () => { + const result = TimerSyncQuerySchema.safeParse({ + since: '2026-03-01T00:00:00.000Z', + limit: '50', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(50); + } + }); + + it('rejects missing since', () => { + const result = TimerSyncQuerySchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects invalid since format', () => { + const result = TimerSyncQuerySchema.safeParse({ since: 'yesterday' }); + expect(result.success).toBe(false); + }); + + it('rejects limit > 500', () => { + const result = TimerSyncQuerySchema.safeParse({ + since: '2026-03-01T00:00:00.000Z', + limit: 1000, + }); + expect(result.success).toBe(false); + }); +}); + +// ── BatchUpsertSchema ── + +describe('BatchUpsertSchema', () => { + const validTimer = { + id: 'timer_batch_1', + label: 'Batch timer', + type: 'countdown', + duration: 600, + targetTime: '2026-03-01T10:00:00.000Z', + }; + + it('accepts array of valid timers', () => { + const result = BatchUpsertSchema.safeParse({ + timers: [validTimer, { ...validTimer, id: 'timer_batch_2', label: 'Second timer' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.timers).toHaveLength(2); + } + }); + + it('rejects empty timers array', () => { + const result = BatchUpsertSchema.safeParse({ timers: [] }); + expect(result.success).toBe(false); + }); + + it('rejects missing timers field', () => { + const result = BatchUpsertSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('validates each timer in the array', () => { + const result = BatchUpsertSchema.safeParse({ + timers: [validTimer, { id: 'bad', type: 'invalid' }], + }); + expect(result.success).toBe(false); + }); + + it('rejects > 100 timers', () => { + const timers = Array.from({ length: 101 }, (_, i) => ({ + ...validTimer, + id: `timer_${i}`, + })); + const result = BatchUpsertSchema.safeParse({ timers }); + expect(result.success).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/timers/types.ts b/services/platform-service/src/modules/timers/types.ts new file mode 100644 index 00000000..0c36a622 --- /dev/null +++ b/services/platform-service/src/modules/timers/types.ts @@ -0,0 +1,175 @@ +/** + * Timer types — ChronoMind cross-platform cloud sync. + * + * Cosmos container: `timers` (partition key: `/userId`) + * Product ID: "chronomind" + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const; +export type TimerType = (typeof TIMER_TYPES)[number]; + +export const TIMER_STATES = [ + 'active', + 'paused', + 'fired', + 'snoozed', + 'dismissed', + 'completed', + 'warning', +] as const; +export type TimerState = (typeof TIMER_STATES)[number]; + +export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const; +export type UrgencyLevel = (typeof URGENCY_LEVELS)[number]; + +export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const; +export type CascadePreset = (typeof CASCADE_PRESETS)[number]; + +// ── Sub-document interfaces ── + +export interface CascadeConfig { + preset: CascadePreset; + intervals: number[]; +} + +export interface PomodoroConfig { + focusMinutes: number; + shortBreakMinutes: number; + longBreakMinutes: number; + roundsBeforeLong: number; + currentRound: number; + isBreak: boolean; + totalRoundsCompleted: number; +} + +// ── Main document ── + +export interface TimerDoc { + id: string; + userId: string; + productId: string; + + label: string; + description?: string; + type: TimerType; + state: TimerState; + urgency: UrgencyLevel; + + duration: number; + targetTime: string; + createdAt: string; + startedAt?: string; + pausedAt?: string; + firedAt?: string; + completedAt?: string; + + cascade?: CascadeConfig; + pomodoro?: PomodoroConfig; + + isCalendarSync?: boolean; + calendarEventId?: string; + category?: string; + + deviceId?: string; + lastSyncedAt?: string; + syncVersion: number; + + _ts?: number; + _etag?: string; +} + +// ── Zod schemas ── + +const CascadeSchema = z.object({ + preset: z.enum(CASCADE_PRESETS), + intervals: z.array(z.number().int().min(0)), +}); + +const PomodoroSchema = z.object({ + focusMinutes: z.number().int().min(1).max(120), + shortBreakMinutes: z.number().int().min(1).max(60), + longBreakMinutes: z.number().int().min(1).max(120), + roundsBeforeLong: z.number().int().min(1).max(20), + currentRound: z.number().int().min(0), + isBreak: z.boolean(), + totalRoundsCompleted: z.number().int().min(0), +}); + +export const CreateTimerSchema = z.object({ + id: z.string().min(1).max(128), + label: z.string().min(1).max(500), + description: z.string().max(2000).optional(), + type: z.enum(TIMER_TYPES), + state: z.enum(TIMER_STATES).default('active'), + urgency: z.enum(URGENCY_LEVELS).default('standard'), + duration: z.number().int().min(0), + targetTime: z.string().datetime(), + cascade: CascadeSchema.optional(), + pomodoro: PomodoroSchema.optional(), + isCalendarSync: z.boolean().optional(), + calendarEventId: z.string().max(500).optional(), + category: z.string().max(128).optional(), + deviceId: z.string().max(256).optional(), + startedAt: z.string().datetime().optional(), + syncVersion: z.number().int().min(0).default(1), +}); + +export const UpdateTimerSchema = z.object({ + label: z.string().min(1).max(500).optional(), + description: z.string().max(2000).optional(), + state: z.enum(TIMER_STATES).optional(), + urgency: z.enum(URGENCY_LEVELS).optional(), + duration: z.number().int().min(0).optional(), + targetTime: z.string().datetime().optional(), + startedAt: z.string().datetime().optional(), + pausedAt: z.string().datetime().optional(), + firedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), + cascade: CascadeSchema.optional(), + pomodoro: PomodoroSchema.optional(), + isCalendarSync: z.boolean().optional(), + calendarEventId: z.string().max(500).optional(), + category: z.string().max(128).optional(), + deviceId: z.string().max(256).optional(), + syncVersion: z.number().int().min(1), +}); + +export const TimerQuerySchema = z.object({ + state: z.enum(TIMER_STATES).optional(), + type: z.enum(TIMER_TYPES).optional(), + urgency: z.enum(URGENCY_LEVELS).optional(), + category: z.string().optional(), + sortBy: z.enum(['createdAt', 'targetTime', 'label']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export const TimerSyncQuerySchema = z.object({ + since: z.string().datetime(), + limit: z.coerce.number().int().min(1).max(500).default(100), +}); + +export const BatchUpsertSchema = z.object({ + timers: z.array(CreateTimerSchema).min(1).max(100), +}); + +// ── Inferred types ── + +export type CreateTimerInput = z.infer; +export type UpdateTimerInput = z.infer; +export type TimerQuery = z.infer; +export type TimerSyncQuery = z.infer; +export type BatchUpsertInput = z.infer; + +// ── Batch result ── + +export interface BatchUpsertResult { + synced: string[]; + conflicts: Array<{ id: string; serverVersion: number }>; + errors: Array<{ id: string; error: string }>; +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index da3f7897..ad4773d3 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -50,6 +50,7 @@ import { telemetryRoutes } from './modules/telemetry/routes.js'; import { fastingSessionRoutes } from './modules/fasting-sessions/routes.js'; import { fastingProtocolRoutes } from './modules/fasting-protocols/routes.js'; import { bodyStageRoutes } from './modules/body-stages/routes.js'; +import { timerRoutes } from './modules/timers/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; @@ -128,5 +129,7 @@ await app.register(publicRoutes, { prefix: '/api' }); await app.register(fastingSessionRoutes, { prefix: '/api' }); await app.register(fastingProtocolRoutes, { prefix: '/api' }); await app.register(bodyStageRoutes, { prefix: '/api' }); +// ChronoMind timer module +await app.register(timerRoutes, { prefix: '/api' }); await startService(app, { port: config.PORT, host: config.HOST });