From 930cb227eb3d49bd1f6baa90ec737ad22f13e1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 19:06:21 +0200 Subject: [PATCH] feat(mental-health): accept client source + externalId with durable dedup The screener POST hardcoded source=WEB and ignored any client-supplied externalId, so the native client could not tag provenance and an outbox replay beyond the 24h idempotency-key window minted duplicate administrations plus duplicate trend points. Accept an optional closed source enum (WEB|IOS, default WEB) and an optional bounded externalId, and persist both. Deduplicate on (userId, externalId) when present: the route returns the existing administration on replay, and a partial unique index over (user_id, external_id) WHERE external_id IS NOT NULL (migration 0221) backstops a concurrent insert race (P2002 resolved to the winner). The derived *_SCORE measurement keeps its stable per-assessment externalId, so dedup at the assessment level prevents duplicate trend points. Item answers stay encrypted and out of AI/MCP/export. Regenerate OpenAPI. --- docs/api/openapi.yaml | 10 ++ .../migration.sql | 22 +++ prisma/schema.prisma | 6 + .../assessments/__tests__/route.test.ts | 75 ++++++++- .../api/mental-health/assessments/route.ts | 150 ++++++++++++++---- src/lib/validations/mental-health.ts | 19 +++ 6 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/0221_v125_mental_health_external_id_dedup/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1845aee25..5697416df 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8137,6 +8137,16 @@ paths: locale: type: string maxLength: 16 + source: + default: WEB + type: string + enum: + - WEB + - IOS + externalId: + type: string + minLength: 1 + maxLength: 120 required: - instrument - items diff --git a/prisma/migrations/0221_v125_mental_health_external_id_dedup/migration.sql b/prisma/migrations/0221_v125_mental_health_external_id_dedup/migration.sql new file mode 100644 index 000000000..7630417ff --- /dev/null +++ b/prisma/migrations/0221_v125_mental_health_external_id_dedup/migration.sql @@ -0,0 +1,22 @@ +-- v1.25 — iOS parity: durable dedup for mental-health screener administrations. +-- +-- The native client queues completed check-ins in an outbox and replays them on +-- reconnect. The 24h idempotency-key window covers a fast retry, but a queued +-- entry can replay long after that window closes. Without a stable client key +-- the replay would mint a duplicate administration AND a duplicate *_SCORE trend +-- point. +-- +-- The `source` + `external_id` columns already exist (migration 0213). This adds +-- the DB-level backstop: a partial UNIQUE index over `(user_id, external_id)` +-- restricted to non-NULL externalIds. It deduplicates across sources (a repeat +-- externalId collides regardless of WEB vs IOS), while NULL externalIds (the +-- web path that supplies none) stay unconstrained by the partial predicate. +-- +-- The route pre-checks `(user_id, external_id)` and returns the existing row; +-- this index is the race backstop (a concurrent insert surfaces P2002, which +-- the route catches and resolves to the winning row). Idempotent + forward-only; +-- reversible with `DROP INDEX IF EXISTS "mental_health_assessments_user_external_id_unique"`. + +CREATE UNIQUE INDEX IF NOT EXISTS "mental_health_assessments_user_external_id_unique" + ON "mental_health_assessments" ("user_id", "external_id") + WHERE "external_id" IS NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 67e260d4d..f10c6427f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1328,6 +1328,12 @@ model MentalHealthAssessment { @@unique([userId, source, externalId]) @@index([userId, instrument, takenAt]) @@index([userId, updatedAt, id]) + // `mental_health_assessments_user_external_id_unique` is a partial UNIQUE + // index over `(user_id, external_id) WHERE external_id IS NOT NULL` — see + // migration 0221. It deduplicates an outbox replay across sources (a repeat + // client externalId returns the existing administration). Prisma can't express + // a partial index in the schema language, so it lives in raw SQL and is + // intentionally omitted here. @@map("mental_health_assessments") } diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index 40bdc9bea..aba10d668 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -15,7 +15,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, - mentalHealthAssessment: { create: vi.fn() }, + mentalHealthAssessment: { create: vi.fn(), findFirst: vi.fn() }, measurement: { create: vi.fn().mockResolvedValue({}) }, }, })); @@ -92,6 +92,10 @@ beforeEach(() => { remaining: 29, resetAt: Date.now(), }); + // No prior administration by default; the dedup test overrides this. + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + null as never, + ); }); describe("POST /api/mental-health/assessments", () => { @@ -188,6 +192,75 @@ describe("POST /api/mental-health/assessments", () => { expect(measArg.data.externalId).toBe("assessment:mha_2"); }); + it("dedups a repeat externalId: returns the existing administration, writes nothing new", async () => { + // The outbox replays a queued check-in with the same client externalId. + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue({ + id: "mha_dup", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 4, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "PHQ9", + items: [1, 1, 1, 1, 0, 0, 0, 0, 0], + source: "IOS", + externalId: "outbox-7f3c", + }), + ); + + // Existing row returned (200, not 201) — no duplicate assessment or trend point. + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { assessment: { id: string } }; + }; + expect(body.data.assessment.id).toBe("mha_dup"); + expect(prisma.mentalHealthAssessment.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", externalId: "outbox-7f3c" }, + }), + ); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + expect(prisma.measurement.create).not.toHaveBeenCalled(); + }); + + it("persists client provenance + externalId on a first write", async () => { + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_3", + instrument: "GAD7", + locale: "en", + version: "standard", + totalScore: 3, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "GAD7", + items: [1, 1, 1, 0, 0, 0, 0], + source: "IOS", + externalId: "outbox-aa01", + }), + ); + expect(res.status).toBe(201); + + const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock + .calls[0][0] as { data: { source: string; externalId: string | null } }; + expect(createArg.data.source).toBe("IOS"); + expect(createArg.data.externalId).toBe("outbox-aa01"); + }); + it("rejects a wrong item count with 422 and never writes", async () => { const res = await callPost( makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9 diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index d98915794..315e547b0 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -75,6 +75,23 @@ function shapeRow(row: { }; } +// Idempotent replay: return an already-recorded administration in the same +// shape a fresh create returns. The crisis set is re-derived from the stored +// item-9 flag + presented locale — no item content is decrypted. 200 (not 201) +// signals "already exists" to the client. +function respondExisting(row: Parameters[0]): Response { + const def = INSTRUMENTS[row.instrument as InstrumentId]; + const crisis = row.item9Flagged ? crisisResourcesForLocale(row.locale) : null; + return apiSuccess( + { + assessment: shapeRow(row), + actionThreshold: def.actionThreshold, + crisis, + }, + 200, + ); +} + export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); // Opt-in module (default OFF): the screener history is unreachable until the @@ -172,11 +189,45 @@ async function postAssessment(request: NextRequest): Promise { return returnAllZodIssues(parsed.error, 422); } - const { instrument, items, functionalDifficulty, takenAt, tz, locale } = - parsed.data; + const { + instrument, + items, + functionalDifficulty, + takenAt, + tz, + locale, + source, + externalId, + } = parsed.data; const id = instrument as InstrumentId; const def = INSTRUMENTS[id]; + // Durable dedup: a repeat externalId (the native client's outbox replaying a + // queued check-in beyond the 24h idempotency-key window) must return the + // existing administration, never a duplicate row + duplicate trend point. The + // partial unique index on `(user_id, external_id)` is the DB-level backstop + // (migration 0221); this pre-check returns the existing row cleanly without + // relying on a constraint throw. Scoped to the caller — externalId is unique + // per user, not globally. + if (externalId) { + const existing = await prisma.mentalHealthAssessment.findFirst({ + where: { userId: user.id, externalId }, + select: { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + }, + }); + if (existing) return respondExisting(existing); + } + // Server-authoritative scoring (the client never computes these). const total = scoreTotal(items); const band = severityBand(id, total); @@ -193,37 +244,74 @@ async function postAssessment(request: NextRequest): Promise { }), ); - const assessment = await prisma.mentalHealthAssessment.create({ - data: { - userId: user.id, - instrument: id, - locale: presentedLocale, - version: "standard", - responsesEncrypted: blob, - totalScore: total, - severityBand: band, - item9Flagged: flagged, - crisisShownAt: flagged ? new Date() : null, - takenAt: when, - tz: tz ?? null, - source: "WEB", - }, - select: { - id: true, - instrument: true, - locale: true, - version: true, - totalScore: true, - severityBand: true, - item9Flagged: true, - crisisShownAt: true, - takenAt: true, - createdAt: true, - }, - }); + const assessmentSelect = { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + } as const; + + let assessment; + try { + assessment = await prisma.mentalHealthAssessment.create({ + data: { + userId: user.id, + instrument: id, + locale: presentedLocale, + version: "standard", + responsesEncrypted: blob, + totalScore: total, + severityBand: band, + item9Flagged: flagged, + crisisShownAt: flagged ? new Date() : null, + takenAt: when, + tz: tz ?? null, + source, + externalId: externalId ?? null, + }, + select: assessmentSelect, + }); + } catch (err: unknown) { + // Concurrent replay: two requests carrying the same externalId raced past + // the pre-check. The partial unique index (migration 0221) rejects the + // second insert with P2002 — re-fetch the winner and return it idempotently + // rather than surfacing a 500 (the cycle day-log conflict precedent). + if ( + externalId && + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "P2002" + ) { + const winner = await prisma.mentalHealthAssessment.findFirst({ + where: { userId: user.id, externalId }, + select: assessmentSelect, + }); + if (winner) { + annotate({ + action: { name: "mental-health.create.dedup" }, + meta: { instrument: id }, + }); + return respondExisting(winner); + } + } + throw err; + } // Read-optimised projection: the total rides a Measurement row so the existing - // chart/rollup infra reads the trend without ever touching item content. + // chart/rollup infra reads the trend without ever touching item content. The + // externalId anchors the trend point to this administration; duplicate trend + // points are structurally impossible because a replayed externalId returns + // the existing assessment above before reaching this create. `MeasurementSource` + // carries no WEB/IOS member (a screener is questionnaire input, not a device + // sample), so the row stays MANUAL — the WEB/IOS provenance + client + // externalId live on the assessment this row links to. await prisma.measurement.create({ data: { userId: user.id, diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts index 3cd8bfefc..9cd08f999 100644 --- a/src/lib/validations/mental-health.ts +++ b/src/lib/validations/mental-health.ts @@ -12,6 +12,15 @@ import { export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]); +/** + * Client provenance for an administration. Closed enum so the column can never + * carry a free-form label. `WEB` is the in-app questionnaire (the DB default); + * `IOS` is the native client. Distinct from `MeasurementSource` on purpose — a + * screener is questionnaire input, not a device sample, so it keeps its own + * narrow vocabulary. + */ +export const assessmentSourceEnum = z.enum(["WEB", "IOS"]); + const itemAnswer = z.number().int().min(0).max(3); export const createAssessmentSchema = z @@ -26,6 +35,16 @@ export const createAssessmentSchema = z tz: z.string().max(64).optional(), /** Locale of the validated wording actually presented. */ locale: z.string().max(16).optional(), + /** Client provenance; defaults to WEB. */ + source: assessmentSourceEnum.optional().default("WEB"), + /** + * Optional client-supplied idempotency anchor. When present, a repeat with + * the same value returns the existing administration instead of minting a + * duplicate — durable replay protection beyond the 24h idempotency-key + * window (the native client's outbox). Bounded to the 120-char measurement + * externalId cap. + */ + externalId: z.string().min(1).max(120).optional(), }) .superRefine((val, ctx) => { const def = INSTRUMENTS[val.instrument as InstrumentId];