mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(topics): compute topic overviews and topic pages on the server
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).
Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.
The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
This commit is contained in:
1 parent
14d0184b63
commit
cc04e0944d
30 files changed
+3937
-35
No files matched your search
@@ -44,6 +44,7 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolvePreviewFeatures } from "@/lib/settings/preview-features";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
@@ -93,6 +94,21 @@ const SERVER_FEATURES = {
|
||||
lifeCircumstances: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
|
||||
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
|
||||
* 500s the whole profile fetch (the field just reads null and the user
|
||||
* re-enters it).
|
||||
*/
|
||||
function decryptInsuranceNumber(encrypted: string | null): string | null {
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
const { user } = auth;
|
||||
@@ -238,10 +254,11 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
// The encrypted insurance number is not on the per-request user
|
||||
// (`SESSION_USER_SELECT`); it is read here, for the one response that
|
||||
// shows it.
|
||||
// shows it. The preview opt-ins ride the same point read of the caller's
|
||||
// own row.
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { insuranceNumberEncrypted: true },
|
||||
select: { insuranceNumberEncrypted: true, previewFeaturesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -289,18 +306,9 @@ export const GET = apiHandler(async () => {
|
||||
sectionsOpen(sections, "cycle") &&
|
||||
isCycleEnabled(record?.gender ?? null, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
|
||||
// one row never 500s the whole profile fetch (the field just reads
|
||||
// null and the user re-enters it).
|
||||
let insuranceNumber: string | null = null;
|
||||
if (identity?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
insuranceNumber = null;
|
||||
}
|
||||
}
|
||||
const insuranceNumber = decryptInsuranceNumber(
|
||||
identity?.insuranceNumberEncrypted ?? null,
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
id: user.id,
|
||||
@@ -447,6 +455,12 @@ export const GET = apiHandler(async () => {
|
||||
// `offIsReal` whether the lowest option is a real off or "Minimal", and
|
||||
// `source` which layer decided. Clients never recompute it.
|
||||
coachReasoning,
|
||||
// The caller's own preview opt-ins (Settings > System > Preview
|
||||
// features), resolved: every flag present, off unless switched on. The
|
||||
// navigation offers the Topics entry from `topics`. The ACTOR's, like the
|
||||
// Coach preferences above, so it does not move under a switch: a preview is
|
||||
// chrome of the person reading, never a property of the record on screen.
|
||||
previewFeatures: resolvePreviewFeatures(identity?.previewFeaturesJson),
|
||||
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
|
||||
// `active` is the record this session is inside (null when it is in its
|
||||
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `GET /api/topics/{topic}` — one topic page (preview).
|
||||
*
|
||||
* The topic's signals with their usual ranges, the side-by-side series for the
|
||||
* chosen window, the relationships between its signals (14 paired days before
|
||||
* anything is said), the 90-day trends, the signals it could show but holds
|
||||
* nothing for, and its extra blocks (training load, lab markers with their
|
||||
* trend, screener trends, today's place in the cycle). Every figure is
|
||||
* computed on the server from existing reads; no model is involved.
|
||||
*
|
||||
* 404 `topics.preview_off` when the viewer has not switched the preview on,
|
||||
* `topics.not_available` when the record's module switches do not offer the
|
||||
* topic. Admission declares the whole record, like the overview.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { hasTopicsPreview, loadTopicDetail } from "@/lib/topics/service";
|
||||
import {
|
||||
topicDetailQuerySchema,
|
||||
topicPathSchema,
|
||||
} from "@/lib/validations/topics";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ topic: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read", "record");
|
||||
annotate({ action: { name: "topics.detail.read" } });
|
||||
|
||||
if (!(await hasTopicsPreview(actor.id))) {
|
||||
return apiError("Topics are a preview that is switched off", 404, {
|
||||
errorCode: "topics.preview_off",
|
||||
});
|
||||
}
|
||||
|
||||
const path = topicPathSchema.safeParse(await params);
|
||||
if (!path.success) {
|
||||
return apiError("No such topic", 404, { errorCode: "topics.not_found" });
|
||||
}
|
||||
const query = topicDetailQuerySchema.safeParse({
|
||||
range: request.nextUrl.searchParams.get("range") ?? undefined,
|
||||
});
|
||||
if (!query.success) return returnAllZodIssues(query.error, 422);
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(actor.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const detail = await loadTopicDetail({
|
||||
userId: user.id,
|
||||
grantId,
|
||||
topic: path.data.topic,
|
||||
range: query.data.range,
|
||||
now: new Date(),
|
||||
});
|
||||
if (!detail) {
|
||||
return apiError("This topic is not available for this record", 404, {
|
||||
errorCode: "topics.not_available",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
topic: detail.key,
|
||||
topic_range: detail.range,
|
||||
topic_status: detail.status,
|
||||
topic_relationships_linked: detail.relationships.filter(
|
||||
(r) => r.status === "linked",
|
||||
).length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(detail);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* `GET /api/topics` — the topic overview (preview).
|
||||
*
|
||||
* One tile per topic the record holds data for: a headline value, a rule-based
|
||||
* sentence and a sparkline, computed on the server from the same daily values
|
||||
* the single-metric pages draw. No model is involved and no AI gate applies.
|
||||
*
|
||||
* The preview is the viewer's own opt-in (Settings > System > Preview
|
||||
* features): with it off the route answers 404 `topics.preview_off`, the same
|
||||
* as for a path that does not exist. The topics, signals and blocks follow the
|
||||
* RECORD's module switches; a topic whose every module is off is named in
|
||||
* `hiddenByModule`, an offered topic with nothing recorded in `empty`.
|
||||
*
|
||||
* Admission declares the whole record, like the timeline: a topic lays several
|
||||
* sections side by side, so a grant scoped to some of them does not open it.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { hasTopicsPreview, loadTopicOverview } from "@/lib/topics/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read", "record");
|
||||
annotate({ action: { name: "topics.overview.read" } });
|
||||
|
||||
if (!(await hasTopicsPreview(actor.id))) {
|
||||
return apiError("Topics are a preview that is switched off", 404, {
|
||||
errorCode: "topics.preview_off",
|
||||
});
|
||||
}
|
||||
|
||||
// The shared analytics-read budget, spent by the person reading.
|
||||
const rl = await checkAnalyticsReadRateLimit(actor.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const overview = await loadTopicOverview({
|
||||
userId: user.id,
|
||||
grantId,
|
||||
now: new Date(),
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
topics_shown: overview.topics.length,
|
||||
topics_empty: overview.empty.length,
|
||||
topics_hidden: overview.hiddenByModule.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
Reference in new issue
Block a user