feat(backend): scaffold product-specific Fastify backend (port 4011)

Add backend/ directory with Fastify 5 + TypeScript ESM service:
- Modules: timers, routines, households, shared-timers, webhooks (migrated from platform-service)
- Cosmos containers: timers, routines, households, shared_timers, webhook_subscriptions, webhook_events
- JWT verification via jose (matches platform-service issuer)
- Shared @bytelyst/* packages via file: refs
- 171 Vitest tests passing

Update AGENTS.md: update backend integration section with product backend details
This commit is contained in:
saravanakumardb1 2026-03-01 20:39:08 -08:00
parent 6b82ca1b33
commit f10b83c122
35 changed files with 6937 additions and 1 deletions

View File

@ -223,7 +223,9 @@ docker build web/ # self-contained build
## 7. Backend Integration ## 7. Backend Integration
ChronoMind uses the shared **platform-service** (port 4003) from `learning_ai_common_plat`. Four ChronoMind-specific modules exist: ChronoMind has a **product-specific backend** in `backend/` (Fastify 5, port 4011) plus the shared **platform-service** (port 4003) for auth, billing, flags, etc.
### Product Backend (`backend/`, port 4011)
| Module | Container | Endpoints | Tests | | Module | Container | Endpoints | Tests |
|--------|-----------|-----------|-------| |--------|-----------|-----------|-------|
@ -234,6 +236,11 @@ ChronoMind uses the shared **platform-service** (port 4003) from `learning_ai_co
All documents include `productId: "chronomind"`. All documents include `productId: "chronomind"`.
```bash
cd backend && npm run dev # Dev server on port 4011
cd backend && npm test # 130 Vitest tests
```
### Sync Protocol ### Sync Protocol
- `syncVersion` monotonic integer — optimistic concurrency, 409 on stale writes - `syncVersion` monotonic integer — optimistic concurrency, 409 on stale writes
- Delta sync: `GET /timers/sync?since=<ISO>` returns only changed timers - Delta sync: `GET /timers/sync?since=<ISO>` returns only changed timers

9
backend/.env.example Normal file
View File

@ -0,0 +1,9 @@
# ChronoMind Backend — Environment Variables
PORT=4011
HOST=0.0.0.0
NODE_ENV=development
CORS_ORIGIN=http://localhost:3000
COSMOS_ENDPOINT=https://cosmos-mywisprai.documents.azure.com:443/
COSMOS_KEY=
COSMOS_DATABASE=lysnrai
JWT_SECRET=

4
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.tsbuildinfo

2689
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
backend/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "@chronomind/backend",
"version": "0.1.0",
"private": true,
"description": "ChronoMind product-specific backend — timers, routines, households, shared timers",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bytelyst/auth": "file:../../learning_ai_common_plat/packages/auth",
"@bytelyst/config": "file:../../learning_ai_common_plat/packages/config",
"@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos",
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
"@azure/cosmos": "^4.2.0",
"fastify": "^5.2.1",
"jose": "^6.0.8",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

6
backend/src/lib/auth.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* Re-export from @bytelyst/auth shared across all services.
* JWT auth middleware validates tokens issued by platform-service.
* Shares the same JWT_SECRET so it can verify without network calls.
*/
export { extractAuth, requireRole, type AuthPayload } from '@bytelyst/auth';

15
backend/src/lib/config.ts Normal file
View File

@ -0,0 +1,15 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(4011),
HOST: z.string().default('0.0.0.0'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
CORS_ORIGIN: z.string().optional(),
SERVICE_NAME: z.string().default('chronomind-backend'),
COSMOS_ENDPOINT: z.string().min(1, 'COSMOS_ENDPOINT is required'),
COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required'),
COSMOS_DATABASE: z.string().default('lysnrai'),
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
});
export const config = envSchema.parse(process.env);

View File

