Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/privacy/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { getMembers, getPreferences } from '@/lib/services/family';
import { listTransactions } from '@/lib/services/finances';
import { listOpen as listOpenGroceries } from '@/lib/services/groceries';
import { listMemories } from '@/lib/services/memory';
import { listRoutines } from '@/lib/services/routines';
import { listAllRoutines } from '@/lib/services/routines';
import { listClasses, listEventsBetween } from '@/lib/services/school';
import { dayKeyInTz, scopeNow } from '@/lib/services/scope';
import { listPracticesBetween, listTeams } from '@/lib/services/sports';
Expand Down Expand Up @@ -176,7 +176,7 @@ async function readSection(scope: ServiceScope, key: ExportSectionKey): Promise<
return { ok: true, data: { data: { teams: teams.data, events: practices.data }, count: teams.data.length + practices.data.length } };
}
case 'routines': {
const r = await listRoutines(scope, { includeDisabled: true });
const r = await listAllRoutines(scope);
return r.ok ? { ok: true, data: { data: r.data, count: r.data.length } } : r;
}
case 'finances': {
Expand Down
37 changes: 37 additions & 0 deletions lib/services/routines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,43 @@ export async function listRoutines(scope: ServiceScope, opts?: { db?: DB; includ
return ok((data ?? []).map((row) => toRoutine(row as RuleRow)));
}

/**
* All scheduled routines for a data export, including paused routines.
* The dashboard's 100-row window is not a complete download. Page by the
* unique ID so equal timestamps and deletions do not shift an offset, and
* keep the caller's client/family predicate on every page. An empty page,
* rather than a short page, finishes the read: PostgREST can enforce a
* smaller server-side cap than the requested page size.
*
* This is a live read, like the other export sections, not a database snapshot.
*/
export async function listAllRoutines(scope: ServiceScope, opts?: { db?: DB }): Promise<ServiceResult<Routine[]>> {
const db = opts?.db ?? scope.db;
const routines: Routine[] = [];
let cursor: string | undefined;
for (;;) {
let query = db.from('family_automation_rules')
.select('*')
.eq('family_id', scope.familyId)
.not('schedule_kind', 'is', null)
.order('id', { ascending: true })
.limit(500);
if (cursor) query = query.gt('id', cursor);
const { data, error } = await query;
if (error) {
console.error('[service:routines] export list failed', error);
return fail(describeDbError(error, 'Could not read your routines.'), { code: SERVICE_CODES.db });
}
if (!data?.length) return ok(routines);
const nextCursor = data[data.length - 1].id;
if (!nextCursor || nextCursor === cursor) {
return fail('Could not finish reading your routines.', { code: SERVICE_CODES.db });
}
routines.push(...data.map((row) => toRoutine(row as RuleRow)));
cursor = nextCursor;
}
}

/** Pause or resume a routine. Manager-only, like creating one. */
export async function setRoutineEnabled(scope: ServiceScope, routineId: string, enabled: boolean, opts?: { db?: DB }): Promise<ServiceResult<Routine>> {
if (!isManager(scope.role)) {
Expand Down
85 changes: 84 additions & 1 deletion tests/privacy-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ const today = new Intl.DateTimeFormat('en-CA', {

type Aal = { currentLevel: string; nextLevel: string };

function household(aal: Aal = { currentLevel: 'aal1', nextLevel: 'aal1' }) {
function household(aal: Aal = { currentLevel: 'aal1', nextLevel: 'aal1' }, maxRows?: number) {
const db = createInMemorySupabase({
userId: 'user-parent',
maxRows,
rpc: { rate_limit_hit: () => [{ allowed: true, retry_after: 0 }] },
});
// The guard reads the level from the session; the fake has no session.
Expand All @@ -61,6 +62,35 @@ function household(aal: Aal = { currentLevel: 'aal1', nextLevel: 'aal1' }) {
return db;
}

function seedRoutines(db: InMemorySupabase, count: number) {
const rows = Array.from({ length: count }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i + 1).padStart(12, '0')}`,
family_id: FAMILY, name: `Routine ${i + 1}`, action_config: { prompt: `Request ${i + 1}` },
is_enabled: i % 2 === 0, schedule_kind: 'cron', schedule_expr: '0 17 * * 0',
said: 'every Sunday at 5pm', next_run_at: null, created_at: '2026-09-01T00:00:00Z',
}));
db.seed('family_automation_rules', [
...rows,
{ ...rows[0], id: 'foreign-rule', family_id: 'other-family', schedule_kind: 'cron', name: 'Foreign marker' },
{ ...rows[0], id: 'unscheduled-rule', family_id: FAMILY, schedule_kind: null, name: 'Unscheduled marker' },
]);
return rows;
}

function failSecondRoutinePage(db: InMemorySupabase) {
const realFrom = db.from.bind(db);
let routineReads = 0;
db.from = ((table: string) => {
if (table === 'family_automation_rules' && ++routineReads === 2) {
// Use the existing failing builder without changing the first page.
const failedDb = createInMemorySupabase();
failing(failedDb, table, { code: '57014', message: 'synthetic later-page timeout' });
return failedDb.from(table);
}
return realFrom(table);
}) as typeof db.from;
}

function scopeFor(db: InMemorySupabase, role: 'parent' | 'teen'): ServiceScope {
return {
db: db as unknown as ServiceScope['db'],
Expand Down Expand Up @@ -127,6 +157,30 @@ describe('buildFamilyExport', () => {
beforeEach(() => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
afterEach(() => vi.restoreAllMocks());

it.each([0, 101, 500, 1001])('exports all %i scheduled routines, including paused ones, through a server cap', async (count) => {
// A deliberately small server cap catches pagination that assumes a
// short page means the table is exhausted. IDs, not insertion offsets,
// keep pages stable when timestamps are identical.
const db = household(undefined, 37);
const rows = seedRoutines(db, count);
const built = await buildFamilyExport(scopeFor(db, 'parent'));
expect(built.ok).toBe(true);
if (!built.ok) throw new Error('export failed');
const section = built.data.sections.find((s) => s.key === 'routines')!;
expect(section).toMatchObject({ count, limit: null, truncated: false });
expect((section.data as { id: string }[]).map((r) => r.id)).toEqual(rows.map((r) => r.id));
expect(new Set((section.data as { id: string }[]).map((r) => r.id)).size).toBe(count);
});

it('refuses a partial export when a later routine page fails', async () => {
const db = household();
seedRoutines(db, 501);
failSecondRoutinePage(db);
expect(await buildFamilyExport(scopeFor(db, 'parent'))).toEqual({
ok: false, failed: [{ key: 'routines', error: expect.any(String) }],
});
});

it('builds a parent\'s export through the services: finances present, both documents listed, nothing withheld', async () => {
const db = household();
const built = await buildFamilyExport(scopeFor(db, 'parent'));
Expand Down Expand Up @@ -207,6 +261,35 @@ describe('GET /api/privacy/export', () => {
mocks.ledgerWriter.mockImplementation(async (fallback: unknown) => fallback);
}

it.each(['parent', 'teen'] as const)('downloads every routine for a %s and records the full count', async (role) => {
const db = household(undefined, 1000);
const rows = seedRoutines(db, 1251);
wire(db, ctxFor(role, `user-routine-export-${role}`));
const response = await GET();
expect(response.status).toBe(200);
const body = JSON.parse(await response.text());
const section = body.sections.find((s: { key: string }) => s.key === 'routines');
expect(section).toMatchObject({ count: 1251, limit: null, truncated: false });
expect(section.data.map((r: { id: string }) => r.id)).toEqual(rows.map((r) => r.id));
expect(db.table('trust_audit_logs')).toHaveLength(1);
const context = db.table('trust_audit_logs')[0].context as { sections: Record<string, number>; truncated: string[] };
expect(context.sections.routines).toBe(1251);
expect(context.truncated).not.toContain('routines');
expect(body.withheld).toEqual(role === 'parent' ? [] : ['finances']);
});

it('sends no attachment or download audit when a later routine page fails', async () => {
const db = household();
seedRoutines(db, 501);
failSecondRoutinePage(db);
wire(db, ctxFor('parent', 'user-routine-page-failure'));
const response = await GET();
expect(response.status).toBe(502);
expect(response.headers.get('Content-Disposition')).toBeNull();
expect(await response.json()).toEqual({ error: 'export_failed', failed: ['routines'], retryable: true });
expect(db.table('trust_audit_logs')).toHaveLength(0);
});

it('refuses a signed-out caller', async () => {
wire(household(), null);
const res = await GET();
Expand Down
Loading