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

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { coachPaths } from "../routes/coach";
describe("anamnesis fact mutation OpenAPI contracts", () => {
it("documents the DELETE revision token, success metadata, and public errors", () => {
const operation = coachPaths["/api/anamnesis/facts/{id}"]?.delete;
expect(operation).toBeDefined();
expect(operation).toMatchObject({
summary: "Remove a current anamnesis fact",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Metadata for the revision whose interval was closed.",
},
"401": expect.any(Object),
"404": expect.any(Object),
"409": expect.any(Object),
},
});
});
it("documents POST's existing-current conflict", () => {
const operation = coachPaths["/api/anamnesis/facts"]?.post;
expect(operation?.responses).toMatchObject({
"201": expect.any(Object),
"409": {
description: expect.stringContaining("anamnesis.fact.currentExists"),
},
});
});
it("documents PATCH's missing-target and revision-race responses", () => {
const operation = coachPaths["/api/anamnesis/facts/{id}"]?.patch;
expect(operation?.responses).toMatchObject({
"200": expect.any(Object),
"404": expect.any(Object),
"409": {
description: expect.stringContaining("anamnesis.fact.conflict"),
},
});
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { insightsPaths } from "../routes/insights/paths";
describe("insight patterns module gate OpenAPI contract", () => {
it("documents module.disabled for list and dismissal updates", () => {
const listResponses =
insightsPaths["/api/insights/patterns"]?.get?.responses;
const updateResponses =
insightsPaths["/api/insights/patterns/{id}"]?.patch?.responses;
for (const responses of [listResponses, updateResponses]) {
expect(Object.keys(responses ?? {})).toContain("403");
expect(responses?.["403"]?.description).toContain("module.disabled");
}
});
});
+143
View File
@@ -8,6 +8,14 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { aboutMePutSchema } from "@/lib/validations/about-me";
import {
healthProfileAiSectionSchema,
healthProfileFactCorrectionSchema,
healthProfileFactDtoSchema,
healthProfileFactsResponseSchema,
healthProfileFactWriteSchema,
removedHealthProfileFactSchema,
} from "@/lib/validations/health-profile-facts";
import {
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
@@ -675,6 +683,22 @@ coachChatRequestSchema.meta({
"Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.",
});
const anamnesisFactCurrentExistsResponse = {
"409": {
description:
"A current revision already exists for this fact kind. `meta.errorCode` = `anamnesis.fact.currentExists`.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const anamnesisFactNotFoundResponse = {
"404": {
description:
"No current revision with this id exists for the authenticated account.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/insights/chat": {
get: {
@@ -1155,6 +1179,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
conditions: z.string().nullable(),
allergies: z.string().nullable(),
coachFocus: z.string().nullable(),
aiIncludedSections: z.array(healthProfileAiSectionSchema),
pendingQuestions: z.array(z.string()),
updatedAt: z.iso.datetime({ offset: true }).nullable(),
maxChars: z.number().int(),
@@ -1195,6 +1220,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
conditions: z.string().nullable(),
allergies: z.string().nullable(),
coachFocus: z.string().nullable(),
aiIncludedSections: z.array(healthProfileAiSectionSchema),
pendingQuestions: z.array(z.string()),
updatedAt: z.iso.datetime({ offset: true }),
maxChars: z.number().int(),
@@ -1210,6 +1236,123 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/anamnesis/facts": {
get: {
tags: ["Insights"],
summary: "Read effective-dated anamnesis facts",
description:
"Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern and shift schedule. Values are decrypted server-side; unreadable ciphertext returns `value: null` with `unreadable: true`.",
responses: {
"200": {
description: "Current facts and their revision history.",
content: {
"application/json": {
schema: dataEnvelope(
healthProfileFactsResponseSchema,
"GetAnamnesisFactsResponse",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Insights"],
summary: "Create an anamnesis fact",
description:
"Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint.",
requestBody: {
required: true,
content: {
"application/json": { schema: healthProfileFactWriteSchema },
},
},
responses: {
"201": {
description: "The newly created current revision.",
content: {
"application/json": {
schema: dataEnvelope(
healthProfileFactDtoSchema,
"CreateAnamnesisFactResponse",
),
},
},
},
...anamnesisFactCurrentExistsResponse,
...stdResponses,
},
},
},
"/api/anamnesis/facts/{id}": {
patch: {
tags: ["Insights"],
summary: "Correct a current anamnesis fact",
description:
"Closes the current revision at the correction instant and creates a successor. Prior ciphertext and its half-open validity interval remain in history.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
},
],
requestBody: {
required: true,
content: {
"application/json": { schema: healthProfileFactCorrectionSchema },
},
},
responses: {
"200": {
description: "The successor revision.",
content: {
"application/json": {
schema: dataEnvelope(
healthProfileFactDtoSchema,
"CorrectAnamnesisFactResponse",
),
},
},
},
...anamnesisFactNotFoundResponse,
...conflictResponse409("Anamnesis fact", "anamnesis.fact.conflict"),
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Remove a current anamnesis fact",
description:
"Closes the caller-owned current revision without deleting its encrypted history. The fact kind then resolves as not recorded until a new value is created. The path revision id is the optimistic concurrency token.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Metadata for the revision whose interval was closed.",
content: {
"application/json": {
schema: dataEnvelope(
removedHealthProfileFactSchema,
"RemoveAnamnesisFactResponse",
),
},
},
},
...anamnesisFactNotFoundResponse,
...conflictResponse409("Anamnesis fact", "anamnesis.fact.conflict"),
...stdResponses,
},
},
},
"/api/coach/about-me/questions": {
get: {
tags: ["Insights"],
+2
View File
@@ -532,6 +532,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
@@ -565,6 +566,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "No current pattern with that id for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...moduleDisabledResponse,
...stdResponses,
},
},