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
|
||||
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 },
|
||||
|
||||
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 { 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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user