mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-tz
This commit is contained in:
commit
ada490c7e9
57 files changed
+1485
-99
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user