diff --git a/src/lib/jobs/lab-document-date-repair.ts b/src/lib/jobs/lab-document-date-repair.ts index 57edb24e5..59f6c9542 100644 --- a/src/lib/jobs/lab-document-date-repair.ts +++ b/src/lib/jobs/lab-document-date-repair.ts @@ -44,7 +44,7 @@ import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; import { userDayKey } from "@/lib/tz/format"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE, resolveUserTimezone } from "@/lib/tz/resolver"; import type { ObservationFactData } from "@/lib/validations/inbound-documents"; import { @@ -76,11 +76,25 @@ interface CandidateRow { function candidateQuery(limit: number | null): Prisma.Sql { // The day comparison runs here as well as in the repair, so a value that // really was taken on its confirmation day drops out of the boot check and - // the pass converges to an empty set. A zone Postgres does not know is read - // as UTC rather than failing the query; the repair re-checks every row in - // the zone the app resolves. + // the pass converges to an empty set. Both must read the same zone: a + // stored zone Postgres does not know falls back the way + // `resolveUserTimezone` falls back, to the instance default and then to + // `DEFAULT_TIMEZONE`. Reading it as UTC instead kept a value confirmed + // near midnight in the candidate set that the repair, in the app's zone, + // left alone, so every boot enqueued the job again. The repair still + // re-checks every row in the zone the app resolves. return Prisma.sql` - WITH "known_zones" AS (SELECT "name" FROM pg_timezone_names) + WITH "known_zones" AS (SELECT "name" FROM pg_timezone_names), + "instance_zone" AS ( + SELECT COALESCE( + ( + SELECT s."default_user_timezone" FROM "app_settings" s + WHERE s."id" = 'singleton' + AND s."default_user_timezone" IN (SELECT "name" FROM "known_zones") + ), + ${DEFAULT_TIMEZONE} + ) AS "name" + ) SELECT r."id", r."user_id" AS "userId", r."taken_at" AS "takenAt", @@ -106,7 +120,8 @@ function candidateQuery(limit: number | null): Prisma.Sql { <> to_char( (r."taken_at" AT TIME ZONE 'UTC') AT TIME ZONE ( CASE WHEN u."timezone" IN (SELECT "name" FROM "known_zones") - THEN u."timezone" ELSE 'UTC' END + THEN u."timezone" + ELSE (SELECT "name" FROM "instance_zone") END ), 'YYYY-MM-DD' ) @@ -128,6 +143,13 @@ export interface LabDocumentDateRepairResult { updated: number; /** Rows left alone: an own date, or already on the document's day. */ unchanged: number; + /** + * Rows whose staged fact would not decrypt (a key retired too early, a + * damaged row). Skipped: without the fact there is no telling whether the + * value carries a date of its own, and one such row must not stop the + * repair of every other account. + */ + undecryptable: number; } /** The repair itself, across every account. */ @@ -138,9 +160,16 @@ export async function repairUndatedDocumentLabValues( const touchedUsers = new Set(); let updated = 0; let unchanged = 0; + let undecryptable = 0; for (const row of rows) { - const data = decryptFactData(row.dataEncrypted) as ObservationFactData; + let data: ObservationFactData; + try { + data = decryptFactData(row.dataEncrypted) as ObservationFactData; + } catch { + undecryptable += 1; + continue; + } // A value with a date of its own is never touched, whatever its row says. if (statedDay(data.effectiveDate ?? null) !== null) { unchanged += 1; @@ -175,7 +204,7 @@ export async function repairUndatedDocumentLabValues( } for (const userId of touchedUsers) invalidateUserHealthScore(userId); - return { scanned: rows.length, updated, unchanged }; + return { scanned: rows.length, updated, unchanged, undecryptable }; } /** The queue handler: one run over every account. */ @@ -193,7 +222,7 @@ export async function handleLabDocumentDateRepair( } workerLog( "info", - `[lab-document-date-repair] scanned=${result.scanned} updated=${result.updated} unchanged=${result.unchanged}`, + `[lab-document-date-repair] scanned=${result.scanned} updated=${result.updated} unchanged=${result.unchanged} undecryptable=${result.undecryptable}`, ); annotate({ action: { name: "labs.documentDate.repair" }, @@ -203,6 +232,7 @@ export async function handleLabDocumentDateRepair( candidates_scanned: result.scanned, updated: result.updated, unchanged: result.unchanged, + undecryptable: result.undecryptable, }); }); } diff --git a/tests/integration/lab-document-date-repair.test.ts b/tests/integration/lab-document-date-repair.test.ts index 82c6aa086..97ba1611c 100644 --- a/tests/integration/lab-document-date-repair.test.ts +++ b/tests/integration/lab-document-date-repair.test.ts @@ -8,12 +8,15 @@ * at noon UTC, and leaves alone a value with a date of its own, a value that * really was taken on its confirmation day in the owner's zone, one the * person re-dated since, and one whose document has no date. A second run - * changes nothing. + * changes nothing. A fact that does not decrypt is skipped and counted + * rather than stopping the run, and an account zone Postgres does not know + * is read the way the app reads it, so the candidate set still converges. */ import { beforeEach, describe, expect, it } from "vitest"; import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store"; import { repairUndatedDocumentLabValues } from "@/lib/jobs/lab-document-date-repair"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; import { getPrismaClient, truncateAllTables } from "./setup"; const USER = "user-lab-document-date-repair"; @@ -22,6 +25,7 @@ const noon = (day: string) => new Date(`${day}T12:00:00.000Z`); beforeEach(async () => { const prisma = getPrismaClient(); await truncateAllTables(prisma); + invalidateUserTimezone(USER); await prisma.user.create({ data: { id: USER, @@ -229,4 +233,58 @@ describe("lab document date repair", () => { expect(result.updated).toBe(0); expect((await takenAt())["linked-twice"]).toBe(confirmed.toISOString()); }); + + it("skips and counts a fact that does not decrypt, and repairs the rest", async () => { + const confirmed = new Date("2026-10-20T08:00:00.000Z"); + for (const id of ["damaged", "misdated"]) { + await confirmedValue({ + id, + reportDate: "2021-03-01", + documentDate: null, + effectiveDate: null, + createdAt: confirmed, + takenAt: confirmed, + }); + } + const prisma = getPrismaClient(); + await prisma.extractedFact.updateMany({ + where: { committedRecordId: "damaged" }, + data: { dataEncrypted: Buffer.from("not a ciphertext at all") }, + }); + + const result = await repairUndatedDocumentLabValues(prisma); + + expect(result).toMatchObject({ updated: 1, undecryptable: 1 }); + expect(await takenAt()).toEqual({ + damaged: confirmed.toISOString(), + misdated: "2021-03-01T12:00:00.000Z", + }); + }); + + it("reads a zone Postgres does not know the way the app does, so the candidates converge", async () => { + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: USER }, + data: { timezone: "Mars/Olympus_Mons" }, + }); + // 00:30 on the report day in the instance default (Europe/Berlin), the + // evening before in UTC: on its document's day for the app. + const justAfterMidnight = new Date("2026-10-19T22:30:00.000Z"); + await confirmedValue({ + id: "taken-on-confirm-day", + reportDate: "2026-10-20", + documentDate: null, + effectiveDate: null, + createdAt: justAfterMidnight, + takenAt: justAfterMidnight, + }); + + const result = await repairUndatedDocumentLabValues(prisma); + + // Not even a candidate: the boot check finds nothing to enqueue. + expect(result).toMatchObject({ scanned: 0, updated: 0 }); + expect((await takenAt())["taken-on-confirm-day"]).toBe( + justAfterMidnight.toISOString(), + ); + }); });