chore(release): v1.4.25

Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-14 23:49:32 +02:00
1 parent de0689e419
commit 17b8d8d2c2
528 files changed
+104033 -6524

No files matched your search

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/source-priority", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(401);
});
it("returns the resolved shape with defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as { data: Record<string, unknown> };
// Resolver always emits the keys the analytics layer needs.
expect(env.data).toHaveProperty("weight");
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
});
describe("PUT /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("persists the partial shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { sourcePriorityJson: body },
});
});
it("rejects an invalid shape with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: "not-an-array" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── v1.4.25 W10 reconcile (security M-3) ──
it("writes an audit-log entry with the previous and new shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const previousShape = {
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: previousShape,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.source-priority.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: previousShape,
next: body,
}),
}),
);
});
});
@@ -0,0 +1,94 @@
/**
* v1.4.25 W5e — per-user, per-metric-class source priority.
*
* GET /api/auth/me/source-priority — returns the fully-defaulted
* shape (missing keys filled
* from `DEFAULT_SOURCE_PRIORITY`).
* PUT /api/auth/me/source-priority — replaces the persisted shape;
* body is validated against
* `sourcePrioritySchema` and
* persisted as the partial form
* (missing keys keep the default
* at read time).
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The analytics aggregator reads this row on every call and
* the read path is cheap (one column) — no caching layer to flush.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
parseSourcePriority,
sourcePrioritySchema,
} from "@/lib/validations/source-priority";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.source-priority.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "source-priority.body.invalid_json");
}
const parsed = sourcePrioritySchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "source-priority.body.invalid_shape");
}
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
// before overwriting so the audit-log entry records the actual
// delta. Source-priority drives every aggregator's source pick,
// so a silent compromise (or accidental client write) was
// previously invisible — mirroring the timezone-route audit
// pattern closes the gap.
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
// Persist the partial form — missing keys read as defaults via
// `parseSourcePriority`. Storing only the user-edited subset keeps
// the Json blob narrow and future-proofs the shape: when a new
// metric class is added, every existing row reads the new key's
// default until the user explicitly changes it.
await prisma.user.update({
where: { id: user.id },
data: { sourcePriorityJson: parsed.data },
});
await auditLog("user.source-priority.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: before?.sourcePriorityJson ?? null,
next: parsed.data,
},
});
annotate({
action: { name: "auth.me.source-priority.put" },
meta: { keys: Object.keys(parsed.data).length },
});
return apiSuccess(parseSourcePriority(parsed.data));
});