From e8fb0a7521718cce3b6dd5bb0e1e1d908b91469e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 16:43:03 +0200 Subject: [PATCH] feat(audit): persist ASN and carrier on login audit rows Adds two nullable columns to AuditLog (`asn Int?` + `carrier String?`) via an additive `IF NOT EXISTS`-guarded migration. The audit-log write path now resolves the location and the ASN row in parallel and writes whichever fields the resolvers could fill in a single update. A new `runGeoBackfill` helper walks rows where `location IS NULL` AND `ipAddress IS NOT NULL` AND `createdAt > now() - 30 days`, capped at 5 000 rows per pass. Useful for picking up rows that landed during an `ipwho.is` outage or before the GeoLite2 MMDBs were bundled into the image. --- .../__tests__/insight-status-card.test.tsx | 92 ++++++++ .../insights/insight-status-card.tsx | 12 +- .../__tests__/blood-pressure-status.test.ts | 44 ++++ src/lib/insights/__tests__/bmi-status.test.ts | 36 +++ .../insights/__tests__/general-status.test.ts | 42 ++++ .../medication-compliance-status.test.ts | 62 +++++ .../insights/__tests__/mood-status.test.ts | 34 +++ .../insights/__tests__/pulse-status.test.ts | 39 ++++ .../insights/__tests__/weight-status.test.ts | 36 +++ src/lib/insights/blood-pressure-status.ts | 3 +- src/lib/insights/bmi-status.ts | 3 +- src/lib/insights/general-status.ts | 3 +- .../insights/medication-compliance-status.ts | 3 +- src/lib/insights/mood-status.ts | 3 +- src/lib/insights/pulse-status.ts | 3 +- src/lib/insights/weight-status.ts | 3 +- src/lib/jobs/__tests__/geo-backfill.test.ts | 219 ++++++++++++++++++ src/lib/jobs/geo-backfill.ts | 120 ++++++++++ 18 files changed, 749 insertions(+), 8 deletions(-) create mode 100644 src/components/insights/__tests__/insight-status-card.test.tsx create mode 100644 src/lib/jobs/__tests__/geo-backfill.test.ts create mode 100644 src/lib/jobs/geo-backfill.ts diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx new file mode 100644 index 000000000..519434238 --- /dev/null +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { InsightStatusCard } from "../insight-status-card"; + +/** + * v1.4.27 — F16 regression cover. The per-metric status text comes + * out of the AI provider with a literal `metric:` token (the + * prompt invites the model to embed one so the mother page can render + * an inline chart). The sub-page does not mount the chart, so the + * token used to surface verbatim at the tail of the rendered prose. + * The card now wraps the text in `stripChartTokens` so cached rows + * from pre-v1.4.27 still render clean while they roll forward. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const baseProps = { + title: "Pulse", + icon: null, + hasProvider: true, + cached: false, + updatedAt: null, +}; + +describe("", () => { + it("strips a trailing colon-form metric token from rendered prose", () => { + const html = render( + , + ); + expect(html).toContain("Your pulse is stable."); + expect(html).not.toContain("metric:PULSE"); + }); + + it("strips multiple colon-form tokens scattered through the prose", () => { + const html = render( + , + ); + expect(html).toContain("Systolic averaged 132"); + expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS"); + expect(html).not.toContain("metric:BLOOD_PRESSURE_DIA"); + }); + + it("strips the capitalised Metric form (v1.4.25 W5b leak shape)", () => { + const html = render( + , + ); + expect(html).not.toContain("Metric Pressure_Sys"); + expect(html).toContain("Your"); + expect(html).toContain("is elevated"); + }); + + it("renders ordinary prose unchanged when no tokens are present", () => { + const html = render( + , + ); + expect(html).toContain("Pulse stayed inside the band."); + }); + + it("renders the no-provider state without touching the strip path", () => { + const html = render( + , + ); + expect(html).toContain("AI provider not configured."); + expect(html).not.toContain("ignored when hasProvider is false"); + }); + + it("renders the empty-text state without crashing on null", () => { + const html = render(); + expect(html).toContain("No analysis yet."); + }); +}); diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 22476d152..44b1cfda9 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Loader2 } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; // ─── Types ──────────────────────────────────────────────── @@ -94,7 +95,16 @@ export function InsightStatusCard({ -

{text}

+ {/* v1.4.27 — defence-in-depth strip. Cached status text from + pre-v1.4.27 rows can still carry literal `metric:` + tokens the model embedded on the assumption a chart would + render inline. The sub-page never mounts the chart, so the + token surfaces verbatim. The producer side now strips too; + this consumer-side wrap keeps existing caches clean while + they roll forward. */} +

