mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
e93f74381e
commit
930cb227eb
6 files changed
+250
-32
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user