mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Revert "feat(topics): compute topic overviews and topic pages on the server"
This reverts commit cc04e0944d.
This commit is contained in:
1 parent
c1911e71e3
commit
0182f91092
30 files changed
+35
-3937
No files matched your search
@@ -44,7 +44,6 @@
|
||||
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";
|
||||
@@ -94,21 +93,6 @@ 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;
|
||||
@@ -254,11 +238,10 @@ 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. The preview opt-ins ride the same point read of the caller's
|
||||
// own row.
|
||||
// shows it.
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { insuranceNumberEncrypted: true, previewFeaturesJson: true },
|
||||
select: { insuranceNumberEncrypted: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -306,9 +289,18 @@ export const GET = apiHandler(async () => {
|
||||
sectionsOpen(sections, "cycle") &&
|
||||
isCycleEnabled(record?.gender ?? null, cycleProfile);
|
||||
|
||||
const insuranceNumber = decryptInsuranceNumber(
|
||||
identity?.insuranceNumberEncrypted ?? null,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
id: user.id,
|
||||
@@ -455,12 +447,6 @@ 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
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* `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);
|
||||
},
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* `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