fix(insights): scope generation guard to prompt fields, clear cache on restore

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 21:11:43 +02:00
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,
+130
View File
@@ -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();
});
});
+76
View File
@@ -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),
);
});
});
+193 -66
View File
@@ -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 () => {
+39 -18
View File
@@ -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();
});
});
+3
View File
@@ -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" }],