Revert "feat(topics): compute topic overviews and topic pages on the server"

This reverts commit cc04e0944d.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 10:46:55 +02:00
1 parent c1911e71e3
commit 0182f91092
30 files changed
+35 -3937

No files matched your search

+14 -28
View File
@@ -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
-80
View File
@@ -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);
},
);
-54
View File
@@ -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);
});