Merge branch 'coach/wpF-contracts' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:10:37 +02:00
commit 78fc22d0f8
75 files changed
+6618 -233

No files matched your search

@@ -217,6 +217,8 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"One stored conversation: read, rename, delete.",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts":
"A rating on a message that already exists.",
"src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts":
"The stored tables of one message: the person's record, withheld per module, never per AI capability.",
"src/app/api/insights/breathing-screening/route.ts":
"Device breathing data, owned by the sleep module.",
"src/app/api/insights/labs-changes/route.ts":
@@ -337,6 +337,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// entry here would fail the "does not hide a gate" check below.
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
// One message's stored tables. No whole-route gate for the same reason,
// but each table is withheld when the module owning its domain is off
// (`isCoachDomainWithheld` over the resolved module map), so a switched-off
// domain's values never come back through a stored answer.
"src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts",
// The Today digest and its dismiss: data, with the AI parts masked by the
// loader through the capability resolver.
"src/app/api/daily/digest/route.ts",
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, readMessageResults, resolveModuleMap } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
readMessageResults: vi.fn(),
resolveModuleMap: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
return {
...actual,
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
handler,
requireAuth,
};
});
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap }));
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageResults }));
import { GET } from "../route";
const USER_ID = "user-1";
function call(id = "c1", messageId = "m1") {
return GET(
new NextRequest(
`http://localhost/api/insights/chat/${id}/messages/${messageId}/results`,
),
{ params: Promise.resolve({ id, messageId }) },
) as Promise<Response>;
}
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
resolveModuleMap.mockResolvedValue({ sleep: false, mood: true });
});
describe("GET /api/insights/chat/[id]/messages/[messageId]/results", () => {
it("reads under the caller's own id and the path ids only", async () => {
readMessageResults.mockResolvedValue([]);
const response = await call("c1", "m1");
expect(response.status).toBe(200);
expect(readMessageResults).toHaveBeenCalledWith(
USER_ID,
"c1",
"m1",
expect.any(Function),
);
expect(await response.json()).toEqual({
data: { results: [] },
error: null,
});
});
it("answers 404 for a message the caller does not own", async () => {
readMessageResults.mockResolvedValue(null);
const response = await call();
expect(response.status).toBe(404);
});
it("withholds a domain whose module is switched off, and only that one", async () => {
readMessageResults.mockResolvedValue([]);
await call();
const withhold = readMessageResults.mock.calls[0][3] as (
domain: string,
) => boolean;
expect(withhold("sleep")).toBe(true);
expect(withhold("mood")).toBe(false);
// No module owns blood pressure or labs: never withheld.
expect(withhold("bp")).toBe(false);
expect(withhold("labs")).toBe(false);
});
it("refuses an unauthenticated caller before any read", async () => {
requireAuth.mockRejectedValue(new Error("Not authenticated"));
await expect(call()).rejects.toThrow("Not authenticated");
expect(readMessageResults).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
/**
* GET /api/insights/chat/[id]/messages/[messageId]/results — the tables of
* values one assistant message read, decrypted for their owner.
*
* A data read, never AI-gated: the tables are the person's stored record,
* readable while the Coach is unavailable for any reason, like the
* conversation they belong to. Narrowed exactly like
* `GET /api/insights/chat/[id]`: the message must sit in a conversation the
* caller owns, and a foreign or unknown id is 404, never 403.
*
* Each table is served whole or withheld with a reason: `module_disabled`
* when its domain's module is switched off for the record now (the table is
* not even decrypted), `unavailable` when the stored tables cannot be read.
* The list follows the order of `metricSource.results` on the message.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { readMessageResults } from "@/lib/ai/coach/persistence";
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
interface RouteCtx {
params: Promise<{ id: string; messageId: string }>;
}
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
const userId = auth.user.id;
const { id, messageId } = await ctx.params;
if (!id || !messageId) return apiError("coach.message.notFound", 404);
const modules = await resolveModuleMap(userId);
const results = await readMessageResults(userId, id, messageId, (domain) =>
isCoachDomainWithheld(domain, modules),
);
if (!results) return apiError("coach.message.notFound", 404);
annotate({
action: { name: "insights.coach.results.read" },
meta: {
conversationId: id,
tables: results.length,
withheld: results.filter((entry) => "withheld" in entry).length,
},
});
return apiSuccess({ results });
});
export const dynamic = "force-dynamic";
+4
View File
@@ -109,6 +109,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
scope,
guidedQuestion,
workoutId,
followUp,
clarification,
} = parsed.data;
// Per-user request-rate ceiling layered in front of the daily budget
@@ -171,6 +173,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
scope,
guidedQuestion,
workoutId,
followUp,
clarification,
recheckCapability: coachCapabilityRefusal,
});
}