feat(chronomind): add routines, households, shared-timers modules (88 new tests, 847 total)

This commit is contained in:
saravanakumardb1 2026-02-28 00:05:18 -08:00
parent eae39cbd4e
commit b4237acaa2
14 changed files with 2137 additions and 1 deletions

View File

@ -39,6 +39,9 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
fasting_protocols: { partitionKeyPath: '/userId' },
// ChronoMind timers
timers: { partitionKeyPath: '/userId' },
routines: { partitionKeyPath: '/userId' },
households: { partitionKeyPath: '/id' },
shared_timers: { partitionKeyPath: '/householdId' },
// 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 },

View File

@ -0,0 +1,198 @@
/**
* Households module unit tests validates schemas, constants, and types.
*/
import { describe, it, expect } from 'vitest';
import {
CreateHouseholdSchema,
UpdateHouseholdSchema,
CreateInviteSchema,
AcceptInviteSchema,
RemoveMemberSchema,
HouseholdQuerySchema,
MEMBER_ROLES,
INVITE_STATUSES,
MAX_HOUSEHOLD_MEMBERS,
} from './types.js';
// ── Constants ──
describe('household constants', () => {
it('has 2 member roles', () => {
expect(MEMBER_ROLES).toEqual(['admin', 'member']);
});
it('has 4 invite statuses', () => {
expect(INVITE_STATUSES).toEqual(['pending', 'accepted', 'expired', 'revoked']);
});
it('has max 6 members', () => {
expect(MAX_HOUSEHOLD_MEMBERS).toBe(6);
});
});
// ── CreateHouseholdSchema ──
describe('CreateHouseholdSchema', () => {
it('accepts valid input', () => {
const result = CreateHouseholdSchema.safeParse({
name: 'Smith Family',
displayName: 'John Smith',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Smith Family');
expect(result.data.displayName).toBe('John Smith');
}
});
it('rejects missing name', () => {
const result = CreateHouseholdSchema.safeParse({ displayName: 'John' });
expect(result.success).toBe(false);
});
it('rejects missing displayName', () => {
const result = CreateHouseholdSchema.safeParse({ name: 'Family' });
expect(result.success).toBe(false);
});
it('rejects empty name', () => {
const result = CreateHouseholdSchema.safeParse({ name: '', displayName: 'John' });
expect(result.success).toBe(false);
});
it('rejects name > 200 chars', () => {
const result = CreateHouseholdSchema.safeParse({
name: 'x'.repeat(201),
displayName: 'John',
});
expect(result.success).toBe(false);
});
});
// ── UpdateHouseholdSchema ──
describe('UpdateHouseholdSchema', () => {
it('accepts name update', () => {
const result = UpdateHouseholdSchema.safeParse({ name: 'New Name' });
expect(result.success).toBe(true);
});
it('accepts empty update', () => {
const result = UpdateHouseholdSchema.safeParse({});
expect(result.success).toBe(true);
});
it('rejects empty name string', () => {
const result = UpdateHouseholdSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
});
// ── CreateInviteSchema ──
describe('CreateInviteSchema', () => {
it('provides default 72h expiry', () => {
const result = CreateInviteSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.expiresInHours).toBe(72);
}
});
it('accepts custom expiry', () => {
const result = CreateInviteSchema.safeParse({ expiresInHours: 24 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.expiresInHours).toBe(24);
}
});
it('rejects expiry > 168 hours', () => {
const result = CreateInviteSchema.safeParse({ expiresInHours: 200 });
expect(result.success).toBe(false);
});
it('rejects expiry < 1 hour', () => {
const result = CreateInviteSchema.safeParse({ expiresInHours: 0 });
expect(result.success).toBe(false);
});
});
// ── AcceptInviteSchema ──
describe('AcceptInviteSchema', () => {
it('accepts valid invite', () => {
const result = AcceptInviteSchema.safeParse({
code: 'ABC123DEF456',
displayName: 'Jane Smith',
});
expect(result.success).toBe(true);
});
it('rejects missing code', () => {
const result = AcceptInviteSchema.safeParse({ displayName: 'Jane' });
expect(result.success).toBe(false);
});
it('rejects missing displayName', () => {
const result = AcceptInviteSchema.safeParse({ code: 'ABC123' });
expect(result.success).toBe(false);
});
it('rejects empty code', () => {
const result = AcceptInviteSchema.safeParse({ code: '', displayName: 'Jane' });
expect(result.success).toBe(false);
});
});
// ── RemoveMemberSchema ──
describe('RemoveMemberSchema', () => {
it('accepts valid userId', () => {
const result = RemoveMemberSchema.safeParse({ userId: 'user_123' });
expect(result.success).toBe(true);
});
it('rejects empty userId', () => {
const result = RemoveMemberSchema.safeParse({ userId: '' });
expect(result.success).toBe(false);
});
it('rejects missing userId', () => {
const result = RemoveMemberSchema.safeParse({});
expect(result.success).toBe(false);
});
});
// ── HouseholdQuerySchema ──
describe('HouseholdQuerySchema', () => {
it('provides defaults for empty query', () => {
const result = HouseholdQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
expect(result.data.offset).toBe(0);
}
});
it('coerces string numbers', () => {
const result = HouseholdQuerySchema.safeParse({ limit: '10', offset: '5' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(10);
expect(result.data.offset).toBe(5);
}
});
it('rejects limit > 50', () => {
const result = HouseholdQuerySchema.safeParse({ limit: 100 });
expect(result.success).toBe(false);
});
it('rejects negative offset', () => {
const result = HouseholdQuerySchema.safeParse({ offset: -1 });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,95 @@
/**
* Households repository Cosmos DB CRUD for household membership.
*
* Container: households (partition key: /id)
*
* Unlike timers/routines (partitioned by /userId), households are
* partitioned by their own /id since multiple users share the same doc.
*/
import { getContainer } from '../../lib/cosmos.js';
import type { HouseholdDoc, HouseholdQuery } from './types.js';
function container() {
return getContainer('households');
}
export async function getHousehold(id: string): Promise<HouseholdDoc | null> {
try {
const { resource } = await container().item(id, id).read<HouseholdDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function createHousehold(doc: HouseholdDoc): Promise<HouseholdDoc> {
const { resource } = await container().items.create(doc);
return resource as HouseholdDoc;
}
export async function replaceHousehold(doc: HouseholdDoc): Promise<HouseholdDoc> {
const { resource } = await container().item(doc.id, doc.id).replace(doc);
return resource as HouseholdDoc;
}
export async function deleteHousehold(id: string): Promise<boolean> {
try {
const existing = await getHousehold(id);
if (!existing) return false;
await container().item(id, id).delete();
return true;
} catch {
return false;
}
}
export async function listHouseholdsForUser(
userId: string,
productId: string,
query: HouseholdQuery
): Promise<{ items: HouseholdDoc[]; total: number }> {
const countResult = await container()
.items.query<number>({
query:
'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND ARRAY_CONTAINS(c.members, { "userId": @userId }, true)',
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
})
.fetchAll();
const total = countResult.resources[0] ?? 0;
const { resources } = await container()
.items.query<HouseholdDoc>({
query:
'SELECT * FROM c WHERE c.productId = @productId AND ARRAY_CONTAINS(c.members, { "userId": @userId }, true) ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit',
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
{ name: '@offset', value: query.offset },
{ name: '@limit', value: query.limit },
],
})
.fetchAll();
return { items: resources, total };
}
export async function findHouseholdByInviteCode(
code: string,
productId: string
): Promise<HouseholdDoc | null> {
const { resources } = await container()
.items.query<HouseholdDoc>({
query:
'SELECT * FROM c WHERE c.productId = @productId AND ARRAY_CONTAINS(c.invites, { "code": @code, "status": "pending" }, true)',
parameters: [
{ name: '@productId', value: productId },
{ name: '@code', value: code },
],
})
.fetchAll();
return resources[0] ?? null;
}

View File

@ -0,0 +1,264 @@
/**
* Household REST endpoints ChronoMind Family tier.
*
* GET /households list user's households
* GET /households/:id single household
* POST /households create household
* PUT /households/:id update household name (admin only)
* DELETE /households/:id delete household (admin only)
* POST /households/:id/invite generate invite code (admin only)
* POST /households/join accept invite code
* DELETE /households/:id/members remove member (admin only)
* DELETE /households/:id/leave leave household (non-admin)
*/
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { BadRequestError, NotFoundError, ForbiddenError, ConflictError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import {
CreateHouseholdSchema,
UpdateHouseholdSchema,
CreateInviteSchema,
AcceptInviteSchema,
RemoveMemberSchema,
HouseholdQuerySchema,
MAX_HOUSEHOLD_MEMBERS,
type HouseholdDoc,
type HouseholdMember,
type HouseholdInvite,
} from './types.js';
const PRODUCT_ID = 'chronomind';
function isAdmin(household: HouseholdDoc, userId: string): boolean {
return household.members.some(m => m.userId === userId && m.role === 'admin');
}
function isMember(household: HouseholdDoc, userId: string): boolean {
return household.members.some(m => m.userId === userId);
}
function generateInviteCode(): string {
return crypto.randomBytes(6).toString('hex').toUpperCase();
}
export async function householdRoutes(app: FastifyInstance) {
// List households for current user
app.get('/households', async req => {
const auth = await extractAuth(req);
const parsed = HouseholdQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listHouseholdsForUser(auth.sub, PRODUCT_ID, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Get single household
app.get('/households/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const household = await repo.getHousehold(id);
if (!household || household.productId !== PRODUCT_ID)
throw new NotFoundError('Household not found');
if (!isMember(household, auth.sub)) throw new ForbiddenError('Not a member of this household');
return household;
});
// Create household
app.post('/households', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateHouseholdSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const now = new Date().toISOString();
const member: HouseholdMember = {
userId: auth.sub,
displayName: parsed.data.displayName,
role: 'admin',
joinedAt: now,
};
const doc: HouseholdDoc = {
id: crypto.randomUUID(),
productId: PRODUCT_ID,
name: parsed.data.name,
members: [member],
invites: [],
createdAt: now,
createdBy: auth.sub,
};
req.log.info({ householdId: doc.id }, 'Creating household');
const created = await repo.createHousehold(doc);
reply.code(201);
return created;
});
// Update household name (admin only)
app.put('/households/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = UpdateHouseholdSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const household = await repo.getHousehold(id);
if (!household || household.productId !== PRODUCT_ID)
throw new NotFoundError('Household not found');
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can update household');
const updated: HouseholdDoc = { ...household, ...parsed.data };
const result = await repo.replaceHousehold(updated);
return result;
});
// Delete household (admin only)
app.delete('/households/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const household = await repo.getHousehold(id);
if (!household || household.productId !== PRODUCT_ID)
throw new NotFoundError('Household not found');
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can delete household');
await repo.deleteHousehold(id);
req.log.info({ householdId: id }, 'Deleted household');
return { success: true };
});
// Generate invite code (admin only)
app.post('/households/:id/invite', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = CreateInviteSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const household = await repo.getHousehold(id);
if (!household || household.productId !== PRODUCT_ID)
throw new NotFoundError('Household not found');
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can create invites');
if (household.members.length >= MAX_HOUSEHOLD_MEMBERS) {
throw new BadRequestError(
`Household is at maximum capacity (${MAX_HOUSEHOLD_MEMBERS} members)`
);
}
const now = new Date();
const invite: HouseholdInvite = {
code: generateInviteCode(),
createdBy: auth.sub,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + parsed.data.expiresInHours * 3600_000).toISOString(),
status: 'pending',
};
household.invites.push(invite);
await repo.replaceHousehold(household);
req.log.info({ householdId: id, inviteCode: invite.code }, 'Created invite');
return { code: invite.code, expiresAt: invite.expiresAt };
});
// Accept invite code (join household)
app.post('/households/join', async req => {
const auth = await extractAuth(req);
const parsed = AcceptInviteSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const household = await repo.findHouseholdByInviteCode(parsed.data.code, PRODUCT_ID);
if (!household) throw new NotFoundError('Invalid or expired invite code');
if (isMember(household, auth.sub)) {
throw new ConflictError('Already a member of this household');
}
if (household.members.length >= MAX_HOUSEHOLD_MEMBERS) {
throw new BadRequestError(
`Household is at maximum capacity (${MAX_HOUSEHOLD_MEMBERS} members)`
);
}
const now = new Date().toISOString();
const invite = household.invites.find(
i => i.code === parsed.data.code && i.status === 'pending'
);
if (!invite || new Date(invite.expiresAt) < new Date()) {
throw new NotFoundError('Invite code has expired');
}
// Mark invite as accepted
invite.status = 'accepted';
invite.acceptedBy = auth.sub;
invite.acceptedAt = now;
// Add member
const member: HouseholdMember = {
userId: auth.sub,
displayName: parsed.data.displayName,
role: 'member',
joinedAt: now,
};
household.members.push(member);
const updated = await repo.replaceHousehold(household);
req.log.info({ householdId: household.id, userId: auth.sub }, 'Member joined household');
return updated;
});
// Remove member (admin only)
app.delete('/households/:id/members', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = RemoveMemberSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const household = await repo.getHousehold(id);
if (!household || household.productId !== PRODUCT_ID)
throw new NotFoundError('Household not found');
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can remove members');
if (parsed.data.userId === auth.sub) {
throw new BadRequestError('Admin cannot remove themselves. Delete the household instead.');
}
const memberIdx = household.members.findIndex(m => m.userId === parsed.data.userId);
if (memberIdx === -1) throw new NotFoundError('Member not found in household');
household.members.splice(memberIdx, 1);
const updated = await repo.replaceHousehold(household);
req.log.info({ householdId: id, removedUserId: parsed.data.userId }, 'Removed member');
return updated;
});
// Leave household (non-admin)
app.delete('/households/:id/leave', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const household = await repo.getHousehold(id);
if (!household || household.productId !== PRODUCT_ID)
throw new NotFoundError('Household not found');
if (!isMember(household, auth.sub)) throw new NotFoundError('Not a member of this household');
if (isAdmin(household, auth.sub)) {
throw new BadRequestError('Admin cannot leave. Transfer admin or delete the household.');
}
household.members = household.members.filter(m => m.userId !== auth.sub);
const updated = await repo.replaceHousehold(household);
req.log.info({ householdId: id, userId: auth.sub }, 'Member left household');
return { success: true, householdId: updated.id };
});
}

View File

@ -0,0 +1,93 @@
/**
* Household types ChronoMind Family tier.
*
* Cosmos container: `households` (partition key: `/id`)
* Product ID: "chronomind"
*
* A household is a group of up to 6 members who can share timers.
* One admin (creator) manages members. Members join via invite code.
*/
import { z } from 'zod';
// ── Enums / constants ──
export const MEMBER_ROLES = ['admin', 'member'] as const;
export type MemberRole = (typeof MEMBER_ROLES)[number];
export const INVITE_STATUSES = ['pending', 'accepted', 'expired', 'revoked'] as const;
export type InviteStatus = (typeof INVITE_STATUSES)[number];
export const MAX_HOUSEHOLD_MEMBERS = 6;
// ── Sub-document interfaces ──
export interface HouseholdMember {
userId: string;
displayName: string;
role: MemberRole;
joinedAt: string;
}
export interface HouseholdInvite {
code: string;
createdBy: string;
createdAt: string;
expiresAt: string;
status: InviteStatus;
acceptedBy?: string;
acceptedAt?: string;
}
// ── Main document ──
export interface HouseholdDoc {
id: string;
productId: string;
name: string;
members: HouseholdMember[];
invites: HouseholdInvite[];
createdAt: string;
createdBy: string;
_ts?: number;
_etag?: string;
}
// ── Zod schemas ──
export const CreateHouseholdSchema = z.object({
name: z.string().min(1).max(200),
displayName: z.string().min(1).max(200),
});
export const UpdateHouseholdSchema = z.object({
name: z.string().min(1).max(200).optional(),
});
export const CreateInviteSchema = z.object({
expiresInHours: z.number().int().min(1).max(168).default(72),
});
export const AcceptInviteSchema = z.object({
code: z.string().min(1).max(32),
displayName: z.string().min(1).max(200),
});
export const RemoveMemberSchema = z.object({
userId: z.string().min(1),
});
export const HouseholdQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
// ── Inferred types ──
export type CreateHouseholdInput = z.infer<typeof CreateHouseholdSchema>;
export type UpdateHouseholdInput = z.infer<typeof UpdateHouseholdSchema>;
export type CreateInviteInput = z.infer<typeof CreateInviteSchema>;
export type AcceptInviteInput = z.infer<typeof AcceptInviteSchema>;
export type RemoveMemberInput = z.infer<typeof RemoveMemberSchema>;
export type HouseholdQuery = z.infer<typeof HouseholdQuerySchema>;

View File

@ -0,0 +1,182 @@
/**
* Routines repository Cosmos DB CRUD + sync + batch upsert.
*
* Container: routines (partition key: /userId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type { RoutineDoc, RoutineQuery, BatchUpsertRoutinesResult } from './types.js';
function container() {
return getContainer('routines');
}
export async function listRoutines(
userId: string,
productId: string,
query: RoutineQuery
): Promise<{ items: RoutineDoc[]; total: number }> {
const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId'];
const params: { name: string; value: string | number | boolean }[] = [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
];
if (query.status) {
conditions.push('c.status = @status');
params.push({ name: '@status', value: query.status });
}
if (query.isTemplate !== undefined) {
conditions.push('c.isTemplate = @isTemplate');
params.push({ name: '@isTemplate', value: query.isTemplate });
}
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();
const countResult = await container()
.items.query<number>({
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
parameters: params,
})
.fetchAll();
const total = countResult.resources[0] ?? 0;
const { resources } = await container()
.items.query<RoutineDoc>({
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 getRoutine(id: string, userId: string): Promise<RoutineDoc | null> {
try {
const { resource } = await container().item(id, userId).read<RoutineDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function createRoutine(doc: RoutineDoc): Promise<RoutineDoc> {
const { resource } = await container().items.create(doc);
return resource as RoutineDoc;
}
export async function updateRoutine(
id: string,
userId: string,
updates: Partial<RoutineDoc>,
expectedSyncVersion: number
): Promise<{ doc: RoutineDoc | null; conflict: boolean; serverVersion?: number }> {
try {
const { resource: existing } = await container().item(id, userId).read<RoutineDoc>();
if (!existing) return { doc: null, conflict: false };
if (expectedSyncVersion <= existing.syncVersion) {
return { doc: null, conflict: true, serverVersion: existing.syncVersion };
}
const now = new Date().toISOString();
const merged: RoutineDoc = {
...existing,
...updates,
syncVersion: expectedSyncVersion,
lastSyncedAt: now,
};
const { resource } = await container().item(id, userId).replace(merged);
return { doc: resource as RoutineDoc, conflict: false };
} catch {
return { doc: null, conflict: false };
}
}
export async function deleteRoutine(id: string, userId: string): Promise<boolean> {
try {
const { resource: existing } = await container().item(id, userId).read<RoutineDoc>();
if (!existing) return false;
await container().item(id, userId).delete();
return true;
} catch {
return false;
}
}
export async function getRoutinesSince(
userId: string,
productId: string,
sinceTimestamp: string,
limit: number
): Promise<RoutineDoc[]> {
const { resources } = await container()
.items.query<RoutineDoc>({
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 batchUpsertRoutines(
userId: string,
productId: string,
routines: Array<Record<string, unknown> & { id: string; syncVersion: number }>
): Promise<BatchUpsertRoutinesResult> {
const synced: string[] = [];
const conflicts: Array<{ id: string; serverVersion: number }> = [];
const errors: Array<{ id: string; error: string }> = [];
for (const routine of routines) {
try {
const existing = await getRoutine(routine.id, userId);
const now = new Date().toISOString();
if (existing) {
if (routine.syncVersion >= existing.syncVersion) {
const merged: RoutineDoc = {
...existing,
...routine,
userId,
productId,
lastSyncedAt: now,
} as RoutineDoc;
await container().item(routine.id, userId).replace(merged);
synced.push(routine.id);
} else {
conflicts.push({ id: routine.id, serverVersion: existing.syncVersion });
}
} else {
const doc = {
...routine,
userId,
productId,
lastSyncedAt: now,
};
await container().items.create(doc);
synced.push(routine.id);
}
} catch (err) {
errors.push({ id: routine.id, error: err instanceof Error ? err.message : 'Unknown error' });
}
}
return { synced, conflicts, errors };
}

View File

@ -0,0 +1,155 @@
/**
* Routine REST endpoints ChronoMind cloud sync.
*
* GET /routines list user's routines (filterable, paginated)
* GET /routines/sync delta sync (routines modified since timestamp)
* GET /routines/:id single routine
* POST /routines create routine
* PUT /routines/:id update routine (with syncVersion conflict check)
* DELETE /routines/:id delete routine
* POST /routines/batch batch upsert
*/
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 {
CreateRoutineSchema,
UpdateRoutineSchema,
RoutineQuerySchema,
RoutineSyncQuerySchema,
BatchUpsertRoutinesSchema,
type RoutineDoc,
} from './types.js';
const PRODUCT_ID = 'chronomind';
export async function routineRoutes(app: FastifyInstance) {
// Sync — must be before :id param route
app.get('/routines/sync', async req => {
const auth = await extractAuth(req);
const parsed = RoutineSyncQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const routines = await repo.getRoutinesSince(
auth.sub,
PRODUCT_ID,
parsed.data.since,
parsed.data.limit
);
return { routines, count: routines.length };
});
// List routines
app.get('/routines', async req => {
const auth = await extractAuth(req);
const parsed = RoutineQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listRoutines(auth.sub, PRODUCT_ID, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Get single routine
app.get('/routines/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const routine = await repo.getRoutine(id, auth.sub);
if (!routine) throw new NotFoundError('Routine not found');
if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found');
return routine;
});
// Create routine
app.post('/routines', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateRoutineSchema.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: RoutineDoc = {
id: input.id,
userId: auth.sub,
productId: PRODUCT_ID,
name: input.name,
description: input.description,
steps: input.steps,
totalDurationMinutes: input.totalDurationMinutes,
status: input.status,
currentStepIndex: input.currentStepIndex,
isTemplate: input.isTemplate,
category: input.category,
createdAt: now,
startedAt: input.startedAt,
elapsedBeforePause: input.elapsedBeforePause,
deviceId: input.deviceId,
lastSyncedAt: now,
syncVersion: input.syncVersion,
};
req.log.info({ routineId: doc.id, isTemplate: doc.isTemplate }, 'Creating routine');
const created = await repo.createRoutine(doc);
reply.code(201);
return created;
});
// Update routine (with syncVersion conflict check)
app.put('/routines/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = UpdateRoutineSchema.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.updateRoutine(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('Routine not found');
req.log.info({ routineId: id, syncVersion }, 'Updated routine');
return result.doc;
});
// Delete routine
app.delete('/routines/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const success = await repo.deleteRoutine(id, auth.sub);
if (!success) throw new NotFoundError('Routine not found');
req.log.info({ routineId: id }, 'Deleted routine');
return { success: true };
});
// Batch upsert
app.post('/routines/batch', async req => {
const auth = await extractAuth(req);
const parsed = BatchUpsertRoutinesSchema.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.routines.map(r => ({
...r,
createdAt: now,
lastSyncedAt: now,
}));
req.log.info({ count: enriched.length }, 'Batch upsert routines');
const result = await repo.batchUpsertRoutines(auth.sub, PRODUCT_ID, enriched);
return result;
});
}

View File

@ -0,0 +1,345 @@
/**
* Routines module unit tests validates schemas, constants, and types.
*/
import { describe, it, expect } from 'vitest';
import {
CreateRoutineSchema,
UpdateRoutineSchema,
RoutineQuerySchema,
RoutineSyncQuerySchema,
BatchUpsertRoutinesSchema,
TRANSITION_TYPES,
ROUTINE_STATUSES,
STEP_STATUSES,
} from './types.js';
// ── Constants ──
describe('routine constants', () => {
it('has 4 transition types', () => {
expect(TRANSITION_TYPES).toEqual(['immediate', '1m_break', '5m_break', 'custom']);
});
it('has 6 routine statuses', () => {
expect(ROUTINE_STATUSES).toEqual([
'template',
'ready',
'active',
'paused',
'completed',
'cancelled',
]);
expect(ROUTINE_STATUSES).toHaveLength(6);
});
it('has 4 step statuses', () => {
expect(STEP_STATUSES).toEqual(['pending', 'active', 'skipped', 'completed']);
});
});
// ── CreateRoutineSchema ──
describe('CreateRoutineSchema', () => {
const validStep = {
id: 'step_1',
label: 'Warm up',
durationMinutes: 5,
transition: 'immediate',
};
const validMinimal = {
id: 'routine_001',
name: 'Morning Routine',
steps: [validStep],
totalDurationMinutes: 5,
};
it('accepts minimal valid input with defaults', () => {
const result = CreateRoutineSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.status).toBe('ready');
expect(result.data.isTemplate).toBe(false);
expect(result.data.currentStepIndex).toBe(0);
expect(result.data.syncVersion).toBe(1);
expect(result.data.elapsedBeforePause).toBe(0);
}
});
it('accepts template routine', () => {
const result = CreateRoutineSchema.safeParse({
...validMinimal,
status: 'template',
isTemplate: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isTemplate).toBe(true);
expect(result.data.status).toBe('template');
}
});
it('accepts multi-step routine with transitions', () => {
const result = CreateRoutineSchema.safeParse({
...validMinimal,
steps: [
validStep,
{
id: 'step_2',
label: 'Exercise',
durationMinutes: 20,
transition: '5m_break',
notes: 'Stretch first',
},
{
id: 'step_3',
label: 'Cool down',
durationMinutes: 10,
transition: 'custom',
customTransitionMinutes: 3,
},
],
totalDurationMinutes: 43,
category: 'health',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.steps).toHaveLength(3);
expect(result.data.steps[1].notes).toBe('Stretch first');
expect(result.data.steps[2].customTransitionMinutes).toBe(3);
}
});
it('rejects missing id', () => {
const result = CreateRoutineSchema.safeParse({
name: 'Test',
steps: [validStep],
totalDurationMinutes: 5,
});
expect(result.success).toBe(false);
});
it('rejects missing name', () => {
const result = CreateRoutineSchema.safeParse({
id: 'routine_001',
steps: [validStep],
totalDurationMinutes: 5,
});
expect(result.success).toBe(false);
});
it('rejects empty steps array', () => {
const result = CreateRoutineSchema.safeParse({
...validMinimal,
steps: [],
});
expect(result.success).toBe(false);
});
it('rejects step with invalid transition', () => {
const result = CreateRoutineSchema.safeParse({
...validMinimal,
steps: [{ ...validStep, transition: 'invalid' }],
});
expect(result.success).toBe(false);
});
it('rejects step duration > 480 minutes', () => {
const result = CreateRoutineSchema.safeParse({
...validMinimal,
steps: [{ ...validStep, durationMinutes: 500 }],
});
expect(result.success).toBe(false);
});
it('rejects step duration < 0.5 minutes', () => {
const result = CreateRoutineSchema.safeParse({
...validMinimal,
steps: [{ ...validStep, durationMinutes: 0.1 }],
});
expect(result.success).toBe(false);
});
it('rejects > 50 steps', () => {
const steps = Array.from({ length: 51 }, (_, i) => ({
...validStep,
id: `step_${i}`,
}));
const result = CreateRoutineSchema.safeParse({ ...validMinimal, steps });
expect(result.success).toBe(false);
});
it('rejects invalid status', () => {
const result = CreateRoutineSchema.safeParse({
...validMinimal,
status: 'deleted',
});
expect(result.success).toBe(false);
});
});
// ── UpdateRoutineSchema ──
describe('UpdateRoutineSchema', () => {
it('accepts status update with syncVersion', () => {
const result = UpdateRoutineSchema.safeParse({ status: 'paused', syncVersion: 2 });
expect(result.success).toBe(true);
});
it('accepts step updates', () => {
const result = UpdateRoutineSchema.safeParse({
steps: [
{
id: 's1',
label: 'Step 1',
durationMinutes: 5,
transition: 'immediate',
status: 'completed',
},
{
id: 's2',
label: 'Step 2',
durationMinutes: 10,
transition: '1m_break',
status: 'active',
},
],
currentStepIndex: 1,
syncVersion: 3,
});
expect(result.success).toBe(true);
});
it('requires syncVersion', () => {
const result = UpdateRoutineSchema.safeParse({ status: 'active' });
expect(result.success).toBe(false);
});
it('rejects syncVersion < 1', () => {
const result = UpdateRoutineSchema.safeParse({ status: 'active', syncVersion: 0 });
expect(result.success).toBe(false);
});
it('rejects invalid status', () => {
const result = UpdateRoutineSchema.safeParse({ status: 'deleted', syncVersion: 2 });
expect(result.success).toBe(false);
});
});
// ── RoutineQuerySchema ──
describe('RoutineQuerySchema', () => {
it('provides defaults for empty query', () => {
const result = RoutineQuerySchema.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('accepts all filter combinations', () => {
const result = RoutineQuerySchema.safeParse({
status: 'template',
isTemplate: 'true',
category: 'health',
sortBy: 'name',
sortOrder: 'asc',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isTemplate).toBe(true);
}
});
it('coerces string numbers', () => {
const result = RoutineQuerySchema.safeParse({ limit: '25', offset: '5' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(25);
expect(result.data.offset).toBe(5);
}
});
it('rejects limit > 100', () => {
const result = RoutineQuerySchema.safeParse({ limit: 200 });
expect(result.success).toBe(false);
});
it('rejects invalid sortBy', () => {
const result = RoutineQuerySchema.safeParse({ sortBy: 'random' });
expect(result.success).toBe(false);
});
});
// ── RoutineSyncQuerySchema ──
describe('RoutineSyncQuerySchema', () => {
it('accepts valid since timestamp', () => {
const result = RoutineSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(100);
}
});
it('rejects missing since', () => {
const result = RoutineSyncQuerySchema.safeParse({});
expect(result.success).toBe(false);
});
it('rejects invalid since format', () => {
const result = RoutineSyncQuerySchema.safeParse({ since: 'yesterday' });
expect(result.success).toBe(false);
});
});
// ── BatchUpsertRoutinesSchema ──
describe('BatchUpsertRoutinesSchema', () => {
const validRoutine = {
id: 'routine_batch_1',
name: 'Batch Routine',
steps: [{ id: 's1', label: 'Step', durationMinutes: 5, transition: 'immediate' }],
totalDurationMinutes: 5,
};
it('accepts array of valid routines', () => {
const result = BatchUpsertRoutinesSchema.safeParse({
routines: [validRoutine, { ...validRoutine, id: 'routine_batch_2', name: 'Second' }],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.routines).toHaveLength(2);
}
});
it('rejects empty routines array', () => {
const result = BatchUpsertRoutinesSchema.safeParse({ routines: [] });
expect(result.success).toBe(false);
});
it('rejects missing routines field', () => {
const result = BatchUpsertRoutinesSchema.safeParse({});
expect(result.success).toBe(false);
});
it('validates each routine in the array', () => {
const result = BatchUpsertRoutinesSchema.safeParse({
routines: [validRoutine, { id: 'bad' }],
});
expect(result.success).toBe(false);
});
it('rejects > 50 routines', () => {
const routines = Array.from({ length: 51 }, (_, i) => ({
...validRoutine,
id: `routine_${i}`,
}));
const result = BatchUpsertRoutinesSchema.safeParse({ routines });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,154 @@
/**
* Routine types ChronoMind cross-platform cloud sync.
*
* Cosmos container: `routines` (partition key: `/userId`)
* Product ID: "chronomind"
*/
import { z } from 'zod';
// ── Enums / constants ──
export const TRANSITION_TYPES = ['immediate', '1m_break', '5m_break', 'custom'] as const;
export type TransitionType = (typeof TRANSITION_TYPES)[number];
export const ROUTINE_STATUSES = [
'template',
'ready',
'active',
'paused',
'completed',
'cancelled',
] as const;
export type RoutineStatus = (typeof ROUTINE_STATUSES)[number];
export const STEP_STATUSES = ['pending', 'active', 'skipped', 'completed'] as const;
export type StepStatus = (typeof STEP_STATUSES)[number];
// ── Sub-document interfaces ──
export interface RoutineStep {
id: string;
label: string;
durationMinutes: number;
transition: TransitionType;
customTransitionMinutes?: number;
notes?: string;
status: StepStatus;
startedAt?: string;
completedAt?: string;
}
// ── Main document ──
export interface RoutineDoc {
id: string;
userId: string;
productId: string;
name: string;
description?: string;
steps: RoutineStep[];
totalDurationMinutes: number;
status: RoutineStatus;
currentStepIndex: number;
isTemplate: boolean;
category?: string;
createdAt: string;
startedAt?: string;
pausedAt?: string;
completedAt?: string;
elapsedBeforePause: number;
// Sync metadata
deviceId?: string;
lastSyncedAt?: string;
syncVersion: number;
_ts?: number;
_etag?: string;
}
// ── Zod schemas ──
const RoutineStepSchema = z.object({
id: z.string().min(1).max(128),
label: z.string().min(1).max(500),
durationMinutes: z.number().min(0.5).max(480),
transition: z.enum(TRANSITION_TYPES),
customTransitionMinutes: z.number().min(0).max(60).optional(),
notes: z.string().max(2000).optional(),
status: z.enum(STEP_STATUSES).default('pending'),
startedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().optional(),
});
export const CreateRoutineSchema = z.object({
id: z.string().min(1).max(128),
name: z.string().min(1).max(500),
description: z.string().max(2000).optional(),
steps: z.array(RoutineStepSchema).min(1).max(50),
totalDurationMinutes: z.number().min(0),
status: z.enum(ROUTINE_STATUSES).default('ready'),
currentStepIndex: z.number().int().min(0).default(0),
isTemplate: z.boolean().default(false),
category: z.string().max(128).optional(),
elapsedBeforePause: z.number().min(0).default(0),
startedAt: z.string().datetime().optional(),
deviceId: z.string().max(256).optional(),
syncVersion: z.number().int().min(0).default(1),
});
export const UpdateRoutineSchema = z.object({
name: z.string().min(1).max(500).optional(),
description: z.string().max(2000).optional(),
steps: z.array(RoutineStepSchema).min(1).max(50).optional(),
totalDurationMinutes: z.number().min(0).optional(),
status: z.enum(ROUTINE_STATUSES).optional(),
currentStepIndex: z.number().int().min(0).optional(),
isTemplate: z.boolean().optional(),
category: z.string().max(128).optional(),
startedAt: z.string().datetime().optional(),
pausedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().optional(),
elapsedBeforePause: z.number().min(0).optional(),
deviceId: z.string().max(256).optional(),
syncVersion: z.number().int().min(1),
});
export const RoutineQuerySchema = z.object({
status: z.enum(ROUTINE_STATUSES).optional(),
isTemplate: z
.string()
.transform(v => v === 'true')
.optional(),
category: z.string().optional(),
sortBy: z.enum(['createdAt', 'name', 'totalDurationMinutes']).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 RoutineSyncQuerySchema = z.object({
since: z.string().datetime(),
limit: z.coerce.number().int().min(1).max(500).default(100),
});
export const BatchUpsertRoutinesSchema = z.object({
routines: z.array(CreateRoutineSchema).min(1).max(50),
});
// ── Inferred types ──
export type CreateRoutineInput = z.infer<typeof CreateRoutineSchema>;
export type UpdateRoutineInput = z.infer<typeof UpdateRoutineSchema>;
export type RoutineQuery = z.infer<typeof RoutineQuerySchema>;
export type RoutineSyncQuery = z.infer<typeof RoutineSyncQuerySchema>;
export type BatchUpsertRoutinesInput = z.infer<typeof BatchUpsertRoutinesSchema>;
export interface BatchUpsertRoutinesResult {
synced: string[];
conflicts: Array<{ id: string; serverVersion: number }>;
errors: Array<{ id: string; error: string }>;
}

View File

@ -0,0 +1,91 @@
/**
* Shared timers repository Cosmos DB CRUD for household shared timers.
*
* Container: shared_timers (partition key: /householdId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type { SharedTimerDoc, SharedTimerQuery } from './types.js';
function container() {
return getContainer('shared_timers');
}
export async function listSharedTimers(
householdId: string,
productId: string,
query: SharedTimerQuery
): Promise<{ items: SharedTimerDoc[]; total: number }> {
const conditions: string[] = ['c.householdId = @householdId', 'c.productId = @productId'];
const params: { name: string; value: string | number }[] = [
{ name: '@householdId', value: householdId },
{ 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 });
}
const where = `WHERE ${conditions.join(' AND ')}`;
const sortField = `c.${query.sortBy}`;
const orderDir = query.sortOrder.toUpperCase();
const countResult = await container()
.items.query<number>({
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
parameters: params,
})
.fetchAll();
const total = countResult.resources[0] ?? 0;
const { resources } = await container()
.items.query<SharedTimerDoc>({
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 getSharedTimer(
id: string,
householdId: string
): Promise<SharedTimerDoc | null> {
try {
const { resource } = await container().item(id, householdId).read<SharedTimerDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function createSharedTimer(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
const { resource } = await container().items.create(doc);
return resource as SharedTimerDoc;
}
export async function replaceSharedTimer(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
const { resource } = await container().item(doc.id, doc.householdId).replace(doc);
return resource as SharedTimerDoc;
}
export async function deleteSharedTimer(id: string, householdId: string): Promise<boolean> {
try {
const existing = await getSharedTimer(id, householdId);
if (!existing) return false;
await container().item(id, householdId).delete();
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,183 @@
/**
* Shared timer REST endpoints ChronoMind Family tier.
*
* All endpoints require the caller to be a member of the household.
*
* GET /households/:householdId/timers list shared timers
* GET /households/:householdId/timers/:id single shared timer
* POST /households/:householdId/timers create shared timer
* PUT /households/:householdId/timers/:id update shared timer (creator only)
* DELETE /households/:householdId/timers/:id delete shared timer (creator or admin)
* POST /households/:householdId/timers/:id/ack acknowledge (dismiss/snooze) a timer
*/
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { BadRequestError, NotFoundError, ForbiddenError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import { getHousehold } from '../households/repository.js';
import * as repo from './repository.js';
import {
CreateSharedTimerSchema,
UpdateSharedTimerSchema,
AcknowledgeTimerSchema,
SharedTimerQuerySchema,
type SharedTimerDoc,
} from './types.js';
const PRODUCT_ID = 'chronomind';
async function requireMembership(householdId: string, userId: string) {
const household = await getHousehold(householdId);
if (!household || household.productId !== PRODUCT_ID) {
throw new NotFoundError('Household not found');
}
const member = household.members.find(m => m.userId === userId);
if (!member) throw new ForbiddenError('Not a member of this household');
return { household, member };
}
export async function sharedTimerRoutes(app: FastifyInstance) {
// List shared timers for a household
app.get('/households/:householdId/timers', async req => {
const auth = await extractAuth(req);
const { householdId } = req.params as { householdId: string };
await requireMembership(householdId, auth.sub);
const parsed = SharedTimerQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listSharedTimers(householdId, PRODUCT_ID, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Get single shared timer
app.get('/households/:householdId/timers/:id', async req => {
const auth = await extractAuth(req);
const { householdId, id } = req.params as { householdId: string; id: string };
await requireMembership(householdId, auth.sub);
const timer = await repo.getSharedTimer(id, householdId);
if (!timer) throw new NotFoundError('Shared timer not found');
return timer;
});
// Create shared timer
app.post('/households/:householdId/timers', async (req, reply) => {
const auth = await extractAuth(req);
const { householdId } = req.params as { householdId: string };
await requireMembership(householdId, auth.sub);
const parsed = CreateSharedTimerSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
if (parsed.data.householdId !== householdId) {
throw new BadRequestError('householdId in body must match URL param');
}
const now = new Date().toISOString();
const doc: SharedTimerDoc = {
id: crypto.randomUUID(),
householdId,
productId: PRODUCT_ID,
createdBy: auth.sub,
label: parsed.data.label,
description: parsed.data.description,
type: parsed.data.type,
state: 'active',
urgency: parsed.data.urgency,
duration: parsed.data.duration,
targetTime: parsed.data.targetTime,
category: parsed.data.category,
cascade: parsed.data.cascade,
acknowledgements: [],
createdAt: now,
updatedAt: now,
};
req.log.info({ sharedTimerId: doc.id, householdId }, 'Creating shared timer');
const created = await repo.createSharedTimer(doc);
reply.code(201);
return created;
});
// Update shared timer (creator only)
app.put('/households/:householdId/timers/:id', async req => {
const auth = await extractAuth(req);
const { householdId, id } = req.params as { householdId: string; id: string };
await requireMembership(householdId, auth.sub);
const timer = await repo.getSharedTimer(id, householdId);
if (!timer) throw new NotFoundError('Shared timer not found');
if (timer.createdBy !== auth.sub)
throw new ForbiddenError('Only the creator can update this timer');
const parsed = UpdateSharedTimerSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const now = new Date().toISOString();
const updated: SharedTimerDoc = { ...timer, ...parsed.data, updatedAt: now };
const result = await repo.replaceSharedTimer(updated);
req.log.info({ sharedTimerId: id, householdId }, 'Updated shared timer');
return result;
});
// Delete shared timer (creator or admin)
app.delete('/households/:householdId/timers/:id', async req => {
const auth = await extractAuth(req);
const { householdId, id } = req.params as { householdId: string; id: string };
const { household } = await requireMembership(householdId, auth.sub);
const timer = await repo.getSharedTimer(id, householdId);
if (!timer) throw new NotFoundError('Shared timer not found');
const isCreator = timer.createdBy === auth.sub;
const isAdmin = household.members.some(m => m.userId === auth.sub && m.role === 'admin');
if (!isCreator && !isAdmin) {
throw new ForbiddenError('Only the creator or admin can delete this timer');
}
await repo.deleteSharedTimer(id, householdId);
req.log.info({ sharedTimerId: id, householdId }, 'Deleted shared timer');
return { success: true };
});
// Acknowledge (dismiss/snooze) a shared timer — per-user
app.post('/households/:householdId/timers/:id/ack', async req => {
const auth = await extractAuth(req);
const { householdId, id } = req.params as { householdId: string; id: string };
await requireMembership(householdId, auth.sub);
const timer = await repo.getSharedTimer(id, householdId);
if (!timer) throw new NotFoundError('Shared timer not found');
const parsed = AcknowledgeTimerSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
// Replace or add acknowledgement for this user
const now = new Date().toISOString();
const existingIdx = timer.acknowledgements.findIndex(a => a.userId === auth.sub);
const ack = { userId: auth.sub, state: parsed.data.state, at: now };
if (existingIdx >= 0) {
timer.acknowledgements[existingIdx] = ack;
} else {
timer.acknowledgements.push(ack);
}
timer.updatedAt = now;
const result = await repo.replaceSharedTimer(timer);
req.log.info(
{ sharedTimerId: id, householdId, ackState: parsed.data.state },
'Timer acknowledged'
);
return result;
});
}

View File

@ -0,0 +1,249 @@
/**
* Shared timers module unit tests validates schemas, constants, and types.
*/
import { describe, it, expect } from 'vitest';
import {
CreateSharedTimerSchema,
UpdateSharedTimerSchema,
AcknowledgeTimerSchema,
SharedTimerQuerySchema,
TIMER_TYPES,
TIMER_STATES,
URGENCY_LEVELS,
CASCADE_PRESETS,
} from './types.js';
// ── Constants ──
describe('shared timer constants', () => {
it('has 3 timer types', () => {
expect(TIMER_TYPES).toEqual(['countdown', 'alarm', 'pomodoro']);
});
it('has 7 timer states', () => {
expect(TIMER_STATES).toHaveLength(7);
});
it('has 5 urgency levels', () => {
expect(URGENCY_LEVELS).toHaveLength(5);
});
it('has 4 cascade presets', () => {
expect(CASCADE_PRESETS).toEqual(['minimal', 'standard', 'aggressive', 'custom']);
});
});
// ── CreateSharedTimerSchema ──
describe('CreateSharedTimerSchema', () => {
const validMinimal = {
householdId: 'household_001',
label: 'Dinner ready',
type: 'countdown',
duration: 1800,
};
it('accepts minimal valid input', () => {
const result = CreateSharedTimerSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.urgency).toBe('standard');
expect(result.data.label).toBe('Dinner ready');
}
});
it('accepts full input with cascade', () => {
const result = CreateSharedTimerSchema.safeParse({
...validMinimal,
description: 'Let everyone know dinner is ready',
urgency: 'important',
targetTime: '2026-03-01T18:00:00.000Z',
category: 'cooking',
cascade: { preset: 'standard', intervals: [15, 5, 1] },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.cascade?.intervals).toEqual([15, 5, 1]);
}
});
it('accepts alarm type with targetTime', () => {
const result = CreateSharedTimerSchema.safeParse({
...validMinimal,
type: 'alarm',
targetTime: '2026-03-01T07:00:00.000Z',
});
expect(result.success).toBe(true);
});
it('rejects missing householdId', () => {
const result = CreateSharedTimerSchema.safeParse({
label: 'Test',
type: 'countdown',
duration: 300,
});
expect(result.success).toBe(false);
});
it('rejects missing label', () => {
const result = CreateSharedTimerSchema.safeParse({
householdId: 'h1',
type: 'countdown',
duration: 300,
});
expect(result.success).toBe(false);
});
it('rejects invalid type', () => {
const result = CreateSharedTimerSchema.safeParse({
...validMinimal,
type: 'stopwatch',
});
expect(result.success).toBe(false);
});
it('rejects invalid urgency', () => {
const result = CreateSharedTimerSchema.safeParse({
...validMinimal,
urgency: 'extreme',
});
expect(result.success).toBe(false);
});
it('rejects negative duration', () => {
const result = CreateSharedTimerSchema.safeParse({
...validMinimal,
duration: -10,
});
expect(result.success).toBe(false);
});
it('rejects invalid targetTime', () => {
const result = CreateSharedTimerSchema.safeParse({
...validMinimal,
targetTime: 'not-a-date',
});
expect(result.success).toBe(false);
});
it('rejects label > 500 chars', () => {
const result = CreateSharedTimerSchema.safeParse({
...validMinimal,
label: 'x'.repeat(501),
});
expect(result.success).toBe(false);
});
});
// ── UpdateSharedTimerSchema ──
describe('UpdateSharedTimerSchema', () => {
it('accepts state update', () => {
const result = UpdateSharedTimerSchema.safeParse({ state: 'fired' });
expect(result.success).toBe(true);
});
it('accepts label and urgency update', () => {
const result = UpdateSharedTimerSchema.safeParse({
label: 'New label',
urgency: 'critical',
});
expect(result.success).toBe(true);
});
it('accepts cascade update', () => {
const result = UpdateSharedTimerSchema.safeParse({
cascade: { preset: 'aggressive', intervals: [30, 10, 5] },
});
expect(result.success).toBe(true);
});
it('accepts empty update', () => {
const result = UpdateSharedTimerSchema.safeParse({});
expect(result.success).toBe(true);
});
it('rejects invalid state', () => {
const result = UpdateSharedTimerSchema.safeParse({ state: 'deleted' });
expect(result.success).toBe(false);
});
it('rejects invalid urgency', () => {
const result = UpdateSharedTimerSchema.safeParse({ urgency: 'extreme' });
expect(result.success).toBe(false);
});
});
// ── AcknowledgeTimerSchema ──
describe('AcknowledgeTimerSchema', () => {
it('accepts dismissed', () => {
const result = AcknowledgeTimerSchema.safeParse({ state: 'dismissed' });
expect(result.success).toBe(true);
});
it('accepts snoozed', () => {
const result = AcknowledgeTimerSchema.safeParse({ state: 'snoozed' });
expect(result.success).toBe(true);
});
it('rejects other states', () => {
const result = AcknowledgeTimerSchema.safeParse({ state: 'active' });
expect(result.success).toBe(false);
});
it('rejects missing state', () => {
const result = AcknowledgeTimerSchema.safeParse({});
expect(result.success).toBe(false);
});
});
// ── SharedTimerQuerySchema ──
describe('SharedTimerQuerySchema', () => {
it('provides defaults for empty query', () => {
const result = SharedTimerQuerySchema.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('accepts state and type filters', () => {
const result = SharedTimerQuerySchema.safeParse({
state: 'active',
type: 'countdown',
sortBy: 'targetTime',
sortOrder: 'asc',
});
expect(result.success).toBe(true);
});
it('coerces string numbers', () => {
const result = SharedTimerQuerySchema.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('rejects limit > 100', () => {
const result = SharedTimerQuerySchema.safeParse({ limit: 200 });
expect(result.success).toBe(false);
});
it('rejects invalid sortBy', () => {
const result = SharedTimerQuerySchema.safeParse({ sortBy: 'random' });
expect(result.success).toBe(false);
});
it('rejects invalid state filter', () => {
const result = SharedTimerQuerySchema.safeParse({ state: 'deleted' });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,118 @@
/**
* Shared timer types ChronoMind Family tier.
*
* Cosmos container: `shared_timers` (partition key: `/householdId`)
* Product ID: "chronomind"
*
* Shared timers are visible to all household members. The creator
* owns the timer; any member can snooze/dismiss their own view.
*/
import { z } from 'zod';
// ── Reuse timer enums ──
export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const;
export const TIMER_STATES = [
'active',
'paused',
'fired',
'snoozed',
'dismissed',
'completed',
'warning',
] as const;
export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const;
export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const;
// ── Sub-document interfaces ──
export interface SharedCascadeConfig {
preset: (typeof CASCADE_PRESETS)[number];
intervals?: number[];
}
export interface SharedTimerAck {
userId: string;
state: 'dismissed' | 'snoozed';
at: string;
}
// ── Main document ──
export interface SharedTimerDoc {
id: string;
householdId: string;
productId: string;
createdBy: string;
label: string;
description?: string;
type: (typeof TIMER_TYPES)[number];
state: (typeof TIMER_STATES)[number];
urgency: (typeof URGENCY_LEVELS)[number];
duration: number;
targetTime?: string;
category?: string;
cascade?: SharedCascadeConfig;
acknowledgements: SharedTimerAck[];
createdAt: string;
updatedAt: string;
completedAt?: string;
_ts?: number;
_etag?: string;
}
// ── Zod schemas ──
const CascadeSchema = z.object({
preset: z.enum(CASCADE_PRESETS),
intervals: z.array(z.number().min(0).max(120)).max(20).optional(),
});
export const CreateSharedTimerSchema = z.object({
householdId: z.string().min(1).max(128),
label: z.string().min(1).max(500),
description: z.string().max(2000).optional(),
type: z.enum(TIMER_TYPES),
urgency: z.enum(URGENCY_LEVELS).default('standard'),
duration: z.number().min(0),
targetTime: z.string().datetime().optional(),
category: z.string().max(128).optional(),
cascade: CascadeSchema.optional(),
});
export const UpdateSharedTimerSchema = 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().min(0).optional(),
targetTime: z.string().datetime().optional(),
category: z.string().max(128).optional(),
cascade: CascadeSchema.optional(),
completedAt: z.string().datetime().optional(),
});
export const AcknowledgeTimerSchema = z.object({
state: z.enum(['dismissed', 'snoozed'] as const),
});
export const SharedTimerQuerySchema = z.object({
state: z.enum(TIMER_STATES).optional(),
type: z.enum(TIMER_TYPES).optional(),
sortBy: z.enum(['createdAt', 'targetTime', 'updatedAt']).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),
});
// ── Inferred types ──
export type CreateSharedTimerInput = z.infer<typeof CreateSharedTimerSchema>;
export type UpdateSharedTimerInput = z.infer<typeof UpdateSharedTimerSchema>;
export type AcknowledgeTimerInput = z.infer<typeof AcknowledgeTimerSchema>;
export type SharedTimerQuery = z.infer<typeof SharedTimerQuerySchema>;

View File

@ -51,6 +51,9 @@ 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 { routineRoutes } from './modules/routines/routes.js';
import { householdRoutes } from './modules/households/routes.js';
import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
@ -129,7 +132,10 @@ 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
// ChronoMind modules
await app.register(timerRoutes, { prefix: '/api' });
await app.register(routineRoutes, { prefix: '/api' });
await app.register(householdRoutes, { prefix: '/api' });
await app.register(sharedTimerRoutes, { prefix: '/api' });
await startService(app, { port: config.PORT, host: config.HOST });