@ -0,0 +1,28 @@
import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos';
import type { ContainerConfig } from '@bytelyst/cosmos';
import { config } from './config.js';
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
timers: { partitionKeyPath: '/userId' },
routines: { partitionKeyPath: '/userId' },
households: { partitionKeyPath: '/id' },
shared_timers: { partitionKeyPath: '/householdId' },
// Webhooks
webhook_subscriptions: { partitionKeyPath: '/userId' },
webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 },
};
export async function initCosmosIfNeeded(): Promise<void> {
registerContainers(CONTAINER_DEFS);
const shouldInit = config.NODE_ENV !== 'production' || process.env.COSMOS_AUTO_INIT === 'true';
if (!shouldInit) return;
try {
await initializeAllContainers();
process.stdout.write('[chronomind-backend] Cosmos containers ensured\n');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[chronomind-backend] Cosmos init failed: ${msg}\n`);
}
}

View File

@ -0,0 +1,4 @@
/**
* Re-export from @bytelyst/cosmos shared across all services.
*/
export { getContainer, getCosmosClient, getDatabase } from '@bytelyst/cosmos';

12
backend/src/lib/errors.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* Re-export from @bytelyst/errors shared across all services.
*/
export {
ServiceError,
BadRequestError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
ConflictError,
TooManyRequestsError,
} from '@bytelyst/errors';

View File

@ -0,0 +1,34 @@
/**
* Request-level product context helpers for ChronoMind backend.
*/
import type { FastifyRequest } from 'fastify';
import { BadRequestError } from './errors.js';
export interface JwtPayload {
sub: string;
email?: string;
role?: string;
productId?: string;
type?: string;
}
declare module 'fastify' {
interface FastifyRequest {
jwtPayload?: JwtPayload;
}
}
const PRODUCT_ID = 'chronomind';
export function getRequestProductId(req: FastifyRequest): string {
const jwtPid = req.jwtPayload?.productId;
if (jwtPid && jwtPid !== PRODUCT_ID) {
throw new BadRequestError(`Invalid productId: expected ${PRODUCT_ID}, got ${jwtPid}`);
}
const header = req.headers['x-product-id'];
if (typeof header === 'string' && header.length > 0 && header !== PRODUCT_ID) {
throw new BadRequestError(`Invalid productId: expected ${PRODUCT_ID}, got ${header}`);
}
return PRODUCT_ID;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,191 @@
/**
* Timers repository Cosmos DB CRUD + sync + batch upsert.
*
* Container: timers (partition key: /userId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type { TimerDoc, TimerQuery, BatchUpsertResult } from './types.js';
function container() {
return getContainer('timers');
}
export async function listTimers(
userId: string,
productId: string,
query: TimerQuery
): Promise<{ items: TimerDoc[]; total: number }> {
const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId'];
const params: { name: string; value: string | number }[] = [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
];
if (query.state) {
conditions.push('c.state = @state');
params.push({ name: '@state', value: query.state });
}
if (query.type) {
conditions.push('c.type = @type');
params.push({ name: '@type', value: query.type });
}
if (query.urgency) {
conditions.push('c.urgency = @urgency');
params.push({ name: '@urgency', value: query.urgency });
}
if (query.category) {
conditions.push('c.category = @category');
params.push({ name: '@category', value: query.category });
}
const where = `WHERE ${conditions.join(' AND ')}`;
const sortField = `c.${query.sortBy}`;
const orderDir = query.sortOrder.toUpperCase();
// Count query
const countResult = await container()
.items.query<number>({
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
parameters: params,
})
.fetchAll();
const total = countResult.resources[0] ?? 0;
// Data query with pagination
const { resources } = await container()
.items.query<TimerDoc>({
query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`,
parameters: [
...params,
{ name: '@offset', value: query.offset },
{ name: '@limit', value: query.limit },
],
})
.fetchAll();
return { items: resources, total };
}
export async function getTimer(id: string, userId: string): Promise<TimerDoc | null> {
try {
const { resource } = await container().item(id, userId).read<TimerDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function createTimer(doc: TimerDoc): Promise<TimerDoc> {
const { resource } = await container().items.create(doc);
return resource as TimerDoc;
}
export async function updateTimer(
id: string,
userId: string,
updates: Partial<TimerDoc>,
expectedSyncVersion: number
): Promise<{ doc: TimerDoc | null; conflict: boolean; serverVersion?: number }> {
try {
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
if (!existing) return { doc: null, conflict: false };
// Optimistic concurrency: reject stale writes
if (expectedSyncVersion <= existing.syncVersion) {
return { doc: null, conflict: true, serverVersion: existing.syncVersion };
}
const now = new Date().toISOString();
const merged: TimerDoc = {
...existing,
...updates,
syncVersion: expectedSyncVersion,
lastSyncedAt: now,
};
const { resource } = await container().item(id, userId).replace(merged);
return { doc: resource as TimerDoc, conflict: false };
} catch {
return { doc: null, conflict: false };
}
}
export async function deleteTimer(id: string, userId: string): Promise<boolean> {
try {
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
if (!existing) return false;
await container().item(id, userId).delete();
return true;
} catch {
return false;
}
}
export async function getTimersSince(
userId: string,
productId: string,
sinceTimestamp: string,
limit: number
): Promise<TimerDoc[]> {
const { resources } = await container()
.items.query<TimerDoc>({
query:
'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit',
parameters: [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
{ name: '@since', value: sinceTimestamp },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
}
export async function batchUpsert(
userId: string,
productId: string,
timers: Array<Record<string, unknown> & { id: string; syncVersion: number }>
): Promise<BatchUpsertResult> {
const synced: string[] = [];
const conflicts: Array<{ id: string; serverVersion: number }> = [];
const errors: Array<{ id: string; error: string }> = [];
for (const timer of timers) {
try {
const existing = await getTimer(timer.id, userId);
const now = new Date().toISOString();
if (existing) {
// Upsert: accept if incoming syncVersion >= existing
if (timer.syncVersion >= existing.syncVersion) {
const merged: TimerDoc = {
...existing,
...timer,
userId,
productId,
lastSyncedAt: now,
};
await container().item(timer.id, userId).replace(merged);
synced.push(timer.id);
} else {
conflicts.push({ id: timer.id, serverVersion: existing.syncVersion });
}
} else {
// New document
const doc: TimerDoc = {
...timer,
userId,
productId,
lastSyncedAt: now,
} as TimerDoc;
await container().items.create(doc);
synced.push(timer.id);
}
} catch (err) {
errors.push({ id: timer.id, error: err instanceof Error ? err.message : 'Unknown error' });
}
}
return { synced, conflicts, errors };
}

View File

@ -0,0 +1,158 @@
/**
* Timer REST endpoints ChronoMind cloud sync.
*
* GET /timers list user's timers (filterable, paginated)
* GET /timers/sync delta sync (timers modified since timestamp)
* GET /timers/:id single timer
* POST /timers create timer
* PUT /timers/:id update timer (with syncVersion conflict check)
* DELETE /timers/:id delete timer
* POST /timers/batch batch upsert (offline queue flush / initial sync)
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError, NotFoundError, ConflictError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import {
CreateTimerSchema,
UpdateTimerSchema,
TimerQuerySchema,
TimerSyncQuerySchema,
BatchUpsertSchema,
type TimerDoc,
} from './types.js';
const PRODUCT_ID = 'chronomind';
export async function timerRoutes(app: FastifyInstance) {
// Sync — must be before :id param route
app.get('/timers/sync', async req => {
const auth = await extractAuth(req);
const parsed = TimerSyncQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const timers = await repo.getTimersSince(
auth.sub,
PRODUCT_ID,
parsed.data.since,
parsed.data.limit
);
return { timers, count: timers.length };
});
// List timers
app.get('/timers', async req => {
const auth = await extractAuth(req);
const parsed = TimerQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listTimers(auth.sub, PRODUCT_ID, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Get single timer
app.get('/timers/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const timer = await repo.getTimer(id, auth.sub);
if (!timer) throw new NotFoundError('Timer not found');
if (timer.productId !== PRODUCT_ID) throw new NotFoundError('Timer not found');
return timer;
});
// Create timer
app.post('/timers', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateTimerSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: TimerDoc = {
id: input.id,
userId: auth.sub,
productId: PRODUCT_ID,
label: input.label,
description: input.description,
type: input.type,
state: input.state,
urgency: input.urgency,
duration: input.duration,
targetTime: input.targetTime,
createdAt: now,
startedAt: input.startedAt,
cascade: input.cascade,
pomodoro: input.pomodoro,
isCalendarSync: input.isCalendarSync,
calendarEventId: input.calendarEventId,
category: input.category,
deviceId: input.deviceId,
lastSyncedAt: now,
syncVersion: input.syncVersion,
};
req.log.info({ timerId: doc.id, type: doc.type }, 'Creating timer');
const created = await repo.createTimer(doc);
reply.code(201);
return created;
});
// Update timer (with syncVersion conflict check)
app.put('/timers/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = UpdateTimerSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { syncVersion, ...updates } = parsed.data;
const result = await repo.updateTimer(id, auth.sub, updates, syncVersion);
if (result.conflict) {
throw new ConflictError(
`Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}`
);
}
if (!result.doc) throw new NotFoundError('Timer not found');
req.log.info({ timerId: id, syncVersion }, 'Updated timer');
return result.doc;
});
// Delete timer
app.delete('/timers/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const success = await repo.deleteTimer(id, auth.sub);
if (!success) throw new NotFoundError('Timer not found');
req.log.info({ timerId: id }, 'Deleted timer');
return { success: true };
});
// Batch upsert (initial sync / offline queue flush)
app.post('/timers/batch', async req => {
const auth = await extractAuth(req);
const parsed = BatchUpsertSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const now = new Date().toISOString();
const enriched = parsed.data.timers.map(t => ({
...t,
createdAt: now,
lastSyncedAt: now,
}));
req.log.info({ count: enriched.length }, 'Batch upsert timers');
const result = await repo.batchUpsert(auth.sub, PRODUCT_ID, enriched);
return result;
});
}

View File

@ -0,0 +1,408 @@
/**
* Timers module unit tests validates schemas, constants, and type guards.
*/
import { describe, it, expect } from 'vitest';
import {
CreateTimerSchema,
UpdateTimerSchema,
TimerQuerySchema,
TimerSyncQuerySchema,
BatchUpsertSchema,
TIMER_TYPES,
TIMER_STATES,
URGENCY_LEVELS,
CASCADE_PRESETS,
} from './types.js';
// ── Constants ──
describe('type constants', () => {
it('has 3 timer types', () => {
expect(TIMER_TYPES).toEqual(['countdown', 'alarm', 'pomodoro']);
});
it('has 7 timer states', () => {
expect(TIMER_STATES).toEqual([
'active',
'paused',
'fired',
'snoozed',
'dismissed',
'completed',
'warning',
]);
expect(TIMER_STATES).toHaveLength(7);
});
it('has 5 urgency levels', () => {
expect(URGENCY_LEVELS).toEqual(['critical', 'important', 'standard', 'gentle', 'passive']);
expect(URGENCY_LEVELS).toHaveLength(5);
});
it('has 4 cascade presets', () => {
expect(CASCADE_PRESETS).toEqual(['minimal', 'standard', 'aggressive', 'custom']);
});
});
// ── CreateTimerSchema ──
describe('CreateTimerSchema', () => {
const validMinimal = {
id: 'timer_001',
label: 'Morning alarm',
type: 'alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
};
it('accepts minimal valid input with defaults', () => {
const result = CreateTimerSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('timer_001');
expect(result.data.label).toBe('Morning alarm');
expect(result.data.type).toBe('alarm');
expect(result.data.state).toBe('active');
expect(result.data.urgency).toBe('standard');
expect(result.data.syncVersion).toBe(1);
}
});
it('accepts full countdown timer with cascade', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
type: 'countdown',
state: 'active',
urgency: 'critical',
duration: 300,
description: 'Important meeting prep',
cascade: {
preset: 'aggressive',
intervals: [30, 15, 5, 1],
},
deviceId: 'iphone-14-pro',
category: 'work',
syncVersion: 3,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.cascade?.preset).toBe('aggressive');
expect(result.data.cascade?.intervals).toEqual([30, 15, 5, 1]);
expect(result.data.urgency).toBe('critical');
expect(result.data.syncVersion).toBe(3);
}
});
it('accepts pomodoro timer with full config', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
type: 'pomodoro',
pomodoro: {
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
roundsBeforeLong: 4,
currentRound: 1,
isBreak: false,
totalRoundsCompleted: 0,
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.pomodoro?.focusMinutes).toBe(25);
expect(result.data.pomodoro?.roundsBeforeLong).toBe(4);
}
});
it('accepts timer with calendar sync', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
isCalendarSync: true,
calendarEventId: 'cal_abc123',
});
expect(result.success).toBe(true);
});
it('rejects missing id', () => {
const result = CreateTimerSchema.safeParse({
label: 'Morning alarm',
type: 'alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects missing label', () => {
const result = CreateTimerSchema.safeParse({
id: 'timer_001',
type: 'alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects missing type', () => {
const result = CreateTimerSchema.safeParse({
id: 'timer_001',
label: 'Morning alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects invalid type', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, type: 'stopwatch' });
expect(result.success).toBe(false);
});
it('rejects invalid state', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, state: 'deleted' });
expect(result.success).toBe(false);
});
it('rejects invalid urgency', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, urgency: 'extreme' });
expect(result.success).toBe(false);
});
it('rejects invalid targetTime format', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, targetTime: 'not-a-date' });
expect(result.success).toBe(false);
});
it('rejects negative duration', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, duration: -10 });
expect(result.success).toBe(false);
});
it('rejects label > 500 chars', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, label: 'x'.repeat(501) });
expect(result.success).toBe(false);
});
it('rejects pomodoro focusMinutes > 120', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
pomodoro: {
focusMinutes: 150,
shortBreakMinutes: 5,
longBreakMinutes: 15,
roundsBeforeLong: 4,
currentRound: 0,
isBreak: false,
totalRoundsCompleted: 0,
},
});
expect(result.success).toBe(false);
});
});
// ── UpdateTimerSchema ──
describe('UpdateTimerSchema', () => {
it('accepts state update with syncVersion', () => {
const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 2 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.state).toBe('paused');
expect(result.data.syncVersion).toBe(2);
}
});
it('accepts complete update with timestamps', () => {
const result = UpdateTimerSchema.safeParse({
state: 'completed',
completedAt: '2026-03-01T08:00:00.000Z',
syncVersion: 5,
});
expect(result.success).toBe(true);
});
it('accepts pomodoro round update', () => {
const result = UpdateTimerSchema.safeParse({
pomodoro: {
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
roundsBeforeLong: 4,
currentRound: 3,
isBreak: true,
totalRoundsCompleted: 2,
},
syncVersion: 4,
});
expect(result.success).toBe(true);
});
it('requires syncVersion', () => {
const result = UpdateTimerSchema.safeParse({ state: 'paused' });
expect(result.success).toBe(false);
});
it('rejects syncVersion < 1', () => {
const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 0 });
expect(result.success).toBe(false);
});
it('rejects invalid state', () => {
const result = UpdateTimerSchema.safeParse({ state: 'deleted', syncVersion: 2 });
expect(result.success).toBe(false);
});
it('rejects invalid completedAt format', () => {
const result = UpdateTimerSchema.safeParse({
completedAt: 'yesterday',
syncVersion: 2,
});
expect(result.success).toBe(false);
});
});
// ── TimerQuerySchema ──
describe('TimerQuerySchema', () => {
it('provides defaults for empty query', () => {
const result = TimerQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sortBy).toBe('createdAt');
expect(result.data.sortOrder).toBe('desc');
expect(result.data.limit).toBe(50);
expect(result.data.offset).toBe(0);
}
});
it('coerces string numbers for limit and offset', () => {
const result = TimerQuerySchema.safeParse({ limit: '25', offset: '10' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(25);
expect(result.data.offset).toBe(10);
}
});
it('accepts all filter combinations', () => {
const result = TimerQuerySchema.safeParse({
state: 'active',
type: 'pomodoro',
urgency: 'critical',
category: 'work',
sortBy: 'targetTime',
sortOrder: 'asc',
});
expect(result.success).toBe(true);
});
it('rejects limit > 100', () => {
const result = TimerQuerySchema.safeParse({ limit: 200 });
expect(result.success).toBe(false);
});
it('rejects negative offset', () => {
const result = TimerQuerySchema.safeParse({ offset: -1 });
expect(result.success).toBe(false);
});
it('rejects invalid sortBy', () => {
const result = TimerQuerySchema.safeParse({ sortBy: 'random' });
expect(result.success).toBe(false);
});
it('rejects invalid state filter', () => {
const result = TimerQuerySchema.safeParse({ state: 'deleted' });
expect(result.success).toBe(false);
});
});
// ── TimerSyncQuerySchema ──
describe('TimerSyncQuerySchema', () => {
it('accepts valid since timestamp', () => {
const result = TimerSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(100);
}
});
it('accepts custom limit', () => {
const result = TimerSyncQuerySchema.safeParse({
since: '2026-03-01T00:00:00.000Z',
limit: '50',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(50);
}
});
it('rejects missing since', () => {
const result = TimerSyncQuerySchema.safeParse({});
expect(result.success).toBe(false);
});
it('rejects invalid since format', () => {
const result = TimerSyncQuerySchema.safeParse({ since: 'yesterday' });
expect(result.success).toBe(false);
});
it('rejects limit > 500', () => {
const result = TimerSyncQuerySchema.safeParse({
since: '2026-03-01T00:00:00.000Z',
limit: 1000,
});
expect(result.success).toBe(false);
});
});
// ── BatchUpsertSchema ──
describe('BatchUpsertSchema', () => {
const validTimer = {
id: 'timer_batch_1',
label: 'Batch timer',
type: 'countdown',
duration: 600,
targetTime: '2026-03-01T10:00:00.000Z',
};
it('accepts array of valid timers', () => {
const result = BatchUpsertSchema.safeParse({
timers: [validTimer, { ...validTimer, id: 'timer_batch_2', label: 'Second timer' }],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.timers).toHaveLength(2);
}
});
it('rejects empty timers array', () => {
const result = BatchUpsertSchema.safeParse({ timers: [] });
expect(result.success).toBe(false);
});
it('rejects missing timers field', () => {
const result = BatchUpsertSchema.safeParse({});
expect(result.success).toBe(false);
});
it('validates each timer in the array', () => {
const result = BatchUpsertSchema.safeParse({
timers: [validTimer, { id: 'bad', type: 'invalid' }],
});
expect(result.success).toBe(false);
});
it('rejects > 100 timers', () => {
const timers = Array.from({ length: 101 }, (_, i) => ({
...validTimer,
id: `timer_${i}`,
}));
const result = BatchUpsertSchema.safeParse({ timers });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,175 @@
/**
* Timer types ChronoMind cross-platform cloud sync.
*
* Cosmos container: `timers` (partition key: `/userId`)
* Product ID: "chronomind"
*/
import { z } from 'zod';
// ── Enums / constants ──
export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const;
export type TimerType = (typeof TIMER_TYPES)[number];
export const TIMER_STATES = [
'active',
'paused',
'fired',
'snoozed',
'dismissed',
'completed',
'warning',
] as const;
export type TimerState = (typeof TIMER_STATES)[number];
export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const;
export type UrgencyLevel = (typeof URGENCY_LEVELS)[number];
export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const;
export type CascadePreset = (typeof CASCADE_PRESETS)[number];
// ── Sub-document interfaces ──
export interface CascadeConfig {
preset: CascadePreset;
intervals: number[];
}
export interface PomodoroConfig {
focusMinutes: number;
shortBreakMinutes: number;
longBreakMinutes: number;
roundsBeforeLong: number;
currentRound: number;
isBreak: boolean;
totalRoundsCompleted: number;
}
// ── Main document ──
export interface TimerDoc {
id: string;
userId: string;
productId: string;
label: string;
description?: string;
type: TimerType;
state: TimerState;
urgency: UrgencyLevel;
duration: number;
targetTime: string;
createdAt: string;
startedAt?: string;
pausedAt?: string;
firedAt?: string;
completedAt?: string;
cascade?: CascadeConfig;
pomodoro?: PomodoroConfig;
isCalendarSync?: boolean;
calendarEventId?: string;
category?: string;
deviceId?: string;
lastSyncedAt?: string;
syncVersion: number;
_ts?: number;
_etag?: string;
}
// ── Zod schemas ──
const CascadeSchema = z.object({
preset: z.enum(CASCADE_PRESETS),
intervals: z.array(z.number().int().min(0)),
});
const PomodoroSchema = z.object({
focusMinutes: z.number().int().min(1).max(120),
shortBreakMinutes: z.number().int().min(1).max(60),
longBreakMinutes: z.number().int().min(1).max(120),
roundsBeforeLong: z.number().int().min(1).max(20),
currentRound: z.number().int().min(0),
isBreak: z.boolean(),
totalRoundsCompleted: z.number().int().min(0),
});
export const CreateTimerSchema = z.object({
id: z.string().min(1).max(128),
label: z.string().min(1).max(500),
description: z.string().max(2000).optional(),
type: z.enum(TIMER_TYPES),
state: z.enum(TIMER_STATES).default('active'),
urgency: z.enum(URGENCY_LEVELS).default('standard'),
duration: z.number().int().min(0),
targetTime: z.string().datetime(),
cascade: CascadeSchema.optional(),
pomodoro: PomodoroSchema.optional(),
isCalendarSync: z.boolean().optional(),
calendarEventId: z.string().max(500).optional(),
category: z.string().max(128).optional(),
deviceId: z.string().max(256).optional(),
startedAt: z.string().datetime().optional(),
syncVersion: z.number().int().min(0).default(1),
});
export const UpdateTimerSchema = z.object({
label: z.string().min(1).max(500).optional(),
description: z.string().max(2000).optional(),
state: z.enum(TIMER_STATES).optional(),
urgency: z.enum(URGENCY_LEVELS).optional(),
duration: z.number().int().min(0).optional(),
targetTime: z.string().datetime().optional(),
startedAt: z.string().datetime().optional(),
pausedAt: z.string().datetime().optional(),
firedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().optional(),
cascade: CascadeSchema.optional(),
pomodoro: PomodoroSchema.optional(),
isCalendarSync: z.boolean().optional(),
calendarEventId: z.string().max(500).optional(),
category: z.string().max(128).optional(),
deviceId: z.string().max(256).optional(),
syncVersion: z.number().int().min(1),
});
export const TimerQuerySchema = z.object({
state: z.enum(TIMER_STATES).optional(),
type: z.enum(TIMER_TYPES).optional(),
urgency: z.enum(URGENCY_LEVELS).optional(),
category: z.string().optional(),
sortBy: z.enum(['createdAt', 'targetTime', 'label']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export const TimerSyncQuerySchema = z.object({
since: z.string().datetime(),
limit: z.coerce.number().int().min(1).max(500).default(100),
});
export const BatchUpsertSchema = z.object({
timers: z.array(CreateTimerSchema).min(1).max(100),
});
// ── Inferred types ──
export type CreateTimerInput = z.infer<typeof CreateTimerSchema>;
export type UpdateTimerInput = z.infer<typeof UpdateTimerSchema>;
export type TimerQuery = z.infer<typeof TimerQuerySchema>;
export type TimerSyncQuery = z.infer<typeof TimerSyncQuerySchema>;
export type BatchUpsertInput = z.infer<typeof BatchUpsertSchema>;
// ── Batch result ──
export interface BatchUpsertResult {
synced: string[];
conflicts: Array<{ id: string; serverVersion: number }>;
errors: Array<{ id: string; error: string }>;
}

View File

@ -0,0 +1,200 @@
import { createHmac } from 'node:crypto';
import type { WebhookEventType, WebhookSubscriptionDoc, WebhookEventDoc } from './types.js';
import * as repo from './repository.js';
// ── HMAC Signing ──────────────────────────────────────────────
export function signPayload(payload: string, secret: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}
export function buildSignatureHeader(payload: string, secret: string): string {
const timestamp = Math.floor(Date.now() / 1000);
const signature = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
return `t=${timestamp},v1=${signature}`;
}
// ── Delivery ──────────────────────────────────────────────────
export interface DeliveryResult {
subscriptionId: string;
eventId: string;
success: boolean;
statusCode?: number;
error?: string;
}
/**
* Dispatch a webhook event to all matching subscriptions for a user.
* Returns delivery results for each subscription.
*/
export async function dispatchEvent(
userId: string,
productId: string,
eventType: WebhookEventType,
payload: Record<string, unknown>,
log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }
): Promise<DeliveryResult[]> {
const subscriptions = await repo.findSubscriptionsForEvent(userId, productId, eventType);
if (subscriptions.length === 0) {
return [];
}
const results: DeliveryResult[] = [];
for (const sub of subscriptions) {
const result = await deliverToSubscription(sub, eventType, payload, log);
results.push(result);
}
return results;
}
/**
* Deliver a single event to a single subscription.
* Creates an event log entry and handles retries.
*/
async function deliverToSubscription(
sub: WebhookSubscriptionDoc,
eventType: WebhookEventType,
payload: Record<string, unknown>,
log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }
): Promise<DeliveryResult> {
const eventId = crypto.randomUUID();
const now = new Date().toISOString();
// Create event log entry
const eventDoc: WebhookEventDoc = {
id: eventId,
subscriptionId: sub.id,
userId: sub.userId,
productId: sub.productId,
eventType,
payload,
createdAt: now,
attempts: 0,
maxRetries: sub.maxRetries,
};
await repo.createEvent(eventDoc);
// Attempt delivery with retries
const maxAttempts = (sub.maxRetries || 3) + 1;
let lastError: string | undefined;
let statusCode: number | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const bodyJson = JSON.stringify({
id: eventId,
type: eventType,
timestamp: now,
data: payload,
});
const signatureHeader = buildSignatureHeader(bodyJson, sub.secret);
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10_000);
const response = await fetch(sub.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signatureHeader,
'X-Webhook-Id': eventId,
'X-Webhook-Event': eventType,
'User-Agent': 'ChronoMind-Webhooks/1.0',
},
body: bodyJson,
signal: controller.signal,
});
globalThis.clearTimeout(timeout);
statusCode = response.status;
if (response.ok) {
// Success — update event log
await repo.updateEvent({
...eventDoc,
deliveredAt: new Date().toISOString(),
statusCode,
attempts: attempt,
});
await repo.resetFailureCount(sub.id, sub.userId);
log?.info({ subscriptionId: sub.id, eventType, attempt, statusCode }, 'webhook delivered');
return {
subscriptionId: sub.id,
eventId,
success: true,
statusCode,
};
}
lastError = `HTTP ${statusCode}`;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : String(err);
}
// Exponential backoff between retries (100ms, 200ms, 400ms, ...)
if (attempt < maxAttempts) {
const delay = Math.min(100 * Math.pow(2, attempt - 1), 5000);
await new Promise<void>(resolve => globalThis.setTimeout(resolve, delay));
}
}
// All attempts failed
await repo.updateEvent({
...eventDoc,
attempts: maxAttempts,
error: lastError,
statusCode,
});
await repo.incrementFailureCount(sub.id, sub.userId);
log?.error({ subscriptionId: sub.id, eventType, error: lastError }, 'webhook delivery failed');
return {
subscriptionId: sub.id,
eventId,
success: false,
statusCode,
error: lastError,
};
}
// ── Verify Signature (for consumers) ──────────────────────────
export function verifySignature(
signatureHeader: string,
body: string,
secret: string,
toleranceSeconds = 300
): boolean {
const parts = signatureHeader.split(',');
const timestampPart = parts.find(p => p.startsWith('t='));
const signaturePart = parts.find(p => p.startsWith('v1='));
if (!timestampPart || !signaturePart) return false;
const timestamp = parseInt(timestampPart.slice(2), 10);
const signature = signaturePart.slice(3);
// Check timestamp tolerance
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
// Verify HMAC
const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
// Constant-time comparison
if (expected.length !== signature.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
}
return diff === 0;
}

