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:
Marc-André Bombeck committed 2026-10-08 14:31:13 +02:00
1 parent b8f6372963
commit 0924285cd6
65 files changed
+7642 -209

No files matched your search

+48 -11
View File
@@ -2,21 +2,58 @@
* `GET /api/day/{date}` — one local day across the record (v1.42, #613): what
* ran through it, the readings in its window, what happened on it, and the
* few deterministic observations worth a second look. The shape is
* `DayResponse` in `src/lib/day/contract.ts`.
* `DayResponse` in `src/lib/day/contract.ts`; `loadDay` builds it.
*
* Core, not a module: the day opens for every account. Sections of a
* switched-off module are left out; a section the caller's grant does not
* cover is named in `sections` and not read.
* switched-off module are left out.
*
* Contract stub: answers 501 behind its admission. It declares the whole
* record for now, the posture every cross-section read in the tree has; a
* scoped grant is refused until the loader narrows each section to the
* grant's domains and the admission is reviewed for that.
* Admission: a read of the record's readings (`measurements`), narrowed
* section by section after that. The day is built around the readings, so a
* grant has to open them before it opens the day; every other section is then
* read only when the grant also covers that section's own domain
* (`actingDomainVisibility`, the same predicate the body-site and visit routes
* narrow with), and a section it does not cover is named in `sections` with
* `not_shared` and never read. No section can show a delegate more than the
* section's own route would: the domain each one maps to is the one those
* routes declare (`src/lib/day/sections.ts`).
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { loadDay } from "@/lib/day/load-day";
import { resolveDayAccess } from "@/lib/day/sections";
import { dayPathSchema } from "@/lib/day/wire-schemas";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "record");
return notImplemented();
type RouteParams = { params: Promise<{ date: string }> };
export const GET = apiHandler(async (_request: Request, ctx: RouteParams) => {
const { user, grantId } = await requireRecordAuth("read", "measurements");
const parsed = dayPathSchema.safeParse(await ctx.params);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" });
}
const access = await resolveDayAccess({
recordId: user.id,
domainVisible: await actingDomainVisibility(prisma, grantId),
owner: grantId === null,
});
const day = await loadDay({
recordId: user.id,
day: parsed.data.date,
access,
});
annotate({
action: { name: "day.read" },
meta: {
values: day.counts.values,
entries: day.counts.entries,
running: day.running.length,
notable: day.notable.length,
not_shared: Object.keys(day.sections).length,
delegated: grantId !== null,
},
});
return apiSuccess(day);
});
+46 -5
View File
@@ -5,12 +5,53 @@
* `DAY_INDEX_MAX_SPAN_DAYS` days per call. The shape is `DayIndexResponse` in
* `src/lib/day/contract.ts`.
*
* Contract stub: answers 501 behind the same admission as the day itself.
* Same admission and the same section narrowing as the day itself: a grant
* that does not cover a section never sees that section's days.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { loadDayIndex } from "@/lib/day/day-index";
import { resolveDayAccess } from "@/lib/day/sections";
import { dayIndexQuerySchema } from "@/lib/day/wire-schemas";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "record");
return notImplemented();
export const GET = apiHandler(async (request: Request) => {
const { user, grantId } = await requireRecordAuth("read", "measurements");
const params = new URL(request.url).searchParams;
const parsed = dayIndexQuerySchema.safeParse({
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" });
}
const [access, tz] = await Promise.all([
actingDomainVisibility(prisma, grantId).then((domainVisible) =>
resolveDayAccess({
recordId: user.id,
domainVisible,
owner: grantId === null,
}),
),
resolveUserTimezone(user.id),
]);
const index = await loadDayIndex({
recordId: user.id,
from: parsed.data.from,
to: parsed.data.to,
access,
tz,
});
annotate({
action: { name: "day.index.read" },
meta: {
days: Object.keys(index.days).length,
notable: index.notable.length,
delegated: grantId !== null,
},
});
return apiSuccess(index);
});
+60
View File
@@ -0,0 +1,60 @@
/**
* `GET /api/day/notable?from=&to=` — what changed and what stood out in a
* window, for the preparation of the next visit (v1.42, #613). Without
* `from` the window starts at the last completed visit the caller may see;
* without `to` it ends today. The shape is `DayNotableWindowResponse` in
* `src/lib/day/contract.ts`.
*
* Observations and changes only, as keys and the record's own names: no
* value judgement, no cause. Same admission and section narrowing as the day.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { resolveDayAccess } from "@/lib/day/sections";
import { loadSinceLastVisit } from "@/lib/day/visit-preparation";
import { dayNotableQuerySchema } from "@/lib/day/wire-schemas";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const GET = apiHandler(async (request: Request) => {
const { user, grantId } = await requireRecordAuth("read", "measurements");
const params = new URL(request.url).searchParams;
const parsed = dayNotableQuerySchema.safeParse({
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" });
}
const [access, tz] = await Promise.all([
actingDomainVisibility(prisma, grantId).then((domainVisible) =>
resolveDayAccess({
recordId: user.id,
domainVisible,
owner: grantId === null,
}),
),
resolveUserTimezone(user.id),
]);
const window = await loadSinceLastVisit({
recordId: user.id,
access,
tz,
today: userDayKey(new Date(), tz),
from: parsed.data.from,
to: parsed.data.to,
});
annotate({
action: { name: "day.notable.read" },
meta: {
anchor: window.anchor,
observations: window.observations.length,
changes: window.changes.length,
delegated: grantId !== null,
},
});
return apiSuccess(window);
});
+136 -15
View File
@@ -1,21 +1,142 @@
/**
* `PATCH /api/life-events/{id}` edits one life event; `DELETE` soft-deletes
* it (v1.42, #613). Record-scoped like the list beside it.
* it (v1.42, #613). Record-scoped like the list beside it, and MANAGE like
* the create: correcting or removing somebody's life event is a guardian's
* act. A foreign, unknown or already deleted id is the same 404.
*
* Contract stub: owner-only until implemented, then answers 501. The
* finished routes resolve the record through `requireRecordAuth` and join
* the sharing guard's delegable lists with their audit rows; a stub cannot
* carry those, so it does not claim the admission.
* The edit files the replaced dates, category and precision (C4) and names a
* changed title or note without quoting either. The delete tombstones the
* row, so the audit row names it and the restore of a backup keeps it.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { NextRequest } from "next/server";
export const PATCH = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import {
lifeEventIdPathSchema,
lifeEventUpdateSchema,
} from "@/lib/day/wire-schemas";
import { prisma } from "@/lib/db";
import {
findOwnLifeEvent,
mergeLifeEventEdit,
toLifeEventDTO,
} from "@/lib/life-events/service";
import { annotate } from "@/lib/logging/context";
import { overwriteDetails } from "@/lib/sharing/audit-details";
export const DELETE = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
type RouteParams = { params: Promise<{ id: string }> };
function notFound() {
return apiError("Life event not found", 404, {
errorCode: "life_event.not_found",
});
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const path = lifeEventIdPathSchema.safeParse(await params);
if (!path.success) return notFound();
const existing = await findOwnLifeEvent(user.id, path.data.id);
if (!existing) return notFound();
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = lifeEventUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "life_event.invalid",
});
}
const merged = mergeLifeEventEdit(existing, parsed.data);
if (!merged.ok) {
return returnAllZodIssues(merged.error, 422, {
errorCode: "life_event.invalid",
});
}
const updated = await prisma.lifeEvent.update({
where: { id: existing.id },
data: merged.data,
});
await auditLog("life_event.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
lifeEventId: existing.id,
...overwriteDetails({
before: {
category: existing.category,
startDate: existing.startDate,
endDate: existing.endDate,
precision: existing.precision,
},
after: {
category: updated.category,
startDate: updated.startDate,
endDate: updated.endDate,
precision: updated.precision,
},
redacted: [
...(parsed.data.title !== undefined ? ["title"] : []),
...(parsed.data.note !== undefined ? ["note"] : []),
],
}),
},
});
annotate({
action: {
name: "life_event.update",
entity_type: "life_event",
entity_id: existing.id,
},
});
return apiSuccess(toLifeEventDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const path = lifeEventIdPathSchema.safeParse(await params);
if (!path.success) return notFound();
const existing = await findOwnLifeEvent(user.id, path.data.id);
if (!existing) return notFound();
const deleted = await prisma.lifeEvent.update({
where: { id: existing.id },
data: { deletedAt: new Date() },
});
await auditLog("life_event.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
lifeEventId: existing.id,
category: existing.category,
startDate: existing.startDate,
precision: existing.precision,
},
});
annotate({
action: {
name: "life_event.delete",
entity_type: "life_event",
entity_id: existing.id,
},
});
return apiSuccess(toLifeEventDTO(deleted));
},
);
+78 -11
View File
@@ -8,19 +8,86 @@
* module is on, the data-layer posture of the other record tables, so a
* restore keeps working and re-enabling finds the events intact.
*
* Contract stub: answers 501. The read declares its final admission; the
* create stays owner-only until it files its rows through the audit trail
* and joins the delegable write list.
* The create is a MANAGE verb: a life event is the person's own anchor, and
* recording one for somebody else is a guardian's act on a managed profile,
* not a helper's. Additive, rate-limited on the actor, audited with the
* category and the precision and never the title.
*/
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { lifeEventCreateSchema } from "@/lib/day/wire-schemas";
import { withIdempotency } from "@/lib/idempotency";
import {
createLifeEvent,
listLifeEvents,
toLifeEventDTO,
} from "@/lib/life-events/service";
import { annotate } from "@/lib/logging/context";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "profile");
return notImplemented();
const { user } = await requireRecordAuth("read", "profile");
const events = await listLifeEvents(user.id);
annotate({
action: { name: "life_event.list", entity_type: "life_event" },
meta: { count: events.length },
});
return apiSuccess({ events });
});
export const POST = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent));
async function postLifeEvent(request: NextRequest): Promise<Response> {
const { user, actor } = await requireRecordAuth("manage", "profile");
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = lifeEventCreateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "life_event.invalid",
});
}
const created = await createLifeEvent(user.id, parsed.data);
await auditLog("life_event.create", {
userId: user.id,
ipAddress: getClientIp(request),
// Never the title or the note: both are encrypted at rest and the audit
// table is not a second store for them.
details: {
lifeEventId: created.id,
category: created.category,
precision: created.precision,
},
});
annotate({
action: {
name: "life_event.create",
entity_type: "life_event",
entity_id: created.id,
},
meta: { category: created.category, precision: created.precision },
});
return apiSuccess(toLifeEventDTO(created), 201);
}
+31 -7
View File
@@ -1,18 +1,42 @@
/**
* `GET /api/timeline/readiness` — what the timeline can already show, lane by
* lane, with one link per gap (v1.42, #613). Counts and keys, never a score.
* The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`.
* The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`;
* `loadTimelineReadiness` builds it.
*
* Gated on the opt-in `timeline` module. Contract stub: answers 501 behind
* its admission and the gate.
* Gated on the opt-in `timeline` module, admitted like the timeline, and
* narrowed the same way: a lane the grant does not cover is not listed.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { loadTimelineReadiness } from "@/lib/timeline/readiness";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
const { user, grantId } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
const [modules, domainVisible, tz] = await Promise.all([
resolveModuleMap(user.id),
actingDomainVisibility(prisma, grantId),
resolveUserTimezone(user.id),
]);
const readiness = await loadTimelineReadiness({
recordId: user.id,
tz,
access: { modules, domainVisible, owner: grantId === null },
});
annotate({
action: { name: "timeline.readiness.read" },
meta: {
verdict: readiness.verdict,
lanes: readiness.lanes.length,
carrying: readiness.lanes.filter((l) => l.status === "carries").length,
},
});
return apiSuccess(readiness);
});
+66 -10
View File
@@ -1,20 +1,76 @@
/**
* `GET /api/timeline?zoom=&from=&to=&values=` — the record laid out over the
* years (v1.42, #613): lanes of conditions, medications, visits, documents and
* life events, the standing items without a start, and monthly value series.
* The shape is `TimelineResponse` in `src/lib/day/contract.ts`.
* life events, the standing items without a start, and value series by zoom.
* The shape is `TimelineResponse` in `src/lib/day/contract.ts`; `loadTimeline`
* builds it.
*
* Gated on the opt-in `timeline` module. Contract stub: answers 501 behind
* its admission and the gate. Like the day, it declares the whole record
* until each lane is narrowed to the grant's domains.
* Gated on the opt-in `timeline` module. Admission declares the whole record:
* the timeline lays every section side by side, so a grant scoped to some of
* them does not open it. The lanes are narrowed to the grant's domains all
* the same (`actingDomainVisibility`), so the day this admission is reviewed
* and narrowed the loader already reads no lane it should not.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { timelineQuerySchema } from "@/lib/day/wire-schemas";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { loadTimeline, parseSeriesKeys } from "@/lib/timeline/load-timeline";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
export const GET = apiHandler(async (request: Request) => {
const { user, grantId } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
const params = new URL(request.url).searchParams;
const parsed = timelineQuerySchema.safeParse({
zoom: params.get("zoom") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
values: params.get("values") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "timeline.invalid",
});
}
if (parsed.data.from && parsed.data.to && parsed.data.from > parsed.data.to) {
return apiError("`from` must not be after `to`", 422, {
errorCode: "timeline.invalid",
});
}
const seriesKeys = parseSeriesKeys(parsed.data.values);
if (seriesKeys === null) {
return apiError("Unknown value series", 422, {
errorCode: "timeline.invalid",
});
}
const [modules, domainVisible, tz] = await Promise.all([
resolveModuleMap(user.id),
actingDomainVisibility(prisma, grantId),
resolveUserTimezone(user.id),
]);
const timeline = await loadTimeline({
recordId: user.id,
query: parsed.data,
seriesKeys,
access: { modules, domainVisible, owner: grantId === null },
tz,
});
annotate({
action: { name: "timeline.read" },
meta: {
zoom: timeline.zoom,
lanes: timeline.lanes.length,
items: timeline.lanes.reduce((n, l) => n + l.items.length, 0),
series: timeline.series.length,
notable: timeline.notable.length,
},
});
return apiSuccess(timeline);
});