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
183 lines
5.5 KiB
TypeScript
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 };
|
|
}
|