learning_ai_clock/backend/src/modules/routines/repository.ts
saravanakumardb1 f10b83c122 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
2026-03-01 20:39:08 -08:00

183 lines
5.5 KiB
TypeScript

/**
* 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 };
}