feat(timers): add timer CRUD endpoints for ChronoMind cloud sync (42 new tests, 759 total)
This commit is contained in:
parent
89b6588e1d
commit
c1a1f86cd6
@ -37,6 +37,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
// NomGap fasting modules
|
// NomGap fasting modules
|
||||||
fasting_sessions: { partitionKeyPath: '/userId' },
|
fasting_sessions: { partitionKeyPath: '/userId' },
|
||||||
fasting_protocols: { partitionKeyPath: '/userId' },
|
fasting_protocols: { partitionKeyPath: '/userId' },
|
||||||
|
// ChronoMind timers
|
||||||
|
timers: { partitionKeyPath: '/userId' },
|
||||||
// Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
|
// Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
|
||||||
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
||||||
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },
|
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },
|
||||||
|
|||||||
191
services/platform-service/src/modules/timers/repository.ts
Normal file
191
services/platform-service/src/modules/timers/repository.ts
Normal file
@ -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<number>({
|
||||||
|
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<TimerDoc>({
|
||||||
|
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<TimerDoc | null> {
|
||||||
|
try {
|
||||||
|
const { resource } = await container().item(id, userId).read<TimerDoc>();
|
||||||
|
return resource ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTimer(doc: TimerDoc): Promise<TimerDoc> {
|
||||||
|
const { resource } = await container().items.create(doc);
|
||||||
|
return resource as TimerDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTimer(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
updates: Partial<TimerDoc>,
|
||||||
|
expectedSyncVersion: number
|
||||||
|
): Promise<{ doc: TimerDoc | null; conflict: boolean; serverVersion?: number }> {
|
||||||
|
try {
|
||||||
|
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
|
||||||
|
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<TimerDoc[]> {
|
||||||
|
const { resources } = await container()
|
||||||
|
.items.query<TimerDoc>({
|
||||||
|
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<Record<string, unknown> & { id: string; syncVersion: number }>
|
||||||
|
): Promise<BatchUpsertResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
158
services/platform-service/src/modules/timers/routes.ts
Normal file
158
services/platform-service/src/modules/timers/routes.ts
Normal file
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
408
services/platform-service/src/modules/timers/timers.test.ts
Normal file
408
services/platform-service/src/modules/timers/timers.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
175
services/platform-service/src/modules/timers/types.ts
Normal file
175
services/platform-service/src/modules/timers/types.ts
Normal file
@ -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<typeof CreateTimerSchema>;
|
||||||
|
export type UpdateTimerInput = z.infer<typeof UpdateTimerSchema>;
|
||||||
|
export type TimerQuery = z.infer<typeof TimerQuerySchema>;
|
||||||
|
export type TimerSyncQuery = z.infer<typeof TimerSyncQuerySchema>;
|
||||||
|
export type BatchUpsertInput = z.infer<typeof BatchUpsertSchema>;
|
||||||
|
|
||||||
|
// ── Batch result ──
|
||||||
|
|
||||||
|
export interface BatchUpsertResult {
|
||||||
|
synced: string[];
|
||||||
|
conflicts: Array<{ id: string; serverVersion: number }>;
|
||||||
|
errors: Array<{ id: string; error: string }>;
|
||||||
|
}
|
||||||
@ -50,6 +50,7 @@ import { telemetryRoutes } from './modules/telemetry/routes.js';
|
|||||||
import { fastingSessionRoutes } from './modules/fasting-sessions/routes.js';
|
import { fastingSessionRoutes } from './modules/fasting-sessions/routes.js';
|
||||||
import { fastingProtocolRoutes } from './modules/fasting-protocols/routes.js';
|
import { fastingProtocolRoutes } from './modules/fasting-protocols/routes.js';
|
||||||
import { bodyStageRoutes } from './modules/body-stages/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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { config } from './lib/config.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(fastingSessionRoutes, { prefix: '/api' });
|
||||||
await app.register(fastingProtocolRoutes, { prefix: '/api' });
|
await app.register(fastingProtocolRoutes, { prefix: '/api' });
|
||||||
await app.register(bodyStageRoutes, { 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 });
|
await startService(app, { port: config.PORT, host: config.HOST });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user