mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge feat/v125-ios-serverdep into release/v1.25.0
Labs OCR provenance + delete-scoping.
This commit is contained in:
commit
9c57d9e38e
7 files changed
+212
-8
No files matched your search
+13
-4
@@ -2363,8 +2363,10 @@ paths:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Delete measurements by external ID (iOS deletion-sync)
|
||||
description: Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are
|
||||
silently skipped (cross-user 404 guard). Up to 500 externalIds per call.
|
||||
description: "Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the
|
||||
request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced
|
||||
row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped
|
||||
(cross-user 404 guard). Up to 500 externalIds per call."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -7056,8 +7058,10 @@ paths:
|
||||
tags:
|
||||
- Labs
|
||||
summary: Record a lab result
|
||||
description: Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is
|
||||
AES-256-GCM encrypted before write. Audits as `labResult.create`.
|
||||
description: Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`);
|
||||
it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client
|
||||
posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path.
|
||||
The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -7104,6 +7108,11 @@ paths:
|
||||
maxLength: 2000
|
||||
- type: string
|
||||
const: ""
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- MANUAL
|
||||
- OCR
|
||||
required:
|
||||
- takenAt
|
||||
responses:
|
||||
|
||||
@@ -179,6 +179,7 @@ async function postLabResult(request: NextRequest) {
|
||||
referenceHigh,
|
||||
takenAt,
|
||||
note,
|
||||
source,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
|
||||
@@ -239,7 +240,11 @@ async function postLabResult(request: NextRequest) {
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
takenAt,
|
||||
source: "MANUAL",
|
||||
// v1.25 (iOS #36) — provenance: the on-device-OCR path posts
|
||||
// `source: "OCR"`; an omitted field stays "MANUAL" so the legacy
|
||||
// hand-entry contract is unchanged. Narrowed to the closed enum by Zod,
|
||||
// never spread from the body.
|
||||
source: source ?? "MANUAL",
|
||||
noteEncrypted: note ? encryptNoteToBytes(note) : null,
|
||||
},
|
||||
});
|
||||
@@ -258,6 +263,8 @@ async function postLabResult(request: NextRequest) {
|
||||
// Whether the client picked a catalog marker (structured) vs the
|
||||
// free-text path that resolved-or-minted one server-side.
|
||||
structured: biomarkerId !== undefined,
|
||||
// v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR").
|
||||
source: source ?? "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* DELETE /api/measurements/by-external-ids — iOS HealthKit deletion-sync.
|
||||
*
|
||||
* Asserts the origin-scoped soft-delete contract (v1.25, iOS #35): the
|
||||
* reconciliation only tombstones rows the app minted through its HealthKit
|
||||
* ingestion path (`source = APPLE_HEALTH`). A foreign-origin externalId — one
|
||||
* that collides with an integration- or manually-sourced row — is excluded by
|
||||
* the source predicate and is a silent no-op, never a delete. Cross-user and
|
||||
* idempotency guards stay as before.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
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 delReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/by-external-ids", {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
|
||||
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("DELETE /api/measurements/by-external-ids", () => {
|
||||
it("scopes both the pre-fetch and the soft-delete to the app-minted APPLE_HEALTH source", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(delReq({ externalIds: ["uuid-aaa"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deletedCount: number } };
|
||||
expect(body.data.deletedCount).toBe(1);
|
||||
|
||||
const findWhere = vi.mocked(prisma.measurement.findMany).mock.calls[0]![0]!
|
||||
.where;
|
||||
expect(findWhere).toMatchObject({
|
||||
userId: "user-1",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: { in: ["uuid-aaa"] },
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const updateWhere = vi.mocked(prisma.measurement.updateMany).mock
|
||||
.calls[0]![0].where;
|
||||
expect(updateWhere).toMatchObject({
|
||||
userId: "user-1",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: { in: ["uuid-aaa"] },
|
||||
deletedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => {
|
||||
// The caller's reconciliation list carries an externalId that, on the
|
||||
// server, maps to a WITHINGS-sourced row (a colliding externalUUID). The
|
||||
// `source = APPLE_HEALTH` predicate excludes it, so the pre-fetch and the
|
||||
// soft-delete both match zero rows.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(delReq({ externalIds: ["withings-collision"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deletedCount: number } };
|
||||
expect(body.data.deletedCount).toBe(0);
|
||||
|
||||
// The delete was still source-scoped (the foreign row was never eligible).
|
||||
const updateWhere = vi.mocked(prisma.measurement.updateMany).mock
|
||||
.calls[0]![0].where;
|
||||
expect(updateWhere).toMatchObject({ source: "APPLE_HEALTH" });
|
||||
|
||||
// Zero deletions → no cache eviction, no rollup recompute, no insight bust.
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
expect(invalidateStatusInsightsForTypes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns deletedCount 0 with 200 for an empty externalIds array", async () => {
|
||||
const res = await DELETE(delReq({ externalIds: [] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deletedCount: number } };
|
||||
expect(body.data.deletedCount).toBe(0);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 500-id cap with 422", async () => {
|
||||
const externalIds = Array.from({ length: 501 }, (_, i) => `uuid-${i}`);
|
||||
const res = await DELETE(delReq({ externalIds }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,13 @@
|
||||
* rather than removing the row, so deletions surface as tombstones on the
|
||||
* `/api/sync/changes` delta feed).
|
||||
*
|
||||
* Origin scope (v1.25, iOS #35): the delete only touches rows the iOS
|
||||
* client minted through its HealthKit ingestion path (`source =
|
||||
* APPLE_HEALTH`). An externalId the app never minted — an integration- or
|
||||
* manually-sourced row that happens to collide on externalUUID — is a
|
||||
* no-op, never a delete. Defence-in-depth so a foreign HealthKit sample
|
||||
* with a colliding UUID cannot be tombstoned by this reconciliation.
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - Cross-user safety: rows owned by another user are silently skipped
|
||||
* (Prisma's `updateMany` with `userId` in the where-clause guarantees
|
||||
@@ -105,9 +112,21 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
// tombstoned by this call. We pre-fetch the (type, measuredAt) tuples
|
||||
// of the live matches so the rollup recompute below knows which
|
||||
// buckets to refresh.
|
||||
//
|
||||
// v1.25 (iOS #35) — scope to the app's HealthKit ingestion source
|
||||
// (`APPLE_HEALTH`). This reconciliation removes HealthKit samples the
|
||||
// iOS client minted; only `source = APPLE_HEALTH` rows are app-minted on
|
||||
// this path. Defence-in-depth against a colliding externalId: an
|
||||
// integration- or manually-sourced row (WITHINGS / OURA / MANUAL / …)
|
||||
// that happens to share an externalUUID with a now-absent HealthKit
|
||||
// sample is NOT app-minted, so it falls outside the predicate and the
|
||||
// delete is a no-op for it rather than tombstoning a foreign-origin row.
|
||||
const APP_MINTED_SOURCE = "APPLE_HEALTH" as const;
|
||||
|
||||
const affectedRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: APP_MINTED_SOURCE,
|
||||
externalId: { in: externalIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -117,6 +136,7 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
const result = await prisma.measurement.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: APP_MINTED_SOURCE,
|
||||
externalId: { in: externalIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
createLabResultSchema.meta({
|
||||
id: "CreateLabResultRequest",
|
||||
description:
|
||||
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest.',
|
||||
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted.',
|
||||
});
|
||||
|
||||
updateLabResultSchema.meta({
|
||||
@@ -119,7 +119,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Labs"],
|
||||
summary: "Record a lab result",
|
||||
description:
|
||||
"Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.",
|
||||
'Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createLabResultSchema } },
|
||||
|
||||
@@ -638,7 +638,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Delete measurements by external ID (iOS deletion-sync)",
|
||||
description:
|
||||
"Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.",
|
||||
"Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
||||
@@ -79,6 +79,16 @@ const biomarkerId = z.string().trim().min(1).max(64).optional();
|
||||
*/
|
||||
const valueText = requiredText(120).optional();
|
||||
|
||||
/**
|
||||
* v1.25 (iOS #36) — optional provenance marker. The on-device-OCR ingestion
|
||||
* path posts `source: "OCR"` so a reading captured from a phone-camera lab
|
||||
* photo records its provenance; the raw image never touches the server on this
|
||||
* path. Omitting the field reads as `"MANUAL"`, preserving the legacy
|
||||
* hand-entry contract exactly. Mirrors the `source: "OCR"` the server-side
|
||||
* `/api/labs/ocr/commit` path already stamps.
|
||||
*/
|
||||
const source = z.enum(["MANUAL", "OCR"]).optional();
|
||||
|
||||
export const createLabResultSchema = z
|
||||
.object({
|
||||
biomarkerId,
|
||||
@@ -93,6 +103,7 @@ export const createLabResultSchema = z
|
||||
referenceHigh,
|
||||
takenAt: takenAtField,
|
||||
note: optionalNote,
|
||||
source,
|
||||
})
|
||||
// Either a catalog link OR a free-text analyte must be present.
|
||||
.refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {
|
||||
|
||||
Reference in new issue
Block a user