View File

@ -0,0 +1,192 @@
import { getContainer } from '../../lib/cosmos.js';
import { NotFoundError, ConflictError } from '../../lib/errors.js';
import type {
WebhookSubscriptionDoc,
WebhookEventDoc,
CreateSubscription,
UpdateSubscription,
WebhookEventType,
} from './types.js';
const SUBS_CONTAINER = 'webhook_subscriptions';
const EVENTS_CONTAINER = 'webhook_events';
function subsContainer() {
return getContainer(SUBS_CONTAINER);
}
function eventsContainer() {
return getContainer(EVENTS_CONTAINER);
}
// ── Subscription CRUD ─────────────────────────────────────────
export async function listSubscriptions(
userId: string,
productId: string
): Promise<WebhookSubscriptionDoc[]> {
const { resources } = await subsContainer()
.items.query<WebhookSubscriptionDoc>(
{
query:
'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.createdAt DESC',
parameters: [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
],
},
{ partitionKey: userId }
)
.fetchAll();
return resources;
}
export async function getSubscription(id: string, userId: string): Promise<WebhookSubscriptionDoc> {
const { resource } = await subsContainer().item(id, userId).read<WebhookSubscriptionDoc>();
if (!resource) {
throw new NotFoundError(`Webhook subscription '${id}' not found`);
}
return resource;
}
export async function createSubscription(
id: string,
userId: string,
productId: string,
input: CreateSubscription
): Promise<WebhookSubscriptionDoc> {
const now = new Date().toISOString();
const doc: WebhookSubscriptionDoc = {
id,
userId,
productId,
url: input.url,
secret: input.secret,
events: input.events,
active: true,
description: input.description,
createdAt: now,
updatedAt: now,
failureCount: 0,
maxRetries: input.maxRetries ?? 3,
};
try {
const { resource } = await subsContainer().items.create(doc);
return resource as WebhookSubscriptionDoc;
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === 409) {
throw new ConflictError(`Subscription '${id}' already exists`);
}
throw err;
}
}
export async function updateSubscription(
id: string,
userId: string,
updates: UpdateSubscription
): Promise<WebhookSubscriptionDoc> {
const existing = await getSubscription(id, userId);
const updated: WebhookSubscriptionDoc = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
const { resource } = await subsContainer().item(id, userId).replace(updated);
return resource as WebhookSubscriptionDoc;
}
export async function deleteSubscription(id: string, userId: string): Promise<void> {
await getSubscription(id, userId); // verify exists
await subsContainer().item(id, userId).delete();
}
// ── Find Subscriptions for Event ──────────────────────────────
export async function findSubscriptionsForEvent(
userId: string,
productId: string,
eventType: WebhookEventType
): Promise<WebhookSubscriptionDoc[]> {
const { resources } = await subsContainer()
.items.query<WebhookSubscriptionDoc>(
{
query:
'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.active = true AND ARRAY_CONTAINS(c.events, @eventType)',
parameters: [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
{ name: '@eventType', value: eventType },
],
},
{ partitionKey: userId }
)
.fetchAll();
return resources;
}
// ── Increment Failure Count ───────────────────────────────────
export async function incrementFailureCount(id: string, userId: string): Promise<void> {
const existing = await getSubscription(id, userId);
const failureCount = (existing.failureCount || 0) + 1;
// Auto-disable after 10 consecutive failures
const active = failureCount < 10;
await subsContainer()
.item(id, userId)
.replace({
...existing,
failureCount,
active,
updatedAt: new Date().toISOString(),
});
}
export async function resetFailureCount(id: string, userId: string): Promise<void> {
const existing = await getSubscription(id, userId);
await subsContainer()
.item(id, userId)
.replace({
...existing,
failureCount: 0,
lastDeliveryAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
// ── Event Log ─────────────────────────────────────────────────
export async function createEvent(doc: WebhookEventDoc): Promise<WebhookEventDoc> {
const { resource } = await eventsContainer().items.create(doc);
return resource as WebhookEventDoc;
}
export async function updateEvent(doc: WebhookEventDoc): Promise<WebhookEventDoc> {
const { resource } = await eventsContainer().item(doc.id, doc.subscriptionId).replace(doc);
return resource as WebhookEventDoc;
}
export async function listEvents(subscriptionId: string, limit = 50): Promise<WebhookEventDoc[]> {
const { resources } = await eventsContainer()
.items.query<WebhookEventDoc>(
{
query:
'SELECT TOP @limit * FROM c WHERE c.subscriptionId = @subscriptionId ORDER BY c.createdAt DESC',
parameters: [
{ name: '@subscriptionId', value: subscriptionId },
{ name: '@limit', value: limit },
],
},
{ partitionKey: subscriptionId }
)
.fetchAll();
return resources;
}

View File

@ -0,0 +1,111 @@
import type { FastifyInstance } from 'fastify';
import {
CreateSubscriptionSchema,
UpdateSubscriptionSchema,
WEBHOOK_EVENT_TYPES,
} from './types.js';
import * as repo from './repository.js';
import { dispatchEvent } from './dispatcher.js';
import { extractAuth } from '../../lib/auth.js';
import { BadRequestError } from '../../lib/errors.js';
const PRODUCT_ID = 'chronomind';
export async function webhookRoutes(app: FastifyInstance) {
// Event types — must be before :id param route
app.get('/webhooks/event-types', async (_req, reply) => {
return reply.send({
eventTypes: WEBHOOK_EVENT_TYPES.map(type => ({
type,
category: type.split('.')[0],
action: type.split('.')[1],
})),
});
});
// Test — must be before :id param route
app.post('/webhooks/test', async (req, reply) => {
const auth = await extractAuth(req);
const body = req.body as { subscriptionId?: string; eventType?: string };
if (!body.subscriptionId) {
throw new BadRequestError('subscriptionId is required');
}
await repo.getSubscription(body.subscriptionId, auth.sub);
const eventType = (body.eventType || 'timer.fired') as (typeof WEBHOOK_EVENT_TYPES)[number];
if (!WEBHOOK_EVENT_TYPES.includes(eventType)) {
throw new BadRequestError(`Invalid event type: ${eventType}`);
}
const results = await dispatchEvent(
auth.sub,
PRODUCT_ID,
eventType,
{
test: true,
message: 'This is a test webhook event from ChronoMind',
timestamp: new Date().toISOString(),
},
req.log
);
return reply.send({ results });
});
// List subscriptions
app.get('/webhooks', async req => {
const auth = await extractAuth(req);
return repo.listSubscriptions(auth.sub, PRODUCT_ID);
});
// Get subscription
app.get('/webhooks/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
return repo.getSubscription(id, auth.sub);
});
// Create subscription
app.post('/webhooks', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateSubscriptionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const id = crypto.randomUUID();
const sub = await repo.createSubscription(id, auth.sub, PRODUCT_ID, parsed.data);
return reply.status(201).send(sub);
});
// Update subscription
app.put('/webhooks/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = UpdateSubscriptionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
return repo.updateSubscription(id, auth.sub, parsed.data);
});
// Delete subscription
app.delete('/webhooks/:id', async (req, reply) => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
await repo.deleteSubscription(id, auth.sub);
return reply.status(204).send();
});
// List events for subscription
app.get('/webhooks/:id/events', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
// Verify ownership
await repo.getSubscription(id, auth.sub);
const limit = parseInt((req.query as Record<string, string>).limit || '50', 10);
return repo.listEvents(id, Math.min(limit, 100));
});
}

