mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'coach/wpF-contracts' into release/v1.39.4
This commit is contained in:
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";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user