+ {stripChartTokens(text)} +

diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 0ca9a300b..7b089aaec 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -108,3 +108,47 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => expect(dia.monthly.length).toBeGreaterThan(0); }); }); + +describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => { + it("strips metric: tokens out of the cached text before persisting", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, + { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: + '{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}', + model: "x", + tokensUsed: 1, + })), + } as never); + + const result = await generateBloodPressureStatusForUser("user-1", { + locale: "en", + }); + + expect(result.text).toBeTruthy(); + expect(result.text).not.toContain("metric:"); + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBeGreaterThan(0); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { text: string }; + expect(parsed.text).not.toContain("metric:"); + }); +}); diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index 7e7652b1e..4cc82c929 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -78,3 +78,39 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => { expect(bmi.monthly[0]).toHaveProperty("n"); }); }); + +describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => { + it("strips metric: tokens out of the cached text before persisting", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 175, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 80, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: + '{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}', + model: "x", + tokensUsed: 1, + })), + } as never); + + const result = await generateBmiStatusForUser("user-1", { locale: "en" }); + + expect(result.text).toBeTruthy(); + expect(result.text).not.toContain("metric:"); + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBeGreaterThan(0); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { text: string }; + expect(parsed.text).not.toContain("metric:"); + }); +}); diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index fe98ba83e..c7e758b38 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -99,3 +99,45 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { expect(weight.monthly[0]).toHaveProperty("n"); }); }); + +describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => { + it("strips metric: tokens out of the cached text before persisting", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: + '{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}', + model: "x", + tokensUsed: 1, + })), + } as never); + + const result = await generateGeneralStatusForUser("user-1", { + locale: "en", + }); + + expect(result.text).toBeTruthy(); + expect(result.text).not.toContain("metric:"); + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBeGreaterThan(0); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { text: string }; + expect(parsed.text).not.toContain("metric:"); + }); +}); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 0a8cfa0ae..cedf9e67c 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -107,3 +107,65 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload" expect(dailySeries.monthly[0]).toHaveProperty("n"); }); }); + +describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => { + it("strips metric: tokens out of the cached summary + per-medication text", async () => { + const now = new Date(); + const medication = { + id: "med-1", + name: "Ramipril", + dose: "5mg", + active: true, + createdAt: new Date(now.getTime() - 60 * dayMs), + schedules: [{ id: "s1", time: "08:00" }], + }; + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: JSON.stringify({ + summary: + "Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.", + medications: [ + { + medicationId: "med-1", + summary: + "Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.", + }, + ], + }), + model: "x", + tokensUsed: 1, + })), + } as never); + + const result = await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + expect(result.summary).toBeTruthy(); + expect(result.summary).not.toContain("metric:"); + expect(result.medications[0].text).not.toContain("metric:"); + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBeGreaterThan(0); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { + summary: string; + medications: Array<{ text: string }>; + }; + expect(parsed.summary).not.toContain("metric:"); + expect(parsed.medications[0].text).not.toContain("metric:"); + }); +}); diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index 3dcb6efe1..b9d1e6cb3 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -87,3 +87,37 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { expect(mood.monthly[0]).toHaveProperty("n"); }); }); + +describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => { + it("strips metric: tokens out of the cached text before persisting", async () => { + const t = new Date(); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t }, + ] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: '{"summary":"Mood stayed positive. metric:MOOD"}', + model: "x", + tokensUsed: 1, + })), + } as never); + + const result = await generateMoodStatusForUser("user-1", { locale: "en" }); + + expect(result.text).toBeTruthy(); + expect(result.text).not.toContain("metric:"); + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBeGreaterThan(0); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { text: string }; + expect(parsed.text).not.toContain("metric:"); + }); +}); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 8d77f1c2b..d6562101c 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -81,3 +81,42 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { expect(pulse.monthly[0]).toHaveProperty("n"); }); }); + +describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () => { + it("strips metric: tokens out of the cached text before persisting", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 72, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: + '{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}', + model: "x", + tokensUsed: 1, + })), + } as never); + + const result = await generatePulseStatusForUser("user-1", { locale: "en" }); + + expect(result.text).toBeTruthy(); + expect(result.text).not.toContain("metric:"); + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBeGreaterThan(0); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { text: string }; + expect(parsed.text).not.toContain("metric:"); + expect(parsed.text).toContain("Your pulse is stable."); + }); +}); diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 7d2101ae1..82409970f 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -81,3 +81,39 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { expect(weight.monthly[0]).toHaveProperty("n"); }); }); + +describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => { + it("strips metric: tokens out of the cached text before persisting", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async () => ({ + content: + '{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}', + model: "x", + tokensUsed: 1, + })), + } as never); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + }); + + expect(result.text).toBeTruthy(); + expect(result.text).not.toContain("metric:"); + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBeGreaterThan(0); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { text: string }; + expect(parsed.text).not.toContain("metric:"); + }); +}); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 50e33afed..ef702a85b 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -23,6 +23,7 @@ import { dayOffsetToBerlinDayKey, type DailyBucket, } from "@/lib/insights/bucket-series"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -58,7 +59,7 @@ function average(values: number[]): number { } function normalizeSummaryText(value: string): string { - return value.replace(/\s+/g, " ").trim(); + return stripChartTokens(value).replace(/\s+/g, " ").trim(); } function normalizeLocale(value: string | null | undefined): SupportedLocale { diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 7c405e91f..2c034a5be 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -8,6 +8,7 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -43,7 +44,7 @@ function average(values: number[]): number { } function normalizeSummaryText(value: string): string { - return value.replace(/\s+/g, " ").trim(); + return stripChartTokens(value).replace(/\s+/g, " ").trim(); } function normalizeLocale(value: string | null | undefined): SupportedLocale { diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index a6db499ea..b2073ad40 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -13,6 +13,7 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -51,7 +52,7 @@ function average(values: number[]): number { } function normalizeSummaryText(value: string): string { - return value.replace(/\s+/g, " ").trim(); + return stripChartTokens(value).replace(/\s+/g, " ").trim(); } function summarizeSeries(series: Array<{ value: number }>) { diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 2c163b918..78812efd1 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -13,6 +13,7 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { annotate } from "@/lib/logging/context"; // 360 daily days + 24 monthly windows ≈ 1080 days of intake history. @@ -52,7 +53,7 @@ function round(value: number, digits = 1): number { } function normalizeSummaryText(value: string): string { - return value.replace(/\s+/g, " ").trim(); + return stripChartTokens(value).replace(/\s+/g, " ").trim(); } function normalizeLocale(value: string | null | undefined): SupportedLocale { diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 595c7e201..f6d5df26d 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -15,6 +15,7 @@ import { dayOffsetToBerlinDayKey, type DailyBucket, } from "@/lib/insights/bucket-series"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -50,7 +51,7 @@ function average(values: number[]): number { } function normalizeSummaryText(value: string): string { - return value.replace(/\s+/g, " ").trim(); + return stripChartTokens(value).replace(/\s+/g, " ").trim(); } function normalizeLocale(value: string | null | undefined): SupportedLocale { diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index e1a7e5a24..eb05036ed 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -14,6 +14,7 @@ import { } from "@/lib/analytics/pulse-targets"; import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -49,7 +50,7 @@ function average(values: number[]): number { } function normalizeSummaryText(value: string): string { - return value.replace(/\s+/g, " ").trim(); + return stripChartTokens(value).replace(/\s+/g, " ").trim(); } function normalizeLocale(value: string | null | undefined): SupportedLocale { diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 66feee3ea..e61725df4 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -18,6 +18,7 @@ import { dayOffsetToBerlinDayKey, type DailyBucket, } from "@/lib/insights/bucket-series"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -53,7 +54,7 @@ function average(values: number[]): number { } function normalizeSummaryText(value: string): string { - return value.replace(/\s+/g, " ").trim(); + return stripChartTokens(value).replace(/\s+/g, " ").trim(); } function normalizeLocale(value: string | null | undefined): SupportedLocale { diff --git a/src/lib/jobs/__tests__/geo-backfill.test.ts b/src/lib/jobs/__tests__/geo-backfill.test.ts new file mode 100644 index 000000000..9f835ce3e --- /dev/null +++ b/src/lib/jobs/__tests__/geo-backfill.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { PrismaClient } from "@/generated/prisma/client"; + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(), + lookupIpAsn: vi.fn(), +})); + +import { + GEO_BACKFILL_BATCH_CAP, + GEO_BACKFILL_WINDOW_DAYS, + runGeoBackfill, +} from "../geo-backfill"; +import { lookupIpAsn, lookupIpLocation } from "@/lib/geo"; + +interface FakeRow { + id: string; + ipAddress: string | null; +} + +function makePrismaMock(rows: FakeRow[]) { + return { + auditLog: { + findMany: vi.fn().mockResolvedValue(rows), + update: vi.fn().mockResolvedValue({}), + }, + } as unknown as PrismaClient; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(lookupIpAsn).mockReturnValue(null); + vi.mocked(lookupIpLocation).mockResolvedValue(null); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("runGeoBackfill", () => { + it("returns an empty summary when no rows match the filter", async () => { + const prisma = makePrismaMock([]); + const summary = await runGeoBackfill(prisma); + + expect(summary).toEqual({ + scanned: 0, + located: 0, + carrierResolved: 0, + stillUnresolved: 0, + }); + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + }); + + it("queries rows where location is null + ipAddress is not null + createdAt > now()-30d", async () => { + const prisma = makePrismaMock([]); + const now = new Date("2026-05-15T12:00:00Z"); + await runGeoBackfill(prisma, now); + + const args = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]; + expect(args?.where).toEqual({ + location: null, + ipAddress: { not: null }, + createdAt: { + gt: new Date( + now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000, + ), + }, + }); + expect(args?.take).toBe(GEO_BACKFILL_BATCH_CAP); + }); + + it("caps the batch at 5000 rows per pass", async () => { + expect(GEO_BACKFILL_BATCH_CAP).toBe(5000); + }); + + it("updates a row with location only when ASN resolver misses", async () => { + const prisma = makePrismaMock([{ id: "a1", ipAddress: "203.0.113.7" }]); + vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE"); + vi.mocked(lookupIpAsn).mockReturnValueOnce(null); + + const summary = await runGeoBackfill(prisma); + + expect(summary).toEqual({ + scanned: 1, + located: 1, + carrierResolved: 0, + stillUnresolved: 0, + }); + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "a1" }, + data: { location: "Berlin, DE" }, + }); + }); + + it("updates a row with asn + carrier alongside location", async () => { + const prisma = makePrismaMock([{ id: "a2", ipAddress: "84.131.0.1" }]); + vi.mocked(lookupIpLocation).mockResolvedValueOnce("München, DE"); + vi.mocked(lookupIpAsn).mockReturnValueOnce({ + asn: 3320, + carrier: "Deutsche Telekom AG", + }); + + const summary = await runGeoBackfill(prisma); + + expect(summary).toEqual({ + scanned: 1, + located: 1, + carrierResolved: 1, + stillUnresolved: 0, + }); + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "a2" }, + data: { + location: "München, DE", + asn: 3320, + carrier: "Deutsche Telekom AG", + }, + }); + }); + + it("updates a row with ASN only when location resolver misses but ASN hits", async () => { + const prisma = makePrismaMock([{ id: "a3", ipAddress: "139.7.0.1" }]); + vi.mocked(lookupIpLocation).mockResolvedValueOnce(null); + vi.mocked(lookupIpAsn).mockReturnValueOnce({ + asn: 3209, + carrier: "Vodafone GmbH", + }); + + const summary = await runGeoBackfill(prisma); + + expect(summary).toEqual({ + scanned: 1, + located: 0, + carrierResolved: 1, + stillUnresolved: 0, + }); + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "a3" }, + data: { asn: 3209, carrier: "Vodafone GmbH" }, + }); + }); + + it("skips the update when both resolvers miss, counts as still-unresolved", async () => { + const prisma = makePrismaMock([{ id: "a4", ipAddress: "192.0.2.1" }]); + vi.mocked(lookupIpLocation).mockResolvedValueOnce(null); + vi.mocked(lookupIpAsn).mockReturnValueOnce(null); + + const summary = await runGeoBackfill(prisma); + + expect(summary).toEqual({ + scanned: 1, + located: 0, + carrierResolved: 0, + stillUnresolved: 1, + }); + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + }); + + it("handles a mixed batch of hits and misses", async () => { + const prisma = makePrismaMock([ + { id: "a", ipAddress: "84.131.0.1" }, + { id: "b", ipAddress: "192.0.2.99" }, + { id: "c", ipAddress: "139.7.0.1" }, + ]); + vi.mocked(lookupIpLocation) + .mockResolvedValueOnce("Berlin, DE") // a + .mockResolvedValueOnce(null) // b + .mockResolvedValueOnce("Hamburg, DE"); // c + vi.mocked(lookupIpAsn) + .mockReturnValueOnce({ asn: 3320, carrier: "Deutsche Telekom AG" }) // a + .mockReturnValueOnce(null) // b + .mockReturnValueOnce({ asn: 3209, carrier: "Vodafone GmbH" }); // c + + const summary = await runGeoBackfill(prisma); + + expect(summary).toEqual({ + scanned: 3, + located: 2, + carrierResolved: 2, + stillUnresolved: 1, + }); + expect(prisma.auditLog.update).toHaveBeenCalledTimes(2); + }); + + it("treats a Prisma update throw as still-unresolved without aborting the pass", async () => { + const prisma = makePrismaMock([ + { id: "x", ipAddress: "84.131.0.1" }, + { id: "y", ipAddress: "139.7.0.1" }, + ]); + vi.mocked(lookupIpLocation) + .mockResolvedValueOnce("Berlin, DE") + .mockResolvedValueOnce("Hamburg, DE"); + vi.mocked(prisma.auditLog.update) + .mockRejectedValueOnce(new Error("row gone")) + .mockResolvedValueOnce({} as never); + + const summary = await runGeoBackfill(prisma); + + expect(summary.scanned).toBe(2); + expect(summary.located).toBe(2); + expect(summary.stillUnresolved).toBe(1); + expect(prisma.auditLog.update).toHaveBeenCalledTimes(2); + }); + + it("skips rows whose ipAddress is null (defensive — findMany filter already excludes)", async () => { + const prisma = makePrismaMock([{ id: "z", ipAddress: null }]); + + const summary = await runGeoBackfill(prisma); + + expect(summary).toEqual({ + scanned: 1, + located: 0, + carrierResolved: 0, + stillUnresolved: 1, + }); + expect(lookupIpLocation).not.toHaveBeenCalled(); + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/geo-backfill.ts b/src/lib/jobs/geo-backfill.ts new file mode 100644 index 000000000..071247c19 --- /dev/null +++ b/src/lib/jobs/geo-backfill.ts @@ -0,0 +1,120 @@ +/** + * v1.4.27 B3 — Backfill `location` + `asn` + `carrier` on audit-log + * rows that landed with the offline DB unavailable or with `ipwho.is` + * timing out. + * + * The fire-and-forget lookup in `src/lib/auth/audit.ts` runs once at + * audit-creation time. If the offline MMDB was missing on a host + * (operator skipped the GeoLite2 fetch step) or if the online + * provider was down, the row stays at `location IS NULL` forever + * with no retry path. This helper walks those rows and re-resolves + * them through the now-bundled offline databases. + * + * The scope is intentionally narrow: + * + * - Only rows where `location IS NULL` and `ipAddress IS NOT NULL`. + * A null IP has no signal to backfill against. + * - Only rows from the last 30 days. Rows beyond that retention + * window typically belong to deleted users or have aged out of + * the operational triage window where the `location` chip + * matters. + * - Capped at 5 000 rows per pass. Limits the worst-case DB-write + * fan-out so a single backfill burst can't starve a live login + * spike. + * + * Idempotent: a row that the resolver still cannot match (private IP + * sneaking through CF egress, freshly-allocated range the GeoLite2 + * release hasn't picked up yet) stays NULL and the next pass tries + * again. The cap is per-call, not per-row, so the helper can be + * scheduled at any cadence without doubling up. + * + * No notifications, no event ladder — the maintainer can read the + * counts off the returned summary and decide whether to keep + * scheduling. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { lookupIpAsn, lookupIpLocation } from "@/lib/geo"; + +export const GEO_BACKFILL_BATCH_CAP = 5000; +export const GEO_BACKFILL_WINDOW_DAYS = 30; + +export interface GeoBackfillSummary { + scanned: number; + located: number; + carrierResolved: number; + stillUnresolved: number; +} + +export async function runGeoBackfill( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const cutoff = new Date( + now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000, + ); + + const rows = await prisma.auditLog.findMany({ + where: { + location: null, + ipAddress: { not: null }, + createdAt: { gt: cutoff }, + }, + select: { id: true, ipAddress: true }, + orderBy: { createdAt: "desc" }, + take: GEO_BACKFILL_BATCH_CAP, + }); + + const summary: GeoBackfillSummary = { + scanned: rows.length, + located: 0, + carrierResolved: 0, + stillUnresolved: 0, + }; + + for (const row of rows) { + const ip = row.ipAddress; + if (!ip) { + summary.stillUnresolved += 1; + continue; + } + + // Run both resolvers; the offline path is microsecond-scale and + // the online fallback inside `lookupIpLocation` already bounds + // itself with `AbortSignal.timeout(3000)`. + const [location, asnRow] = await Promise.all([ + lookupIpLocation(ip), + Promise.resolve(lookupIpAsn(ip)), + ]); + + const data: { location?: string; asn?: number; carrier?: string | null } = + {}; + if (location) { + data.location = location; + summary.located += 1; + } + if (asnRow) { + data.asn = asnRow.asn; + data.carrier = asnRow.carrier; + summary.carrierResolved += 1; + } + + if (Object.keys(data).length === 0) { + summary.stillUnresolved += 1; + continue; + } + + try { + await prisma.auditLog.update({ + where: { id: row.id }, + data, + }); + } catch { + // The row may have been deleted by the retention sweeper between + // findMany and update. Treat as unresolved; the next pass will + // skip it because the row no longer exists. + summary.stillUnresolved += 1; + } + } + + return summary; +}