diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 708b3144..a8cf9a30 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -39,6 +39,9 @@ const CONTAINER_DEFS: Record = { 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 }, diff --git a/services/platform-service/src/modules/households/households.test.ts b/services/platform-service/src/modules/households/households.test.ts new file mode 100644 index 00000000..b178f919 --- /dev/null +++ b/services/platform-service/src/modules/households/households.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/households/repository.ts b/services/platform-service/src/modules/households/repository.ts new file mode 100644 index 00000000..377d1584 --- /dev/null +++ b/services/platform-service/src/modules/households/repository.ts @@ -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 { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createHousehold(doc: HouseholdDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as HouseholdDoc; +} + +export async function replaceHousehold(doc: HouseholdDoc): Promise { + const { resource } = await container().item(doc.id, doc.id).replace(doc); + return resource as HouseholdDoc; +} + +export async function deleteHousehold(id: string): Promise { + 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({ + 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({ + 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 { + const { resources } = await container() + .items.query({ + 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; +} diff --git a/services/platform-service/src/modules/households/routes.ts b/services/platform-service/src/modules/households/routes.ts new file mode 100644 index 00000000..5eb49313 --- /dev/null +++ b/services/platform-service/src/modules/households/routes.ts @@ -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 }; + }); +} diff --git a/services/platform-service/src/modules/households/types.ts b/services/platform-service/src/modules/households/types.ts new file mode 100644 index 00000000..583dbfee --- /dev/null +++ b/services/platform-service/src/modules/households/types.ts @@ -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; +export type UpdateHouseholdInput = z.infer; +export type CreateInviteInput = z.infer; +export type AcceptInviteInput = z.infer; +export type RemoveMemberInput = z.infer; +export type HouseholdQuery = z.infer; diff --git a/services/platform-service/src/modules/routines/repository.ts b/services/platform-service/src/modules/routines/repository.ts new file mode 100644 index 00000000..6892b69d --- /dev/null +++ b/services/platform-service/src/modules/routines/repository.ts @@ -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({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getRoutine(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createRoutine(doc: RoutineDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as RoutineDoc; +} + +export async function updateRoutine( + id: string, + userId: string, + updates: Partial, + expectedSyncVersion: number +): Promise<{ doc: RoutineDoc | null; conflict: boolean; serverVersion?: number }> { + try { + const { resource: existing } = await container().item(id, userId).read(); + 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 { + try { + const { resource: existing } = await container().item(id, userId).read(); + if (!existing) return false; + await container().item(id, userId).delete(); + return true; + } catch { + return false; + } +} + +export async function getRoutinesSince( + userId: string, + productId: string, + sinceTimestamp: string, + limit: number +): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@since', value: sinceTimestamp }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +export async function batchUpsertRoutines( + userId: string, + productId: string, + routines: Array & { id: string; syncVersion: number }> +): Promise { + const synced: string[] = []; + const conflicts: Array<{ id: string; serverVersion: number }> = []; + const errors: Array<{ id: string; error: string }> = []; + + for (const 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 }; +} diff --git a/services/platform-service/src/modules/routines/routes.ts b/services/platform-service/src/modules/routines/routes.ts new file mode 100644 index 00000000..e5f5c6d8 --- /dev/null +++ b/services/platform-service/src/modules/routines/routes.ts @@ -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; + }); +} diff --git a/services/platform-service/src/modules/routines/routines.test.ts b/services/platform-service/src/modules/routines/routines.test.ts new file mode 100644 index 00000000..d52976f4 --- /dev/null +++ b/services/platform-service/src/modules/routines/routines.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/routines/types.ts b/services/platform-service/src/modules/routines/types.ts new file mode 100644 index 00000000..773aa59f --- /dev/null +++ b/services/platform-service/src/modules/routines/types.ts @@ -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; +export type UpdateRoutineInput = z.infer; +export type RoutineQuery = z.infer; +export type RoutineSyncQuery = z.infer; +export type BatchUpsertRoutinesInput = z.infer; + +export interface BatchUpsertRoutinesResult { + synced: string[]; + conflicts: Array<{ id: string; serverVersion: number }>; + errors: Array<{ id: string; error: string }>; +} diff --git a/services/platform-service/src/modules/shared-timers/repository.ts b/services/platform-service/src/modules/shared-timers/repository.ts new file mode 100644 index 00000000..75930299 --- /dev/null +++ b/services/platform-service/src/modules/shared-timers/repository.ts @@ -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({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getSharedTimer( + id: string, + householdId: string +): Promise { + try { + const { resource } = await container().item(id, householdId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createSharedTimer(doc: SharedTimerDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as SharedTimerDoc; +} + +export async function replaceSharedTimer(doc: SharedTimerDoc): Promise { + const { resource } = await container().item(doc.id, doc.householdId).replace(doc); + return resource as SharedTimerDoc; +} + +export async function deleteSharedTimer(id: string, householdId: string): Promise { + try { + const existing = await getSharedTimer(id, householdId); + if (!existing) return false; + await container().item(id, householdId).delete(); + return true; + } catch { + return false; + } +} diff --git a/services/platform-service/src/modules/shared-timers/routes.ts b/services/platform-service/src/modules/shared-timers/routes.ts new file mode 100644 index 00000000..32f46e3f --- /dev/null +++ b/services/platform-service/src/modules/shared-timers/routes.ts @@ -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; + }); +} diff --git a/services/platform-service/src/modules/shared-timers/shared-timers.test.ts b/services/platform-service/src/modules/shared-timers/shared-timers.test.ts new file mode 100644 index 00000000..aa54014d --- /dev/null +++ b/services/platform-service/src/modules/shared-timers/shared-timers.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/shared-timers/types.ts b/services/platform-service/src/modules/shared-timers/types.ts new file mode 100644 index 00000000..0e60e161 --- /dev/null +++ b/services/platform-service/src/modules/shared-timers/types.ts @@ -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; +export type UpdateSharedTimerInput = z.infer; +export type AcknowledgeTimerInput = z.infer; +export type SharedTimerQuery = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index ad4773d3..9b684424 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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 });