View File

@ -0,0 +1,95 @@
import { z } from 'zod';
// ── Webhook Event Types ───────────────────────────────────────
export const WEBHOOK_EVENT_TYPES = [
'timer.created',
'timer.fired',
'timer.dismissed',
'timer.completed',
'timer.snoozed',
'timer.paused',
'timer.resumed',
'routine.started',
'routine.completed',
'routine.step_completed',
'household.member_joined',
'household.member_left',
'shared_timer.created',
'shared_timer.fired',
'shared_timer.acknowledged',
] as const;
export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
// ── Subscription Schemas ──────────────────────────────────────
export const WebhookSubscriptionSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
productId: z.string().min(1),
url: z.string().url(),
secret: z.string().min(16).max(256),
events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1),
active: z.boolean().default(true),
description: z.string().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
lastDeliveryAt: z.string().optional(),
failureCount: z.number().default(0),
maxRetries: z.number().default(3),
});
export const CreateSubscriptionSchema = z.object({
url: z.string().url(),
secret: z.string().min(16).max(256),
events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1),
description: z.string().optional(),
maxRetries: z.number().min(0).max(10).optional(),
});
export const UpdateSubscriptionSchema = z.object({
url: z.string().url().optional(),
secret: z.string().min(16).max(256).optional(),
events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1).optional(),
active: z.boolean().optional(),
description: z.string().optional(),
maxRetries: z.number().min(0).max(10).optional(),
});
// ── Event Payload Schema ──────────────────────────────────────
export const WebhookEventSchema = z.object({
id: z.string().min(1),
subscriptionId: z.string().min(1),
userId: z.string().min(1),
productId: z.string().min(1),
eventType: z.enum(WEBHOOK_EVENT_TYPES),
payload: z.record(z.unknown()),
createdAt: z.string(),
deliveredAt: z.string().optional(),
statusCode: z.number().optional(),
attempts: z.number().default(0),
maxRetries: z.number().default(3),
nextRetryAt: z.string().optional(),
error: z.string().optional(),
});
// ── TypeScript Types ──────────────────────────────────────────
export type WebhookSubscription = z.infer<typeof WebhookSubscriptionSchema>;
export type CreateSubscription = z.infer<typeof CreateSubscriptionSchema>;
export type UpdateSubscription = z.infer<typeof UpdateSubscriptionSchema>;
export type WebhookEvent = z.infer<typeof WebhookEventSchema>;
// ── Cosmos Document Shapes ────────────────────────────────────
export interface WebhookSubscriptionDoc extends WebhookSubscription {
_ts?: number;
_etag?: string;
}
export interface WebhookEventDoc extends WebhookEvent {
_ts?: number;
_etag?: string;
}

