mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(FIX-E): v1.20.2 audit fixes
This commit is contained in:
commit
ca1cf53d2c
19 files changed
+223
-30
No files matched your search
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Verknüpft mit vorhandenem {name}",
|
||||
"duplicateWarning": "Ein passender Wert existiert bereits",
|
||||
"lowConfidence": "Bitte diesen Wert überprüfen",
|
||||
"lowConfidenceBadge": "Geringe Sicherheit",
|
||||
"valueUnreadable": "Wert unleserlich — bitte manuell eingeben",
|
||||
"saveSelected": "{count} ausgewählte speichern",
|
||||
"discardAll": "Alle verwerfen",
|
||||
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Links to existing {name}",
|
||||
"duplicateWarning": "A matching reading already exists",
|
||||
"lowConfidence": "Please double-check this value",
|
||||
"lowConfidenceBadge": "Low confidence",
|
||||
"valueUnreadable": "Value unreadable — enter it manually",
|
||||
"saveSelected": "Save {count} selected",
|
||||
"discardAll": "Discard all",
|
||||
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Se vincula a {name} existente",
|
||||
"duplicateWarning": "Ya existe un valor coincidente",
|
||||
"lowConfidence": "Verifica este valor",
|
||||
"lowConfidenceBadge": "Confianza baja",
|
||||
"valueUnreadable": "Valor ilegible — introdúcelo manualmente",
|
||||
"saveSelected": "Guardar {count} seleccionados",
|
||||
"discardAll": "Descartar todo",
|
||||
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Lié au {name} existant",
|
||||
"duplicateWarning": "Une valeur correspondante existe déjà",
|
||||
"lowConfidence": "Veuillez vérifier cette valeur",
|
||||
"lowConfidenceBadge": "Confiance faible",
|
||||
"valueUnreadable": "Valeur illisible — saisissez-la manuellement",
|
||||
"saveSelected": "Enregistrer {count} sélectionnées",
|
||||
"discardAll": "Tout abandonner",
|
||||
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Collegato a {name} esistente",
|
||||
"duplicateWarning": "Esiste già un valore corrispondente",
|
||||
"lowConfidence": "Controlla questo valore",
|
||||
"lowConfidenceBadge": "Bassa attendibilità",
|
||||
"valueUnreadable": "Valore illeggibile — inseriscilo manualmente",
|
||||
"saveSelected": "Salva {count} selezionati",
|
||||
"discardAll": "Scarta tutto",
|
||||
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Łączy z istniejącym {name}",
|
||||
"duplicateWarning": "Pasujący wynik już istnieje",
|
||||
"lowConfidence": "Sprawdź tę wartość",
|
||||
"lowConfidenceBadge": "Niska pewność",
|
||||
"valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie",
|
||||
"saveSelected": "Zapisz {count} wybranych",
|
||||
"discardAll": "Odrzuć wszystko",
|
||||
|
||||
@@ -119,11 +119,20 @@ describe("GET /api/admin/app-logs", () => {
|
||||
expect(json.data.events).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("rejects an invalid level value", async () => {
|
||||
it("rejects an invalid level value with a 422 (not a thrown 500)", async () => {
|
||||
appendLogEvent(makeEvent());
|
||||
// Invalid `level` — Zod parse should throw and the apiHandler test
|
||||
// mock rethrows it (we mock only `requireAdmin`, not the wrapper
|
||||
// error-translator). We simply assert the call rejects.
|
||||
await expect(GET(req("level=critical"))).rejects.toThrow();
|
||||
// Invalid `level` — the query is parsed with `safeParse` and returns the
|
||||
// standard 422 multi-issue envelope rather than throwing (which the
|
||||
// apiHandler catch ladder would otherwise surface as a 500 + incident).
|
||||
const res = await GET(req("level=critical"));
|
||||
expect(res.status).toBe(422);
|
||||
const json = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: unknown[] };
|
||||
};
|
||||
expect(json.data).toBeNull();
|
||||
expect(json.error).toBe("Validation failed");
|
||||
expect(json.details.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
@@ -35,7 +35,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "admin.app-logs.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.parse({
|
||||
const result = querySchema.safeParse({
|
||||
traceId: searchParams.get("traceId") ?? undefined,
|
||||
level: searchParams.get("level") ?? undefined,
|
||||
action: searchParams.get("action") ?? undefined,
|
||||
@@ -43,6 +43,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
until: searchParams.get("until") ?? undefined,
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!result.success) return returnAllZodIssues(result.error);
|
||||
const parsed = result.data;
|
||||
|
||||
const events = readLogBuffer({
|
||||
traceId: parsed.traceId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -56,7 +56,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "admin.audit-log.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.parse({
|
||||
const result = querySchema.safeParse({
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
offset: searchParams.get("offset") ?? undefined,
|
||||
filter: searchParams.get("filter") ?? undefined,
|
||||
@@ -68,6 +68,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
since: searchParams.get("since") ?? undefined,
|
||||
until: searchParams.get("until") ?? undefined,
|
||||
});
|
||||
if (!result.success) return returnAllZodIssues(result.error);
|
||||
const parsed = result.data;
|
||||
|
||||
// Resolve perPage: clamp to allowed set, fall back to 50.
|
||||
const perPageResolved =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -59,9 +59,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "admin.host-metrics.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.parse({
|
||||
const result = querySchema.safeParse({
|
||||
since: searchParams.get("since") ?? undefined,
|
||||
});
|
||||
if (!result.success) return returnAllZodIssues(result.error);
|
||||
const parsed = result.data;
|
||||
|
||||
const sinceMs = presetToMs(parsed.since);
|
||||
const cutoff = new Date(Date.now() - sinceMs);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
|
||||
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
|
||||
* for the calling user's configured AI provider. The surface stays dark for
|
||||
* codex-only / text-only-model users.
|
||||
* users with no provider configured and no local-OCR opt-in.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* v1.20.1 — POST /api/labs/ocr/extract, TEXT mode.
|
||||
*
|
||||
* Focus: the text-mode structuring pass reserves the proportionate text budget
|
||||
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling, and
|
||||
* on a clean extraction failure it refunds the reservation in full rather than
|
||||
* charging it.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAuth: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveTextProvider: vi.fn(),
|
||||
resolveVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-26"),
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-extract", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
|
||||
"@/lib/labs/ocr-extract",
|
||||
);
|
||||
return { ...actual, runOcrExtraction: vi.fn() };
|
||||
});
|
||||
|
||||
import { POST } from "../route";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveTextProvider } from "@/lib/labs/ocr-capability";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function textReq(text = "Glucose 95 mg/dL"): Request {
|
||||
return new Request("http://localhost/api/labs/ocr/extract", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "text", text }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
labsLocalOcrEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(resolveTextProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "openai", instance: {} as never }],
|
||||
pick: {
|
||||
entry: { providerType: "openai", instance: {} as never },
|
||||
providerType: "openai",
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
|
||||
totalAfter: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — text mode budget", () => {
|
||||
it("reserves the cheaper text ceiling, not the vision ceiling", async () => {
|
||||
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The reservation must use the text ceiling — proven distinct from the
|
||||
// vision ceiling so a future merge can't silently re-point it.
|
||||
expect(AI_BUDGETS.ocrExtractText.maxTokens).toBeLessThan(
|
||||
AI_BUDGETS.ocrExtract.maxTokens ?? Infinity,
|
||||
);
|
||||
expect(reserveBudget).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
"2026-06-26",
|
||||
);
|
||||
});
|
||||
|
||||
it("refunds the reservation in full on a clean extract failure", async () => {
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(
|
||||
new OcrExtractError("unreadable"),
|
||||
);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
// actual spend reconciles to 0 — the failed structuring pass is refunded,
|
||||
// not charged at the reserved estimate.
|
||||
expect(reconcileSpend).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
0,
|
||||
"2026-06-26",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -138,12 +138,14 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
// Budget (text is cheaper than vision; reuse the same ceiling as the upper
|
||||
// bound — a text-only structuring call always spends at most this).
|
||||
// Budget — text mode is a plain text→JSON structuring pass, far cheaper than
|
||||
// a vision call, so it reserves the proportionate text ceiling rather than
|
||||
// the vision budget. Over-charging the vision rate for a text call would
|
||||
// exhaust the day budget against spend that never happened.
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
@@ -163,6 +165,8 @@ async function handleTextExtract(
|
||||
providerType: pick.providerType,
|
||||
ocrText: parsed.data.text,
|
||||
});
|
||||
// A clean structuring pass spent (at most) the reservation; the provider
|
||||
// already billed it, so reconcile against the reserved estimate.
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
@@ -171,12 +175,9 @@ async function handleTextExtract(
|
||||
);
|
||||
return apiSuccess(result);
|
||||
} catch (err) {
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
// A failed structuring call produced no usable rows; mirror the vision
|
||||
// path and refund the reservation in full rather than charging it.
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof OcrExtractError) {
|
||||
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
|
||||
errorCode: "labs.ocr.extractFailed",
|
||||
|
||||
@@ -49,6 +49,17 @@ export function OcrRowEditor({
|
||||
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
|
||||
const valueUnreadable = !isQualitative && row.value === null;
|
||||
|
||||
// The model self-scores each field it transcribed. Surface a single calm
|
||||
// per-row flag when ANY tracked field came back below the threshold so a
|
||||
// shaky read is visible at a glance — including on qualitative rows, which
|
||||
// have no value-confidence footnote of their own. Informative, not alarming.
|
||||
const lowRowConfidence =
|
||||
row.confidence.analyte < CONFIDENCE_THRESHOLD ||
|
||||
row.confidence.unit < CONFIDENCE_THRESHOLD ||
|
||||
(isQualitative
|
||||
? row.confidence.value < CONFIDENCE_THRESHOLD
|
||||
: lowValueConfidence);
|
||||
|
||||
// v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range
|
||||
// from THIS extracted row. The range is therefore load-bearing and worth a
|
||||
// second look. Surface the range confidence prominently for new numeric
|
||||
@@ -106,6 +117,12 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.duplicateWarning")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{lowRowConfidence ? (
|
||||
<Badge variant="secondary" className="text-muted-foreground">
|
||||
<AlertCircle aria-hidden />
|
||||
{t("labs.ocr.lowConfidenceBadge")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,4 +109,16 @@ export const AI_BUDGETS = {
|
||||
* gates with a tight 6/hour rate bucket + reserveBudget + upload-size cap.
|
||||
*/
|
||||
ocrExtract: { temperature: 0, maxTokens: 4000 },
|
||||
|
||||
/**
|
||||
* v1.20.1 — Lab-OCR text-mode structuring. The browser OCR's the image
|
||||
* (tesseract.js) and POSTs only the extracted TEXT, so the provider does a
|
||||
* plain text→JSON structuring pass — no expensive vision input tokens. The
|
||||
* output contract is identical (the same dozen-plus analyte rows), but the
|
||||
* spend ceiling is far lower than the vision path's 4000, so the day-budget
|
||||
* reservation should reflect that rather than over-charging a text call at
|
||||
* the vision rate. 1500 tokens comfortably covers a dense report's JSON
|
||||
* envelope. Temperature 0 — faithful structuring, not generation.
|
||||
*/
|
||||
ocrExtractText: { temperature: 0, maxTokens: 1500 },
|
||||
} as const satisfies Record<string, AiBudget>;
|
||||
@@ -20,8 +20,10 @@
|
||||
* mutation or egress surface.
|
||||
*
|
||||
* The builder result is memoised by the 60s snapshot LRU keyed on
|
||||
* `(userId, window, sources)`, so several tool calls in one turn (and the
|
||||
* base-context inventory build) share the underlying reads.
|
||||
* `(userId, window, sources)`. Each tool scopes to a different source set, so
|
||||
* tool calls for distinct domains do NOT share a cache entry; sharing happens
|
||||
* only when the SAME scope recurs (a repeated call, or a turn that re-enters
|
||||
* the same domain) within the 60s window.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
|
||||
+16
-3
@@ -47,6 +47,14 @@ import {
|
||||
/** Refresh the access token this many ms before `tokenExpiresAt`. */
|
||||
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Delimiter joining `(type, externalId)` into a single dedup map key. A
|
||||
* control character keeps it unambiguous against any value a real type or
|
||||
* externalId could contain, while staying a normal printable-safe constant
|
||||
* (a literal NUL in the source made text tooling treat the file as binary).
|
||||
*/
|
||||
const DEDUP_KEY_DELIM = "\u0000";
|
||||
|
||||
/**
|
||||
* Overlap window for the incremental sync, in ms. Fitbit finalises daily
|
||||
* summaries after the fact (a daily SpO2 / HRV / RHR can settle hours after the
|
||||
@@ -396,7 +404,10 @@ export async function upsertFitbitMeasurements(
|
||||
liveByKey = new Map(
|
||||
existing
|
||||
.filter((e) => e.externalId !== null)
|
||||
.map((e) => [`${e.type} | ||||