feat(chronomind): add routines, households, shared-timers modules (88 new tests, 847 total)
This commit is contained in:
parent
eae39cbd4e
commit
b4237acaa2
@ -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 },
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
264
services/platform-service/src/modules/households/routes.ts
Normal file
264
services/platform-service/src/modules/households/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
93
services/platform-service/src/modules/households/types.ts
Normal file
93
services/platform-service/src/modules/households/types.ts
Normal 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>;
|
||||
182
services/platform-service/src/modules/routines/repository.ts
Normal file
182
services/platform-service/src/modules/routines/repository.ts
Normal 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 };
|
||||
}
|
||||
155
services/platform-service/src/modules/routines/routes.ts
Normal file
155
services/platform-service/src/modules/routines/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
345
services/platform-service/src/modules/routines/routines.test.ts
Normal file
345
services/platform-service/src/modules/routines/routines.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
154
services/platform-service/src/modules/routines/types.ts
Normal file
154
services/platform-service/src/modules/routines/types.ts
Normal 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 }>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
183
services/platform-service/src/modules/shared-timers/routes.ts
Normal file
183
services/platform-service/src/modules/shared-timers/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
118
services/platform-service/src/modules/shared-timers/types.ts
Normal file
118
services/platform-service/src/modules/shared-timers/types.ts
Normal 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>;
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user