mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext PHI columns left after the v1.23 note-encryption rollout. Add the three *Encrypted Bytes columns (migration 0208), register them in the encrypted- column rotation set, route every read/write through the note cipher, and add a transactional fail-closed boot-time backfill for legacy rows. Plaintext columns drop in a later release once the backfill reports zero remaining rows. Require a fresh second factor on password change for MFA-enrolled accounts, matching the existing step-up on MFA-disable and recovery-code regen. Redact free-text note values from the Zod validation excerpt that can reach a wide event.
209 lines
7.3 KiB
TypeScript
209 lines
7.3 KiB
TypeScript
/**
|
|
* GET / PUT / DELETE insights tile layout.
|
|
*
|
|
* GET returns the resolved effective layout (defaults merged in if the
|
|
* user hasn't customised yet). PUT replaces the layout atomically.
|
|
* DELETE resets to default.
|
|
*
|
|
* Mirrors the shape and semantics of `/api/dashboard/widgets` so the
|
|
* iOS client + the Settings UI can persist insights tile order +
|
|
* visibility through the same contract. The two surfaces stay separate
|
|
* columns on `User` (`dashboard_widgets_json` / `insights_layout_json`)
|
|
* to keep each resolver's known-id set authoritative — overloading a
|
|
* single blob with a `surface` discriminator would force every read to
|
|
* demux first.
|
|
*/
|
|
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 {
|
|
resolveInsightsLayout,
|
|
serializeInsightsLayout,
|
|
DEFAULT_INSIGHTS_LAYOUT,
|
|
ACCEPTED_INSIGHTS_TILE_IDS,
|
|
INSIGHTS_SECTION_IDS,
|
|
type InsightsLayout,
|
|
} from "@/lib/insights-layout";
|
|
import { Prisma } from "@/generated/prisma/client";
|
|
import { z } from "zod/v4";
|
|
import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate";
|
|
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
|
import { redactForExcerpt } from "@/lib/observability/redact-payload";
|
|
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
// Derive the enum from `ACCEPTED_INSIGHTS_TILE_IDS` so the schema + the
|
|
// default layout cannot drift — the same root-cause class that produced
|
|
// the v1.4.16 A5 silent-422 on dashboard widgets when `achievements`
|
|
// was added to the layout but not to the validation enum.
|
|
//
|
|
// v1.8.0 — the accepted set is canonical English ids PLUS the legacy
|
|
// German aliases, so an iOS client still PUTting `blutdruck` / `puls` /
|
|
// … passes validation rather than tripping a 422 on the rename.
|
|
// `serializeInsightsLayout` normalises any legacy id onto its canonical
|
|
// English replacement before the row persists, so the stored blob is
|
|
// always canonical regardless of which id the client sent.
|
|
const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS);
|
|
|
|
// v1.15.11 — section ids are new in layout v2, English from birth, so the
|
|
// enum is the canonical id universe with no legacy-alias widening.
|
|
const sectionIdEnum = z.enum(INSIGHTS_SECTION_IDS);
|
|
|
|
const layoutSchema = z.object({
|
|
// v1.15.11 QA C1 — accept BOTH version 1 and 2 on input. The live iOS
|
|
// client still PUTs `version: 1`; `serializeInsightsLayout` hardcodes the
|
|
// stored version to the canonical `INSIGHTS_LAYOUT_VERSION` (2) regardless,
|
|
// so accepting a v1 body writes a canonical v2 blob with zero downside.
|
|
version: z.union([z.literal(1), z.literal(2)]),
|
|
// Additive + optional so a current iOS client PUTting only `tiles`
|
|
// (no `sections` key) still validates; `serializeInsightsLayout` fills
|
|
// the section defaults when the field is absent.
|
|
sections: z
|
|
.array(
|
|
z.object({
|
|
id: sectionIdEnum,
|
|
visible: z.boolean(),
|
|
order: z.number().int().min(0).max(99),
|
|
}),
|
|
)
|
|
.max(50)
|
|
.optional(),
|
|
// Optional too — a section-only PUT (no `tiles` key) is valid; the
|
|
// serializer fills the canonical default tile set. When present it must
|
|
// still carry at least one tile so an empty `[]` is a clear client bug.
|
|
tiles: z
|
|
.array(
|
|
z.object({
|
|
id: tileIdEnum,
|
|
visible: z.boolean(),
|
|
order: z.number().int().min(0).max(99),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(50)
|
|
.optional(),
|
|
});
|
|
|
|
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
|
|
const row = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { insightsLayoutJson: true },
|
|
});
|
|
return resolveInsightsLayout(row?.insightsLayoutJson);
|
|
}
|
|
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
|
|
// 5-minute TTL matches the dashboard-widgets cache; the layout
|
|
// changes only when the user hits the Settings save button, which
|
|
// invalidates this cache via `invalidateUserInsightsLayout()`.
|
|
const layout = await cached(
|
|
caches.insightsLayout as ServerCache<InsightsLayout>,
|
|
user.id,
|
|
() => buildInsightsLayout(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: 256 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const parsed = layoutSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
// Multi-issue 422 envelope matches the dashboard-widgets route so
|
|
// iOS contract debugging surfaces every wrong field in one
|
|
// round-trip; 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(redactForExcerpt(body));
|
|
annotate({
|
|
action: { name: "insights.layout.validation-failed" },
|
|
meta: {
|
|
issue_count: issues.length,
|
|
...payloadDiagnostic,
|
|
zod_issues: issues,
|
|
},
|
|
});
|
|
if (
|
|
shouldEmitAuditRow(
|
|
user.id,
|
|
"insights.layout.validation-failed",
|
|
Date.now(),
|
|
)
|
|
) {
|
|
// Best-effort breadcrumb — never block the 422 on a write miss.
|
|
// `message` strips from the audit row so a future Zod code that
|
|
// embeds the offending value cannot leak through the audit
|
|
// surface (the wide-event excerpt above already carries the
|
|
// shape signal for debugging).
|
|
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
|
stripValuesFromMessage: true,
|
|
});
|
|
prisma.auditLog
|
|
.create({
|
|
data: {
|
|
userId: user.id,
|
|
action: "insights.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);
|
|
}
|
|
|
|
// v1.16.13 — load the user's stored layout so a PUT that omits a
|
|
// dimension (iOS reorders tiles with a tiles-only body, no `sections`
|
|
// key) preserves the other dimension's customization instead of
|
|
// resetting it to defaults.
|
|
const previous = await buildInsightsLayout(user.id);
|
|
const normalized = serializeInsightsLayout(parsed.data, previous);
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
insightsLayoutJson: toJson(normalized),
|
|
},
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "insights.layout.update" },
|
|
meta: { visible_count: normalized.tiles.filter((t) => t.visible).length },
|
|
});
|
|
|
|
invalidateUserInsightsLayout(user.id);
|
|
|
|
return apiSuccess(normalized);
|
|
});
|
|
|
|
export const DELETE = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { insightsLayoutJson: Prisma.JsonNull },
|
|
});
|
|
|
|
annotate({ action: { name: "insights.layout.reset" } });
|
|
|
|
invalidateUserInsightsLayout(user.id);
|
|
|
|
return apiSuccess(DEFAULT_INSIGHTS_LAYOUT);
|
|
});
|