mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat: serve the day, its index, the timeline and life events
Replace the day, timeline and life-event stubs with their implementations (#613) and drop the not-implemented helper, its error code and its OpenAPI response with the last stub. loadDay reads one local day for every reader: the readings live inside the record's own day window (never the UTC rollups), through the canonical-source engine the mood sheet already uses (resolveDayReadings in linked-context.ts), with the person's usual range over the 30 days before; the record sections from their own tables, each in the storage form it speaks (an instant in the local window, a date string with its own zone, a calendar date). A switched-off module's section is not read; a section the grant does not cover is named not_shared and not read. The day, the day index and the new visit preparation (GET /api/day/notable) are admitted on the readings and narrowed per section through actingDomainVisibility, so a scoped grant sees exactly its sections. The timeline and its readiness stay whole-record reads and narrow their lanes the same way. Notable days are deterministic keys: a three-month high or low with history behind it, a first reading, and in the visit preparation a regular reading that stopped. The timeline lays out spans and points from the owning tables, standing items without a date, and series by zoom: months from the rollup tier inside its five-year window and from one cached live query before it. The readiness inventory rates each lane with one link per gap. Life events are created, edited and soft-deleted as MANAGE verbs with audit rows, encrypted at rest, and join the sharing guard and the handler matrix. get_day reads a day for the Coach and the MCP endpoint without life events, notes or document names; its step carries the date it read.
This commit is contained in:
1 parent
b8f6372963
commit
0924285cd6
65 files changed
+7642
-209
No files matched your search
+25
@@ -482,6 +482,31 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
// v1.42 (#613) — life events, MANAGE on all three verbs.
|
||||
handler(
|
||||
"app/api/life-events/route.ts",
|
||||
"/api/life-events",
|
||||
"POST",
|
||||
"profile",
|
||||
"manage",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/life-events/[id]/route.ts",
|
||||
"/api/life-events/[id]",
|
||||
"PATCH",
|
||||
"profile",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/life-events/[id]/route.ts",
|
||||
"/api/life-events/[id]",
|
||||
"DELETE",
|
||||
"profile",
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/illness/episodes/[id]/day-logs/route.ts",
|
||||
"/api/illness/episodes/[id]/day-logs",
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* v1.42 (#613) — the day view, its index, the visit preparation, the
|
||||
* timeline and life events, driven as routes against real Postgres.
|
||||
*
|
||||
* What the unit tests cannot show: that each of the three ways this schema
|
||||
* stores a day lands on the right local day across a clock change, that a
|
||||
* switched-off module's rows are not in the answer, that a grant scoped to
|
||||
* some sections reads exactly those sections, that a life event is encrypted
|
||||
* at rest, audited, and never reaches the model-facing read of the same day.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string, modules: Record<string, boolean> = {}) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `dv-${suffix}`,
|
||||
email: `dv-${suffix}@example.test`,
|
||||
displayName: `DV ${suffix}`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
modulePreferencesJson: modules,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function shareWith(
|
||||
ownerId: string,
|
||||
delegateId: string,
|
||||
scope: Array<
|
||||
| "measurements"
|
||||
| "medications"
|
||||
| "labs"
|
||||
| "profile"
|
||||
| "illness"
|
||||
| "mind"
|
||||
| "cycle"
|
||||
| "documents"
|
||||
> | null,
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access: "READ",
|
||||
scope,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegateId });
|
||||
const session = await signIn(delegateId);
|
||||
await switchSessionTo(session.id, ownerId);
|
||||
}
|
||||
|
||||
async function call(
|
||||
handler: Handler,
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<Response> {
|
||||
return handler(
|
||||
new NextRequest(`http://localhost${url}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
}),
|
||||
{ params: Promise.resolve(params) },
|
||||
);
|
||||
}
|
||||
|
||||
async function json<T>(response: Response): Promise<T> {
|
||||
return ((await response.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
async function getDay(date: string) {
|
||||
const { GET } = await import("@/app/api/day/[date]/route");
|
||||
return call(GET as Handler, "GET", `/api/day/${date}`, undefined, { date });
|
||||
}
|
||||
|
||||
type Day = import("@/lib/day/contract").DayResponse;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("a local day across a clock change", () => {
|
||||
it("cuts readings, date strings and calendar dates on the record's own day", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("tz");
|
||||
// 00:30 local on the spring-forward day is still the 28th in UTC.
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-28T23:30:00.000Z"),
|
||||
},
|
||||
});
|
||||
// 23:59 local (CEST) is inside the 23-hour day; midnight is not.
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 81,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-29T21:59:00.000Z"),
|
||||
},
|
||||
});
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 82,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-29T22:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
// A mood entry filed in New York on the 29th, logged when Berlin was
|
||||
// already on the 30th: the row's own date decides.
|
||||
await db.moodEntry.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
date: "2026-03-29",
|
||||
tz: "America/New_York",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date("2026-03-30T02:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
// A calendar date: starts on the 29th whatever the zone.
|
||||
await db.medication.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
startsOn: dayKeyAsUtcMidnight("2026-03-29"),
|
||||
},
|
||||
});
|
||||
await signIn(owner.id);
|
||||
|
||||
const day = await json<Day>(await getDay("2026-03-29"));
|
||||
expect(day.tz).toBe("Europe/Berlin");
|
||||
expect(day.values.map((v) => v.value).sort()).toEqual([80, 81]);
|
||||
expect(day.events.map((e) => e.kind)).toEqual(
|
||||
expect.arrayContaining(["mood", "medicationStart"]),
|
||||
);
|
||||
expect(day.running.find((r) => r.kind === "medication")).toMatchObject({
|
||||
title: "Ramipril",
|
||||
since: "2026-03-29",
|
||||
dayIndex: 1,
|
||||
});
|
||||
|
||||
const before = await json<Day>(await getDay("2026-03-28"));
|
||||
expect(before.values).toEqual([]);
|
||||
expect(before.events.some((e) => e.kind === "mood")).toBe(false);
|
||||
|
||||
const after = await json<Day>(await getDay("2026-03-30"));
|
||||
expect(after.values.map((v) => v.value)).toEqual([82]);
|
||||
expect(after.events.some((e) => e.kind === "mood")).toBe(false);
|
||||
});
|
||||
|
||||
it("holds 25 hours on the fall-back day", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("fall");
|
||||
// 23:30 CET on the 25th, after the extra hour.
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 79,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-10-25T22:30:00.000Z"),
|
||||
},
|
||||
});
|
||||
// 00:30 CEST on the 25th, before the change.
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 78,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-10-24T22:30:00.000Z"),
|
||||
},
|
||||
});
|
||||
await signIn(owner.id);
|
||||
const day = await json<Day>(await getDay("2026-10-25"));
|
||||
expect(day.values.map((v) => v.value).sort()).toEqual([78, 79]);
|
||||
});
|
||||
|
||||
it("refuses a date that is not a calendar date", async () => {
|
||||
const owner = await makeUser("bad");
|
||||
await signIn(owner.id);
|
||||
const response = await getDay("2026-02-30");
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modules", () => {
|
||||
it("leaves a switched-off module's rows out without naming them", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("mod", { mood: false, recovery: false });
|
||||
await db.moodEntry.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
date: "2026-03-10",
|
||||
tz: "Europe/Berlin",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date("2026-03-10T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 45,
|
||||
unit: "ms",
|
||||
measuredAt: new Date("2026-03-10T06:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-10T06:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await signIn(owner.id);
|
||||
const day = await json<Day>(await getDay("2026-03-10"));
|
||||
expect(day.events).toEqual([]);
|
||||
expect(day.values.map((v) => v.type)).toEqual(["WEIGHT"]);
|
||||
expect(day.sections).toEqual({});
|
||||
});
|
||||
|
||||
it("answers the timeline only with the opt-in module on", async () => {
|
||||
const off = await makeUser("tl-off");
|
||||
await signIn(off.id);
|
||||
const { GET } = await import("@/app/api/timeline/route");
|
||||
const refused = await call(GET as Handler, "GET", "/api/timeline");
|
||||
expect(refused.status).toBe(403);
|
||||
|
||||
const on = await makeUser("tl-on", { timeline: true });
|
||||
await signIn(on.id);
|
||||
const ok = await call(GET as Handler, "GET", "/api/timeline");
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharing", () => {
|
||||
async function seedOwner() {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("share");
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-10T06:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const med = await db.medication.create({
|
||||
data: { userId: owner.id, name: "Ramipril", dose: "5 mg" },
|
||||
});
|
||||
await db.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date("2026-03-10T07:00:00.000Z"),
|
||||
takenAt: new Date("2026-03-10T07:05:00.000Z"),
|
||||
},
|
||||
});
|
||||
await db.moodEntry.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
date: "2026-03-10",
|
||||
tz: "Europe/Berlin",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date("2026-03-10T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
return owner;
|
||||
}
|
||||
|
||||
it("reads only the sections a scoped grant covers and names the rest", async () => {
|
||||
const owner = await seedOwner();
|
||||
const delegate = await makeUser("scoped");
|
||||
await shareWith(owner.id, delegate.id, ["measurements"]);
|
||||
const response = await getDay("2026-03-10");
|
||||
expect(response.status).toBe(200);
|
||||
const day = await json<Day>(response);
|
||||
expect(day.values.map((v) => v.value)).toEqual([80]);
|
||||
expect(day.events).toEqual([]);
|
||||
expect(day.sections.medications).toEqual({
|
||||
available: false,
|
||||
reason: "not_shared",
|
||||
});
|
||||
expect(day.sections.mood).toEqual({
|
||||
available: false,
|
||||
reason: "not_shared",
|
||||
});
|
||||
expect(day.sections.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens the covered sections of a wider grant", async () => {
|
||||
const owner = await seedOwner();
|
||||
const delegate = await makeUser("wider");
|
||||
await shareWith(owner.id, delegate.id, ["measurements", "medications"]);
|
||||
const day = await json<Day>(await getDay("2026-03-10"));
|
||||
expect(day.events.map((e) => e.kind)).toEqual(["intake"]);
|
||||
expect(day.sections.mood?.reason).toBe("not_shared");
|
||||
});
|
||||
|
||||
it("refuses a grant that does not open the readings", async () => {
|
||||
const owner = await seedOwner();
|
||||
const delegate = await makeUser("mind-only");
|
||||
await shareWith(owner.id, delegate.id, ["mind"]);
|
||||
expect((await getDay("2026-03-10")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("gives a full grant the record but keeps the environment for the owner", async () => {
|
||||
const owner = await seedOwner();
|
||||
const delegate = await makeUser("full");
|
||||
await shareWith(owner.id, delegate.id, null);
|
||||
const day = await json<Day>(await getDay("2026-03-10"));
|
||||
expect(day.events.map((e) => e.kind).sort()).toEqual(["intake", "mood"]);
|
||||
expect(day.sections).toEqual({
|
||||
environment: { available: false, reason: "not_shared" },
|
||||
});
|
||||
});
|
||||
|
||||
it("narrows the day index the same way", async () => {
|
||||
const owner = await seedOwner();
|
||||
const delegate = await makeUser("index");
|
||||
await shareWith(owner.id, delegate.id, ["measurements"]);
|
||||
const { GET } = await import("@/app/api/day/index/route");
|
||||
const index = await json<{ days: Record<string, string[]> }>(
|
||||
await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
"/api/day/index?from=2026-03-01&to=2026-03-31",
|
||||
),
|
||||
);
|
||||
expect(index.days).toEqual({ "2026-03-10": ["values"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("life events", () => {
|
||||
it("encrypts, audits, shows in the day and never reaches the model read", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("le", { timeline: true });
|
||||
await signIn(owner.id);
|
||||
const { POST, GET } = await import("@/app/api/life-events/route");
|
||||
const created = await call(POST as Handler, "POST", "/api/life-events", {
|
||||
category: "LOSS",
|
||||
startDate: "2026-03-10",
|
||||
precision: "DAY",
|
||||
title: "Zqx grandmother",
|
||||
note: "Zqx note",
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const event = await json<{ id: string; title: string }>(created);
|
||||
expect(event.title).toBe("Zqx grandmother");
|
||||
|
||||
const row = await db.lifeEvent.findUniqueOrThrow({
|
||||
where: { id: event.id },
|
||||
});
|
||||
expect(Buffer.from(row.titleEncrypted).toString("utf8")).not.toContain(
|
||||
"Zqx",
|
||||
);
|
||||
const audit = await db.auditLog.findFirst({
|
||||
where: { action: "life_event.create" },
|
||||
});
|
||||
expect(audit?.details ?? "").not.toContain("Zqx");
|
||||
|
||||
const list = await json<{ events: Array<{ title: string }> }>(
|
||||
await call(GET as Handler, "GET", "/api/life-events"),
|
||||
);
|
||||
expect(list.events.map((e) => e.title)).toEqual(["Zqx grandmother"]);
|
||||
|
||||
const day = await json<Day>(await getDay("2026-03-10"));
|
||||
expect(day.events.map((e) => e.kind)).toEqual(["lifeEvent"]);
|
||||
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-10T06:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const { executeCoachTool } = await import("@/lib/ai/coach/tools/executor");
|
||||
const forModel = await executeCoachTool({
|
||||
userId: owner.id,
|
||||
name: "get_day",
|
||||
rawArguments: JSON.stringify({ date: "2026-03-10" }),
|
||||
});
|
||||
expect(forModel.present).toBe(true);
|
||||
expect(JSON.stringify(forModel)).not.toMatch(/Zqx|lifeEvent/);
|
||||
|
||||
// Edit with a misaligned precision is refused; a valid one lands.
|
||||
const { PATCH, DELETE } = await import("@/app/api/life-events/[id]/route");
|
||||
const bad = await call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/life-events/${event.id}`,
|
||||
{ precision: "YEAR" },
|
||||
{ id: event.id },
|
||||
);
|
||||
expect(bad.status).toBe(422);
|
||||
const good = await call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/life-events/${event.id}`,
|
||||
{ category: "FAMILY" },
|
||||
{ id: event.id },
|
||||
);
|
||||
expect(good.status).toBe(200);
|
||||
const removed = await call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/life-events/${event.id}`,
|
||||
undefined,
|
||||
{ id: event.id },
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
expect(
|
||||
(await db.lifeEvent.findUniqueOrThrow({ where: { id: event.id } }))
|
||||
.deletedAt,
|
||||
).not.toBeNull();
|
||||
const again = await call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/life-events/${event.id}`,
|
||||
undefined,
|
||||
{ id: event.id },
|
||||
);
|
||||
expect(again.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses another record's event as not found", async () => {
|
||||
const db = getPrismaClient();
|
||||
const a = await makeUser("a");
|
||||
const b = await makeUser("b");
|
||||
const theirs = await db.lifeEvent.create({
|
||||
data: {
|
||||
userId: a.id,
|
||||
category: "HOME",
|
||||
startDate: "2020-01-01",
|
||||
precision: "YEAR",
|
||||
titleEncrypted: encryptToBytes("Moved"),
|
||||
},
|
||||
});
|
||||
await signIn(b.id);
|
||||
const { PATCH } = await import("@/app/api/life-events/[id]/route");
|
||||
const response = await call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/life-events/${theirs.id}`,
|
||||
{ category: "WORK" },
|
||||
{ id: theirs.id },
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notable days", () => {
|
||||
it("marks a three-month high in the day, the index and the visit window", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("notable");
|
||||
const rows = [];
|
||||
for (let i = 0; i < 150; i += 1) {
|
||||
rows.push({
|
||||
userId: owner.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(Date.UTC(2025, 10, 1, 7) + i * 86_400_000),
|
||||
});
|
||||
}
|
||||
await db.measurement.createMany({ data: rows });
|
||||
// 2026-03-31 (day 150) reads higher than every day before.
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 84,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-31T07:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await db.encounter.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
occurredAt: new Date("2026-02-01T09:00:00.000Z"),
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
},
|
||||
});
|
||||
await signIn(owner.id);
|
||||
|
||||
const day = await json<Day>(await getDay("2026-03-31"));
|
||||
expect(day.notable).toEqual([
|
||||
{
|
||||
kind: "extremeHigh",
|
||||
type: "WEIGHT",
|
||||
params: { since: "2025-11-01", value: 84 },
|
||||
},
|
||||
]);
|
||||
expect(day.values[0].band).toMatchObject({ lo: expect.any(Number) });
|
||||
|
||||
const first = await json<Day>(await getDay("2025-11-01"));
|
||||
expect(first.notable).toEqual([
|
||||
{ kind: "firstValue", type: "WEIGHT", params: { type: "WEIGHT" } },
|
||||
]);
|
||||
|
||||
const { GET: index } = await import("@/app/api/day/index/route");
|
||||
const idx = await json<{ notable: string[] }>(
|
||||
await call(
|
||||
index as Handler,
|
||||
"GET",
|
||||
"/api/day/index?from=2026-03-01&to=2026-03-31",
|
||||
),
|
||||
);
|
||||
expect(idx.notable).toEqual(["2026-03-31"]);
|
||||
|
||||
const { GET: notable } = await import("@/app/api/day/notable/route");
|
||||
const window = await json<{
|
||||
from: string;
|
||||
anchor: string;
|
||||
observations: Array<{ date: string; kind: string }>;
|
||||
}>(await call(notable as Handler, "GET", "/api/day/notable?to=2026-04-30"));
|
||||
expect(window.anchor).toBe("lastVisit");
|
||||
expect(window.from).toBe("2026-02-01");
|
||||
expect(window.observations.map((o) => [o.date, o.kind])).toEqual([
|
||||
["2026-03-31", "extremeHigh"],
|
||||
["2026-04-01", "gap"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline", () => {
|
||||
it("lays the record out, lists the standing items and rates the lanes", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("tl", { timeline: true });
|
||||
const med = await db.medication.create({
|
||||
data: { userId: owner.id, name: "Metformin", dose: "500 mg" },
|
||||
});
|
||||
await db.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date("2026-01-05T07:00:00.000Z"),
|
||||
takenAt: new Date("2026-01-05T07:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await db.medication.create({
|
||||
data: { userId: owner.id, name: "Never taken", dose: "1" },
|
||||
});
|
||||
await db.allergy.create({
|
||||
data: { userId: owner.id, substance: "Penicillin" },
|
||||
});
|
||||
await db.illnessEpisode.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
label: "Flu",
|
||||
type: "INFECTION",
|
||||
onsetAt: new Date("2026-02-01T08:00:00.000Z"),
|
||||
resolvedAt: new Date("2026-02-08T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await signIn(owner.id);
|
||||
|
||||
const { GET } = await import("@/app/api/timeline/route");
|
||||
const timeline = await json<import("@/lib/day/contract").TimelineResponse>(
|
||||
await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
"/api/timeline?zoom=year&to=2026-03-31",
|
||||
),
|
||||
);
|
||||
const meds = timeline.lanes.find((l) => l.key === "medications");
|
||||
expect(meds?.items[0]).toMatchObject({
|
||||
label: "Metformin",
|
||||
start: "2026-01-05",
|
||||
startKnown: false,
|
||||
open: true,
|
||||
});
|
||||
expect(
|
||||
timeline.lanes.find((l) => l.key === "illness")?.items[0],
|
||||
).toMatchObject({
|
||||
start: "2026-02-01",
|
||||
end: "2026-02-08",
|
||||
kind: "episode",
|
||||
});
|
||||
expect(timeline.standing.map((s) => s.label).sort()).toEqual([
|
||||
"Never taken",
|
||||
"Penicillin",
|
||||
]);
|
||||
expect(timeline.range.dataFrom).toBe("2026-01-05");
|
||||
|
||||
const bad = await call(GET as Handler, "GET", "/api/timeline?values=NOPE");
|
||||
expect(bad.status).toBe(422);
|
||||
|
||||
const { GET: readiness } =
|
||||
await import("@/app/api/timeline/readiness/route");
|
||||
const inventory = await json<
|
||||
import("@/lib/day/contract").TimelineReadinessResponse
|
||||
>(await call(readiness as Handler, "GET", "/api/timeline/readiness"));
|
||||
const byKey = Object.fromEntries(inventory.lanes.map((l) => [l.key, l]));
|
||||
expect(byKey.medications.status).toBe("thin");
|
||||
expect(byKey.medications.gaps[0].href).toMatch(
|
||||
/^\/medications\/.+\?edit=1$/,
|
||||
);
|
||||
expect(byKey.illness.status).toBe("carries");
|
||||
expect(byKey.allergies).toMatchObject({ status: "thin", gaps: [] });
|
||||
expect(byKey.values.status).toBe("empty");
|
||||
expect(inventory.verdict).toBe("thin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance smoke", () => {
|
||||
it("loads a dense day in well under a second", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("perf");
|
||||
// Ninety days of minute-level pulse around the day, plus a daily weight.
|
||||
const rows = [];
|
||||
const start = Date.UTC(2026, 0, 1);
|
||||
for (let d = 0; d < 90; d += 1) {
|
||||
for (let m = 0; m < 24 * 60; m += 5) {
|
||||
rows.push({
|
||||
userId: owner.id,
|
||||
type: "PULSE" as const,
|
||||
value: 60 + (m % 30),
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH" as const,
|
||||
measuredAt: new Date(start + d * 86_400_000 + m * 60_000),
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
userId: owner.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + (d % 3),
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(start + d * 86_400_000 + 7 * 3_600_000),
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < rows.length; i += 5000) {
|
||||
await db.measurement.createMany({ data: rows.slice(i, i + 5000) });
|
||||
}
|
||||
await db.$executeRawUnsafe(`ANALYZE "measurements"`);
|
||||
await signIn(owner.id);
|
||||
await getDay("2026-03-15");
|
||||
const t0 = performance.now();
|
||||
const response = await getDay("2026-03-15");
|
||||
const elapsed = performance.now() - t0;
|
||||
expect(response.status).toBe(200);
|
||||
const day = await json<Day>(response);
|
||||
expect(day.values.find((v) => v.type === "PULSE")?.band).not.toBeNull();
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import type { McpAuthContext } from "@/lib/mcp/auth";
|
||||
|
||||
import { sealLocation } from "@/lib/environment/location-cipher";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
@@ -282,6 +283,23 @@ async function seedRecord(): Promise<string> {
|
||||
},
|
||||
});
|
||||
}
|
||||
// v1.42 — a life event on a day the record also holds readings on, with
|
||||
// the timeline module on, so get_day reads a day where one exists and has
|
||||
// to leave it out under the strict validator.
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { modulePreferencesJson: { timeline: true } },
|
||||
});
|
||||
await prisma.lifeEvent.create({
|
||||
data: {
|
||||
userId: id,
|
||||
category: "LOSS",
|
||||
startDate: isoDay(daysAgo(3)),
|
||||
precision: "DAY",
|
||||
titleEncrypted: encryptToBytes("Zqx-life-event"),
|
||||
noteEncrypted: encryptToBytes("Zqx-life-note"),
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -339,6 +357,7 @@ const CASES: Record<string, Array<Record<string, unknown>>> = {
|
||||
get_illness_recovery: [{}],
|
||||
get_cycle: [{}],
|
||||
get_environment: [{}, { window: "last7days" }],
|
||||
get_day: [{ date: isoDay(daysAgo(3)) }, { date: "2001-01-01" }],
|
||||
get_metrics: [
|
||||
{ metrics: ["weight", "bp", "steps"], window: "last30days" },
|
||||
{ metrics: ["hrv", "vo2_max"] },
|
||||
@@ -481,6 +500,28 @@ describe("MCP tool outputs against their advertised schemas", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("get_day answers with the day and never a life event", async () => {
|
||||
const { client, close } = await connect(userId);
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: "get_day",
|
||||
arguments: { date: isoDay(daysAgo(3)) },
|
||||
});
|
||||
const out = result.structuredContent as {
|
||||
present: boolean;
|
||||
url?: string;
|
||||
data?: { values: unknown[] };
|
||||
};
|
||||
expect(out.present).toBe(true);
|
||||
expect(out.data!.values.length).toBeGreaterThan(0);
|
||||
expect(out.url).toMatch(/\/\?day=\d{4}-\d{2}-\d{2}$/);
|
||||
const text = JSON.stringify(out);
|
||||
expect(text).not.toMatch(/Zqx|lifeEvent/);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("get_environment answers with data and no location anywhere", async () => {
|
||||
const { client, close } = await connect(userId);
|
||||
try {
|
||||
|
||||
@@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("starts with a non-empty, exact handler inventory", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1875,6 +1875,89 @@ manageContract("DELETE /api/vaccinations/custom/[id]", {
|
||||
)?.deletedAt !== null,
|
||||
});
|
||||
|
||||
/* v1.42 (#613) — life events: MANAGE on all three verbs. */
|
||||
|
||||
async function makeLifeEvent(userId: string) {
|
||||
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
||||
return getPrismaClient().lifeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
category: "HOME",
|
||||
startDate: "2023-09-01",
|
||||
precision: "MONTH",
|
||||
titleEncrypted: encryptToBytes("Moved"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
manageContract("POST /api/life-events", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/life-events" && entry.action === "POST",
|
||||
)!,
|
||||
prepare: async (ownerId) => ({ ownerId }),
|
||||
act: async () => {
|
||||
const { POST } = await import("@/app/api/life-events/route");
|
||||
return call(POST as Handler, "POST", "/api/life-events", {
|
||||
category: "WORK",
|
||||
startDate: "2024-01-01",
|
||||
precision: "YEAR",
|
||||
title: "New job",
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "life_event.create",
|
||||
applied: async ({ ownerId }) =>
|
||||
(await getPrismaClient().lifeEvent.count({
|
||||
where: { userId: ownerId, category: "WORK" },
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("PATCH /api/life-events/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/life-events/[id]" && entry.action === "PATCH",
|
||||
)!,
|
||||
prepare: (ownerId) => makeLifeEvent(ownerId),
|
||||
act: async (row) => {
|
||||
const { PATCH } = await import("@/app/api/life-events/[id]/route");
|
||||
return call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/life-events/${row.id}`,
|
||||
{ category: "FAMILY" },
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "life_event.update",
|
||||
applied: async (row) =>
|
||||
(await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } }))
|
||||
?.category === "FAMILY",
|
||||
});
|
||||
|
||||
manageContract("DELETE /api/life-events/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/life-events/[id]" && entry.action === "DELETE",
|
||||
)!,
|
||||
prepare: (ownerId) => makeLifeEvent(ownerId),
|
||||
act: async (row) => {
|
||||
const { DELETE } = await import("@/app/api/life-events/[id]/route");
|
||||
return call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/life-events/${row.id}`,
|
||||
undefined,
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "life_event.delete",
|
||||
applied: async (row) =>
|
||||
(await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } }))
|
||||
?.deletedAt !== null,
|
||||
});
|
||||
|
||||
proveWriteRoute("POST /api/vaccinations/[id]/booster", {
|
||||
route: "/api/vaccinations/[id]/booster",
|
||||
// The dose the booster is planned against. `makeVaccination` records a
|
||||
@@ -2846,13 +2929,13 @@ describe("the conditions the admissions were granted on", () => {
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("registers one strict actor-and-effect driver for every admission", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(87);
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(90);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("executes every registered driver through its owned effect and actor audit", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(87);
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(90);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user