mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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