View File

@ -0,0 +1,356 @@
import { describe, it, expect } from 'vitest';
import {
WebhookSubscriptionSchema,
CreateSubscriptionSchema,
UpdateSubscriptionSchema,
WebhookEventSchema,
WEBHOOK_EVENT_TYPES,
type WebhookSubscription,
type CreateSubscription,
type WebhookEvent,
} from './types.js';
import { signPayload, buildSignatureHeader, verifySignature } from './dispatcher.js';
// ── Types & Schema Tests ──────────────────────────────────────
describe('Webhook Types', () => {
it('should define 15 event types', () => {
expect(WEBHOOK_EVENT_TYPES).toHaveLength(15);
});
it('should include all timer event types', () => {
const timerEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('timer.'));
expect(timerEvents).toEqual([
'timer.created',
'timer.fired',
'timer.dismissed',
'timer.completed',
'timer.snoozed',
'timer.paused',
'timer.resumed',
]);
});
it('should include all routine event types', () => {
const routineEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('routine.'));
expect(routineEvents).toEqual([
'routine.started',
'routine.completed',
'routine.step_completed',
]);
});
it('should include all household event types', () => {
const householdEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('household.'));
expect(householdEvents).toEqual(['household.member_joined', 'household.member_left']);
});
it('should include all shared_timer event types', () => {
const sharedEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('shared_timer.'));
expect(sharedEvents).toEqual([
'shared_timer.created',
'shared_timer.fired',
'shared_timer.acknowledged',
]);
});
it('should have unique event types', () => {
const unique = new Set(WEBHOOK_EVENT_TYPES);
expect(unique.size).toBe(WEBHOOK_EVENT_TYPES.length);
});
});
describe('WebhookSubscriptionSchema', () => {
const validSub: WebhookSubscription = {
id: 'sub-1',
userId: 'user-1',
productId: 'chronomind',
url: 'https://example.com/webhook',
secret: 'super-secret-key-1234567',
events: ['timer.fired', 'timer.dismissed'],
active: true,
failureCount: 0,
maxRetries: 3,
};
it('should validate a correct subscription', () => {
const result = WebhookSubscriptionSchema.safeParse(validSub);
expect(result.success).toBe(true);
});
it('should reject subscription without url', () => {
const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: '' });
expect(result.success).toBe(false);
});
it('should reject subscription with invalid url', () => {
const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: 'not-a-url' });
expect(result.success).toBe(false);
});
it('should reject subscription with short secret', () => {
const result = WebhookSubscriptionSchema.safeParse({ ...validSub, secret: 'short' });
expect(result.success).toBe(false);
});
it('should reject subscription with empty events', () => {
const result = WebhookSubscriptionSchema.safeParse({ ...validSub, events: [] });
expect(result.success).toBe(false);
});
it('should reject subscription with invalid event type', () => {
const result = WebhookSubscriptionSchema.safeParse({
...validSub,
events: ['timer.fired', 'invalid.event'],
});
expect(result.success).toBe(false);
});
it('should default active to true', () => {
const withoutActive = { ...validSub };
delete (withoutActive as Record<string, unknown>).active;
const result = WebhookSubscriptionSchema.safeParse(withoutActive);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.active).toBe(true);
}
});
it('should default failureCount to 0', () => {
const withoutCount = { ...validSub };
delete (withoutCount as Record<string, unknown>).failureCount;
const result = WebhookSubscriptionSchema.safeParse(withoutCount);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.failureCount).toBe(0);
}
});
});
describe('CreateSubscriptionSchema', () => {
const validCreate: CreateSubscription = {
url: 'https://hooks.zapier.com/abc123',
secret: 'webhook-signing-secret-abc123',
events: ['timer.fired'],
};
it('should validate a correct create payload', () => {
const result = CreateSubscriptionSchema.safeParse(validCreate);
expect(result.success).toBe(true);
});
it('should accept optional description', () => {
const result = CreateSubscriptionSchema.safeParse({
...validCreate,
description: 'My Zapier integration',
});
expect(result.success).toBe(true);
});
it('should accept optional maxRetries', () => {
const result = CreateSubscriptionSchema.safeParse({
...validCreate,
maxRetries: 5,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.maxRetries).toBe(5);
}
});
it('should reject maxRetries > 10', () => {
const result = CreateSubscriptionSchema.safeParse({
...validCreate,
maxRetries: 15,
});
expect(result.success).toBe(false);
});
it('should accept multiple event types', () => {
const result = CreateSubscriptionSchema.safeParse({
...validCreate,
events: ['timer.fired', 'timer.dismissed', 'routine.completed'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.events).toHaveLength(3);
}
});
});
describe('UpdateSubscriptionSchema', () => {
it('should validate partial updates', () => {
const result = UpdateSubscriptionSchema.safeParse({ active: false });
expect(result.success).toBe(true);
});
it('should validate url-only update', () => {
const result = UpdateSubscriptionSchema.safeParse({
url: 'https://new-endpoint.example.com/hook',
});
expect(result.success).toBe(true);
});
it('should validate events update', () => {
const result = UpdateSubscriptionSchema.safeParse({
events: ['timer.created', 'timer.completed'],
});
expect(result.success).toBe(true);
});
it('should reject empty events array in update', () => {
const result = UpdateSubscriptionSchema.safeParse({ events: [] });
expect(result.success).toBe(false);
});
});
describe('WebhookEventSchema', () => {
const validEvent: WebhookEvent = {
id: 'evt-1',
subscriptionId: 'sub-1',
userId: 'user-1',
productId: 'chronomind',
eventType: 'timer.fired',
payload: { timerId: 'timer-1', label: 'Meeting' },
createdAt: new Date().toISOString(),
attempts: 1,
maxRetries: 3,
};
it('should validate a correct event', () => {
const result = WebhookEventSchema.safeParse(validEvent);
expect(result.success).toBe(true);
});
it('should accept delivered event with statusCode', () => {
const result = WebhookEventSchema.safeParse({
...validEvent,
deliveredAt: new Date().toISOString(),
statusCode: 200,
});
expect(result.success).toBe(true);
});
it('should accept failed event with error', () => {
const result = WebhookEventSchema.safeParse({
...validEvent,
error: 'Connection refused',
attempts: 4,
});
expect(result.success).toBe(true);
});
});
// ── Dispatcher Tests ──────────────────────────────────────────
describe('Webhook Dispatcher — HMAC Signing', () => {
const secret = 'test-secret-key-for-hmac-1234';
const payload = JSON.stringify({ type: 'timer.fired', data: { id: 'timer-1' } });
it('should produce consistent HMAC signatures', () => {
const sig1 = signPayload(payload, secret);
const sig2 = signPayload(payload, secret);
expect(sig1).toBe(sig2);
expect(sig1).toMatch(/^[0-9a-f]{64}$/); // SHA-256 hex
});
it('should produce different signatures for different payloads', () => {
const sig1 = signPayload('payload-1', secret);
const sig2 = signPayload('payload-2', secret);
expect(sig1).not.toBe(sig2);
});
it('should produce different signatures for different secrets', () => {
const sig1 = signPayload(payload, 'secret-1-aaaaaaaaaa');
const sig2 = signPayload(payload, 'secret-2-bbbbbbbbbb');
expect(sig1).not.toBe(sig2);
});
});
describe('Webhook Dispatcher — Signature Header', () => {
const secret = 'test-secret-key-for-hmac-5678';
const body = '{"type":"timer.fired","data":{}}';
it('should build a valid signature header', () => {
const header = buildSignatureHeader(body, secret);
expect(header).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/);
});
it('should include a recent timestamp', () => {
const header = buildSignatureHeader(body, secret);
const tPart = header.split(',')[0];
const timestamp = parseInt(tPart.slice(2), 10);
const now = Math.floor(Date.now() / 1000);
expect(Math.abs(now - timestamp)).toBeLessThan(5);
});
});
describe('Webhook Dispatcher — Signature Verification', () => {
const secret = 'test-secret-for-verification!';
const body = JSON.stringify({ type: 'timer.dismissed', data: { id: 't-99' } });
it('should verify a valid signature', () => {
const header = buildSignatureHeader(body, secret);
expect(verifySignature(header, body, secret)).toBe(true);
});
it('should reject a tampered body', () => {
const header = buildSignatureHeader(body, secret);
expect(verifySignature(header, body + 'tampered', secret)).toBe(false);
});
it('should reject a wrong secret', () => {
const header = buildSignatureHeader(body, secret);
expect(verifySignature(header, body, 'wrong-secret-1234567890')).toBe(false);
});
it('should reject a malformed header', () => {
expect(verifySignature('invalid', body, secret)).toBe(false);
});
it('should reject missing timestamp', () => {
expect(verifySignature('v1=abc123', body, secret)).toBe(false);
});
it('should reject missing signature', () => {
expect(verifySignature('t=1234567890', body, secret)).toBe(false);
});
it('should reject expired timestamp', () => {
// Build a header with a timestamp from 10 minutes ago
const oldTimestamp = Math.floor(Date.now() / 1000) - 600;
// signPayload produces HMAC of the raw string, matching verifySignature's `${timestamp}.${body}` pattern
const sig = signPayload(`${oldTimestamp}.${body}`, secret);
const header = `t=${oldTimestamp},v1=${sig}`;
// Default tolerance is 300 seconds (5 minutes) — 600s ago should be rejected
expect(verifySignature(header, body, secret, 300)).toBe(false);
});
it('should accept within tolerance window', () => {
const header = buildSignatureHeader(body, secret);
// Use a large tolerance window
expect(verifySignature(header, body, secret, 3600)).toBe(true);
});
});
// ── Event Type Categorization Tests ───────────────────────────
describe('Event Type Categories', () => {
it('all event types should have category.action format', () => {
for (const type of WEBHOOK_EVENT_TYPES) {
const parts = type.split('.');
expect(parts).toHaveLength(2);
expect(parts[0].length).toBeGreaterThan(0);
expect(parts[1].length).toBeGreaterThan(0);
}
});
it('should have 4 categories', () => {
const categories = new Set(WEBHOOK_EVENT_TYPES.map(t => t.split('.')[0]));
expect(categories.size).toBe(4);
expect(categories).toContain('timer');
expect(categories).toContain('routine');
expect(categories).toContain('household');
expect(categories).toContain('shared_timer');
});
});

