mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user