diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index c1fa13ee5..0ab967c9d 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -376,7 +376,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. expiresAt: null, lastUsedAt: null, user: { deletionRequestedAt: null }, - } as Awaited>); + } as never); } it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => { diff --git a/src/lib/jobs/__tests__/pending-deletion-gate.test.ts b/src/lib/jobs/__tests__/pending-deletion-gate.test.ts index 83b38d5de..51f6d2c49 100644 --- a/src/lib/jobs/__tests__/pending-deletion-gate.test.ts +++ b/src/lib/jobs/__tests__/pending-deletion-gate.test.ts @@ -76,4 +76,21 @@ describe("the pending-deletion gate on queued jobs", () => { expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith([job("3", { userId: "u" })]); }); + + it("lets a queue that only changes how rows are stored run for it", async () => { + pending.clear(); + pending.add("frozen"); + const handler = vi.fn(async () => ({ ok: true as const, did: {} })); + const bound = bindableHandler( + { + queue: "free-text-encryption-backfill", + work: {}, + actsForPendingAccounts: true, + load: async () => handler, + } as never, + { boss: {} as PgBoss }, + ); + await bound([job("1", { userId: "frozen" })]); + expect(handler).toHaveBeenCalledWith([job("1", { userId: "frozen" })]); + }); }); diff --git a/src/lib/jobs/registry/define-job.ts b/src/lib/jobs/registry/define-job.ts index acaa67254..1e31e990a 100644 --- a/src/lib/jobs/registry/define-job.ts +++ b/src/lib/jobs/registry/define-job.ts @@ -65,6 +65,14 @@ export interface JobSpec> { * filter of the jobs that are heavy on a queue that also carries light ones. */ readonly lane?: true | ((job: J) => boolean); + /** + * The queue's per-account jobs run for an account pending deletion too. + * Without it a job whose payload names such an account is completed + * without running (`pending-deletion-gate`). Only for work that changes + * how rows are stored and not what the record holds (an encryption + * backfill), which is done or erased the same either way. + */ + readonly actsForPendingAccounts?: true; /** Loads the handler that drains the queue. */ readonly load: (ctx: JobContext) => Promise>; } diff --git a/src/lib/jobs/registry/maintenance.ts b/src/lib/jobs/registry/maintenance.ts index e56de8081..587123022 100644 --- a/src/lib/jobs/registry/maintenance.ts +++ b/src/lib/jobs/registry/maintenance.ts @@ -660,6 +660,8 @@ const jobs: readonly JobDefinition[] = [ "Per-user encryption backfill (Coach title, custom-metric note, practitioner phone + address, workout GPS track); discovery drops the user once no readable value remains.", ), work: { localConcurrency: FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY }, + // Seals what is already stored; see the module's account-deletion note. + actsForPendingAccounts: true, load: async () => (await encryption()).handleFreeTextEncryptionBackfill, }), // Physical purge of documents past the 30-day undo grace. diff --git a/src/lib/jobs/registry/register.ts b/src/lib/jobs/registry/register.ts index 412495a56..6e162bbd6 100644 --- a/src/lib/jobs/registry/register.ts +++ b/src/lib/jobs/registry/register.ts @@ -87,6 +87,7 @@ export function bindableHandler( // A job for an account pending deletion completes without running: it // was queued before the account was frozen (see `pending-deletion-gate`). let handler: BatchHandler> = async (jobs) => { + if (definition.actsForPendingAccounts) return (await load())(jobs); const { live, dropped } = await dropJobsOfPendingAccounts(jobs); if (live.length === 0) { return jobDone({ jobs_skipped_deletion_pending: dropped }); diff --git a/tests/integration/account-deletion-grace.test.ts b/tests/integration/account-deletion-grace.test.ts index 8e91fbc17..410474e40 100644 --- a/tests/integration/account-deletion-grace.test.ts +++ b/tests/integration/account-deletion-grace.test.ts @@ -621,7 +621,6 @@ describe("a handover link and a profile pending deletion", () => { }); it("revokes the links a Guardian minted when the Guardian deletes their account", async () => { - const prisma = getPrismaClient(); const leaving = await seedAccount("guardian-leaving"); const staying = await seedAccount("guardian-staying"); const profile = await profileGuardedBy("managed-kid", [