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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 19:06:21 +02:00
1 parent e93f74381e
commit 930cb227eb
6 files changed
+250 -32

No files matched your search

+10
View File
@@ -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
@@ -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;
+6
View File
@@ -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")
}
@@ -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
+119 -31
View File
@@ -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<typeof shapeRow>[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<Response> {
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<Response> {
}),
);
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,
+19
View File
@@ -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];