Merge remote-tracking branch 'origin/main' into fix/v1397-tz

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:59:51 +02:00
commit ada490c7e9
57 files changed
+1485 -99

No files matched your search

+6 -5
View File
@@ -346,11 +346,12 @@ export const GET = apiHandler(async () => {
// defaults rather than to `undefined`, so the switch is what renders.
// No extra query — the session already loads the row this column is on.
notificationPrefs: parseNotificationPrefs(user.notificationPrefs ?? null),
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0078 commentary). Every Coach mount point on the client checks
// `user.disableCoach` BELOW the operator-level `flags.coach`
// short-circuit; both gates must agree to paint the affordance.
// v1.4.47 W3 — the caller's own stored Coach opt-out, published for the
// switch that edits it. Default `false` if the column is absent
// (partial-deploy rollback safety, see migration 0078 commentary). It is
// not a gate: whether the Coach shows for the record on screen is the
// resolved `ai.capabilities.coach` and `modules.coach` below, which fold
// in the record's own opt-out, the operator's switches and the grant.
disableCoach: user.disableCoach ?? false,
// v1.7.0 — health-record export identity fields. All optional.
fullName: user.fullName ?? null,
@@ -229,11 +229,15 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
pageCount: 1,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
// The whole PDF was read: no coverage note on the response.
const whole = (await res.json()) as { data: Record<string, unknown> };
expect(whole.data).not.toHaveProperty("pageCoverage");
expect(rasterizePdf).toHaveBeenCalledOnce();
// The rendered page images flow through as `input_image`s; no native PDF
// document block is sent for a non-Anthropic provider.
@@ -245,6 +249,26 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
);
});
it("says on the response when only the first pages of a long PDF were read", async () => {
const tenPages = Array.from({ length: 10 }, () => ({
mediaType: "image/jpeg" as const,
dataBase64: "cGFnZQ==",
}));
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: tenPages,
pageCount: 23,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { pageCoverage?: { read: number; total: number } };
};
expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 });
});
it("hands the slot back when the upload is not an image or PDF", async () => {
const form = new FormData();
form.append(
+16 -1
View File
@@ -374,6 +374,7 @@ async function handleVisionExtract(
dataBase64: string;
}[];
let documents: { mediaType: "application/pdf"; dataBase64: string }[];
let pageCoverage: { read: number; total: number } | undefined;
if (mime === "application/pdf") {
if (pick.pdfSupported) {
@@ -404,6 +405,20 @@ async function handleVisionExtract(
}
images = raster.images;
documents = [];
// A PDF longer than the page cap is read from its first pages only.
// That used to live in the wide event alone; the person reviewing the
// rows is the one who needs to know the rest of the report was not
// read.
if (raster.pageCount > raster.images.length) {
pageCoverage = {
read: raster.images.length,
total: raster.pageCount,
};
annotate({
action: { name: "labs.ocr.pagesCapped" },
meta: { read: raster.images.length, total: raster.pageCount },
});
}
}
} else {
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
@@ -431,7 +446,7 @@ async function handleVisionExtract(
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(result);
return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result);
} catch (err) {
// Provider/extraction failure — the call may still have burned tokens, so
// reconcile against the reserved estimate rather than refunding in full.
+3 -1
View File
@@ -2,7 +2,9 @@
* MCP connector tokens — list + mint.
*
* The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT
* the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the
* one of the write scopes the other token surfaces mint — the per-medication
* ingest pair `medication:ingest` + `medication:<id>:ingest`,
* `measurements:write`, `workouts:write`, `documents:write` — and NEVER the
* `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual /
* stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same
* `health:read` scope automatically for cloud connectors. Both surface here so
+7 -5
View File
@@ -109,11 +109,13 @@ export const GET = apiHandler(async () => {
// Bearer scope. All sourced from the canonical `rest.ts` constants.
restBaseUrl: "/api/fhir",
// The face checks a narrow `fhir:read` scope, and NO code path mints a
// token carrying it — every mint grants a wildcard, the medication
// ingest scope, or an MCP scope. So the face is reachable by the owner's
// cookie session or a wildcard device token, and by nothing else. It is
// reported that way rather than advertising a credential a client cannot
// obtain.
// token carrying it — every mint grants a wildcard (login, passkey,
// refresh), the per-medication ingest pair (`medication:ingest` +
// `medication:<id>:ingest`), `measurements:write`, `workouts:write`,
// `documents:write`, or an MCP scope (`health:read`, `health:write`).
// So the face is reachable by the owner's cookie session or a wildcard
// device token, and by nothing else. It is reported that way rather than advertising a credential a
// client cannot obtain.
scopeMintable: false,
resourceTypes: FHIR_REST_RESOURCE_TYPES,
operations: [FHIR_EVERYTHING_OPERATION],
@@ -126,8 +126,8 @@ beforeEach(() => {
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 });
txClient.moodContext.findUnique.mockResolvedValue(null);
vi.mocked(replaceTagLinks).mockResolvedValue(undefined);
vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined);
vi.mocked(replaceTagLinks).mockResolvedValue([]);
vi.mocked(replaceRatedFactorLinks).mockResolvedValue([]);
// v1.7.0 sync — PUT now looks the row up via `findFirst` with a
// `deletedAt: null` guard (refuses to resurrect-edit a tombstone).
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue(
@@ -316,6 +316,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => {
};
vi.mocked(replaceTagLinks).mockImplementationOnce(async () => {
draft.binary = ["calm"];
return [];
});
vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => {
draft.rated = [{ key: "factor_work", rating: 5 }];
+21 -5
View File
@@ -25,6 +25,7 @@ import {
RatedFactorOutOfRangeError,
replaceRatedFactorLinks,
replaceTagLinks,
droppedLinkKeysForWire,
} from "@/lib/mood/tag-links";
type RouteParams = { params: Promise<{ id: string }> };
@@ -197,11 +198,15 @@ export const PUT = apiHandler(
// Omission preserves the corresponding link set. Explicit null/empty
// clears it, matching the validated update contract.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx);
}
// Each replacement returns the submitted keys it could not store,
// reported on the response rather than dropped in silence.
const droppedTagKeys =
data.tagKeys !== undefined
? await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx)
: [];
let droppedFactorKeys: string[] = [];
if (data.ratedFactors !== undefined) {
await replaceRatedFactorLinks(
droppedFactorKeys = await replaceRatedFactorLinks(
id,
user.id,
data.ratedFactors ?? [],
@@ -236,6 +241,7 @@ export const PUT = apiHandler(
return {
entry: updated,
contextOutcome,
droppedKeys: { droppedTagKeys, droppedFactorKeys },
persistedContext: storedContext,
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
@@ -282,6 +288,7 @@ export const PUT = apiHandler(
persistedRatedFactors,
persistedContext,
contextOutcome,
droppedKeys,
} = transactionOutcome.result;
await auditLog("moodEntry.update", {
@@ -315,7 +322,12 @@ export const PUT = apiHandler(
annotate({
action: { name: "mood-entries.update" },
meta: { moodEntryId: id, mood_context: contextOutcome },
meta: {
moodEntryId: id,
mood_context: contextOutcome,
dropped_tag_keys: droppedKeys.droppedTagKeys.length,
dropped_factor_keys: droppedKeys.droppedFactorKeys.length,
},
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
@@ -360,6 +372,10 @@ export const PUT = apiHandler(
ratedFactors: persistedRatedFactors,
// v1.38 — the stored context after the edit, or null when there is none.
context: persistedContext ? contextForWire(persistedContext) : null,
// Submitted keys that are not on the entry after the edit, each list
// present only when non-empty (an archived link the edit preserved is
// on the entry and is not listed).
...droppedLinkKeysForWire(droppedKeys),
});
},
);
@@ -66,8 +66,10 @@ vi.mock("@/lib/mood/tag-links", async () => {
"@/lib/mood/tag-links",
);
return {
createTagLinks: vi.fn().mockResolvedValue(undefined),
createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS),
RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError,
droppedLinkKeysForWire: actual.droppedLinkKeysForWire,
NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS,
};
});
@@ -137,7 +139,10 @@ beforeEach(() => {
(fn as (tx: typeof txClient) => unknown)(txClient),
);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
vi.mocked(createTagLinks).mockResolvedValue(undefined);
vi.mocked(createTagLinks).mockResolvedValue({
droppedTagKeys: [],
droppedFactorKeys: [],
});
// `reset` blanks the post-commit best-effort mock; restore the promise
// return so `recompute(...)` awaits.
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
@@ -349,6 +354,37 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", ()
expect(body.data.tagKeys).toEqual(["happy"]);
});
it("names the keys it did not store on the 201, and omits the fields when all landed", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-1",
tags: null,
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
});
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ moodTag: { key: "happy" } },
]);
vi.mocked(createTagLinks).mockResolvedValueOnce({
droppedTagKeys: ["custom:archived"],
droppedFactorKeys: [],
});
const res = await POST(
postReq({ ...VALID_BODY, tagKeys: ["happy", "custom:archived"] }),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.droppedTagKeys).toEqual(["custom:archived"]);
expect(body.data).not.toHaveProperty("droppedFactorKeys");
const clean = await POST(postReq({ ...VALID_BODY, tagKeys: ["happy"] }));
const cleanBody = (await clean.json()) as { data: Record<string, unknown> };
expect(cleanBody.data).not.toHaveProperty("droppedTagKeys");
});
it("rolls the entry back when the tag-link write fails (no commit)", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-2",
@@ -55,8 +55,10 @@ vi.mock("@/lib/mood/tag-links", async () => {
"@/lib/mood/tag-links",
);
return {
createTagLinks: vi.fn().mockResolvedValue(undefined),
createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS),
RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError,
droppedLinkKeysForWire: actual.droppedLinkKeysForWire,
NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS,
};
});
@@ -140,7 +142,10 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(createTagLinks).mockResolvedValue(undefined);
vi.mocked(createTagLinks).mockResolvedValue({
droppedTagKeys: [],
droppedFactorKeys: [],
});
});
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
@@ -317,6 +322,45 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
);
});
it("lists the keys that did not land on that entry's result, and only there", async () => {
vi.mocked(createTagLinks)
.mockResolvedValueOnce({
droppedTagKeys: ["custom:archived"],
droppedFactorKeys: ["factor_retired"],
})
.mockResolvedValueOnce({ droppedTagKeys: [], droppedFactorKeys: [] });
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tagKeys: ["movies", "custom:archived"],
ratedFactors: [{ key: "factor_retired", rating: 2 }],
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T09:00:00.000Z",
tagKeys: ["movies"],
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { entries: Array<Record<string, unknown>> };
};
expect(json.data.entries[0]).toMatchObject({
status: "inserted",
droppedTagKeys: ["custom:archived"],
droppedFactorKeys: ["factor_retired"],
});
// Nothing dropped: the fields are absent, so the result reads exactly
// as it did before they existed.
expect(json.data.entries[1]).not.toHaveProperty("droppedTagKeys");
expect(json.data.entries[1]).not.toHaveProperty("droppedFactorKeys");
});
it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => {
const res = await POST(
postReq({
+29 -6
View File
@@ -58,7 +58,11 @@ import { persistMoodContext } from "@/lib/mood/context";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { createTagLinks } from "@/lib/mood/tag-links";
import {
createTagLinks,
droppedLinkKeysForWire,
NO_DROPPED_LINK_KEYS,
} from "@/lib/mood/tag-links";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -73,8 +77,9 @@ const bulkEntrySchema = z.object({
* Without this the bulk path Zod-stripped the field, so iOS-sent
* taxonomy links were silently dropped on the adopt-on-pair backfill.
* The server resolves each key to a `MoodTag` row and writes the
* `MoodEntryTagLink` join; unknown keys are dropped silently (the
* catalog is the source of truth). Bounds match the single-entry
* `MoodEntryTagLink` join; an unknown or archived key is not stored (the
* catalog is the source of truth) and is listed on that entry's result
* as `droppedTagKeys`. Bounds match the single-entry
* `structuredTagKeys` schema so one entry can't fan out an unbounded
* link set.
*/
@@ -178,6 +183,11 @@ interface EntryResult {
// result so iOS can map a server row id onto its local SwiftData row
// without re-deriving it. Omitted when the entry sent no externalId.
externalId?: string;
// Submitted keys the catalog did not resolve (unknown, archived, another
// account's custom tag, or the wrong kind). Present only when non-empty,
// on an "inserted" or "duplicate" result: the entry landed, these did not.
droppedTagKeys?: string[];
droppedFactorKeys?: string[];
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
@@ -251,6 +261,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
const results: EntryResult[] = [];
let inserted = 0;
let duplicates = 0;
let droppedTagKeyCount = 0;
let droppedFactorKeyCount = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
@@ -437,7 +449,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// the row committed) left a persisted mood entry with no links while the
// per-entry catch reported the entry as "skipped" — the client believes
// nothing saved and a half-written row is on disk.
const result = await prisma.$transaction(async (tx) => {
const written = await prisma.$transaction(async (tx) => {
const upserted = await tx.moodEntry.upsert({
where: probeWhere,
create: {
@@ -489,11 +501,14 @@ async function postBulk(request: NextRequest): Promise<Response> {
// an entry reported as skipped really did write nothing.
await persistMoodContext(tx, upserted.id, user.id, parsedContext.data);
// The keys the catalog did not resolve are reported on this entry's
// result: "inserted" alone told a client its tags had landed.
let droppedKeys = NO_DROPPED_LINK_KEYS;
if (
(entry.tagKeys && entry.tagKeys.length > 0) ||
(entry.ratedFactors && entry.ratedFactors.length > 0)
) {
await createTagLinks(
droppedKeys = await createTagLinks(
upserted.id,
user.id,
entry.tagKeys ?? [],
@@ -502,8 +517,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
);
}
return upserted;
return { upserted, droppedKeys };
});
const result = written.upserted;
const droppedKeys = written.droppedKeys;
droppedTagKeyCount += droppedKeys.droppedTagKeys.length;
droppedFactorKeyCount += droppedKeys.droppedFactorKeys.length;
if (existing) {
duplicates += 1;
@@ -512,6 +531,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
status: "duplicate",
id: result.id,
...(entry.externalId ? { externalId: entry.externalId } : {}),
...droppedLinkKeysForWire(droppedKeys),
});
} else {
inserted += 1;
@@ -520,6 +540,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
status: "inserted",
id: result.id,
...(entry.externalId ? { externalId: entry.externalId } : {}),
...droppedLinkKeysForWire(droppedKeys),
});
}
} catch (err: unknown) {
@@ -586,6 +607,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
inserted,
duplicates,
skipped: skipped.length,
dropped_tag_keys: droppedTagKeyCount,
dropped_factor_keys: droppedFactorKeyCount,
...(unstableShapes.length > 0
? unstableExternalIdMeta("mood.bulk", unstableShapes)
: {}),
+21 -5
View File
@@ -33,6 +33,8 @@ import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import {
createTagLinks,
droppedLinkKeysForWire,
NO_DROPPED_LINK_KEYS,
RatedFactorOutOfRangeError,
} from "@/lib/mood/tag-links";
@@ -219,6 +221,7 @@ async function postMoodEntry(request: NextRequest) {
persistedRatedFactors,
persistedContext,
contextOutcome,
droppedKeys,
} = await prisma.$transaction(async (tx) => {
// v1.12.1 — when the client supplies a source-stable `externalId`,
// upsert on the NULL-distinct `(userId, source, externalId)` key so
@@ -287,14 +290,16 @@ async function postMoodEntry(request: NextRequest) {
},
});
let droppedKeys = NO_DROPPED_LINK_KEYS;
if (
(tagKeys && tagKeys.length > 0) ||
(ratedFactors && ratedFactors.length > 0)
) {
// Unknown / non-RATED keys are dropped inside the helper (the
// catalog is the source of truth). An out-of-scale rating
// throws `RatedFactorOutOfRangeError`, rolling the tx back.
await createTagLinks(
// Unknown, archived or non-RATED keys are not stored (the catalog
// is the source of truth); the helper returns them so the response
// can say so. An out-of-scale rating throws
// `RatedFactorOutOfRangeError`, rolling the tx back.
droppedKeys = await createTagLinks(
created.id,
user.id,
tagKeys ?? [],
@@ -333,6 +338,7 @@ async function postMoodEntry(request: NextRequest) {
return {
entry: created,
contextOutcome,
droppedKeys,
persistedContext: storedContext,
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
@@ -360,7 +366,13 @@ async function postMoodEntry(request: NextRequest) {
// boolean, because "the request said nothing" and "the request cleared
// it" are different acts and a dashboard that folded them together
// could not tell an abandoned section from a deliberate one.
meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome },
meta: {
moodEntryId: entry.id,
mood,
mood_context: contextOutcome,
dropped_tag_keys: droppedKeys.droppedTagKeys.length,
dropped_factor_keys: droppedKeys.droppedFactorKeys.length,
},
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
@@ -398,6 +410,10 @@ async function postMoodEntry(request: NextRequest) {
// when the entry carries none, which is a different answer from an
// object full of nulls and reads as one.
context: persistedContext ? contextForWire(persistedContext) : null,
// The submitted keys that were not stored (unknown, archived, or the
// wrong kind), each list present only when non-empty. Without them
// a 201 read as "everything landed" when part of it had not.
...droppedLinkKeysForWire(droppedKeys),
},
201,
);