diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 60d26ffdf..f4ebcbe0a 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -631,40 +631,37 @@ export const GET = apiHandler(async (request: Request) => { } } - // Condition links for the page in ONE grouped query (no N+1). - const [linkMap, visitLinkMap] = await Promise.all([ - loadConditionLinks( - user.id, - page.map((d) => d.id), - ), - loadDocumentEncounterLinks( - user.id, - page.map((d) => d.id), - ), + // Condition/visit links + the content-index and thumbnail probes for the + // page, all in ONE Promise.all — four independent grouped queries (no + // N+1, never a ciphertext/blob column), and the vault polls this list + // every few seconds so two serialized round-trips were pure added + // latency (A5-4). + const pageIds = page.map((d) => d.id); + const [linkMap, visitLinkMap, indexedRows, thumbRows] = await Promise.all([ + loadConditionLinks(user.id, pageIds), + loadDocumentEncounterLinks(user.id, pageIds), + // Which of the page's documents have a content index (drives the + // searchable status + the provenance the UI reads to tell an AI-read + // document from a locally-indexed one). + page.length > 0 + ? prisma.documentContentIndex.findMany({ + where: { userId: user.id, documentId: { in: pageIds } }, + select: { documentId: true, source: true }, + }) + : Promise.resolve([]), + // Which of the page's documents have a preview thumbnail (gates the + // card's ). Grouped on the 1:1 side table. + page.length > 0 + ? prisma.documentThumbnail.findMany({ + where: { userId: user.id, documentId: { in: pageIds } }, + select: { documentId: true }, + }) + : Promise.resolve([]), ]); - - // Which of the page's documents have a content index (drives the searchable - // status + the provenance the UI reads to tell an AI-read document from a - // locally-indexed one). One grouped query; never the ciphertext. const indexSources = new Map(); - if (page.length > 0) { - const indexed = await prisma.documentContentIndex.findMany({ - where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, - select: { documentId: true, source: true }, - }); - for (const row of indexed) indexSources.set(row.documentId, row.source); - } - - // Which of the page's documents have a preview thumbnail (gates the card's - // ). One grouped query on the 1:1 side table; never the blob column. + for (const row of indexedRows) indexSources.set(row.documentId, row.source); const thumbnailIds = new Set(); - if (page.length > 0) { - const thumbs = await prisma.documentThumbnail.findMany({ - where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, - select: { documentId: true }, - }); - for (const row of thumbs) thumbnailIds.add(row.documentId); - } + for (const row of thumbRows) thumbnailIds.add(row.documentId); annotate({ action: { name: "documents.inbound.list" }, diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 9062e59dc..e897edbdc 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -19,7 +19,12 @@ import { NextRequest } from "next/server"; // assertion below is vacuous. It SHARES the model mocks so every existing // per-model assertion in this file keeps working unchanged. const { moodEntryMock, txClient } = vi.hoisted(() => { - const moodEntryMock = { findMany: vi.fn(), upsert: vi.fn() }; + const moodEntryMock = { + findMany: vi.fn(), + upsert: vi.fn(), + // v1.37.19 (A6-18) — the P2002-race arm resolves the winning row. + findFirst: vi.fn(), + }; return { moodEntryMock, txClient: { __brand: "transaction-client", moodEntry: moodEntryMock }, @@ -462,6 +467,49 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { expect(json.data.entries[0].status).toBe("skipped"); expect(json.data.entries[1].status).toBe("inserted"); }); + + // Watched red: with the P2002 arm removed from the loop catch (the + // pre-v1.37.19 route), this fails — a concurrent batch losing the + // `(userId, source, externalId)` race was reported as "skipped" with a + // raw Prisma message although the row IS stored (the racing twin wrote + // it). It is the plain duplicate the sequential probe would have found. + it("classifies a lost P2002 race as duplicate with the winning row id", async () => { + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + vi.mocked(moodEntryMock.upsert).mockRejectedValueOnce(p2002); + vi.mocked(moodEntryMock.findFirst).mockResolvedValueOnce({ + id: "winner-1", + } as never); + vi.mocked(moodEntryMock.findMany).mockResolvedValueOnce([] as never); + + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + source: "MOODLOG", + externalId: "hk-race-1", + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + inserted: number; + duplicates: number; + skipped: unknown[]; + entries: Array<{ status: string; id?: string; externalId?: string }>; + }; + }; + expect(json.data.entries[0].status).toBe("duplicate"); + expect(json.data.entries[0].id).toBe("winner-1"); + expect(json.data.duplicates).toBe(1); + expect(json.data.inserted).toBe(0); + expect(json.data.skipped).toEqual([]); + }); }); describe("POST /api/mood-entries/bulk — unstable external ids", () => { diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 00a50e75b..29bb815d7 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -522,6 +522,39 @@ async function postBulk(request: NextRequest): Promise { }); } } catch (err: unknown) { + // v1.37.19 (A6-18) — P2002 = a concurrent batch raced the same + // NULL-distinct `(userId, source, externalId)` key and won. That is + // the plain duplicate the sequential probe would have classified; + // reporting it as "skipped" with a raw Prisma message told the + // client its row was dropped when it is in fact stored. Resolve the + // winning row's id so the caller's cursor advances, exactly like + // the intake bulk route. + if ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" && + entry.externalId + ) { + const winner = await prisma.moodEntry.findFirst({ + where: { + userId: user.id, + // Same resolution as the upsert key: the schema defaults + // `source` to "MANUAL" at parse time. + source: entry.source, + externalId: entry.externalId, + }, + select: { id: true }, + }); + duplicates += 1; + results.push({ + index: i, + status: "duplicate", + ...(winner ? { id: winner.id } : {}), + externalId: entry.externalId, + }); + continue; + } const reason = err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; skipped.push({ index: i, reason }); diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index bf813ce74..5ba9cb414 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -16,7 +16,20 @@ * * The Prisma surface is stubbed manually to avoid a testcontainer boot. */ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; + +// v1.37.19 (A6-7) — the tick claims the slot BEFORE provider egress via +// the shared record-event ledger. The manual Prisma double has no +// notification_events surface, so the claim is mocked controllable: +// default granted, individual cases flip it to prove the guard. +const claimState = vi.hoisted(() => ({ granted: true })); +const claimMock = vi.hoisted(() => vi.fn(async () => claimState.granted)); +vi.mock("@/lib/notifications/reminder-dedup", async () => { + const actual = await vi.importActual< + typeof import("@/lib/notifications/reminder-dedup") + >("@/lib/notifications/reminder-dedup"); + return { ...actual, claimNotificationEvent: claimMock }; +}); import { evaluateMeasurementReminderDue, @@ -35,6 +48,11 @@ const OK: DispatchOutcome = { const TZ = "Europe/Berlin"; +beforeEach(() => { + claimState.granted = true; + claimMock.mockClear(); +}); + // 09:00 Berlin in June = 07:00Z. const NINE_LOCAL = new Date("2026-06-15T07:00:00Z"); @@ -228,6 +246,51 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); }); + // Watched red: with the claim-before-dispatch guard removed from the + // tick (the pre-v1.37.19 order: dispatch first, advance after), the + // denied-claim case below fails — the provider was contacted although + // another worker (or a crash-recovery replay) already owned the slot. + it("claims the slot BEFORE provider egress and skips when denied", async () => { + claimState.granted = false; + const { prisma, updates } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(claimMock).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedAlreadyClaimed).toBe(1); + expect(summary.dispatched).toBe(0); + // No advance either — the claimant owns the slot's lifecycle. + expect(updates).toHaveLength(0); + }); + + it("keys the claim on the reminder + the user-local date", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(claimMock).toHaveBeenCalledWith( + prisma, + expect.objectContaining({ + eventType: "MEASUREMENT_REMINDER", + dedupKey: expect.stringMatching(/^measurement:.+:2026-06-15$/), + }), + ); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => { const { prisma, updates } = makePrisma({ reminders: [reminder({})], diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 49a12d2dc..c53f6c365 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -34,6 +34,10 @@ import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { + claimNotificationEvent, + REMINDER_DEDUP_LOOKBACK_MS, +} from "@/lib/notifications/reminder-dedup"; import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import type { ModuleKey } from "@/lib/modules/registry"; @@ -92,6 +96,8 @@ export interface MeasurementReminderSummary { skippedOutsideWindow: number; skippedModuleDisabled: number; skippedNoChannel: number; + /** v1.37.19 (A6-7) — slot already claimed for this local day (racing worker or crash-recovery replay). */ + skippedAlreadyClaimed: number; /** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */ expiredCleaned: number; failed: number; @@ -204,6 +210,7 @@ export async function runMeasurementReminderTick( skippedOutsideWindow: 0, skippedModuleDisabled: 0, skippedNoChannel: 0, + skippedAlreadyClaimed: 0, expiredCleaned: 0, failed: 0, }; @@ -325,6 +332,28 @@ export async function runMeasurementReminderTick( // user was filed as handled and vanished from the card and the digest. // Now, when nothing delivers, the no-channel branch below leaves the // reminder overdue and visible. + // v1.37.19 (A6-7) — claim the slot BEFORE provider egress, the + // documented claim-first pattern from `reminder-dedup.ts`. The old + // order (dispatch, then advance `nextDueAt`) double-sent when the + // worker crashed between the two; the claim is the durable record + // that this reminder's due cycle went out today. The trade is the + // same one the medication tick accepts: a crash mid-dispatch burns + // the slot for this local day rather than risking a repeat, and the + // next local day starts clean. + const localDate = new Date(now).toLocaleDateString("sv-SE", { + timeZone: timezone, + }); + const claimed = await claimNotificationEvent(prisma, { + recordUserId: reminder.user.id, + eventType: "MEASUREMENT_REMINDER", + dedupKey: `measurement:${reminder.id}:${localDate}`, + since: new Date(now.getTime() - REMINDER_DEDUP_LOOKBACK_MS), + }); + if (!claimed) { + summary.skippedAlreadyClaimed += 1; + continue; + } + const { title, body } = buildMeasurementReminderPayload( reminder.user.locale, reminder.label, @@ -359,9 +388,11 @@ export async function runMeasurementReminderTick( }, }); - // No channel succeeded — leave `nextDueAt` where it is so the next - // tick (or the user's next channel) retries. The reminder simply - // stays overdue, which the surface already shows as "überfällig". + // No channel succeeded — leave `nextDueAt` where it is. The claim + // above holds for the rest of the local day (claim-first burns the + // slot rather than re-flooding a failing channel — see + // reminder-dedup.ts); the reminder stays overdue and visible on the + // surface, and the next local day retries with a fresh key. if (!outcome.dispatched) { summary.skippedNoChannel += 1; continue;