55
backend/src/server.ts Normal file
View File

@ -0,0 +1,55 @@
/**
* ChronoMind Backend Fastify server entry point.
*
* Product-specific service for timers, routines, households, shared timers.
* Common platform features (auth, billing, flags, etc.) come from platform-service.
* Port: 4011 (configurable via PORT env var).
*/
import { createServiceApp, startService } from '@bytelyst/fastify-core';
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 { webhookRoutes } from './modules/webhooks/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
import { jwtVerify } from 'jose';
import type { JwtPayload } from './lib/request-context.js';
const jwtSecret = new TextEncoder().encode(process.env.JWT_SECRET || '');
await initCosmosIfNeeded();
const app = await createServiceApp({
name: 'chronomind-backend',
version: '0.1.0',
description: 'ChronoMind product-specific backend — timers, routines, households, shared timers, webhooks',
corsOrigin: config.CORS_ORIGIN,
swagger: {
title: 'ChronoMind Backend',
description: 'Timers, routines, households, shared timers, webhooks',
port: config.PORT,
},
metrics: true,
});
app.addHook('onRequest', async req => {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) return;
try {
const { payload } = await jwtVerify(auth.slice(7), jwtSecret, { issuer: 'bytelyst-platform' });
req.jwtPayload = payload as unknown as JwtPayload;
} catch {
// Token invalid/expired — leave jwtPayload undefined.
}
});
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 app.register(webhookRoutes, { prefix: '/api' });
await startService(app, { port: config.PORT, host: config.HOST });

20
backend/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "src/**/*.test.ts"]
}

8
backend/vitest.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
passWithNoTests: true,
},
});