mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(insights): scope generation guard to prompt fields, clear cache on restore
This commit is contained in:
1 parent
666a85d311
commit
3aff2c16db
104 files changed
+8082
-651
No files matched your search
@@ -33,6 +33,7 @@ vi.mock("@/lib/db", () => ({
|
||||
dataBackup: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
toJson: <T>(value: T) => value,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") }));
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -48,6 +49,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: mocks.transaction,
|
||||
},
|
||||
toJson: <T>(value: T) => value,
|
||||
}));
|
||||
|
||||
// Only `decrypt` is stubbed — it stands in for reading the stored backup blob.
|
||||
@@ -167,7 +169,34 @@ function sourceClient() {
|
||||
illnessEpisode: empty,
|
||||
allergy: empty,
|
||||
familyHistoryEntry: empty,
|
||||
workout: empty,
|
||||
workout: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "workout-fitbit-1",
|
||||
userId: OWNER,
|
||||
sportType: "other",
|
||||
startedAt: new Date("2026-07-18T08:00:00.000Z"),
|
||||
endedAt: new Date("2026-07-18T08:30:00.000Z"),
|
||||
durationSec: 1800,
|
||||
totalEnergyKcal: 180,
|
||||
totalDistanceM: null,
|
||||
avgHeartRate: 125,
|
||||
maxHeartRate: null,
|
||||
minHeartRate: null,
|
||||
stepCount: null,
|
||||
elevationM: null,
|
||||
pauseDurationSec: null,
|
||||
source: "FITBIT",
|
||||
externalId: "91002",
|
||||
metadata: {
|
||||
fitbitActivityName: "Activity Zeta",
|
||||
fitbitActivityTypeId: 9002,
|
||||
},
|
||||
createdAt: new Date("2026-07-18T08:30:00.000Z"),
|
||||
updatedAt: new Date("2026-07-18T08:30:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
inboundDocument: empty,
|
||||
// The two domains this stream adds.
|
||||
userHealthProfile: {
|
||||
@@ -179,10 +208,37 @@ function sourceClient() {
|
||||
allergiesEncrypted: encryptToBytes("penicillin"),
|
||||
coachFocusEncrypted: null,
|
||||
pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'),
|
||||
aiIncludedSections: ["CONDITIONS", "SMOKING_STATUS"],
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
healthProfileFactRevision: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "fact-old",
|
||||
userId: OWNER,
|
||||
kind: "SMOKING_STATUS",
|
||||
valueEncrypted: encryptToBytes("CURRENT"),
|
||||
validFrom: new Date("2026-07-01T00:00:00.000Z"),
|
||||
validUntil: new Date("2026-07-10T00:00:00.000Z"),
|
||||
provenance: "USER_REPORTED",
|
||||
supersededByRevisionId: "fact-current",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "fact-current",
|
||||
userId: OWNER,
|
||||
kind: "SMOKING_STATUS",
|
||||
valueEncrypted: encryptToBytes("FORMER"),
|
||||
validFrom: new Date("2026-07-10T00:00:00.000Z"),
|
||||
validUntil: null,
|
||||
provenance: "USER_CORRECTION",
|
||||
supersededByRevisionId: null,
|
||||
createdAt: new Date("2026-07-10T00:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
// The hourly shape of a drained day. The per-sample rows it was folded
|
||||
// from are already gone, so nothing but this row can rebuild it.
|
||||
intradayCumulativeProfile: {
|
||||
@@ -266,7 +322,17 @@ interface Written {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function recordingTx(written: Written[]) {
|
||||
/**
|
||||
* `seed` lets a test answer `findUnique`/`findMany` reads with the live
|
||||
* pre-restore state (the row set the restore's cache-invalidation check
|
||||
* reads before it deletes and rebuilds), keyed by model name. Every other
|
||||
* test leaves it empty, which reproduces the old "nothing lives here yet"
|
||||
* behaviour exactly.
|
||||
*/
|
||||
function recordingTx(
|
||||
written: Written[],
|
||||
seed: Record<string, { findUnique?: unknown; findMany?: unknown[] }> = {},
|
||||
) {
|
||||
// Symptom definitions the restore has written back, so link resolution can
|
||||
// only succeed if the definitions actually rode the file.
|
||||
const restoredSymptoms = new Map<string, string>();
|
||||
@@ -282,14 +348,21 @@ function recordingTx(written: Written[]) {
|
||||
if (model === "cycleSymptom") {
|
||||
return [...restoredSymptoms].map(([key, id]) => ({ id, key }));
|
||||
}
|
||||
return [];
|
||||
return seed[model]?.findMany ?? [];
|
||||
}
|
||||
if (op === "findFirst" || op === "findUnique") {
|
||||
return seed[model]?.findUnique ?? null;
|
||||
}
|
||||
if (op === "findFirst" || op === "findUnique") return null;
|
||||
const data = (args.data ?? args.create ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (op === "create" || op === "upsert" || op === "createMany") {
|
||||
if (
|
||||
op === "create" ||
|
||||
op === "upsert" ||
|
||||
op === "createMany" ||
|
||||
op === "update"
|
||||
) {
|
||||
if (model === "cycleSymptom" && typeof data.key === "string") {
|
||||
restoredSymptoms.set(data.key, (data.id as string) ?? "new-id");
|
||||
}
|
||||
@@ -314,27 +387,45 @@ function request(): NextRequest {
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a real backup for the account above, then restore it. */
|
||||
async function roundTrip(): Promise<{ res: Response; written: Written[] }> {
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
sourceClient() as never,
|
||||
OWNER,
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
/**
|
||||
* Build a real backup for the account above, then restore it.
|
||||
*
|
||||
* `payloadOverride` lets a test build the payload once, derive a "live"
|
||||
* seed from its exact bytes, and restore that SAME payload — rebuilding it
|
||||
* would re-encrypt every ciphertext field with a fresh random IV, making an
|
||||
* unchanged-scope restore look changed for reasons that have nothing to do
|
||||
* with scope.
|
||||
*/
|
||||
async function roundTrip(
|
||||
seed: Record<string, { findUnique?: unknown; findMany?: unknown[] }> = {},
|
||||
payloadOverride?: Record<string, unknown>,
|
||||
): Promise<{
|
||||
res: Response;
|
||||
written: Written[];
|
||||
payload: Record<string, unknown>;
|
||||
}> {
|
||||
const payload =
|
||||
payloadOverride ??
|
||||
(
|
||||
await buildFullBackupPayload(sourceClient() as never, OWNER, {
|
||||
purpose: "disaster-recovery",
|
||||
})
|
||||
).payload;
|
||||
|
||||
// The file as it lands on disk: JSON, nothing else.
|
||||
mocks.decrypt.mockReturnValue(JSON.stringify(payload));
|
||||
|
||||
const written: Written[] = [];
|
||||
mocks.transaction.mockImplementation(
|
||||
async (fn: (tx: unknown) => Promise<unknown>) => fn(recordingTx(written)),
|
||||
async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn(recordingTx(written, seed)),
|
||||
);
|
||||
|
||||
const res = await (
|
||||
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
|
||||
)(request(), { params: Promise.resolve({ id: "b-1" }) });
|
||||
|
||||
return { res, written };
|
||||
return { res, written, payload };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -372,6 +463,29 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
expect(profile!.data.aboutMeEncrypted).toBeInstanceOf(Uint8Array);
|
||||
expect(profile!.data.conditionsEncrypted).toBeNull();
|
||||
expect(profile!.data.pendingQuestionsEncrypted).toBeInstanceOf(Uint8Array);
|
||||
expect(profile!.data.aiIncludedSections).toEqual([
|
||||
"CONDITIONS",
|
||||
"SMOKING_STATUS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes every encrypted fact revision back", async () => {
|
||||
const { written } = await roundTrip();
|
||||
const facts = written.filter(
|
||||
(write) =>
|
||||
write.model === "healthProfileFactRevision" && write.op === "create",
|
||||
);
|
||||
expect(facts).toHaveLength(2);
|
||||
expect(facts.map((write) => write.data.id)).toEqual([
|
||||
"fact-old",
|
||||
"fact-current",
|
||||
]);
|
||||
expect(facts[0].data.valueEncrypted).toBeInstanceOf(Uint8Array);
|
||||
expect(facts[1].data.valueEncrypted).toBeInstanceOf(Uint8Array);
|
||||
expect(facts[0].data.validUntil).toEqual(
|
||||
new Date("2026-07-10T00:00:00.000Z"),
|
||||
);
|
||||
expect(facts[1].data.validUntil).toBeNull();
|
||||
});
|
||||
|
||||
it("writes each custom metric back with its readings", async () => {
|
||||
@@ -424,6 +538,25 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("writes unknown Fitbit workout provenance back with the workout", async () => {
|
||||
const { written } = await roundTrip();
|
||||
const write = written.find(
|
||||
(entry) => entry.model === "workout" && entry.op === "createMany",
|
||||
);
|
||||
const rows = write!.data as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
source: "FITBIT",
|
||||
sportType: "other",
|
||||
externalId: "91002",
|
||||
metadata: {
|
||||
fitbitActivityName: "Activity Zeta",
|
||||
fitbitActivityTypeId: 9002,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the day's cumulative shape back, every hour of it", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
@@ -470,4 +603,100 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(written.find((w) => w.model === "cycleDayLog")).toBeDefined();
|
||||
});
|
||||
|
||||
describe("cached briefing invalidation on AI-scope change", () => {
|
||||
// The source account's backup carries `aiIncludedSections: ["CONDITIONS",
|
||||
// "SMOKING_STATUS"]` and the two SMOKING_STATUS fact revisions from
|
||||
// `sourceClient()`. Reused below to build a byte-identical "live" seed so
|
||||
// the unchanged-scope case is a genuine no-op, not a coincidence of two
|
||||
// separately encrypted ciphertexts happening to differ.
|
||||
function liveFactRowsFromPayload(payload: Record<string, unknown>) {
|
||||
const facts = payload.healthProfileFacts as Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
valueEncrypted: string;
|
||||
validFrom: string;
|
||||
validUntil: string | null;
|
||||
provenance: string;
|
||||
supersededByRevisionId: string | null;
|
||||
}>;
|
||||
return facts.map((fact) => ({
|
||||
id: fact.id,
|
||||
userId: OWNER,
|
||||
kind: fact.kind,
|
||||
valueEncrypted: Buffer.from(fact.valueEncrypted, "base64"),
|
||||
validFrom: new Date(fact.validFrom),
|
||||
validUntil: fact.validUntil ? new Date(fact.validUntil) : null,
|
||||
provenance: fact.provenance,
|
||||
supersededByRevisionId: fact.supersededByRevisionId,
|
||||
}));
|
||||
}
|
||||
|
||||
it("clears the cached briefing when the restored scope narrows", async () => {
|
||||
// Live account currently has every section included — strictly wider
|
||||
// than the backup's ["CONDITIONS", "SMOKING_STATUS"], so the restore
|
||||
// narrows scope and must invalidate the pre-restore cache.
|
||||
const { written } = await roundTrip({
|
||||
userHealthProfile: {
|
||||
findUnique: {
|
||||
aiIncludedSections: [
|
||||
"ABOUT_ME",
|
||||
"CONDITIONS",
|
||||
"ALLERGIES",
|
||||
"COACH_FOCUS",
|
||||
"FAMILY_HISTORY",
|
||||
"SMOKING_STATUS",
|
||||
"ALCOHOL_PATTERN",
|
||||
"SHIFT_SCHEDULE",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userUpdate = written.find(
|
||||
(w) => w.model === "user" && w.op === "update",
|
||||
);
|
||||
expect(
|
||||
userUpdate,
|
||||
"a scope-narrowing restore must clear the cached briefing",
|
||||
).toBeDefined();
|
||||
expect(userUpdate!.data).toEqual({
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the cached briefing alone when the restored scope is unchanged", async () => {
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
sourceClient() as never,
|
||||
OWNER,
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
const liveSeed = {
|
||||
userHealthProfile: {
|
||||
findUnique: {
|
||||
aiIncludedSections: (
|
||||
payload.healthProfile as { aiIncludedSections: string[] }
|
||||
).aiIncludedSections,
|
||||
},
|
||||
},
|
||||
healthProfileFactRevision: {
|
||||
findMany: liveFactRowsFromPayload(payload),
|
||||
},
|
||||
};
|
||||
|
||||
// Restore that SAME payload — rebuilding it would re-encrypt every
|
||||
// ciphertext field with a fresh IV, so the only variable this way is
|
||||
// whether the restore over-invalidates on a genuine no-op.
|
||||
const { written: writtenUnchanged } = await roundTrip(liveSeed, payload);
|
||||
|
||||
const userUpdate = writtenUnchanged.find(
|
||||
(w) => w.model === "user" && w.op === "update",
|
||||
);
|
||||
expect(
|
||||
userUpdate,
|
||||
"an unchanged AI scope must not clear the cached briefing",
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -67,6 +67,7 @@ interface RestoreResponse {
|
||||
workouts: number;
|
||||
documents: number;
|
||||
healthProfile: number;
|
||||
healthProfileFactRevisions: number;
|
||||
customMetrics: number;
|
||||
correlationPatterns: number;
|
||||
intradayProfiles: number;
|
||||
@@ -1041,6 +1042,9 @@ const handler = apiHandler(
|
||||
pauseDurationSec: workout.pauseDurationSec ?? null,
|
||||
source: workout.source as never,
|
||||
externalId: workout.externalId ?? null,
|
||||
...(workout.metadata == null
|
||||
? {}
|
||||
: { metadata: toJson(workout.metadata) }),
|
||||
...(workout.createdAt
|
||||
? { createdAt: new Date(workout.createdAt) }
|
||||
: {}),
|
||||
@@ -1121,6 +1125,8 @@ const handler = apiHandler(
|
||||
workouts: workouts.count,
|
||||
documents: documents.count,
|
||||
healthProfile: profileCleared.healthProfile,
|
||||
healthProfileFactRevisions:
|
||||
profileCleared.healthProfileFactRevisions,
|
||||
customMetrics: profileCleared.customMetrics,
|
||||
correlationPatterns: profileCleared.correlationPatterns,
|
||||
intradayProfiles: intradayCleared.intradayProfiles,
|
||||
@@ -1249,6 +1255,7 @@ const handler = apiHandler(
|
||||
workouts: summary.workouts,
|
||||
documents: summary.documents,
|
||||
healthProfile: summary.healthProfile,
|
||||
healthProfileFactRevisions: summary.healthProfileFactRevisions,
|
||||
customMetrics: summary.customMetrics,
|
||||
customMetricEntries: summary.customMetricEntries,
|
||||
correlationPatterns: summary.correlationPatterns,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
correctHealthProfileFact,
|
||||
removeHealthProfileFact,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import {
|
||||
healthProfileFactCorrectionSchema,
|
||||
validateCorrectedFactValue,
|
||||
type HealthProfileFactKind,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
const { data: rawBody, error } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = healthProfileFactCorrectionSchema.safeParse(rawBody);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const current = await prisma.healthProfileFactRevision.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: { kind: true },
|
||||
});
|
||||
if (!current) return apiError("Current profile fact not found", 404);
|
||||
|
||||
const value = validateCorrectedFactValue(
|
||||
current.kind as HealthProfileFactKind,
|
||||
parsed.data.value,
|
||||
);
|
||||
if (!value) {
|
||||
return apiError("Value is not valid for this fact kind", 422, {
|
||||
errorCode: "anamnesis.fact.invalidValue",
|
||||
});
|
||||
}
|
||||
|
||||
const corrected = await correctHealthProfileFact(user.id, id, value);
|
||||
if (!corrected) {
|
||||
return apiError("Profile fact changed since it was loaded", 409, {
|
||||
errorCode: "anamnesis.fact.conflict",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("anamnesis.fact.correct", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
priorRevisionId: id,
|
||||
revisionId: corrected.id,
|
||||
kind: corrected.kind,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "anamnesis.fact.correct",
|
||||
entity_type: "health_profile_fact_revision",
|
||||
entity_id: corrected.id,
|
||||
},
|
||||
meta: { kind: corrected.kind },
|
||||
});
|
||||
return apiSuccess(corrected);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const current = await prisma.healthProfileFactRevision.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: { kind: true },
|
||||
});
|
||||
if (!current) return apiError("Current profile fact not found", 404);
|
||||
|
||||
const removed = await removeHealthProfileFact(user.id, id);
|
||||
if (!removed) {
|
||||
return apiError("Profile fact changed since it was loaded", 409, {
|
||||
errorCode: "anamnesis.fact.conflict",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("anamnesis.fact.remove", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
revisionId: removed.id,
|
||||
kind: removed.kind,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "anamnesis.fact.remove",
|
||||
entity_type: "health_profile_fact_revision",
|
||||
entity_id: removed.id,
|
||||
},
|
||||
meta: { kind: removed.kind },
|
||||
});
|
||||
return apiSuccess(removed);
|
||||
},
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -0,0 +1,222 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
read: vi.fn(),
|
||||
create: vi.fn(),
|
||||
correct: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
currentFindFirst: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency: (handler: unknown) => handler,
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.audit }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
healthProfileFactRevision: { findFirst: mocks.currentFindFirst },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/profile/health-facts", () => ({
|
||||
readHealthProfileFacts: mocks.read,
|
||||
createHealthProfileFact: mocks.create,
|
||||
correctHealthProfileFact: mocks.correct,
|
||||
removeHealthProfileFact: mocks.remove,
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { DELETE, PATCH } from "../[id]/route";
|
||||
|
||||
function request(method: string, body?: unknown): Request {
|
||||
return new Request("http://localhost/api/anamnesis/facts", {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const dto = {
|
||||
id: "fact-1",
|
||||
kind: "SMOKING_STATUS",
|
||||
value: "FORMER",
|
||||
unreadable: false,
|
||||
validFrom: "2026-07-28T10:00:00.000Z",
|
||||
validUntil: null,
|
||||
provenance: "USER_REPORTED",
|
||||
supersededByRevisionId: null,
|
||||
createdAt: "2026-07-28T10:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.read.mockResolvedValue({
|
||||
current: {
|
||||
SMOKING_STATUS: dto,
|
||||
ALCOHOL_PATTERN: null,
|
||||
SHIFT_SCHEDULE: null,
|
||||
},
|
||||
history: [dto],
|
||||
});
|
||||
mocks.create.mockResolvedValue(dto);
|
||||
mocks.currentFindFirst.mockResolvedValue({ kind: "SMOKING_STATUS" });
|
||||
mocks.correct.mockResolvedValue({
|
||||
...dto,
|
||||
id: "fact-2",
|
||||
value: "NEVER",
|
||||
provenance: "USER_CORRECTION",
|
||||
});
|
||||
mocks.remove.mockResolvedValue({
|
||||
id: "fact-1",
|
||||
kind: "SMOKING_STATUS",
|
||||
removedAt: "2026-07-28T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
describe("anamnesis facts API", () => {
|
||||
it("returns current facts together with immutable history", async () => {
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.data.current.SMOKING_STATUS.value).toBe("FORMER");
|
||||
expect(body.data.history).toHaveLength(1);
|
||||
expect(mocks.read).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("accepts only a value from the selected closed fact kind", async () => {
|
||||
const response = await (
|
||||
POST as unknown as (request: Request) => Promise<Response>
|
||||
)(request("POST", { kind: "SMOKING_STATUS", value: "FORMER" }));
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mocks.create).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"SMOKING_STATUS",
|
||||
"FORMER",
|
||||
);
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects a value belonging to another fact kind", async () => {
|
||||
const response = await (
|
||||
POST as unknown as (request: Request) => Promise<Response>
|
||||
)(request("POST", { kind: "SMOKING_STATUS", value: "ROTATING" }));
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("corrects only the caller's current revision", async () => {
|
||||
const response = await (
|
||||
PATCH as unknown as (
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(request("PATCH", { value: "NEVER" }), {
|
||||
params: Promise.resolve({ id: "fact-1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.currentFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ id: "fact-1", userId: "user-1" }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.correct).toHaveBeenCalledWith("user-1", "fact-1", "NEVER");
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not expose another account's revision as existing", async () => {
|
||||
mocks.currentFindFirst.mockResolvedValue(null);
|
||||
const response = await (
|
||||
PATCH as unknown as (
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(request("PATCH", { value: "NEVER" }), {
|
||||
params: Promise.resolve({ id: "other-user-fact" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mocks.correct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes only the caller's current revision and audits metadata without its value", async () => {
|
||||
const response = await (
|
||||
DELETE as unknown as (
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(request("DELETE"), {
|
||||
params: Promise.resolve({ id: "fact-1" }),
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.data).toEqual({
|
||||
id: "fact-1",
|
||||
kind: "SMOKING_STATUS",
|
||||
removedAt: "2026-07-28T12:00:00.000Z",
|
||||
});
|
||||
expect(body.data).not.toHaveProperty("value");
|
||||
expect(mocks.currentFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: "fact-1",
|
||||
userId: "user-1",
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mocks.remove).toHaveBeenCalledWith("user-1", "fact-1");
|
||||
expect(mocks.audit).toHaveBeenCalledWith("anamnesis.fact.remove", {
|
||||
userId: "user-1",
|
||||
ipAddress: null,
|
||||
details: {
|
||||
revisionId: "fact-1",
|
||||
kind: "SMOKING_STATUS",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(mocks.audit.mock.calls[0])).not.toContain("FORMER");
|
||||
});
|
||||
|
||||
it("does not expose a missing, closed, or other-owner removal target", async () => {
|
||||
mocks.currentFindFirst.mockResolvedValue(null);
|
||||
const response = await (
|
||||
DELETE as unknown as (
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(request("DELETE"), {
|
||||
params: Promise.resolve({ id: "other-user-fact" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mocks.remove).not.toHaveBeenCalled();
|
||||
expect(mocks.audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a conflict when the revision changes after the owner-scoped read", async () => {
|
||||
mocks.remove.mockResolvedValue(null);
|
||||
const response = await (
|
||||
DELETE as unknown as (
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(request("DELETE"), {
|
||||
params: Promise.resolve({ id: "fact-1" }),
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(body.meta.errorCode).toBe("anamnesis.fact.conflict");
|
||||
expect(mocks.audit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import {
|
||||
createHealthProfileFact,
|
||||
readHealthProfileFacts,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const payload = await readHealthProfileFacts(user.id);
|
||||
annotate({
|
||||
action: { name: "anamnesis.facts.get" },
|
||||
meta: {
|
||||
current_count: Object.values(payload.current).filter(Boolean).length,
|
||||
revision_count: payload.history.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
async function postFact(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { data: rawBody, error } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = healthProfileFactWriteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
try {
|
||||
const fact = await createHealthProfileFact(
|
||||
user.id,
|
||||
parsed.data.kind,
|
||||
parsed.data.value,
|
||||
);
|
||||
await auditLog("anamnesis.fact.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { revisionId: fact.id, kind: fact.kind },
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "anamnesis.fact.create",
|
||||
entity_type: "health_profile_fact_revision",
|
||||
entity_id: fact.id,
|
||||
},
|
||||
meta: { kind: fact.kind },
|
||||
});
|
||||
return apiSuccess(fact, 201);
|
||||
} catch (error) {
|
||||
if (isP2002(error)) {
|
||||
return apiError("A current value already exists for this fact", 409, {
|
||||
errorCode: "anamnesis.fact.currentExists",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postFact));
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -41,15 +41,33 @@ vi.mock("@/lib/api-response", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
|
||||
const { profile } = vi.hoisted(() => ({
|
||||
profile: {
|
||||
const { profile, userUpdate, transaction } = vi.hoisted(() => {
|
||||
const profile = {
|
||||
upsert: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
};
|
||||
const userUpdate = vi.fn(async () => ({}));
|
||||
const tx = {
|
||||
userHealthProfile: profile,
|
||||
user: { update: userUpdate },
|
||||
};
|
||||
return {
|
||||
profile,
|
||||
userUpdate,
|
||||
transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: transaction,
|
||||
userHealthProfile: profile,
|
||||
user: { update: userUpdate },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { userHealthProfile: profile } }));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
@@ -65,8 +83,9 @@ vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
})),
|
||||
filterSelfContextForAi: (ctx: Record<string, string | null>) => ctx,
|
||||
getPendingQuestionsForUser: vi.fn(async () => []),
|
||||
setPendingQuestionsForUser: vi.fn(),
|
||||
setPendingQuestionsForUser: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
deriveClarifyingQuestions: vi.fn(async () => ({
|
||||
@@ -76,21 +95,35 @@ vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import {
|
||||
getPendingQuestionsForUser,
|
||||
getSelfContextForUser,
|
||||
setPendingQuestionsForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
|
||||
type Envelope = {
|
||||
data: { updatedAt?: string } | null;
|
||||
data: {
|
||||
updatedAt?: string;
|
||||
pendingQuestions?: string[];
|
||||
aiIncludedSections?: string[];
|
||||
} | null;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
const put = PUT as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
function putReq(baseUpdatedAt?: string): Request {
|
||||
function putReq(
|
||||
baseUpdatedAt?: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Request {
|
||||
return new Request("http://localhost/api/coach/about-me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
aboutMe: "I run daily",
|
||||
...overrides,
|
||||
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
|
||||
}),
|
||||
});
|
||||
@@ -123,19 +156,54 @@ describe("about-me — optimistic concurrency", () => {
|
||||
expect(res.data?.updatedAt).toBe(advanced.toISOString());
|
||||
});
|
||||
|
||||
it("a stale base token 409s and clobbers nothing (existing row advanced)", async () => {
|
||||
it("returns the post-question token so the next guarded write can use it", async () => {
|
||||
const afterProfileWrite = new Date("2026-07-24T10:05:00.000Z");
|
||||
const afterQuestions = new Date("2026-07-24T10:06:00.000Z");
|
||||
const secondProfileWrite = new Date("2026-07-24T10:07:00.000Z");
|
||||
const secondQuestions = new Date("2026-07-24T10:08:00.000Z");
|
||||
profile.upsert.mockResolvedValue({ updatedAt: afterProfileWrite });
|
||||
profile.updateMany.mockResolvedValue({ count: 1 });
|
||||
profile.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: afterProfileWrite,
|
||||
aiIncludedSections: ["ABOUT_ME"],
|
||||
})
|
||||
.mockResolvedValueOnce({ updatedAt: afterQuestions })
|
||||
.mockResolvedValueOnce({ updatedAt: secondProfileWrite })
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: secondProfileWrite,
|
||||
aiIncludedSections: ["ABOUT_ME"],
|
||||
})
|
||||
.mockResolvedValueOnce({ updatedAt: secondQuestions });
|
||||
|
||||
const first = await put(putReq());
|
||||
expect(first.data?.updatedAt).toBe(afterQuestions.toISOString());
|
||||
|
||||
const second = await put(putReq(first.data?.updatedAt));
|
||||
expect(second.status).toBe(200);
|
||||
expect(profile.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "u1", updatedAt: afterQuestions },
|
||||
}),
|
||||
);
|
||||
expect(second.data?.updatedAt).toBe(secondQuestions.toISOString());
|
||||
});
|
||||
|
||||
it("a stale inclusion save 409s before it can clobber newer About Me text", async () => {
|
||||
profile.updateMany.mockResolvedValue({ count: 0 });
|
||||
// The row exists but its token advanced → real conflict, not a delete.
|
||||
profile.findUnique.mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await put(putReq("2026-07-24T09:00:00.000Z"));
|
||||
const res = await put(
|
||||
putReq("2026-07-24T09:00:00.000Z", {
|
||||
aboutMe: "stale text from the inclusion manager",
|
||||
aiIncludedSections: ["ABOUT_ME"],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.data).toBeNull();
|
||||
expect(res.meta?.errorCode).toBe("about_me_conflict");
|
||||
|
||||
// No write happened: neither the upsert nor a create ran.
|
||||
expect(profile.upsert).not.toHaveBeenCalled();
|
||||
expect(profile.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -153,6 +221,28 @@ describe("about-me — optimistic concurrency", () => {
|
||||
expect(res.data?.updatedAt).toBe("2026-07-24T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("updates inclusion controls without rewriting encrypted profile text", async () => {
|
||||
profile.upsert.mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await put(
|
||||
putReq(undefined, {
|
||||
aboutMe: undefined,
|
||||
aiIncludedSections: ["CONDITIONS"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const write = profile.upsert.mock.calls[0]?.[0] as {
|
||||
create: Record<string, unknown>;
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
expect(write.create).not.toHaveProperty("aboutMeEncrypted");
|
||||
expect(write.update).not.toHaveProperty("aboutMeEncrypted");
|
||||
expect(write.update.aiIncludedSections).toEqual(["CONDITIONS"]);
|
||||
});
|
||||
|
||||
it("keeps the unconditional upsert when the token is omitted (iOS compat)", async () => {
|
||||
profile.upsert.mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
|
||||
@@ -165,6 +255,14 @@ describe("about-me — optimistic concurrency", () => {
|
||||
expect(res.data?.updatedAt).toBe("2026-07-24T11:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects an empty update without touching the row", async () => {
|
||||
const res = await put(putReq(undefined, { aboutMe: undefined }));
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(profile.upsert).not.toHaveBeenCalled();
|
||||
expect(profile.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a malformed base token without touching the row", async () => {
|
||||
const res = await put(putReq("not-a-date"));
|
||||
expect(res.status).toBe(422);
|
||||
@@ -172,4 +270,82 @@ describe("about-me — optimistic concurrency", () => {
|
||||
expect(profile.updateMany).not.toHaveBeenCalled();
|
||||
expect(profile.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not let a stale derivation overwrite newer scoped questions", async () => {
|
||||
const versionA = new Date("2026-07-24T10:00:00.000Z");
|
||||
const versionB = new Date("2026-07-24T10:01:00.000Z");
|
||||
const staleQuestions = ["What does your running routine look like?"];
|
||||
const newerQuestions = ["How is asthma affecting you today?"];
|
||||
let currentUpdatedAt = versionA;
|
||||
let currentSections = ["ABOUT_ME"];
|
||||
let storedQuestions = [] as string[];
|
||||
|
||||
profile.upsert.mockResolvedValue({ updatedAt: versionA });
|
||||
profile.findUnique.mockImplementation(
|
||||
async (args: { select: Record<string, boolean> }) =>
|
||||
args.select.aiIncludedSections
|
||||
? { aiIncludedSections: currentSections }
|
||||
: { updatedAt: currentUpdatedAt },
|
||||
);
|
||||
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
|
||||
aboutMe: "I run daily",
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
vi.mocked(getPendingQuestionsForUser).mockImplementation(
|
||||
async () => storedQuestions,
|
||||
);
|
||||
vi.mocked(setPendingQuestionsForUser).mockImplementation(
|
||||
async (_userId, questions, expectedUpdatedAt) => {
|
||||
if (
|
||||
expectedUpdatedAt === undefined ||
|
||||
expectedUpdatedAt.getTime() !== currentUpdatedAt.getTime()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
storedQuestions = questions ?? [];
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
let markDerivationStarted!: () => void;
|
||||
const derivationStarted = new Promise<void>((resolve) => {
|
||||
markDerivationStarted = resolve;
|
||||
});
|
||||
let finishDerivation!: (value: {
|
||||
questions: string[];
|
||||
source: "ai";
|
||||
}) => void;
|
||||
const derivationFinished = new Promise<{
|
||||
questions: string[];
|
||||
source: "ai";
|
||||
}>((resolve) => {
|
||||
finishDerivation = resolve;
|
||||
});
|
||||
vi.mocked(deriveClarifyingQuestions).mockImplementationOnce(async () => {
|
||||
markDerivationStarted();
|
||||
return derivationFinished;
|
||||
});
|
||||
|
||||
const requestA = put(
|
||||
putReq(undefined, { aiIncludedSections: ["ABOUT_ME"] }),
|
||||
);
|
||||
await derivationStarted;
|
||||
|
||||
currentUpdatedAt = versionB;
|
||||
currentSections = ["CONDITIONS"];
|
||||
storedQuestions = newerQuestions;
|
||||
finishDerivation({ questions: staleQuestions, source: "ai" });
|
||||
|
||||
const responseA = await requestA;
|
||||
|
||||
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
staleQuestions,
|
||||
versionA,
|
||||
);
|
||||
expect(storedQuestions).toEqual(newerQuestions);
|
||||
expect(getPendingQuestionsForUser).toHaveBeenCalledWith("u1");
|
||||
expect(responseA.data?.pendingQuestions).toEqual(newerQuestions);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the about-me self-context GET/PUT enforce the two-layer
|
||||
* Coach module gate (operator availability AND the per-user
|
||||
* `disableCoach` opt-out) right after auth. A disabled module returns
|
||||
* the 403 `module.disabled` envelope before any self-context read or
|
||||
* write; an enabled module proceeds to the normal 200 path.
|
||||
* The shared self-context route is available when Coach or Insights can
|
||||
* consume it. Coach-only work within that route stays behind the Coach gate.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
@@ -36,14 +33,46 @@ vi.mock("@/lib/api-response", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
const {
|
||||
profileFindUnique,
|
||||
profileUpsert,
|
||||
userUpdate,
|
||||
transaction,
|
||||
invalidateInsights,
|
||||
} = vi.hoisted(() => {
|
||||
const profileFindUnique = vi.fn();
|
||||
const profileUpsert = vi.fn(async () => ({ updatedAt: new Date(0) }));
|
||||
const userUpdate = vi.fn(async () => ({}));
|
||||
const tx = {
|
||||
userHealthProfile: {
|
||||
findUnique: profileFindUnique,
|
||||
upsert: profileUpsert,
|
||||
},
|
||||
user: { update: userUpdate },
|
||||
};
|
||||
return {
|
||||
profileFindUnique,
|
||||
profileUpsert,
|
||||
userUpdate,
|
||||
transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
invalidateInsights: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: transaction,
|
||||
userHealthProfile: {
|
||||
findUnique: vi.fn(async () => ({ updatedAt: new Date(0) })),
|
||||
upsert: vi.fn(async () => ({ updatedAt: new Date(0) })),
|
||||
findUnique: profileFindUnique,
|
||||
upsert: profileUpsert,
|
||||
},
|
||||
user: { update: userUpdate },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserInsights: invalidateInsights,
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
@@ -59,8 +88,20 @@ const emptyCtx = {
|
||||
};
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextForUser: vi.fn(async () => emptyCtx),
|
||||
filterSelfContextForAi: (
|
||||
ctx: Record<string, string | null>,
|
||||
includedSections: string[],
|
||||
) => {
|
||||
const included = new Set(includedSections);
|
||||
return {
|
||||
aboutMe: included.has("ABOUT_ME") ? ctx.aboutMe : null,
|
||||
conditions: included.has("CONDITIONS") ? ctx.conditions : null,
|
||||
allergies: included.has("ALLERGIES") ? ctx.allergies : null,
|
||||
coachFocus: included.has("COACH_FOCUS") ? ctx.coachFocus : null,
|
||||
};
|
||||
},
|
||||
getPendingQuestionsForUser: vi.fn(async () => []),
|
||||
setPendingQuestionsForUser: vi.fn(),
|
||||
setPendingQuestionsForUser: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
deriveClarifyingQuestions: vi.fn(async () => ({
|
||||
@@ -70,6 +111,13 @@ vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import {
|
||||
getPendingQuestionsForUser,
|
||||
setPendingQuestionsForUser,
|
||||
getSelfContextForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
@@ -87,47 +135,215 @@ const disabledResponse: Envelope = {
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function putReq(): Request {
|
||||
function putReq(
|
||||
body: Record<string, unknown> = {
|
||||
aboutMe: "I run daily",
|
||||
aiIncludedSections: ["ABOUT_ME"],
|
||||
},
|
||||
): Request {
|
||||
return new Request("http://localhost/api/coach/about-me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aboutMe: "I run daily" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("about-me module gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
profileFindUnique.mockResolvedValue({
|
||||
updatedAt: new Date(0),
|
||||
aiIncludedSections: ["ABOUT_ME"],
|
||||
});
|
||||
});
|
||||
|
||||
function setGates(coach: boolean, insights: boolean) {
|
||||
requireModuleEnabled.mockImplementation(
|
||||
async (_userId: string, module: string) =>
|
||||
module === "coach"
|
||||
? coach
|
||||
? { enabled: true }
|
||||
: { enabled: false, response: disabledResponse }
|
||||
: insights
|
||||
? { enabled: true }
|
||||
: { enabled: false, response: disabledResponse },
|
||||
);
|
||||
}
|
||||
|
||||
it("GET returns 403 when every self-context consumer is disabled", async () => {
|
||||
setGates(false, false);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "insights");
|
||||
});
|
||||
|
||||
it("GET returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
it("GET remains available to an Insights-only account", async () => {
|
||||
setGates(false, true);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
expect(getPendingQuestionsForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("PUT returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
it("GET does not consult Insights when Coach is enabled", async () => {
|
||||
setGates(true, false);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireModuleEnabled).not.toHaveBeenCalledWith("u1", "insights");
|
||||
expect(getPendingQuestionsForUser).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("PUT returns 403 when every self-context consumer is disabled", async () => {
|
||||
setGates(false, false);
|
||||
const res = await put(putReq());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
});
|
||||
|
||||
it("PUT returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
it("clears stale Coach questions after an Insights-only exclusion save", async () => {
|
||||
setGates(false, true);
|
||||
const res = await put(putReq({ aiIncludedSections: ["ABOUT_ME"] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(deriveClarifyingQuestions).not.toHaveBeenCalled();
|
||||
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
null,
|
||||
new Date(0),
|
||||
);
|
||||
expect(userUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "u1" },
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
},
|
||||
});
|
||||
expect(invalidateInsights).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("does not commit an exclusion when persistent cache clearing fails", async () => {
|
||||
setGates(false, true);
|
||||
const originalSections = ["ABOUT_ME", "CONDITIONS"];
|
||||
let persistedSections = originalSections;
|
||||
let stagedSections = persistedSections;
|
||||
const txProfileUpsert = vi.fn(
|
||||
async (args: { update: { aiIncludedSections?: string[] } }) => {
|
||||
stagedSections = args.update.aiIncludedSections ?? stagedSections;
|
||||
return { updatedAt: new Date(0) };
|
||||
},
|
||||
);
|
||||
transaction.mockImplementationOnce(
|
||||
async (callback: (client: unknown) => unknown) => {
|
||||
const result = await callback({
|
||||
userHealthProfile: {
|
||||
findUnique: profileFindUnique,
|
||||
upsert: txProfileUpsert,
|
||||
},
|
||||
user: {
|
||||
update: vi.fn(async () => {
|
||||
throw new Error("cache clear failed");
|
||||
}),
|
||||
},
|
||||
});
|
||||
persistedSections = stagedSections;
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
put(putReq({ aiIncludedSections: ["ABOUT_ME"] })),
|
||||
).rejects.toThrow("cache clear failed");
|
||||
|
||||
expect(persistedSections).toEqual(originalSections);
|
||||
expect(txProfileUpsert).toHaveBeenCalledOnce();
|
||||
expect(profileUpsert).not.toHaveBeenCalled();
|
||||
expect(invalidateInsights).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-scopes pending questions after an inclusion-only update", async () => {
|
||||
setGates(true, false);
|
||||
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
|
||||
aboutMe: "I run daily",
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
|
||||
const res = await put(putReq({ aiIncludedSections: ["ABOUT_ME"] }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(getPendingQuestionsForUser).not.toHaveBeenCalled();
|
||||
expect(deriveClarifyingQuestions).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
{
|
||||
aboutMe: "I run daily",
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
},
|
||||
"en",
|
||||
["ABOUT_ME"],
|
||||
);
|
||||
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
[],
|
||||
new Date(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears pending questions when inclusion removes every text field", async () => {
|
||||
setGates(true, false);
|
||||
profileFindUnique.mockResolvedValue({
|
||||
updatedAt: new Date(0),
|
||||
aiIncludedSections: [],
|
||||
});
|
||||
|
||||
const res = await put(putReq({ aiIncludedSections: [] }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(deriveClarifyingQuestions).not.toHaveBeenCalled();
|
||||
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
null,
|
||||
new Date(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("audits an excluded nonempty stored field as updated, not cleared", async () => {
|
||||
setGates(true, false);
|
||||
profileFindUnique.mockResolvedValue({
|
||||
updatedAt: new Date(0),
|
||||
aiIncludedSections: [],
|
||||
});
|
||||
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
|
||||
aboutMe: null,
|
||||
conditions: "Asthma",
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
|
||||
const res = await put(putReq({ aboutMe: "" }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
null,
|
||||
new Date(0),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"coach.about_me.updated",
|
||||
expect.objectContaining({ userId: "u1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("PUT retains Coach-only derivation when Coach is enabled", async () => {
|
||||
setGates(true, false);
|
||||
const res = await put(putReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
null,
|
||||
new Date(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,9 @@
|
||||
* the Coach composer renders them as tappable chips
|
||||
* (`/api/coach/about-me/questions`).
|
||||
*
|
||||
* PUT field semantics: `aboutMe` is required (empty string clears).
|
||||
* The structured fields are optional — omitted leaves the stored value
|
||||
* untouched, an empty string clears it — so older clients that only
|
||||
* send `aboutMe` keep working unchanged.
|
||||
* PUT field semantics: every text field is optional. An omitted field stays
|
||||
* untouched; an empty string clears it. This also lets the inclusion controls
|
||||
* change without resubmitting encrypted text that the current key cannot read.
|
||||
*
|
||||
* Plain text end to end: the client renders every value as a React
|
||||
* text child only — no markdown library exists in the tree and none
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -41,6 +41,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
getPendingQuestionsForUser,
|
||||
getSelfContextForUser,
|
||||
filterSelfContextForAi,
|
||||
setPendingQuestionsForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
@@ -50,22 +51,42 @@ import {
|
||||
ABOUT_ME_MAX_CHARS,
|
||||
aboutMePutSchema,
|
||||
} from "@/lib/validations/about-me";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
type HealthProfileAiSection,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
|
||||
const PUT_RATE_LIMIT = 30;
|
||||
const PUT_WINDOW_MS = 60_000;
|
||||
|
||||
type SelfContextConsumerGate =
|
||||
| { enabled: true; coachEnabled: boolean }
|
||||
| { enabled: false; response: Response };
|
||||
|
||||
async function requireSelfContextConsumer(
|
||||
userId: string,
|
||||
): Promise<SelfContextConsumerGate> {
|
||||
const coachGate = await requireModuleEnabled(userId, "coach");
|
||||
if (coachGate.enabled) return { enabled: true, coachEnabled: true };
|
||||
|
||||
const insightsGate = await requireModuleEnabled(userId, "insights");
|
||||
if (insightsGate.enabled) return { enabled: true, coachEnabled: false };
|
||||
|
||||
return coachGate;
|
||||
}
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.18.0 — Coach module gate (operator availability + disableCoach).
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
const gate = await requireSelfContextConsumer(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [ctx, pendingQuestions, row] = await Promise.all([
|
||||
getSelfContextForUser(user.id),
|
||||
getPendingQuestionsForUser(user.id),
|
||||
gate.coachEnabled
|
||||
? getPendingQuestionsForUser(user.id)
|
||||
: Promise.resolve<string[]>([]),
|
||||
prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
select: { updatedAt: true, aiIncludedSections: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -86,6 +107,9 @@ export const GET = apiHandler(async () => {
|
||||
allergies: ctx.allergies,
|
||||
coachFocus: ctx.coachFocus,
|
||||
pendingQuestions,
|
||||
aiIncludedSections:
|
||||
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
updatedAt: row?.updatedAt?.toISOString() ?? null,
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
@@ -94,8 +118,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.18.0 — Coach module gate (operator availability + disableCoach).
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
const gate = await requireSelfContextConsumer(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
@@ -130,8 +153,14 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const text = parsed.data.aboutMe.trim();
|
||||
const cleared = text.length === 0;
|
||||
const text = parsed.data.aboutMe?.trim();
|
||||
const textFieldsChanged =
|
||||
parsed.data.aboutMe !== undefined ||
|
||||
parsed.data.conditions !== undefined ||
|
||||
parsed.data.allergies !== undefined ||
|
||||
parsed.data.coachFocus !== undefined;
|
||||
const questionsNeedRefresh =
|
||||
textFieldsChanged || parsed.data.aiIncludedSections !== undefined;
|
||||
|
||||
// Field-by-field data assembly (no mass assignment): omitted fields
|
||||
// never appear in the update object, so they stay untouched.
|
||||
@@ -143,13 +172,16 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
return value.length === 0 ? null : encryptToBytes(value);
|
||||
};
|
||||
const update: {
|
||||
aboutMeEncrypted: Uint8Array<ArrayBuffer> | null;
|
||||
aboutMeEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
conditionsEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
} = {
|
||||
aboutMeEncrypted: cleared ? null : encryptToBytes(text),
|
||||
};
|
||||
aiIncludedSections?: HealthProfileAiSection[];
|
||||
} = {};
|
||||
const aboutMePayload = encryptOptional(parsed.data.aboutMe);
|
||||
if (aboutMePayload !== undefined) {
|
||||
update.aboutMeEncrypted = aboutMePayload;
|
||||
}
|
||||
const conditionsPayload = encryptOptional(parsed.data.conditions);
|
||||
if (conditionsPayload !== undefined) {
|
||||
update.conditionsEncrypted = conditionsPayload;
|
||||
@@ -162,8 +194,11 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
if (coachFocusPayload !== undefined) {
|
||||
update.coachFocusEncrypted = coachFocusPayload;
|
||||
}
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
update.aiIncludedSections = parsed.data.aiIncludedSections;
|
||||
}
|
||||
const fieldLengths: Record<string, number> = {
|
||||
aboutMe: text.length,
|
||||
...(text !== undefined ? { aboutMe: text.length } : {}),
|
||||
...(parsed.data.conditions !== undefined
|
||||
? { conditions: parsed.data.conditions.trim().length }
|
||||
: {}),
|
||||
@@ -175,80 +210,163 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
: {}),
|
||||
};
|
||||
|
||||
// v1.32.21 (R5a) — optimistic concurrency on `UserHealthProfile.updatedAt`.
|
||||
// - no base token → today's upsert (backward-compat arm, byte-identical);
|
||||
// - base present + row still carries it → conditional update, echo fresh;
|
||||
// - base present + zero match → the row either vanished (deleted since the
|
||||
// read → create wins, nothing to clobber) or advanced (real conflict →
|
||||
// 409, no write).
|
||||
let updatedAtIso: string;
|
||||
if (base === undefined) {
|
||||
const row = await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...update },
|
||||
update,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = row.updatedAt.toISOString();
|
||||
} else {
|
||||
const guarded = await prisma.userHealthProfile.updateMany({
|
||||
where: { userId: user.id, updatedAt: base },
|
||||
data: update,
|
||||
});
|
||||
if (guarded.count === 0) {
|
||||
const existing = await prisma.userHealthProfile.findUnique({
|
||||
// Keep the profile write and the persistent Insights cache clear in the
|
||||
// same transaction. If either write fails, an inclusion change cannot
|
||||
// commit while an hour-fresh advisor payload still references excluded
|
||||
// profile content.
|
||||
const persistence = await prisma.$transaction(async (tx) => {
|
||||
let updatedAtIso: string;
|
||||
let savedProfileUpdatedAt: Date | null = null;
|
||||
|
||||
// v1.32.21 (R5a) — optimistic concurrency on
|
||||
// `UserHealthProfile.updatedAt`.
|
||||
// - no base token → today's upsert (backward-compat arm);
|
||||
// - base present + row still carries it → conditional update;
|
||||
// - base present + zero match → create if the row vanished, otherwise
|
||||
// report a real conflict without clearing the cache.
|
||||
if (base === undefined) {
|
||||
const row = await tx.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...update },
|
||||
update,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (existing) {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.conflict" },
|
||||
meta: { base_updated_at: base.toISOString() },
|
||||
});
|
||||
return apiError("Self-context changed since it was loaded", 409, {
|
||||
errorCode: "about_me_conflict",
|
||||
});
|
||||
}
|
||||
const created = await prisma.userHealthProfile.create({
|
||||
data: { userId: user.id, ...update },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = created.updatedAt.toISOString();
|
||||
updatedAtIso = row.updatedAt.toISOString();
|
||||
savedProfileUpdatedAt = row.updatedAt;
|
||||
} else {
|
||||
const fresh = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
const guarded = await tx.userHealthProfile.updateMany({
|
||||
where: { userId: user.id, updatedAt: base },
|
||||
data: update,
|
||||
});
|
||||
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
|
||||
if (guarded.count === 0) {
|
||||
const existing = await tx.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (existing) {
|
||||
return {
|
||||
conflict: true as const,
|
||||
baseUpdatedAt: base.toISOString(),
|
||||
};
|
||||
}
|
||||
const created = await tx.userHealthProfile.create({
|
||||
data: { userId: user.id, ...update },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = created.updatedAt.toISOString();
|
||||
savedProfileUpdatedAt = created.updatedAt;
|
||||
} else {
|
||||
const fresh = await tx.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
|
||||
savedProfileUpdatedAt = fresh?.updatedAt ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
conflict: false as const,
|
||||
updatedAtIso,
|
||||
savedProfileUpdatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
if (persistence.conflict) {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.conflict" },
|
||||
meta: { base_updated_at: persistence.baseUpdatedAt },
|
||||
});
|
||||
return apiError("Self-context changed since it was loaded", 409, {
|
||||
errorCode: "about_me_conflict",
|
||||
});
|
||||
}
|
||||
|
||||
let { updatedAtIso } = persistence;
|
||||
const { savedProfileUpdatedAt } = persistence;
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
invalidateUserInsights(user.id);
|
||||
}
|
||||
|
||||
// Read back the effective state (covers omitted fields) and derive
|
||||
// the clarifying questions. An entirely empty self-context clears
|
||||
// the pending questions instead of generating new ones.
|
||||
const ctx = await getSelfContextForUser(user.id);
|
||||
const isEmpty =
|
||||
const [ctx, controlRow] = await Promise.all([
|
||||
getSelfContextForUser(user.id),
|
||||
prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aiIncludedSections: true },
|
||||
}),
|
||||
]);
|
||||
const aiIncludedSections = (controlRow?.aiIncludedSections as
|
||||
HealthProfileAiSection[] | undefined) ?? [
|
||||
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
];
|
||||
const aiContext = filterSelfContextForAi(ctx, aiIncludedSections);
|
||||
const aiContextIsEmpty =
|
||||
aiContext.aboutMe === null &&
|
||||
aiContext.conditions === null &&
|
||||
aiContext.allergies === null &&
|
||||
aiContext.coachFocus === null;
|
||||
const storedContextIsEmpty =
|
||||
ctx.aboutMe === null &&
|
||||
ctx.conditions === null &&
|
||||
ctx.allergies === null &&
|
||||
ctx.coachFocus === null;
|
||||
|
||||
let pendingQuestions: string[] = [];
|
||||
let questionsToPersist: string[] | null | undefined;
|
||||
let questionsSource: "ai" | "fallback" | "none" = "none";
|
||||
if (isEmpty) {
|
||||
await setPendingQuestionsForUser(user.id, null);
|
||||
if (!gate.coachEnabled) {
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
questionsToPersist = null;
|
||||
}
|
||||
} else if (!questionsNeedRefresh) {
|
||||
pendingQuestions = await getPendingQuestionsForUser(user.id);
|
||||
} else if (aiContextIsEmpty) {
|
||||
questionsToPersist = null;
|
||||
} else {
|
||||
const derived = await deriveClarifyingQuestions(user.id, ctx, user.locale);
|
||||
const derived = await deriveClarifyingQuestions(
|
||||
user.id,
|
||||
aiContext,
|
||||
user.locale,
|
||||
aiIncludedSections,
|
||||
);
|
||||
pendingQuestions = derived.questions;
|
||||
questionsSource = derived.source;
|
||||
await setPendingQuestionsForUser(user.id, pendingQuestions);
|
||||
questionsToPersist = pendingQuestions;
|
||||
}
|
||||
|
||||
if (questionsToPersist !== undefined) {
|
||||
const persisted =
|
||||
savedProfileUpdatedAt !== null &&
|
||||
(await setPendingQuestionsForUser(
|
||||
user.id,
|
||||
questionsToPersist,
|
||||
savedProfileUpdatedAt,
|
||||
));
|
||||
if (!persisted) {
|
||||
pendingQuestions = await getPendingQuestionsForUser(user.id);
|
||||
questionsSource = "none";
|
||||
}
|
||||
}
|
||||
const cleared = textFieldsChanged && storedContextIsEmpty;
|
||||
|
||||
// The audit row carries per-field lengths only — the text is
|
||||
// free-form health prose and must not land in the plaintext audit
|
||||
// details.
|
||||
await auditLog(
|
||||
isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
{
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
@@ -258,21 +376,30 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
name: cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
|
||||
},
|
||||
meta: {
|
||||
length: text.length,
|
||||
length: text?.length,
|
||||
questions_source: questionsSource,
|
||||
questions_count: pendingQuestions.length,
|
||||
},
|
||||
});
|
||||
|
||||
const finalProfileRow = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (finalProfileRow) {
|
||||
updatedAtIso = finalProfileRow.updatedAt.toISOString();
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
aboutMe: ctx.aboutMe,
|
||||
conditions: ctx.conditions,
|
||||
allergies: ctx.allergies,
|
||||
coachFocus: ctx.coachFocus,
|
||||
pendingQuestions,
|
||||
aiIncludedSections,
|
||||
updatedAt: updatedAtIso,
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
|
||||
@@ -21,7 +21,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// Durable self-context + user-defined metrics, read by
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
healthProfileFactRevision: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
correlationPattern: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
@@ -86,7 +88,11 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.healthProfileFactRevision.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.correlationPattern.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -19,7 +19,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// Durable self-context + user-defined metrics, read by
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
healthProfileFactRevision: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
correlationPattern: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
toJson: (value: unknown) => value,
|
||||
@@ -48,6 +50,11 @@ vi.mock("@/lib/cycle/export-data", () => ({
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn((payload: Uint8Array) =>
|
||||
Buffer.from(payload).toString("utf8"),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -125,6 +132,15 @@ async function pdfText(res: Response): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfBytesText(bytes: Uint8Array): Promise<string> {
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
return (await parser.getText()).text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue({} as never);
|
||||
@@ -146,6 +162,12 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.healthProfileFactRevision.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
// First findUnique = aggregator profile select; second = route KVNR select.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "sample",
|
||||
@@ -583,6 +605,94 @@ describe("POST /api/export/health-record — the selection reaches every format"
|
||||
expect(types).not.toContain("AllergyIntolerance");
|
||||
expect(types).not.toContain("FamilyMemberHistory");
|
||||
});
|
||||
it("ships selected anamnesis in both package artefacts and omits it otherwise", async () => {
|
||||
const encrypted = (value: string) => new TextEncoder().encode(value);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue({
|
||||
conditionsEncrypted: encrypted("Asthma"),
|
||||
} as never);
|
||||
vi.mocked(prisma.healthProfileFactRevision.findMany).mockResolvedValue([
|
||||
{
|
||||
kind: "SMOKING_STATUS",
|
||||
valueEncrypted: encrypted("FORMER"),
|
||||
},
|
||||
{
|
||||
kind: "ALCOHOL_PATTERN",
|
||||
valueEncrypted: encrypted("OCCASIONAL"),
|
||||
},
|
||||
{
|
||||
kind: "SHIFT_SCHEDULE",
|
||||
valueEncrypted: encrypted("ROTATING"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const selected = await POST(
|
||||
mkReq({
|
||||
format: "package",
|
||||
locale: "en",
|
||||
selection: sel("ANAMNESIS"),
|
||||
}),
|
||||
);
|
||||
expect(selected.status).toBe(200);
|
||||
const selectedFiles = unzipSync(
|
||||
new Uint8Array(await selected.arrayBuffer()),
|
||||
);
|
||||
const selectedPdf = await pdfBytesText(selectedFiles["report.pdf"]!);
|
||||
expect(selectedPdf).toContain("Asthma");
|
||||
expect(selectedPdf).toContain("Former smoker");
|
||||
expect(selectedPdf).toContain("Occasional");
|
||||
expect(selectedPdf).toContain("Rotating shifts");
|
||||
|
||||
const selectedBundle = JSON.parse(
|
||||
new TextDecoder().decode(selectedFiles["bundle.json"]!),
|
||||
) as {
|
||||
entry: {
|
||||
resource: {
|
||||
resourceType: string;
|
||||
code?: { coding?: { code?: string }[] };
|
||||
valueCodeableConcept?: { text?: string };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
const selectedFacts = selectedBundle.entry
|
||||
.map((entry) => entry.resource)
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.resourceType === "Observation" &&
|
||||
["72166-2", "11331-6", "74159-5"].includes(
|
||||
resource.code?.coding?.[0]?.code ?? "",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
selectedFacts.map((fact) => fact.valueCodeableConcept?.text),
|
||||
).toEqual(["Former smoker", "Occasional", "Rotating shifts"]);
|
||||
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockClear();
|
||||
vi.mocked(prisma.healthProfileFactRevision.findMany).mockClear();
|
||||
const omitted = await POST(
|
||||
mkReq({
|
||||
format: "package",
|
||||
locale: "en",
|
||||
selection: sel("PULSE"),
|
||||
}),
|
||||
);
|
||||
expect(omitted.status).toBe(200);
|
||||
const omittedFiles = unzipSync(new Uint8Array(await omitted.arrayBuffer()));
|
||||
const omittedPdf = await pdfBytesText(omittedFiles["report.pdf"]!);
|
||||
expect(omittedPdf).not.toContain("Asthma");
|
||||
const omittedBundle = JSON.parse(
|
||||
new TextDecoder().decode(omittedFiles["bundle.json"]!),
|
||||
) as typeof selectedBundle;
|
||||
expect(
|
||||
omittedBundle.entry.some((entry) =>
|
||||
["72166-2", "11331-6", "74159-5"].includes(
|
||||
entry.resource.code?.coding?.[0]?.code ?? "",
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(prisma.userHealthProfile.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.healthProfileFactRevision.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,8 @@ vi.mock("@/lib/fhir/resources", () => ({
|
||||
coverageResource: vi.fn(() => null),
|
||||
observationsFromReportData: vi.fn(() => []),
|
||||
cycleObservationsFromReportData: vi.fn(() => []),
|
||||
anamnesisObservationsFromReportData: vi.fn(() => []),
|
||||
anamnesisNarrativeFromReportData: vi.fn(() => null),
|
||||
conditionsFromReportData: vi.fn(() => ({ conditions: [], encounters: [] })),
|
||||
medicationStatementsFromReportData: vi.fn(() => []),
|
||||
medicationAdministrationsFromReportData: vi.fn(() => []),
|
||||
|
||||
@@ -118,6 +118,10 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// `insights` module, not the Coach surface. Same posture as health-status /
|
||||
// labs-changes directly above — disabling Coach must not wedge this read.
|
||||
"src/app/api/insights/pulse/intraday/route.ts",
|
||||
// Persisted correlation decisions are deterministic Insights data. They
|
||||
// gate on the Insights module and remain usable when Coach is disabled.
|
||||
"src/app/api/insights/patterns/route.ts",
|
||||
"src/app/api/insights/patterns/[id]/route.ts",
|
||||
// v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach
|
||||
// mutations. These live under /insights/chat for URL grouping but are
|
||||
// DOCUMENT-surface routes: they gate on the `inboundDocuments` module
|
||||
|
||||
@@ -130,6 +130,7 @@ beforeEach(() => {
|
||||
(
|
||||
prisma.environmentContext.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -31,6 +31,8 @@ vi.mock("@/lib/feature-flags", () => ({
|
||||
AssistantDisabledError: class extends Error {},
|
||||
}));
|
||||
|
||||
const selfContextText = vi.hoisted(() => vi.fn<() => Promise<string | null>>());
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// The briefing path now reserves against the day's token ledger before
|
||||
@@ -46,7 +48,7 @@ vi.mock("@/lib/db", () => ({
|
||||
insightsCachedText: null,
|
||||
locale: "en",
|
||||
})),
|
||||
update: vi.fn(async () => ({})),
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
auditLog: {
|
||||
// v1.4.16 A7: route now evicts stale per-status cache rows
|
||||
@@ -71,6 +73,11 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: selfContextText,
|
||||
buildAboutMeInsightBlock: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(async () => undefined),
|
||||
}));
|
||||
@@ -178,6 +185,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation((async () => [
|
||||
{ total_tokens: 0 },
|
||||
]) as never);
|
||||
selfContextText.mockResolvedValue(null);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
@@ -255,7 +263,7 @@ describe("POST /api/insights/generate — daily token ceiling", () => {
|
||||
expect(body.error).toContain("Daily AI token budget");
|
||||
// Not a provider failure: no cache row written, so the last good briefing
|
||||
// stays intact and the card does not blank.
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -432,8 +440,8 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.user.update).mock.calls[0][0] as {
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.user.updateMany).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(args.data.insightsCachedText).toEqual(expect.any(String));
|
||||
@@ -441,6 +449,89 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("refuses a stale cache write when AI profile inclusion changes during generation", async () => {
|
||||
makeWorkingProvider();
|
||||
selfContextText
|
||||
.mockResolvedValueOnce("Chronic conditions: Asthma")
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits successfully even when an unrelated User update (e.g. a provider credential refresh) lands mid-generation", async () => {
|
||||
// resolveProviderChain()'s token-maintenance write (and any other User
|
||||
// update outside the prompt-scope columns) bumps `updatedAt` but must
|
||||
// never collide with this guard — the where-clause below no longer
|
||||
// references it at all.
|
||||
makeWorkingProvider();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsExcludeMetrics: ["sleep"],
|
||||
locale: "en",
|
||||
dateOfBirth: null,
|
||||
gender: "FEMALE",
|
||||
heightCm: 170,
|
||||
displayName: "Sam",
|
||||
} as never);
|
||||
// The real DB returns count: 1 here too, since the exact-field
|
||||
// predicate never touches `updatedAt` — a concurrent OAuth
|
||||
// token-refresh write to the row cannot make it miss.
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
const where = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
|
||||
.where as Record<string, unknown>;
|
||||
expect(where).not.toHaveProperty("updatedAt");
|
||||
expect(where).toMatchObject({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsExcludeMetrics: { equals: ["sleep"] },
|
||||
locale: "en",
|
||||
dateOfBirth: null,
|
||||
gender: "FEMALE",
|
||||
heightCm: 170,
|
||||
displayName: "Sam",
|
||||
});
|
||||
expect(enqueueStatusRefillForUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a stale cache write when an exact prompt-scope field (privacy mode / exclude list / locale / profile) changed mid-generation", async () => {
|
||||
makeWorkingProvider();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsExcludeMetrics: [],
|
||||
locale: "en",
|
||||
} as never);
|
||||
// Simulates a genuine concurrent settings change (e.g. the exclude
|
||||
// list edited) between the read above and the commit: the guarded
|
||||
// write's exact-field predicate now matches zero rows.
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsExcludeMetrics: { equals: [] },
|
||||
locale: "en",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT touch the cache when serving from the 24h DB cache", async () => {
|
||||
// Cached path: route returns early without touching the LLM or the
|
||||
// cache write. v1.28.30 — the short-circuit requires the cached
|
||||
@@ -460,7 +551,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -547,7 +638,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST without force + fresh cache WITHOUT briefing + provider → regenerates", async () => {
|
||||
@@ -560,7 +651,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
// The stale-for-briefing cache was bypassed: a real generation ran and
|
||||
// wrote a fresh cache row.
|
||||
expect(body.data.cached).toBe(false);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("POST without force + fresh cache WITHOUT briefing + NO provider → serves cached (regeneration is futile)", async () => {
|
||||
@@ -571,7 +662,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("degrades to the cached payload instead of 429 when the briefingless fall-through is rate-limited", async () => {
|
||||
@@ -589,7 +680,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explicit force keeps the honest 429 when rate-limited", async () => {
|
||||
|
||||
@@ -301,6 +301,23 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
aiResponseTimeoutSeconds: true,
|
||||
},
|
||||
});
|
||||
const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated";
|
||||
// v1.34 — the commit guard below (line ~966) matches on these exact
|
||||
// columns instead of the broad `updatedAt` timestamp. Every User write
|
||||
// bumps `updatedAt`, including `resolveProviderChain()`'s OAuth
|
||||
// token-refresh update later in this same request — keying the
|
||||
// optimistic-concurrency check on it produced a false 409 whenever a
|
||||
// provider credential happened to refresh mid-generation. These are
|
||||
// exactly the columns selected above that feed the prompt built below;
|
||||
// capturing them here and requiring an exact match at commit still
|
||||
// catches a genuine privacy-mode / exclude-list / locale / profile
|
||||
// change without colliding on unrelated column writes.
|
||||
const excludeMetricsAtRead = dbUser?.insightsExcludeMetrics ?? [];
|
||||
const localeAtRead = dbUser?.locale ?? null;
|
||||
const dateOfBirthAtRead = dbUser?.dateOfBirth ?? null;
|
||||
const genderAtRead = dbUser?.gender ?? null;
|
||||
const heightCmAtRead = dbUser?.heightCm ?? null;
|
||||
const displayNameAtRead = dbUser?.displayName ?? null;
|
||||
|
||||
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
|
||||
dbUser?.aiResponseTimeoutSeconds,
|
||||
@@ -949,27 +966,26 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
const currentSelfContext = await getSelfContextTextForUser(userId, locale);
|
||||
if (currentSelfContext !== aboutMe) {
|
||||
return apiError("Profile scope changed during insight generation", 409, {
|
||||
errorCode: "insights.generate.profileScopeChanged",
|
||||
});
|
||||
}
|
||||
const committed = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
insightsPrivacyMode: modeAtRead,
|
||||
insightsExcludeMetrics: { equals: excludeMetricsAtRead },
|
||||
locale: localeAtRead,
|
||||
dateOfBirth: dateOfBirthAtRead,
|
||||
gender: genderAtRead,
|
||||
heightCm: heightCmAtRead,
|
||||
displayName: displayNameAtRead,
|
||||
},
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
// v1.16.8 — store the snapshot fingerprint so the nightly / forced
|
||||
// regeneration paths can detect "nothing changed" and skip the
|
||||
// provider call. The user-initiated POST itself stays un-gated: an
|
||||
// explicit regenerate request is honoured even on unchanged data
|
||||
// (it is already bounded by the hourly rate limit above). The
|
||||
// composite shape MUST match the gate in `comprehensive-generate.ts`,
|
||||
// or every off-request warm after a manual regenerate re-pays a
|
||||
// full generation on unchanged data. Beyond the features, three
|
||||
// prompt inputs that can change with NO data change join in:
|
||||
// - aboutMe (Coach remember / Settings → AI edit),
|
||||
// - comparisonBaseline (the comparison toggle adds/removes the
|
||||
// prior-period context block; `null` snapshot means "none"),
|
||||
// - generationLocale (the language the cached text renders in —
|
||||
// hashing it makes a locale switch regenerate exactly once, so
|
||||
// the briefing follows the reader; named `generationLocale`
|
||||
// because the canonicaliser strips `locale` keys as volatile).
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
aboutMe: aboutMe ?? null,
|
||||
@@ -978,6 +994,11 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (committed.count === 0) {
|
||||
return apiError("Insight scope changed during generation", 409, {
|
||||
errorCode: "insights.generate.scopeChanged",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.28.30 — persist the grounding omission (marker AFTER the cache
|
||||
// write so it is newer than `insightsCachedAt`). The transient
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ModuleGateModule from "@/lib/modules/gate";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
@@ -15,6 +16,10 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ModuleGateModule>()),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -27,6 +32,8 @@ vi.mock("next/headers", () => ({
|
||||
import { PATCH } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SESSION = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -59,6 +66,19 @@ describe("PATCH /api/insights/patterns/[id]", () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
it("refuses a dismissal when Insights is disabled", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403),
|
||||
});
|
||||
|
||||
const response = await PATCH(request({ dismissed: true }), params);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the current evidence as the account-scoped dismissal baseline", async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { updateCorrelationPatternSchema } from "@/lib/validations/correlation-patterns";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -21,6 +22,8 @@ const patternIdSchema = z.string().min(1).max(128);
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const parsedId = patternIdSchema.safeParse((await params).id);
|
||||
if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422);
|
||||
const id = parsedId.data;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type * as ModuleGateModule from "@/lib/modules/gate";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
correlationPattern: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ModuleGateModule>()),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "owner-1", username: "owner", role: "USER", locale: "en" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(prisma.correlationPattern.findMany).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/patterns", () => {
|
||||
it("lists only the authenticated account's current patterns", async () => {
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.correlationPattern.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "owner-1", isCurrent: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses the list when Insights is disabled", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403),
|
||||
});
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(prisma.correlationPattern.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const patterns = await prisma.correlationPattern.findMany({
|
||||
where: { userId: user.id, isCurrent: true },
|
||||
orderBy: [{ lastComputedAt: "desc" }, { canonicalKey: "asc" }],
|
||||
|
||||
Reference in new issue
Block a user