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:
parent
6b82ca1b33
commit
f10b83c122
@ -223,7 +223,9 @@ docker build web/ # self-contained build
|
||||
|
||||
## 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 |
|
||||
|--------|-----------|-----------|-------|
|
||||
@ -234,6 +236,11 @@ ChronoMind uses the shared **platform-service** (port 4003) from `learning_ai_co
|
||||
|
||||
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
|
||||
- `syncVersion` monotonic integer — optimistic concurrency, 409 on stale writes
|
||||
- Delta sync: `GET /timers/sync?since=<ISO>` returns only changed timers
|
||||
|
||||
9
backend/.env.example
Normal file
9
backend/.env.example
Normal 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
4
backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
2689
backend/package-lock.json
generated
Normal file
2689
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/package.json
Normal file
32
backend/package.json
Normal 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
6
backend/src/lib/auth.ts
Normal 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
15
backend/src/lib/config.ts
Normal 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);
|
||||
28
backend/src/lib/cosmos-init.ts
Normal file
28
backend/src/lib/cosmos-init.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
4
backend/src/lib/cosmos.ts
Normal file
4
backend/src/lib/cosmos.ts
Normal 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
12
backend/src/lib/errors.ts
Normal 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';
|
||||
34
backend/src/lib/request-context.ts
Normal file
34
backend/src/lib/request-context.ts
Normal 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;
|
||||
}
|
||||
198
backend/src/modules/households/households.test.ts
Normal file
198
backend/src/modules/households/households.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
backend/src/modules/households/repository.ts
Normal file
95
backend/src/modules/households/repository.ts
Normal 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;
|
||||
}
|
||||
264
backend/src/modules/households/routes.ts
Normal file
264
backend/src/modules/households/routes.ts
Normal file
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Household REST endpoints — ChronoMind Family tier.
|
||||
*
|
||||
* GET /households — list user's households
|
||||
* GET /households/:id — single household
|
||||
* POST /households — create household
|
||||
* PUT /households/:id — update household name (admin only)
|
||||
* DELETE /households/:id — delete household (admin only)
|
||||
* POST /households/:id/invite — generate invite code (admin only)
|
||||
* POST /households/join — accept invite code
|
||||
* DELETE /households/:id/members — remove member (admin only)
|
||||
* DELETE /households/:id/leave — leave household (non-admin)
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, NotFoundError, ForbiddenError, ConflictError } from '../../lib/errors.js';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
CreateHouseholdSchema,
|
||||
UpdateHouseholdSchema,
|
||||
CreateInviteSchema,
|
||||
AcceptInviteSchema,
|
||||
RemoveMemberSchema,
|
||||
HouseholdQuerySchema,
|
||||
MAX_HOUSEHOLD_MEMBERS,
|
||||
type HouseholdDoc,
|
||||
type HouseholdMember,
|
||||
type HouseholdInvite,
|
||||
} from './types.js';
|
||||
|
||||
const PRODUCT_ID = 'chronomind';
|
||||
|
||||
function isAdmin(household: HouseholdDoc, userId: string): boolean {
|
||||
return household.members.some(m => m.userId === userId && m.role === 'admin');
|
||||
}
|
||||
|
||||
function isMember(household: HouseholdDoc, userId: string): boolean {
|
||||
return household.members.some(m => m.userId === userId);
|
||||
}
|
||||
|
||||
function generateInviteCode(): string {
|
||||
return crypto.randomBytes(6).toString('hex').toUpperCase();
|
||||
}
|
||||
|
||||
export async function householdRoutes(app: FastifyInstance) {
|
||||
// List households for current user
|
||||
app.get('/households', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = HouseholdQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { items, total } = await repo.listHouseholdsForUser(auth.sub, PRODUCT_ID, parsed.data);
|
||||
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
|
||||
});
|
||||
|
||||
// Get single household
|
||||
app.get('/households/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const household = await repo.getHousehold(id);
|
||||
if (!household || household.productId !== PRODUCT_ID)
|
||||
throw new NotFoundError('Household not found');
|
||||
if (!isMember(household, auth.sub)) throw new ForbiddenError('Not a member of this household');
|
||||
return household;
|
||||
});
|
||||
|
||||
// Create household
|
||||
app.post('/households', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = CreateHouseholdSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const member: HouseholdMember = {
|
||||
userId: auth.sub,
|
||||
displayName: parsed.data.displayName,
|
||||
role: 'admin',
|
||||
joinedAt: now,
|
||||
};
|
||||
|
||||
const doc: HouseholdDoc = {
|
||||
id: crypto.randomUUID(),
|
||||
productId: PRODUCT_ID,
|
||||
name: parsed.data.name,
|
||||
members: [member],
|
||||
invites: [],
|
||||
createdAt: now,
|
||||
createdBy: auth.sub,
|
||||
};
|
||||
|
||||
req.log.info({ householdId: doc.id }, 'Creating household');
|
||||
const created = await repo.createHousehold(doc);
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Update household name (admin only)
|
||||
app.put('/households/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = UpdateHouseholdSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const household = await repo.getHousehold(id);
|
||||
if (!household || household.productId !== PRODUCT_ID)
|
||||
throw new NotFoundError('Household not found');
|
||||
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can update household');
|
||||
|
||||
const updated: HouseholdDoc = { ...household, ...parsed.data };
|
||||
const result = await repo.replaceHousehold(updated);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Delete household (admin only)
|
||||
app.delete('/households/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const household = await repo.getHousehold(id);
|
||||
if (!household || household.productId !== PRODUCT_ID)
|
||||
throw new NotFoundError('Household not found');
|
||||
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can delete household');
|
||||
|
||||
await repo.deleteHousehold(id);
|
||||
req.log.info({ householdId: id }, 'Deleted household');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Generate invite code (admin only)
|
||||
app.post('/households/:id/invite', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = CreateInviteSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const household = await repo.getHousehold(id);
|
||||
if (!household || household.productId !== PRODUCT_ID)
|
||||
throw new NotFoundError('Household not found');
|
||||
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can create invites');
|
||||
|
||||
if (household.members.length >= MAX_HOUSEHOLD_MEMBERS) {
|
||||
throw new BadRequestError(
|
||||
`Household is at maximum capacity (${MAX_HOUSEHOLD_MEMBERS} members)`
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const invite: HouseholdInvite = {
|
||||
code: generateInviteCode(),
|
||||
createdBy: auth.sub,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + parsed.data.expiresInHours * 3600_000).toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
household.invites.push(invite);
|
||||
await repo.replaceHousehold(household);
|
||||
req.log.info({ householdId: id, inviteCode: invite.code }, 'Created invite');
|
||||
return { code: invite.code, expiresAt: invite.expiresAt };
|
||||
});
|
||||
|
||||
// Accept invite code (join household)
|
||||
app.post('/households/join', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = AcceptInviteSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const household = await repo.findHouseholdByInviteCode(parsed.data.code, PRODUCT_ID);
|
||||
if (!household) throw new NotFoundError('Invalid or expired invite code');
|
||||
|
||||
if (isMember(household, auth.sub)) {
|
||||
throw new ConflictError('Already a member of this household');
|
||||
}
|
||||
|
||||
if (household.members.length >= MAX_HOUSEHOLD_MEMBERS) {
|
||||
throw new BadRequestError(
|
||||
`Household is at maximum capacity (${MAX_HOUSEHOLD_MEMBERS} members)`
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const invite = household.invites.find(
|
||||
i => i.code === parsed.data.code && i.status === 'pending'
|
||||
);
|
||||
if (!invite || new Date(invite.expiresAt) < new Date()) {
|
||||
throw new NotFoundError('Invite code has expired');
|
||||
}
|
||||
|
||||
// Mark invite as accepted
|
||||
invite.status = 'accepted';
|
||||
invite.acceptedBy = auth.sub;
|
||||
invite.acceptedAt = now;
|
||||
|
||||
// Add member
|
||||
const member: HouseholdMember = {
|
||||
userId: auth.sub,
|
||||
displayName: parsed.data.displayName,
|
||||
role: 'member',
|
||||
joinedAt: now,
|
||||
};
|
||||
household.members.push(member);
|
||||
|
||||
const updated = await repo.replaceHousehold(household);
|
||||
req.log.info({ householdId: household.id, userId: auth.sub }, 'Member joined household');
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Remove member (admin only)
|
||||
app.delete('/households/:id/members', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = RemoveMemberSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const household = await repo.getHousehold(id);
|
||||
if (!household || household.productId !== PRODUCT_ID)
|
||||
throw new NotFoundError('Household not found');
|
||||
if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can remove members');
|
||||
|
||||
if (parsed.data.userId === auth.sub) {
|
||||
throw new BadRequestError('Admin cannot remove themselves. Delete the household instead.');
|
||||
}
|
||||
|
||||
const memberIdx = household.members.findIndex(m => m.userId === parsed.data.userId);
|
||||
if (memberIdx === -1) throw new NotFoundError('Member not found in household');
|
||||
|
||||
household.members.splice(memberIdx, 1);
|
||||
const updated = await repo.replaceHousehold(household);
|
||||
req.log.info({ householdId: id, removedUserId: parsed.data.userId }, 'Removed member');
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Leave household (non-admin)
|
||||
app.delete('/households/:id/leave', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const household = await repo.getHousehold(id);
|
||||
if (!household || household.productId !== PRODUCT_ID)
|
||||
throw new NotFoundError('Household not found');
|
||||
if (!isMember(household, auth.sub)) throw new NotFoundError('Not a member of this household');
|
||||
|
||||
if (isAdmin(household, auth.sub)) {
|
||||
throw new BadRequestError('Admin cannot leave. Transfer admin or delete the household.');
|
||||
}
|
||||
|
||||
household.members = household.members.filter(m => m.userId !== auth.sub);
|
||||
const updated = await repo.replaceHousehold(household);
|
||||
req.log.info({ householdId: id, userId: auth.sub }, 'Member left household');
|
||||
return { success: true, householdId: updated.id };
|
||||
});
|
||||
}
|
||||
93
backend/src/modules/households/types.ts
Normal file
93
backend/src/modules/households/types.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Household types — ChronoMind Family tier.
|
||||
*
|
||||
* Cosmos container: `households` (partition key: `/id`)
|
||||
* Product ID: "chronomind"
|
||||
*
|
||||
* A household is a group of up to 6 members who can share timers.
|
||||
* One admin (creator) manages members. Members join via invite code.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Enums / constants ──
|
||||
|
||||
export const MEMBER_ROLES = ['admin', 'member'] as const;
|
||||
export type MemberRole = (typeof MEMBER_ROLES)[number];
|
||||
|
||||
export const INVITE_STATUSES = ['pending', 'accepted', 'expired', 'revoked'] as const;
|
||||
export type InviteStatus = (typeof INVITE_STATUSES)[number];
|
||||
|
||||
export const MAX_HOUSEHOLD_MEMBERS = 6;
|
||||
|
||||
// ── Sub-document interfaces ──
|
||||
|
||||
export interface HouseholdMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
role: MemberRole;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface HouseholdInvite {
|
||||
code: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
status: InviteStatus;
|
||||
acceptedBy?: string;
|
||||
acceptedAt?: string;
|
||||
}
|
||||
|
||||
// ── Main document ──
|
||||
|
||||
export interface HouseholdDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
members: HouseholdMember[];
|
||||
invites: HouseholdInvite[];
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
// ── Zod schemas ──
|
||||
|
||||
export const CreateHouseholdSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
displayName: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
export const UpdateHouseholdSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export const CreateInviteSchema = z.object({
|
||||
expiresInHours: z.number().int().min(1).max(168).default(72),
|
||||
});
|
||||
|
||||
export const AcceptInviteSchema = z.object({
|
||||
code: z.string().min(1).max(32),
|
||||
displayName: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
export const RemoveMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const HouseholdQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(50).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
// ── Inferred types ──
|
||||
|
||||
export type CreateHouseholdInput = z.infer<typeof CreateHouseholdSchema>;
|
||||
export type UpdateHouseholdInput = z.infer<typeof UpdateHouseholdSchema>;
|
||||
export type CreateInviteInput = z.infer<typeof CreateInviteSchema>;
|
||||
export type AcceptInviteInput = z.infer<typeof AcceptInviteSchema>;
|
||||
export type RemoveMemberInput = z.infer<typeof RemoveMemberSchema>;
|
||||
export type HouseholdQuery = z.infer<typeof HouseholdQuerySchema>;
|
||||
182
backend/src/modules/routines/repository.ts
Normal file
182
backend/src/modules/routines/repository.ts
Normal file
@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Routines repository — Cosmos DB CRUD + sync + batch upsert.
|
||||
*
|
||||
* Container: routines (partition key: /userId)
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { RoutineDoc, RoutineQuery, BatchUpsertRoutinesResult } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('routines');
|
||||
}
|
||||
|
||||
export async function listRoutines(
|
||||
userId: string,
|
||||
productId: string,
|
||||
query: RoutineQuery
|
||||
): Promise<{ items: RoutineDoc[]; total: number }> {
|
||||
const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId'];
|
||||
const params: { name: string; value: string | number | boolean }[] = [
|
||||
{ name: '@userId', value: userId },
|
||||
{ name: '@productId', value: productId },
|
||||
];
|
||||
|
||||
if (query.status) {
|
||||
conditions.push('c.status = @status');
|
||||
params.push({ name: '@status', value: query.status });
|
||||
}
|
||||
if (query.isTemplate !== undefined) {
|
||||
conditions.push('c.isTemplate = @isTemplate');
|
||||
params.push({ name: '@isTemplate', value: query.isTemplate });
|
||||
}
|
||||
if (query.category) {
|
||||
conditions.push('c.category = @category');
|
||||
params.push({ name: '@category', value: query.category });
|
||||
}
|
||||
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const sortField = `c.${query.sortBy}`;
|
||||
const orderDir = query.sortOrder.toUpperCase();
|
||||
|
||||
const countResult = await container()
|
||||
.items.query<number>({
|
||||
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
|
||||
parameters: params,
|
||||
})
|
||||
.fetchAll();
|
||||
const total = countResult.resources[0] ?? 0;
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query<RoutineDoc>({
|
||||
query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`,
|
||||
parameters: [
|
||||
...params,
|
||||
{ name: '@offset', value: query.offset },
|
||||
{ name: '@limit', value: query.limit },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
return { items: resources, total };
|
||||
}
|
||||
|
||||
export async function getRoutine(id: string, userId: string): Promise<RoutineDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<RoutineDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoutine(doc: RoutineDoc): Promise<RoutineDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as RoutineDoc;
|
||||
}
|
||||
|
||||
export async function updateRoutine(
|
||||
id: string,
|
||||
userId: string,
|
||||
updates: Partial<RoutineDoc>,
|
||||
expectedSyncVersion: number
|
||||
): Promise<{ doc: RoutineDoc | null; conflict: boolean; serverVersion?: number }> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, userId).read<RoutineDoc>();
|
||||
if (!existing) return { doc: null, conflict: false };
|
||||
|
||||
if (expectedSyncVersion <= existing.syncVersion) {
|
||||
return { doc: null, conflict: true, serverVersion: existing.syncVersion };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const merged: RoutineDoc = {
|
||||
...existing,
|
||||
...updates,
|
||||
syncVersion: expectedSyncVersion,
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
const { resource } = await container().item(id, userId).replace(merged);
|
||||
return { doc: resource as RoutineDoc, conflict: false };
|
||||
} catch {
|
||||
return { doc: null, conflict: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoutine(id: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, userId).read<RoutineDoc>();
|
||||
if (!existing) return false;
|
||||
await container().item(id, userId).delete();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoutinesSince(
|
||||
userId: string,
|
||||
productId: string,
|
||||
sinceTimestamp: string,
|
||||
limit: number
|
||||
): Promise<RoutineDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<RoutineDoc>({
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit',
|
||||
parameters: [
|
||||
{ name: '@userId', value: userId },
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@since', value: sinceTimestamp },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function batchUpsertRoutines(
|
||||
userId: string,
|
||||
productId: string,
|
||||
routines: Array<Record<string, unknown> & { id: string; syncVersion: number }>
|
||||
): Promise<BatchUpsertRoutinesResult> {
|
||||
const synced: string[] = [];
|
||||
const conflicts: Array<{ id: string; serverVersion: number }> = [];
|
||||
const errors: Array<{ id: string; error: string }> = [];
|
||||
|
||||
for (const routine of routines) {
|
||||
try {
|
||||
const existing = await getRoutine(routine.id, userId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
if (routine.syncVersion >= existing.syncVersion) {
|
||||
const merged: RoutineDoc = {
|
||||
...existing,
|
||||
...routine,
|
||||
userId,
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
} as RoutineDoc;
|
||||
await container().item(routine.id, userId).replace(merged);
|
||||
synced.push(routine.id);
|
||||
} else {
|
||||
conflicts.push({ id: routine.id, serverVersion: existing.syncVersion });
|
||||
}
|
||||
} else {
|
||||
const doc = {
|
||||
...routine,
|
||||
userId,
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
await container().items.create(doc);
|
||||
synced.push(routine.id);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ id: routine.id, error: err instanceof Error ? err.message : 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, conflicts, errors };
|
||||
}
|
||||
155
backend/src/modules/routines/routes.ts
Normal file
155
backend/src/modules/routines/routes.ts
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Routine REST endpoints — ChronoMind cloud sync.
|
||||
*
|
||||
* GET /routines — list user's routines (filterable, paginated)
|
||||
* GET /routines/sync — delta sync (routines modified since timestamp)
|
||||
* GET /routines/:id — single routine
|
||||
* POST /routines — create routine
|
||||
* PUT /routines/:id — update routine (with syncVersion conflict check)
|
||||
* DELETE /routines/:id — delete routine
|
||||
* POST /routines/batch — batch upsert
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, NotFoundError, ConflictError } from '../../lib/errors.js';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
CreateRoutineSchema,
|
||||
UpdateRoutineSchema,
|
||||
RoutineQuerySchema,
|
||||
RoutineSyncQuerySchema,
|
||||
BatchUpsertRoutinesSchema,
|
||||
type RoutineDoc,
|
||||
} from './types.js';
|
||||
|
||||
const PRODUCT_ID = 'chronomind';
|
||||
|
||||
export async function routineRoutes(app: FastifyInstance) {
|
||||
// Sync — must be before :id param route
|
||||
app.get('/routines/sync', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = RoutineSyncQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const routines = await repo.getRoutinesSince(
|
||||
auth.sub,
|
||||
PRODUCT_ID,
|
||||
parsed.data.since,
|
||||
parsed.data.limit
|
||||
);
|
||||
return { routines, count: routines.length };
|
||||
});
|
||||
|
||||
// List routines
|
||||
app.get('/routines', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = RoutineQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { items, total } = await repo.listRoutines(auth.sub, PRODUCT_ID, parsed.data);
|
||||
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
|
||||
});
|
||||
|
||||
// Get single routine
|
||||
app.get('/routines/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const routine = await repo.getRoutine(id, auth.sub);
|
||||
if (!routine) throw new NotFoundError('Routine not found');
|
||||
if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found');
|
||||
return routine;
|
||||
});
|
||||
|
||||
// Create routine
|
||||
app.post('/routines', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = CreateRoutineSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const input = parsed.data;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const doc: RoutineDoc = {
|
||||
id: input.id,
|
||||
userId: auth.sub,
|
||||
productId: PRODUCT_ID,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
steps: input.steps,
|
||||
totalDurationMinutes: input.totalDurationMinutes,
|
||||
status: input.status,
|
||||
currentStepIndex: input.currentStepIndex,
|
||||
isTemplate: input.isTemplate,
|
||||
category: input.category,
|
||||
createdAt: now,
|
||||
startedAt: input.startedAt,
|
||||
elapsedBeforePause: input.elapsedBeforePause,
|
||||
deviceId: input.deviceId,
|
||||
lastSyncedAt: now,
|
||||
syncVersion: input.syncVersion,
|
||||
};
|
||||
|
||||
req.log.info({ routineId: doc.id, isTemplate: doc.isTemplate }, 'Creating routine');
|
||||
const created = await repo.createRoutine(doc);
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Update routine (with syncVersion conflict check)
|
||||
app.put('/routines/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const parsed = UpdateRoutineSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const { syncVersion, ...updates } = parsed.data;
|
||||
const result = await repo.updateRoutine(id, auth.sub, updates, syncVersion);
|
||||
|
||||
if (result.conflict) {
|
||||
throw new ConflictError(
|
||||
`Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}`
|
||||
);
|
||||
}
|
||||
if (!result.doc) throw new NotFoundError('Routine not found');
|
||||
|
||||
req.log.info({ routineId: id, syncVersion }, 'Updated routine');
|
||||
return result.doc;
|
||||
});
|
||||
|
||||
// Delete routine
|
||||
app.delete('/routines/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const success = await repo.deleteRoutine(id, auth.sub);
|
||||
if (!success) throw new NotFoundError('Routine not found');
|
||||
req.log.info({ routineId: id }, 'Deleted routine');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Batch upsert
|
||||
app.post('/routines/batch', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = BatchUpsertRoutinesSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const enriched = parsed.data.routines.map(r => ({
|
||||
...r,
|
||||
createdAt: now,
|
||||
lastSyncedAt: now,
|
||||
}));
|
||||
|
||||
req.log.info({ count: enriched.length }, 'Batch upsert routines');
|
||||
const result = await repo.batchUpsertRoutines(auth.sub, PRODUCT_ID, enriched);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
345
backend/src/modules/routines/routines.test.ts
Normal file
345
backend/src/modules/routines/routines.test.ts
Normal file
@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Routines module unit tests — validates schemas, constants, and types.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateRoutineSchema,
|
||||
UpdateRoutineSchema,
|
||||
RoutineQuerySchema,
|
||||
RoutineSyncQuerySchema,
|
||||
BatchUpsertRoutinesSchema,
|
||||
TRANSITION_TYPES,
|
||||
ROUTINE_STATUSES,
|
||||
STEP_STATUSES,
|
||||
} from './types.js';
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
describe('routine constants', () => {
|
||||
it('has 4 transition types', () => {
|
||||
expect(TRANSITION_TYPES).toEqual(['immediate', '1m_break', '5m_break', 'custom']);
|
||||
});
|
||||
|
||||
it('has 6 routine statuses', () => {
|
||||
expect(ROUTINE_STATUSES).toEqual([
|
||||
'template',
|
||||
'ready',
|
||||
'active',
|
||||
'paused',
|
||||
'completed',
|
||||
'cancelled',
|
||||
]);
|
||||
expect(ROUTINE_STATUSES).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('has 4 step statuses', () => {
|
||||
expect(STEP_STATUSES).toEqual(['pending', 'active', 'skipped', 'completed']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CreateRoutineSchema ──
|
||||
|
||||
describe('CreateRoutineSchema', () => {
|
||||
const validStep = {
|
||||
id: 'step_1',
|
||||
label: 'Warm up',
|
||||
durationMinutes: 5,
|
||||
transition: 'immediate',
|
||||
};
|
||||
|
||||
const validMinimal = {
|
||||
id: 'routine_001',
|
||||
name: 'Morning Routine',
|
||||
steps: [validStep],
|
||||
totalDurationMinutes: 5,
|
||||
};
|
||||
|
||||
it('accepts minimal valid input with defaults', () => {
|
||||
const result = CreateRoutineSchema.safeParse(validMinimal);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('ready');
|
||||
expect(result.data.isTemplate).toBe(false);
|
||||
expect(result.data.currentStepIndex).toBe(0);
|
||||
expect(result.data.syncVersion).toBe(1);
|
||||
expect(result.data.elapsedBeforePause).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts template routine', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
...validMinimal,
|
||||
status: 'template',
|
||||
isTemplate: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.isTemplate).toBe(true);
|
||||
expect(result.data.status).toBe('template');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts multi-step routine with transitions', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
...validMinimal,
|
||||
steps: [
|
||||
validStep,
|
||||
{
|
||||
id: 'step_2',
|
||||
label: 'Exercise',
|
||||
durationMinutes: 20,
|
||||
transition: '5m_break',
|
||||
notes: 'Stretch first',
|
||||
},
|
||||
{
|
||||
id: 'step_3',
|
||||
label: 'Cool down',
|
||||
durationMinutes: 10,
|
||||
transition: 'custom',
|
||||
customTransitionMinutes: 3,
|
||||
},
|
||||
],
|
||||
totalDurationMinutes: 43,
|
||||
category: 'health',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.steps).toHaveLength(3);
|
||||
expect(result.data.steps[1].notes).toBe('Stretch first');
|
||||
expect(result.data.steps[2].customTransitionMinutes).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing id', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
name: 'Test',
|
||||
steps: [validStep],
|
||||
totalDurationMinutes: 5,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing name', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
id: 'routine_001',
|
||||
steps: [validStep],
|
||||
totalDurationMinutes: 5,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty steps array', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
...validMinimal,
|
||||
steps: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects step with invalid transition', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
...validMinimal,
|
||||
steps: [{ ...validStep, transition: 'invalid' }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects step duration > 480 minutes', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
...validMinimal,
|
||||
steps: [{ ...validStep, durationMinutes: 500 }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects step duration < 0.5 minutes', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
...validMinimal,
|
||||
steps: [{ ...validStep, durationMinutes: 0.1 }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects > 50 steps', () => {
|
||||
const steps = Array.from({ length: 51 }, (_, i) => ({
|
||||
...validStep,
|
||||
id: `step_${i}`,
|
||||
}));
|
||||
const result = CreateRoutineSchema.safeParse({ ...validMinimal, steps });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
const result = CreateRoutineSchema.safeParse({
|
||||
...validMinimal,
|
||||
status: 'deleted',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── UpdateRoutineSchema ──
|
||||
|
||||
describe('UpdateRoutineSchema', () => {
|
||||
it('accepts status update with syncVersion', () => {
|
||||
const result = UpdateRoutineSchema.safeParse({ status: 'paused', syncVersion: 2 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts step updates', () => {
|
||||
const result = UpdateRoutineSchema.safeParse({
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
label: 'Step 1',
|
||||
durationMinutes: 5,
|
||||
transition: 'immediate',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
label: 'Step 2',
|
||||
durationMinutes: 10,
|
||||
transition: '1m_break',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
currentStepIndex: 1,
|
||||
syncVersion: 3,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('requires syncVersion', () => {
|
||||
const result = UpdateRoutineSchema.safeParse({ status: 'active' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects syncVersion < 1', () => {
|
||||
const result = UpdateRoutineSchema.safeParse({ status: 'active', syncVersion: 0 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
const result = UpdateRoutineSchema.safeParse({ status: 'deleted', syncVersion: 2 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── RoutineQuerySchema ──
|
||||
|
||||
describe('RoutineQuerySchema', () => {
|
||||
it('provides defaults for empty query', () => {
|
||||
const result = RoutineQuerySchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sortBy).toBe('createdAt');
|
||||
expect(result.data.sortOrder).toBe('desc');
|
||||
expect(result.data.limit).toBe(50);
|
||||
expect(result.data.offset).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts all filter combinations', () => {
|
||||
const result = RoutineQuerySchema.safeParse({
|
||||
status: 'template',
|
||||
isTemplate: 'true',
|
||||
category: 'health',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.isTemplate).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('coerces string numbers', () => {
|
||||
const result = RoutineQuerySchema.safeParse({ limit: '25', offset: '5' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(25);
|
||||
expect(result.data.offset).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects limit > 100', () => {
|
||||
const result = RoutineQuerySchema.safeParse({ limit: 200 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid sortBy', () => {
|
||||
const result = RoutineQuerySchema.safeParse({ sortBy: 'random' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── RoutineSyncQuerySchema ──
|
||||
|
||||
describe('RoutineSyncQuerySchema', () => {
|
||||
it('accepts valid since timestamp', () => {
|
||||
const result = RoutineSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing since', () => {
|
||||
const result = RoutineSyncQuerySchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid since format', () => {
|
||||
const result = RoutineSyncQuerySchema.safeParse({ since: 'yesterday' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── BatchUpsertRoutinesSchema ──
|
||||
|
||||
describe('BatchUpsertRoutinesSchema', () => {
|
||||
const validRoutine = {
|
||||
id: 'routine_batch_1',
|
||||
name: 'Batch Routine',
|
||||
steps: [{ id: 's1', label: 'Step', durationMinutes: 5, transition: 'immediate' }],
|
||||
totalDurationMinutes: 5,
|
||||
};
|
||||
|
||||
it('accepts array of valid routines', () => {
|
||||
const result = BatchUpsertRoutinesSchema.safeParse({
|
||||
routines: [validRoutine, { ...validRoutine, id: 'routine_batch_2', name: 'Second' }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.routines).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty routines array', () => {
|
||||
const result = BatchUpsertRoutinesSchema.safeParse({ routines: [] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing routines field', () => {
|
||||
const result = BatchUpsertRoutinesSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates each routine in the array', () => {
|
||||
const result = BatchUpsertRoutinesSchema.safeParse({
|
||||
routines: [validRoutine, { id: 'bad' }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects > 50 routines', () => {
|
||||
const routines = Array.from({ length: 51 }, (_, i) => ({
|
||||
...validRoutine,
|
||||
id: `routine_${i}`,
|
||||
}));
|
||||
const result = BatchUpsertRoutinesSchema.safeParse({ routines });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
154
backend/src/modules/routines/types.ts
Normal file
154
backend/src/modules/routines/types.ts
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Routine types — ChronoMind cross-platform cloud sync.
|
||||
*
|
||||
* Cosmos container: `routines` (partition key: `/userId`)
|
||||
* Product ID: "chronomind"
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Enums / constants ──
|
||||
|
||||
export const TRANSITION_TYPES = ['immediate', '1m_break', '5m_break', 'custom'] as const;
|
||||
export type TransitionType = (typeof TRANSITION_TYPES)[number];
|
||||
|
||||
export const ROUTINE_STATUSES = [
|
||||
'template',
|
||||
'ready',
|
||||
'active',
|
||||
'paused',
|
||||
'completed',
|
||||
'cancelled',
|
||||
] as const;
|
||||
export type RoutineStatus = (typeof ROUTINE_STATUSES)[number];
|
||||
|
||||
export const STEP_STATUSES = ['pending', 'active', 'skipped', 'completed'] as const;
|
||||
export type StepStatus = (typeof STEP_STATUSES)[number];
|
||||
|
||||
// ── Sub-document interfaces ──
|
||||
|
||||
export interface RoutineStep {
|
||||
id: string;
|
||||
label: string;
|
||||
durationMinutes: number;
|
||||
transition: TransitionType;
|
||||
customTransitionMinutes?: number;
|
||||
notes?: string;
|
||||
status: StepStatus;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
// ── Main document ──
|
||||
|
||||
export interface RoutineDoc {
|
||||
id: string;
|
||||
userId: string;
|
||||
productId: string;
|
||||
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: RoutineStep[];
|
||||
totalDurationMinutes: number;
|
||||
status: RoutineStatus;
|
||||
currentStepIndex: number;
|
||||
isTemplate: boolean;
|
||||
category?: string;
|
||||
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
pausedAt?: string;
|
||||
completedAt?: string;
|
||||
elapsedBeforePause: number;
|
||||
|
||||
// Sync metadata
|
||||
deviceId?: string;
|
||||
lastSyncedAt?: string;
|
||||
syncVersion: number;
|
||||
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
// ── Zod schemas ──
|
||||
|
||||
const RoutineStepSchema = z.object({
|
||||
id: z.string().min(1).max(128),
|
||||
label: z.string().min(1).max(500),
|
||||
durationMinutes: z.number().min(0.5).max(480),
|
||||
transition: z.enum(TRANSITION_TYPES),
|
||||
customTransitionMinutes: z.number().min(0).max(60).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
status: z.enum(STEP_STATUSES).default('pending'),
|
||||
startedAt: z.string().datetime().optional(),
|
||||
completedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const CreateRoutineSchema = z.object({
|
||||
id: z.string().min(1).max(128),
|
||||
name: z.string().min(1).max(500),
|
||||
description: z.string().max(2000).optional(),
|
||||
steps: z.array(RoutineStepSchema).min(1).max(50),
|
||||
totalDurationMinutes: z.number().min(0),
|
||||
status: z.enum(ROUTINE_STATUSES).default('ready'),
|
||||
currentStepIndex: z.number().int().min(0).default(0),
|
||||
isTemplate: z.boolean().default(false),
|
||||
category: z.string().max(128).optional(),
|
||||
elapsedBeforePause: z.number().min(0).default(0),
|
||||
startedAt: z.string().datetime().optional(),
|
||||
deviceId: z.string().max(256).optional(),
|
||||
syncVersion: z.number().int().min(0).default(1),
|
||||
});
|
||||
|
||||
export const UpdateRoutineSchema = z.object({
|
||||
name: z.string().min(1).max(500).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
steps: z.array(RoutineStepSchema).min(1).max(50).optional(),
|
||||
totalDurationMinutes: z.number().min(0).optional(),
|
||||
status: z.enum(ROUTINE_STATUSES).optional(),
|
||||
currentStepIndex: z.number().int().min(0).optional(),
|
||||
isTemplate: z.boolean().optional(),
|
||||
category: z.string().max(128).optional(),
|
||||
startedAt: z.string().datetime().optional(),
|
||||
pausedAt: z.string().datetime().optional(),
|
||||
completedAt: z.string().datetime().optional(),
|
||||
elapsedBeforePause: z.number().min(0).optional(),
|
||||
deviceId: z.string().max(256).optional(),
|
||||
syncVersion: z.number().int().min(1),
|
||||
});
|
||||
|
||||
export const RoutineQuerySchema = z.object({
|
||||
status: z.enum(ROUTINE_STATUSES).optional(),
|
||||
isTemplate: z
|
||||
.string()
|
||||
.transform(v => v === 'true')
|
||||
.optional(),
|
||||
category: z.string().optional(),
|
||||
sortBy: z.enum(['createdAt', 'name', 'totalDurationMinutes']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const RoutineSyncQuerySchema = z.object({
|
||||
since: z.string().datetime(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
|
||||
export const BatchUpsertRoutinesSchema = z.object({
|
||||
routines: z.array(CreateRoutineSchema).min(1).max(50),
|
||||
});
|
||||
|
||||
// ── Inferred types ──
|
||||
|
||||
export type CreateRoutineInput = z.infer<typeof CreateRoutineSchema>;
|
||||
export type UpdateRoutineInput = z.infer<typeof UpdateRoutineSchema>;
|
||||
export type RoutineQuery = z.infer<typeof RoutineQuerySchema>;
|
||||
export type RoutineSyncQuery = z.infer<typeof RoutineSyncQuerySchema>;
|
||||
export type BatchUpsertRoutinesInput = z.infer<typeof BatchUpsertRoutinesSchema>;
|
||||
|
||||
export interface BatchUpsertRoutinesResult {
|
||||
synced: string[];
|
||||
conflicts: Array<{ id: string; serverVersion: number }>;
|
||||
errors: Array<{ id: string; error: string }>;
|
||||
}
|
||||
91
backend/src/modules/shared-timers/repository.ts
Normal file
91
backend/src/modules/shared-timers/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
183
backend/src/modules/shared-timers/routes.ts
Normal file
183
backend/src/modules/shared-timers/routes.ts
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Shared timer REST endpoints — ChronoMind Family tier.
|
||||
*
|
||||
* All endpoints require the caller to be a member of the household.
|
||||
*
|
||||
* GET /households/:householdId/timers — list shared timers
|
||||
* GET /households/:householdId/timers/:id — single shared timer
|
||||
* POST /households/:householdId/timers — create shared timer
|
||||
* PUT /households/:householdId/timers/:id — update shared timer (creator only)
|
||||
* DELETE /households/:householdId/timers/:id — delete shared timer (creator or admin)
|
||||
* POST /households/:householdId/timers/:id/ack — acknowledge (dismiss/snooze) a timer
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, NotFoundError, ForbiddenError } from '../../lib/errors.js';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { getHousehold } from '../households/repository.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
CreateSharedTimerSchema,
|
||||
UpdateSharedTimerSchema,
|
||||
AcknowledgeTimerSchema,
|
||||
SharedTimerQuerySchema,
|
||||
type SharedTimerDoc,
|
||||
} from './types.js';
|
||||
|
||||
const PRODUCT_ID = 'chronomind';
|
||||
|
||||
async function requireMembership(householdId: string, userId: string) {
|
||||
const household = await getHousehold(householdId);
|
||||
if (!household || household.productId !== PRODUCT_ID) {
|
||||
throw new NotFoundError('Household not found');
|
||||
}
|
||||
const member = household.members.find(m => m.userId === userId);
|
||||
if (!member) throw new ForbiddenError('Not a member of this household');
|
||||
return { household, member };
|
||||
}
|
||||
|
||||
export async function sharedTimerRoutes(app: FastifyInstance) {
|
||||
// List shared timers for a household
|
||||
app.get('/households/:householdId/timers', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { householdId } = req.params as { householdId: string };
|
||||
await requireMembership(householdId, auth.sub);
|
||||
|
||||
const parsed = SharedTimerQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { items, total } = await repo.listSharedTimers(householdId, PRODUCT_ID, parsed.data);
|
||||
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
|
||||
});
|
||||
|
||||
// Get single shared timer
|
||||
app.get('/households/:householdId/timers/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { householdId, id } = req.params as { householdId: string; id: string };
|
||||
await requireMembership(householdId, auth.sub);
|
||||
|
||||
const timer = await repo.getSharedTimer(id, householdId);
|
||||
if (!timer) throw new NotFoundError('Shared timer not found');
|
||||
return timer;
|
||||
});
|
||||
|
||||
// Create shared timer
|
||||
app.post('/households/:householdId/timers', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const { householdId } = req.params as { householdId: string };
|
||||
await requireMembership(householdId, auth.sub);
|
||||
|
||||
const parsed = CreateSharedTimerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
if (parsed.data.householdId !== householdId) {
|
||||
throw new BadRequestError('householdId in body must match URL param');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const doc: SharedTimerDoc = {
|
||||
id: crypto.randomUUID(),
|
||||
householdId,
|
||||
productId: PRODUCT_ID,
|
||||
createdBy: auth.sub,
|
||||
label: parsed.data.label,
|
||||
description: parsed.data.description,
|
||||
type: parsed.data.type,
|
||||
state: 'active',
|
||||
urgency: parsed.data.urgency,
|
||||
duration: parsed.data.duration,
|
||||
targetTime: parsed.data.targetTime,
|
||||
category: parsed.data.category,
|
||||
cascade: parsed.data.cascade,
|
||||
acknowledgements: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
req.log.info({ sharedTimerId: doc.id, householdId }, 'Creating shared timer');
|
||||
const created = await repo.createSharedTimer(doc);
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Update shared timer (creator only)
|
||||
app.put('/households/:householdId/timers/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { householdId, id } = req.params as { householdId: string; id: string };
|
||||
await requireMembership(householdId, auth.sub);
|
||||
|
||||
const timer = await repo.getSharedTimer(id, householdId);
|
||||
if (!timer) throw new NotFoundError('Shared timer not found');
|
||||
if (timer.createdBy !== auth.sub)
|
||||
throw new ForbiddenError('Only the creator can update this timer');
|
||||
|
||||
const parsed = UpdateSharedTimerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: SharedTimerDoc = { ...timer, ...parsed.data, updatedAt: now };
|
||||
const result = await repo.replaceSharedTimer(updated);
|
||||
req.log.info({ sharedTimerId: id, householdId }, 'Updated shared timer');
|
||||
return result;
|
||||
});
|
||||
|
||||
// Delete shared timer (creator or admin)
|
||||
app.delete('/households/:householdId/timers/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { householdId, id } = req.params as { householdId: string; id: string };
|
||||
const { household } = await requireMembership(householdId, auth.sub);
|
||||
|
||||
const timer = await repo.getSharedTimer(id, householdId);
|
||||
if (!timer) throw new NotFoundError('Shared timer not found');
|
||||
|
||||
const isCreator = timer.createdBy === auth.sub;
|
||||
const isAdmin = household.members.some(m => m.userId === auth.sub && m.role === 'admin');
|
||||
if (!isCreator && !isAdmin) {
|
||||
throw new ForbiddenError('Only the creator or admin can delete this timer');
|
||||
}
|
||||
|
||||
await repo.deleteSharedTimer(id, householdId);
|
||||
req.log.info({ sharedTimerId: id, householdId }, 'Deleted shared timer');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Acknowledge (dismiss/snooze) a shared timer — per-user
|
||||
app.post('/households/:householdId/timers/:id/ack', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { householdId, id } = req.params as { householdId: string; id: string };
|
||||
await requireMembership(householdId, auth.sub);
|
||||
|
||||
const timer = await repo.getSharedTimer(id, householdId);
|
||||
if (!timer) throw new NotFoundError('Shared timer not found');
|
||||
|
||||
const parsed = AcknowledgeTimerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
// Replace or add acknowledgement for this user
|
||||
const now = new Date().toISOString();
|
||||
const existingIdx = timer.acknowledgements.findIndex(a => a.userId === auth.sub);
|
||||
const ack = { userId: auth.sub, state: parsed.data.state, at: now };
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
timer.acknowledgements[existingIdx] = ack;
|
||||
} else {
|
||||
timer.acknowledgements.push(ack);
|
||||
}
|
||||
timer.updatedAt = now;
|
||||
|
||||
const result = await repo.replaceSharedTimer(timer);
|
||||
req.log.info(
|
||||
{ sharedTimerId: id, householdId, ackState: parsed.data.state },
|
||||
'Timer acknowledged'
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
249
backend/src/modules/shared-timers/shared-timers.test.ts
Normal file
249
backend/src/modules/shared-timers/shared-timers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
backend/src/modules/shared-timers/types.ts
Normal file
118
backend/src/modules/shared-timers/types.ts
Normal file
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Shared timer types — ChronoMind Family tier.
|
||||
*
|
||||
* Cosmos container: `shared_timers` (partition key: `/householdId`)
|
||||
* Product ID: "chronomind"
|
||||
*
|
||||
* Shared timers are visible to all household members. The creator
|
||||
* owns the timer; any member can snooze/dismiss their own view.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Reuse timer enums ──
|
||||
|
||||
export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const;
|
||||
export const TIMER_STATES = [
|
||||
'active',
|
||||
'paused',
|
||||
'fired',
|
||||
'snoozed',
|
||||
'dismissed',
|
||||
'completed',
|
||||
'warning',
|
||||
] as const;
|
||||
export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const;
|
||||
export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const;
|
||||
|
||||
// ── Sub-document interfaces ──
|
||||
|
||||
export interface SharedCascadeConfig {
|
||||
preset: (typeof CASCADE_PRESETS)[number];
|
||||
intervals?: number[];
|
||||
}
|
||||
|
||||
export interface SharedTimerAck {
|
||||
userId: string;
|
||||
state: 'dismissed' | 'snoozed';
|
||||
at: string;
|
||||
}
|
||||
|
||||
// ── Main document ──
|
||||
|
||||
export interface SharedTimerDoc {
|
||||
id: string;
|
||||
householdId: string;
|
||||
productId: string;
|
||||
createdBy: string;
|
||||
|
||||
label: string;
|
||||
description?: string;
|
||||
type: (typeof TIMER_TYPES)[number];
|
||||
state: (typeof TIMER_STATES)[number];
|
||||
urgency: (typeof URGENCY_LEVELS)[number];
|
||||
duration: number;
|
||||
targetTime?: string;
|
||||
category?: string;
|
||||
|
||||
cascade?: SharedCascadeConfig;
|
||||
acknowledgements: SharedTimerAck[];
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
// ── Zod schemas ──
|
||||
|
||||
const CascadeSchema = z.object({
|
||||
preset: z.enum(CASCADE_PRESETS),
|
||||
intervals: z.array(z.number().min(0).max(120)).max(20).optional(),
|
||||
});
|
||||
|
||||
export const CreateSharedTimerSchema = z.object({
|
||||
householdId: z.string().min(1).max(128),
|
||||
label: z.string().min(1).max(500),
|
||||
description: z.string().max(2000).optional(),
|
||||
type: z.enum(TIMER_TYPES),
|
||||
urgency: z.enum(URGENCY_LEVELS).default('standard'),
|
||||
duration: z.number().min(0),
|
||||
targetTime: z.string().datetime().optional(),
|
||||
category: z.string().max(128).optional(),
|
||||
cascade: CascadeSchema.optional(),
|
||||
});
|
||||
|
||||
export const UpdateSharedTimerSchema = z.object({
|
||||
label: z.string().min(1).max(500).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
state: z.enum(TIMER_STATES).optional(),
|
||||
urgency: z.enum(URGENCY_LEVELS).optional(),
|
||||
duration: z.number().min(0).optional(),
|
||||
targetTime: z.string().datetime().optional(),
|
||||
category: z.string().max(128).optional(),
|
||||
cascade: CascadeSchema.optional(),
|
||||
completedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const AcknowledgeTimerSchema = z.object({
|
||||
state: z.enum(['dismissed', 'snoozed'] as const),
|
||||
});
|
||||
|
||||
export const SharedTimerQuerySchema = z.object({
|
||||
state: z.enum(TIMER_STATES).optional(),
|
||||
type: z.enum(TIMER_TYPES).optional(),
|
||||
sortBy: z.enum(['createdAt', 'targetTime', 'updatedAt']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
// ── Inferred types ──
|
||||
|
||||
export type CreateSharedTimerInput = z.infer<typeof CreateSharedTimerSchema>;
|
||||
export type UpdateSharedTimerInput = z.infer<typeof UpdateSharedTimerSchema>;
|
||||
export type AcknowledgeTimerInput = z.infer<typeof AcknowledgeTimerSchema>;
|
||||
export type SharedTimerQuery = z.infer<typeof SharedTimerQuerySchema>;
|
||||
191
backend/src/modules/timers/repository.ts
Normal file
191
backend/src/modules/timers/repository.ts
Normal file
@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Timers repository — Cosmos DB CRUD + sync + batch upsert.
|
||||
*
|
||||
* Container: timers (partition key: /userId)
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { TimerDoc, TimerQuery, BatchUpsertResult } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('timers');
|
||||
}
|
||||
|
||||
export async function listTimers(
|
||||
userId: string,
|
||||
productId: string,
|
||||
query: TimerQuery
|
||||
): Promise<{ items: TimerDoc[]; total: number }> {
|
||||
const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId'];
|
||||
const params: { name: string; value: string | number }[] = [
|
||||
{ name: '@userId', value: userId },
|
||||
{ name: '@productId', value: productId },
|
||||
];
|
||||
|
||||
if (query.state) {
|
||||
conditions.push('c.state = @state');
|
||||
params.push({ name: '@state', value: query.state });
|
||||
}
|
||||
if (query.type) {
|
||||
conditions.push('c.type = @type');
|
||||
params.push({ name: '@type', value: query.type });
|
||||
}
|
||||
if (query.urgency) {
|
||||
conditions.push('c.urgency = @urgency');
|
||||
params.push({ name: '@urgency', value: query.urgency });
|
||||
}
|
||||
if (query.category) {
|
||||
conditions.push('c.category = @category');
|
||||
params.push({ name: '@category', value: query.category });
|
||||
}
|
||||
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const sortField = `c.${query.sortBy}`;
|
||||
const orderDir = query.sortOrder.toUpperCase();
|
||||
|
||||
// Count query
|
||||
const countResult = await container()
|
||||
.items.query<number>({
|
||||
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
|
||||
parameters: params,
|
||||
})
|
||||
.fetchAll();
|
||||
const total = countResult.resources[0] ?? 0;
|
||||
|
||||
// Data query with pagination
|
||||
const { resources } = await container()
|
||||
.items.query<TimerDoc>({
|
||||
query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`,
|
||||
parameters: [
|
||||
...params,
|
||||
{ name: '@offset', value: query.offset },
|
||||
{ name: '@limit', value: query.limit },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
return { items: resources, total };
|
||||
}
|
||||
|
||||
export async function getTimer(id: string, userId: string): Promise<TimerDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<TimerDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTimer(doc: TimerDoc): Promise<TimerDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as TimerDoc;
|
||||
}
|
||||
|
||||
export async function updateTimer(
|
||||
id: string,
|
||||
userId: string,
|
||||
updates: Partial<TimerDoc>,
|
||||
expectedSyncVersion: number
|
||||
): Promise<{ doc: TimerDoc | null; conflict: boolean; serverVersion?: number }> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
|
||||
if (!existing) return { doc: null, conflict: false };
|
||||
|
||||
// Optimistic concurrency: reject stale writes
|
||||
if (expectedSyncVersion <= existing.syncVersion) {
|
||||
return { doc: null, conflict: true, serverVersion: existing.syncVersion };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const merged: TimerDoc = {
|
||||
...existing,
|
||||
...updates,
|
||||
syncVersion: expectedSyncVersion,
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
const { resource } = await container().item(id, userId).replace(merged);
|
||||
return { doc: resource as TimerDoc, conflict: false };
|
||||
} catch {
|
||||
return { doc: null, conflict: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTimer(id: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
|
||||
if (!existing) return false;
|
||||
await container().item(id, userId).delete();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTimersSince(
|
||||
userId: string,
|
||||
productId: string,
|
||||
sinceTimestamp: string,
|
||||
limit: number
|
||||
): Promise<TimerDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<TimerDoc>({
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit',
|
||||
parameters: [
|
||||
{ name: '@userId', value: userId },
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@since', value: sinceTimestamp },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function batchUpsert(
|
||||
userId: string,
|
||||
productId: string,
|
||||
timers: Array<Record<string, unknown> & { id: string; syncVersion: number }>
|
||||
): Promise<BatchUpsertResult> {
|
||||
const synced: string[] = [];
|
||||
const conflicts: Array<{ id: string; serverVersion: number }> = [];
|
||||
const errors: Array<{ id: string; error: string }> = [];
|
||||
|
||||
for (const timer of timers) {
|
||||
try {
|
||||
const existing = await getTimer(timer.id, userId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
// Upsert: accept if incoming syncVersion >= existing
|
||||
if (timer.syncVersion >= existing.syncVersion) {
|
||||
const merged: TimerDoc = {
|
||||
...existing,
|
||||
...timer,
|
||||
userId,
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
await container().item(timer.id, userId).replace(merged);
|
||||
synced.push(timer.id);
|
||||
} else {
|
||||
conflicts.push({ id: timer.id, serverVersion: existing.syncVersion });
|
||||
}
|
||||
} else {
|
||||
// New document
|
||||
const doc: TimerDoc = {
|
||||
...timer,
|
||||
userId,
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
} as TimerDoc;
|
||||
await container().items.create(doc);
|
||||
synced.push(timer.id);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ id: timer.id, error: err instanceof Error ? err.message : 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, conflicts, errors };
|
||||
}
|
||||
158
backend/src/modules/timers/routes.ts
Normal file
158
backend/src/modules/timers/routes.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Timer REST endpoints — ChronoMind cloud sync.
|
||||
*
|
||||
* GET /timers — list user's timers (filterable, paginated)
|
||||
* GET /timers/sync — delta sync (timers modified since timestamp)
|
||||
* GET /timers/:id — single timer
|
||||
* POST /timers — create timer
|
||||
* PUT /timers/:id — update timer (with syncVersion conflict check)
|
||||
* DELETE /timers/:id — delete timer
|
||||
* POST /timers/batch — batch upsert (offline queue flush / initial sync)
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, NotFoundError, ConflictError } from '../../lib/errors.js';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
CreateTimerSchema,
|
||||
UpdateTimerSchema,
|
||||
TimerQuerySchema,
|
||||
TimerSyncQuerySchema,
|
||||
BatchUpsertSchema,
|
||||
type TimerDoc,
|
||||
} from './types.js';
|
||||
|
||||
const PRODUCT_ID = 'chronomind';
|
||||
|
||||
export async function timerRoutes(app: FastifyInstance) {
|
||||
// Sync — must be before :id param route
|
||||
app.get('/timers/sync', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = TimerSyncQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const timers = await repo.getTimersSince(
|
||||
auth.sub,
|
||||
PRODUCT_ID,
|
||||
parsed.data.since,
|
||||
parsed.data.limit
|
||||
);
|
||||
return { timers, count: timers.length };
|
||||
});
|
||||
|
||||
// List timers
|
||||
app.get('/timers', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = TimerQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { items, total } = await repo.listTimers(auth.sub, PRODUCT_ID, parsed.data);
|
||||
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
|
||||
});
|
||||
|
||||
// Get single timer
|
||||
app.get('/timers/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const timer = await repo.getTimer(id, auth.sub);
|
||||
if (!timer) throw new NotFoundError('Timer not found');
|
||||
if (timer.productId !== PRODUCT_ID) throw new NotFoundError('Timer not found');
|
||||
return timer;
|
||||
});
|
||||
|
||||
// Create timer
|
||||
app.post('/timers', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = CreateTimerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const input = parsed.data;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const doc: TimerDoc = {
|
||||
id: input.id,
|
||||
userId: auth.sub,
|
||||
productId: PRODUCT_ID,
|
||||
label: input.label,
|
||||
description: input.description,
|
||||
type: input.type,
|
||||
state: input.state,
|
||||
urgency: input.urgency,
|
||||
duration: input.duration,
|
||||
targetTime: input.targetTime,
|
||||
createdAt: now,
|
||||
startedAt: input.startedAt,
|
||||
cascade: input.cascade,
|
||||
pomodoro: input.pomodoro,
|
||||
isCalendarSync: input.isCalendarSync,
|
||||
calendarEventId: input.calendarEventId,
|
||||
category: input.category,
|
||||
deviceId: input.deviceId,
|
||||
lastSyncedAt: now,
|
||||
syncVersion: input.syncVersion,
|
||||
};
|
||||
|
||||
req.log.info({ timerId: doc.id, type: doc.type }, 'Creating timer');
|
||||
const created = await repo.createTimer(doc);
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Update timer (with syncVersion conflict check)
|
||||
app.put('/timers/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const parsed = UpdateTimerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const { syncVersion, ...updates } = parsed.data;
|
||||
const result = await repo.updateTimer(id, auth.sub, updates, syncVersion);
|
||||
|
||||
if (result.conflict) {
|
||||
throw new ConflictError(
|
||||
`Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}`
|
||||
);
|
||||
}
|
||||
if (!result.doc) throw new NotFoundError('Timer not found');
|
||||
|
||||
req.log.info({ timerId: id, syncVersion }, 'Updated timer');
|
||||
return result.doc;
|
||||
});
|
||||
|
||||
// Delete timer
|
||||
app.delete('/timers/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const success = await repo.deleteTimer(id, auth.sub);
|
||||
if (!success) throw new NotFoundError('Timer not found');
|
||||
req.log.info({ timerId: id }, 'Deleted timer');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Batch upsert (initial sync / offline queue flush)
|
||||
app.post('/timers/batch', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = BatchUpsertSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const enriched = parsed.data.timers.map(t => ({
|
||||
...t,
|
||||
createdAt: now,
|
||||
lastSyncedAt: now,
|
||||
}));
|
||||
|
||||
req.log.info({ count: enriched.length }, 'Batch upsert timers');
|
||||
const result = await repo.batchUpsert(auth.sub, PRODUCT_ID, enriched);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
408
backend/src/modules/timers/timers.test.ts
Normal file
408
backend/src/modules/timers/timers.test.ts
Normal file
@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Timers module unit tests — validates schemas, constants, and type guards.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateTimerSchema,
|
||||
UpdateTimerSchema,
|
||||
TimerQuerySchema,
|
||||
TimerSyncQuerySchema,
|
||||
BatchUpsertSchema,
|
||||
TIMER_TYPES,
|
||||
TIMER_STATES,
|
||||
URGENCY_LEVELS,
|
||||
CASCADE_PRESETS,
|
||||
} from './types.js';
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
describe('type constants', () => {
|
||||
it('has 3 timer types', () => {
|
||||
expect(TIMER_TYPES).toEqual(['countdown', 'alarm', 'pomodoro']);
|
||||
});
|
||||
|
||||
it('has 7 timer states', () => {
|
||||
expect(TIMER_STATES).toEqual([
|
||||
'active',
|
||||
'paused',
|
||||
'fired',
|
||||
'snoozed',
|
||||
'dismissed',
|
||||
'completed',
|
||||
'warning',
|
||||
]);
|
||||
expect(TIMER_STATES).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('has 5 urgency levels', () => {
|
||||
expect(URGENCY_LEVELS).toEqual(['critical', 'important', 'standard', 'gentle', 'passive']);
|
||||
expect(URGENCY_LEVELS).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('has 4 cascade presets', () => {
|
||||
expect(CASCADE_PRESETS).toEqual(['minimal', 'standard', 'aggressive', 'custom']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CreateTimerSchema ──
|
||||
|
||||
describe('CreateTimerSchema', () => {
|
||||
const validMinimal = {
|
||||
id: 'timer_001',
|
||||
label: 'Morning alarm',
|
||||
type: 'alarm',
|
||||
duration: 0,
|
||||
targetTime: '2026-03-01T07:00:00.000Z',
|
||||
};
|
||||
|
||||
it('accepts minimal valid input with defaults', () => {
|
||||
const result = CreateTimerSchema.safeParse(validMinimal);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('timer_001');
|
||||
expect(result.data.label).toBe('Morning alarm');
|
||||
expect(result.data.type).toBe('alarm');
|
||||
expect(result.data.state).toBe('active');
|
||||
expect(result.data.urgency).toBe('standard');
|
||||
expect(result.data.syncVersion).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts full countdown timer with cascade', () => {
|
||||
const result = CreateTimerSchema.safeParse({
|
||||
...validMinimal,
|
||||
type: 'countdown',
|
||||
state: 'active',
|
||||
urgency: 'critical',
|
||||
duration: 300,
|
||||
description: 'Important meeting prep',
|
||||
cascade: {
|
||||
preset: 'aggressive',
|
||||
intervals: [30, 15, 5, 1],
|
||||
},
|
||||
deviceId: 'iphone-14-pro',
|
||||
category: 'work',
|
||||
syncVersion: 3,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.cascade?.preset).toBe('aggressive');
|
||||
expect(result.data.cascade?.intervals).toEqual([30, 15, 5, 1]);
|
||||
expect(result.data.urgency).toBe('critical');
|
||||
expect(result.data.syncVersion).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts pomodoro timer with full config', () => {
|
||||
const result = CreateTimerSchema.safeParse({
|
||||
...validMinimal,
|
||||
type: 'pomodoro',
|
||||
pomodoro: {
|
||||
focusMinutes: 25,
|
||||
shortBreakMinutes: 5,
|
||||
longBreakMinutes: 15,
|
||||
roundsBeforeLong: 4,
|
||||
currentRound: 1,
|
||||
isBreak: false,
|
||||
totalRoundsCompleted: 0,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.pomodoro?.focusMinutes).toBe(25);
|
||||
expect(result.data.pomodoro?.roundsBeforeLong).toBe(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts timer with calendar sync', () => {
|
||||
const result = CreateTimerSchema.safeParse({
|
||||
...validMinimal,
|
||||
isCalendarSync: true,
|
||||
calendarEventId: 'cal_abc123',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing id', () => {
|
||||
const result = CreateTimerSchema.safeParse({
|
||||
label: 'Morning alarm',
|
||||
type: 'alarm',
|
||||
duration: 0,
|
||||
targetTime: '2026-03-01T07:00:00.000Z',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing label', () => {
|
||||
const result = CreateTimerSchema.safeParse({
|
||||
id: 'timer_001',
|
||||
type: 'alarm',
|
||||
duration: 0,
|
||||
targetTime: '2026-03-01T07:00:00.000Z',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing type', () => {
|
||||
const result = CreateTimerSchema.safeParse({
|
||||
id: 'timer_001',
|
||||
label: 'Morning alarm',
|
||||
duration: 0,
|
||||
targetTime: '2026-03-01T07:00:00.000Z',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid type', () => {
|
||||
const result = CreateTimerSchema.safeParse({ ...validMinimal, type: 'stopwatch' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid state', () => {
|
||||
const result = CreateTimerSchema.safeParse({ ...validMinimal, state: 'deleted' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid urgency', () => {
|
||||
const result = CreateTimerSchema.safeParse({ ...validMinimal, urgency: 'extreme' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid targetTime format', () => {
|
||||
const result = CreateTimerSchema.safeParse({ ...validMinimal, targetTime: 'not-a-date' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative duration', () => {
|
||||
const result = CreateTimerSchema.safeParse({ ...validMinimal, duration: -10 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects label > 500 chars', () => {
|
||||
const result = CreateTimerSchema.safeParse({ ...validMinimal, label: 'x'.repeat(501) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects pomodoro focusMinutes > 120', () => {
|
||||
const result = CreateTimerSchema.safeParse({
|
||||
...validMinimal,
|
||||
pomodoro: {
|
||||
focusMinutes: 150,
|
||||
shortBreakMinutes: 5,
|
||||
longBreakMinutes: 15,
|
||||
roundsBeforeLong: 4,
|
||||
currentRound: 0,
|
||||
isBreak: false,
|
||||
totalRoundsCompleted: 0,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── UpdateTimerSchema ──
|
||||
|
||||
describe('UpdateTimerSchema', () => {
|
||||
it('accepts state update with syncVersion', () => {
|
||||
const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 2 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.state).toBe('paused');
|
||||
expect(result.data.syncVersion).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts complete update with timestamps', () => {
|
||||
const result = UpdateTimerSchema.safeParse({
|
||||
state: 'completed',
|
||||
completedAt: '2026-03-01T08:00:00.000Z',
|
||||
syncVersion: 5,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts pomodoro round update', () => {
|
||||
const result = UpdateTimerSchema.safeParse({
|
||||
pomodoro: {
|
||||
focusMinutes: 25,
|
||||
shortBreakMinutes: 5,
|
||||
longBreakMinutes: 15,
|
||||
roundsBeforeLong: 4,
|
||||
currentRound: 3,
|
||||
isBreak: true,
|
||||
totalRoundsCompleted: 2,
|
||||
},
|
||||
syncVersion: 4,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('requires syncVersion', () => {
|
||||
const result = UpdateTimerSchema.safeParse({ state: 'paused' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects syncVersion < 1', () => {
|
||||
const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 0 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid state', () => {
|
||||
const result = UpdateTimerSchema.safeParse({ state: 'deleted', syncVersion: 2 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid completedAt format', () => {
|
||||
const result = UpdateTimerSchema.safeParse({
|
||||
completedAt: 'yesterday',
|
||||
syncVersion: 2,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── TimerQuerySchema ──
|
||||
|
||||
describe('TimerQuerySchema', () => {
|
||||
it('provides defaults for empty query', () => {
|
||||
const result = TimerQuerySchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sortBy).toBe('createdAt');
|
||||
expect(result.data.sortOrder).toBe('desc');
|
||||
expect(result.data.limit).toBe(50);
|
||||
expect(result.data.offset).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('coerces string numbers for limit and offset', () => {
|
||||
const result = TimerQuerySchema.safeParse({ limit: '25', offset: '10' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(25);
|
||||
expect(result.data.offset).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts all filter combinations', () => {
|
||||
const result = TimerQuerySchema.safeParse({
|
||||
state: 'active',
|
||||
type: 'pomodoro',
|
||||
urgency: 'critical',
|
||||
category: 'work',
|
||||
sortBy: 'targetTime',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects limit > 100', () => {
|
||||
const result = TimerQuerySchema.safeParse({ limit: 200 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative offset', () => {
|
||||
const result = TimerQuerySchema.safeParse({ offset: -1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid sortBy', () => {
|
||||
const result = TimerQuerySchema.safeParse({ sortBy: 'random' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid state filter', () => {
|
||||
const result = TimerQuerySchema.safeParse({ state: 'deleted' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── TimerSyncQuerySchema ──
|
||||
|
||||
describe('TimerSyncQuerySchema', () => {
|
||||
it('accepts valid since timestamp', () => {
|
||||
const result = TimerSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts custom limit', () => {
|
||||
const result = TimerSyncQuerySchema.safeParse({
|
||||
since: '2026-03-01T00:00:00.000Z',
|
||||
limit: '50',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(50);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing since', () => {
|
||||
const result = TimerSyncQuerySchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid since format', () => {
|
||||
const result = TimerSyncQuerySchema.safeParse({ since: 'yesterday' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects limit > 500', () => {
|
||||
const result = TimerSyncQuerySchema.safeParse({
|
||||
since: '2026-03-01T00:00:00.000Z',
|
||||
limit: 1000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── BatchUpsertSchema ──
|
||||
|
||||
describe('BatchUpsertSchema', () => {
|
||||
const validTimer = {
|
||||
id: 'timer_batch_1',
|
||||
label: 'Batch timer',
|
||||
type: 'countdown',
|
||||
duration: 600,
|
||||
targetTime: '2026-03-01T10:00:00.000Z',
|
||||
};
|
||||
|
||||
it('accepts array of valid timers', () => {
|
||||
const result = BatchUpsertSchema.safeParse({
|
||||
timers: [validTimer, { ...validTimer, id: 'timer_batch_2', label: 'Second timer' }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.timers).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty timers array', () => {
|
||||
const result = BatchUpsertSchema.safeParse({ timers: [] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing timers field', () => {
|
||||
const result = BatchUpsertSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates each timer in the array', () => {
|
||||
const result = BatchUpsertSchema.safeParse({
|
||||
timers: [validTimer, { id: 'bad', type: 'invalid' }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects > 100 timers', () => {
|
||||
const timers = Array.from({ length: 101 }, (_, i) => ({
|
||||
...validTimer,
|
||||
id: `timer_${i}`,
|
||||
}));
|
||||
const result = BatchUpsertSchema.safeParse({ timers });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
175
backend/src/modules/timers/types.ts
Normal file
175
backend/src/modules/timers/types.ts
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Timer types — ChronoMind cross-platform cloud sync.
|
||||
*
|
||||
* Cosmos container: `timers` (partition key: `/userId`)
|
||||
* Product ID: "chronomind"
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Enums / constants ──
|
||||
|
||||
export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const;
|
||||
export type TimerType = (typeof TIMER_TYPES)[number];
|
||||
|
||||
export const TIMER_STATES = [
|
||||
'active',
|
||||
'paused',
|
||||
'fired',
|
||||
'snoozed',
|
||||
'dismissed',
|
||||
'completed',
|
||||
'warning',
|
||||
] as const;
|
||||
export type TimerState = (typeof TIMER_STATES)[number];
|
||||
|
||||
export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const;
|
||||
export type UrgencyLevel = (typeof URGENCY_LEVELS)[number];
|
||||
|
||||
export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const;
|
||||
export type CascadePreset = (typeof CASCADE_PRESETS)[number];
|
||||
|
||||
// ── Sub-document interfaces ──
|
||||
|
||||
export interface CascadeConfig {
|
||||
preset: CascadePreset;
|
||||
intervals: number[];
|
||||
}
|
||||
|
||||
export interface PomodoroConfig {
|
||||
focusMinutes: number;
|
||||
shortBreakMinutes: number;
|
||||
longBreakMinutes: number;
|
||||
roundsBeforeLong: number;
|
||||
currentRound: number;
|
||||
isBreak: boolean;
|
||||
totalRoundsCompleted: number;
|
||||
}
|
||||
|
||||
// ── Main document ──
|
||||
|
||||
export interface TimerDoc {
|
||||
id: string;
|
||||
userId: string;
|
||||
productId: string;
|
||||
|
||||
label: string;
|
||||
description?: string;
|
||||
type: TimerType;
|
||||
state: TimerState;
|
||||
urgency: UrgencyLevel;
|
||||
|
||||
duration: number;
|
||||
targetTime: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
pausedAt?: string;
|
||||
firedAt?: string;
|
||||
completedAt?: string;
|
||||
|
||||
cascade?: CascadeConfig;
|
||||
pomodoro?: PomodoroConfig;
|
||||
|
||||
isCalendarSync?: boolean;
|
||||
calendarEventId?: string;
|
||||
category?: string;
|
||||
|
||||
deviceId?: string;
|
||||
lastSyncedAt?: string;
|
||||
syncVersion: number;
|
||||
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
// ── Zod schemas ──
|
||||
|
||||
const CascadeSchema = z.object({
|
||||
preset: z.enum(CASCADE_PRESETS),
|
||||
intervals: z.array(z.number().int().min(0)),
|
||||
});
|
||||
|
||||
const PomodoroSchema = z.object({
|
||||
focusMinutes: z.number().int().min(1).max(120),
|
||||
shortBreakMinutes: z.number().int().min(1).max(60),
|
||||
longBreakMinutes: z.number().int().min(1).max(120),
|
||||
roundsBeforeLong: z.number().int().min(1).max(20),
|
||||
currentRound: z.number().int().min(0),
|
||||
isBreak: z.boolean(),
|
||||
totalRoundsCompleted: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const CreateTimerSchema = z.object({
|
||||
id: z.string().min(1).max(128),
|
||||
label: z.string().min(1).max(500),
|
||||
description: z.string().max(2000).optional(),
|
||||
type: z.enum(TIMER_TYPES),
|
||||
state: z.enum(TIMER_STATES).default('active'),
|
||||
urgency: z.enum(URGENCY_LEVELS).default('standard'),
|
||||
duration: z.number().int().min(0),
|
||||
targetTime: z.string().datetime(),
|
||||
cascade: CascadeSchema.optional(),
|
||||
pomodoro: PomodoroSchema.optional(),
|
||||
isCalendarSync: z.boolean().optional(),
|
||||
calendarEventId: z.string().max(500).optional(),
|
||||
category: z.string().max(128).optional(),
|
||||
deviceId: z.string().max(256).optional(),
|
||||
startedAt: z.string().datetime().optional(),
|
||||
syncVersion: z.number().int().min(0).default(1),
|
||||
});
|
||||
|
||||
export const UpdateTimerSchema = z.object({
|
||||
label: z.string().min(1).max(500).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
state: z.enum(TIMER_STATES).optional(),
|
||||
urgency: z.enum(URGENCY_LEVELS).optional(),
|
||||
duration: z.number().int().min(0).optional(),
|
||||
targetTime: z.string().datetime().optional(),
|
||||
startedAt: z.string().datetime().optional(),
|
||||
pausedAt: z.string().datetime().optional(),
|
||||
firedAt: z.string().datetime().optional(),
|
||||
completedAt: z.string().datetime().optional(),
|
||||
cascade: CascadeSchema.optional(),
|
||||
pomodoro: PomodoroSchema.optional(),
|
||||
isCalendarSync: z.boolean().optional(),
|
||||
calendarEventId: z.string().max(500).optional(),
|
||||
category: z.string().max(128).optional(),
|
||||
deviceId: z.string().max(256).optional(),
|
||||
syncVersion: z.number().int().min(1),
|
||||
});
|
||||
|
||||
export const TimerQuerySchema = z.object({
|
||||
state: z.enum(TIMER_STATES).optional(),
|
||||
type: z.enum(TIMER_TYPES).optional(),
|
||||
urgency: z.enum(URGENCY_LEVELS).optional(),
|
||||
category: z.string().optional(),
|
||||
sortBy: z.enum(['createdAt', 'targetTime', 'label']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const TimerSyncQuerySchema = z.object({
|
||||
since: z.string().datetime(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
|
||||
export const BatchUpsertSchema = z.object({
|
||||
timers: z.array(CreateTimerSchema).min(1).max(100),
|
||||
});
|
||||
|
||||
// ── Inferred types ──
|
||||
|
||||
export type CreateTimerInput = z.infer<typeof CreateTimerSchema>;
|
||||
export type UpdateTimerInput = z.infer<typeof UpdateTimerSchema>;
|
||||
export type TimerQuery = z.infer<typeof TimerQuerySchema>;
|
||||
export type TimerSyncQuery = z.infer<typeof TimerSyncQuerySchema>;
|
||||
export type BatchUpsertInput = z.infer<typeof BatchUpsertSchema>;
|
||||
|
||||
// ── Batch result ──
|
||||
|
||||
export interface BatchUpsertResult {
|
||||
synced: string[];
|
||||
conflicts: Array<{ id: string; serverVersion: number }>;
|
||||
errors: Array<{ id: string; error: string }>;
|
||||
}
|
||||
200
backend/src/modules/webhooks/dispatcher.ts
Normal file
200
backend/src/modules/webhooks/dispatcher.ts
Normal 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;
|
||||
}
|
||||
192
backend/src/modules/webhooks/repository.ts
Normal file
192
backend/src/modules/webhooks/repository.ts
Normal 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;
|
||||
}
|
||||
111
backend/src/modules/webhooks/routes.ts
Normal file
111
backend/src/modules/webhooks/routes.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
95
backend/src/modules/webhooks/types.ts
Normal file
95
backend/src/modules/webhooks/types.ts
Normal 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;
|
||||
}
|
||||
356
backend/src/modules/webhooks/webhooks.test.ts
Normal file
356
backend/src/modules/webhooks/webhooks.test.ts
Normal 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
55
backend/src/server.ts
Normal 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
20
backend/tsconfig.json
Normal 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
8
backend/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user