Files
HealthLog/src/app/api/medications/layout/route.ts
T
Marc-André Bombeck e21dfd040e feat(medications): a sortable table view with an order you choose
The medications page gains a compact table beside the card view — one
row per medication with the same served-due status pill, next-intake
line, cadence-scaled adherence bars, dose-derived stock and the take
and skip actions the cards use, all from the payloads the cards
already read; the list response aggregates stock in the same round
trip. Columns sort tri-state with nulls last, the default order is
yours to arrange — the same drag dialog the dashboard and insights
use, applied to both views — and the chosen view and order persist
per user on their own settings column with field-scoped writes, so
toggling the view never touches the order. Inactive medications pin
to the end, the first column stays put under horizontal scroll, and
the loading skeleton follows the view you left.
2026-06-12 18:10:21 +02:00

178 lines
6.0 KiB
TypeScript

/**
* GET / PUT / DELETE medications list presentation.
*
* GET returns the resolved presentation (view + manual order, defaults
* merged in if the user hasn't customised yet). PUT updates it with
* preserve-when-absent semantics — a view-only PUT keeps the stored
* order and vice versa, matching the `heroVisible` contract on
* `/api/dashboard/widgets`. DELETE resets to default.
*
* Mirrors the shape and semantics of `/api/insights/layout`; the blob
* lives on its own `User` column (`medication_list_layout_json`) per
* the per-surface-column convention.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
safeJson,
returnAllZodIssues,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
resolveMedicationListLayout,
serializeMedicationListLayout,
DEFAULT_MEDICATION_LIST_LAYOUT,
MEDICATION_LIST_VIEWS,
MEDICATION_ORDER_ID_MAX_LENGTH,
MEDICATION_ORDER_MAX_ENTRIES,
type MedicationListLayout,
} from "@/lib/medication-list-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
const layoutSchema = z.object({
version: z.literal(1),
// Both fields are optional so a client can PUT exactly the field it
// changed; the handler merges the absent one from the stored blob
// (preserve-when-absent, like `heroVisible` on the dashboard layout).
view: z.enum(MEDICATION_LIST_VIEWS).optional(),
// Medication ids are opaque here — display-only ordering, unknown /
// deleted ids are ignored at apply time, so the schema bounds size
// and shape but does not assert ownership.
order: z
.array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH))
.max(MEDICATION_ORDER_MAX_ENTRIES)
.optional(),
});
async function buildMedicationListLayout(
userId: string,
): Promise<MedicationListLayout> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { medicationListLayoutJson: true },
});
return resolveMedicationListLayout(row?.medicationListLayoutJson);
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// 5-minute TTL matches the dashboard-widgets / insights-layout
// caches; the blob changes only on a view toggle or an order save,
// which invalidates via `invalidateUserMedicationListLayout()`.
const layout = await cached(
caches.medicationListLayout as ServerCache<MedicationListLayout>,
user.id,
() => buildMedicationListLayout(user.id),
annotate,
);
return apiSuccess(layout);
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = layoutSchema.safeParse(body);
if (!parsed.success) {
// Multi-issue 422 envelope matches the dashboard-widgets / insights
// layout routes; the wide-event line carries the redacted payload
// shape for operator debugging without leaking the raw body.
const issues = sanitiseZodIssues(parsed.error.issues);
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
annotate({
action: { name: "medication.layout.validation-failed" },
meta: {
issue_count: issues.length,
...payloadDiagnostic,
zod_issues: issues,
},
});
if (
shouldEmitAuditRow(
user.id,
"medication.layout.validation-failed",
Date.now(),
)
) {
// Best-effort breadcrumb — never block the 422 on a write miss.
// `message` strips from the audit row so a Zod code that embeds
// the offending value cannot leak through the audit surface.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medication.layout.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
}
return returnAllZodIssues(parsed.error, 422);
}
// Preserve-when-absent: a PUT carrying only `view` must not wipe the
// stored manual order, and an order-only PUT must not reset the view.
// One stored-blob read covers both fallbacks.
let mergedView = parsed.data.view;
let mergedOrder = parsed.data.order;
if (mergedView === undefined || mergedOrder === undefined) {
const existing = await buildMedicationListLayout(user.id);
if (mergedView === undefined) mergedView = existing.view;
if (mergedOrder === undefined) mergedOrder = existing.order;
}
const normalized = serializeMedicationListLayout({
view: mergedView,
order: mergedOrder,
});
await prisma.user.update({
where: { id: user.id },
data: { medicationListLayoutJson: toJson(normalized) },
});
annotate({
action: { name: "medication.layout.update" },
meta: { view: normalized.view, order_count: normalized.order.length },
});
invalidateUserMedicationListLayout(user.id);
return apiSuccess(normalized);
});
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await prisma.user.update({
where: { id: user.id },
data: { medicationListLayoutJson: Prisma.JsonNull },
});
annotate({ action: { name: "medication.layout.reset" } });
invalidateUserMedicationListLayout(user.id);
return apiSuccess(DEFAULT_MEDICATION_LIST_LAYOUT);
});