mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Add a user-defined custom-metric store alongside the built-in measurements on the Measurements surface. A user defines an arbitrary metric (name, unit, optional target window, decimals, description), logs values, and charts them. The store is a separate generic catalog + values pair, isolated from the closed measurement-type system: not synced, not in FHIR, not in insights — log and chart only. Names and units are plaintext, mirroring the biomarker catalog. The inline list lives beneath the measurement list (no new nav entry); tapping a metric opens a detail page with the trend chart, target band, stat strip, and an editable value history. Migration 0224 adds custom_metrics and custom_metric_entries.
173 lines
5.4 KiB
TypeScript
173 lines
5.4 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import {
|
|
apiError,
|
|
apiSuccess,
|
|
getClientIp,
|
|
returnAllZodIssues,
|
|
safeJson,
|
|
sanitiseZodIssues,
|
|
} from "@/lib/api-response";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store";
|
|
import { prisma } from "@/lib/db";
|
|
import { withIdempotency } from "@/lib/idempotency";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { createCustomMetricSchema } from "@/lib/validations/custom-metrics";
|
|
|
|
/**
|
|
* v1.25.5 — user-defined custom-metric catalog (`/api/custom-metrics`).
|
|
*
|
|
* A SEPARATE generic store from the closed `MeasurementType` system: not synced,
|
|
* not in FHIR, not in AI insights — log + chart only. GET lists the caller's
|
|
* metrics with their latest logged value; POST defines a new one. `userId` is
|
|
* always narrowed from the session — never a body field — and the write `data`
|
|
* object is built field-by-field (no mass assignment). The `@@unique([userId,
|
|
* name])` index means there is never a second definition of the same name.
|
|
*/
|
|
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
|
|
const rows = await prisma.customMetric.findMany({
|
|
where: { userId: user.id, deletedAt: null },
|
|
orderBy: { name: "asc" },
|
|
include: {
|
|
entries: {
|
|
orderBy: { measuredAt: "desc" },
|
|
take: 1,
|
|
select: { value: true, unit: true, measuredAt: true },
|
|
},
|
|
_count: { select: { entries: true } },
|
|
},
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "custom-metric.metric.list" },
|
|
meta: { total: rows.length },
|
|
});
|
|
|
|
return apiSuccess({
|
|
customMetrics: rows.map((row) =>
|
|
serialiseCustomMetricWithLatest({
|
|
...row,
|
|
latest: row.entries[0] ?? null,
|
|
entryCount: row._count.entries,
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
export const POST = apiHandler(
|
|
withIdempotency<[NextRequest]>(postCustomMetric),
|
|
);
|
|
|
|
async function postCustomMetric(request: NextRequest) {
|
|
const { user } = await requireAuth();
|
|
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 16 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const parsed = createCustomMetricSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
annotate({
|
|
action: { name: "custom-metric.metric.create.validation-failed" },
|
|
meta: { issue_count: parsed.error.issues.length },
|
|
});
|
|
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
|
stripValuesFromMessage: true,
|
|
});
|
|
prisma.auditLog
|
|
.create({
|
|
data: {
|
|
userId: user.id,
|
|
action: "custom-metric.metric.create.validation-failed",
|
|
details: JSON.stringify({ issues: auditIssues }),
|
|
},
|
|
})
|
|
.catch(() => {
|
|
/* swallow — the 422 response is the contract */
|
|
});
|
|
return returnAllZodIssues(parsed.error, 422);
|
|
}
|
|
|
|
const { name, unit, targetLow, targetHigh, decimals, description } =
|
|
parsed.data;
|
|
|
|
// The `@@unique([userId, name])` index is the structural backstop and spans
|
|
// soft-deleted rows too. Resolve the name up front: a LIVE collision is a
|
|
// clean 409; a row that was previously soft-deleted under this name is
|
|
// REVIVED (un-deleted + redefined) rather than blocked by the index, so the
|
|
// user can re-create a metric they once removed.
|
|
const existing = await prisma.customMetric.findFirst({
|
|
where: { userId: user.id, name },
|
|
select: { id: true, deletedAt: true },
|
|
});
|
|
if (existing && existing.deletedAt === null) {
|
|
return apiError("A custom metric with this name already exists", 409);
|
|
}
|
|
|
|
// Field-by-field assignment — never spread `parsed.data`.
|
|
const created = existing
|
|
? await prisma.customMetric.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
unit,
|
|
targetLow: targetLow ?? null,
|
|
targetHigh: targetHigh ?? null,
|
|
decimals: decimals ?? null,
|
|
description: description ?? null,
|
|
deletedAt: null,
|
|
},
|
|
})
|
|
: await prisma.customMetric.create({
|
|
data: {
|
|
userId: user.id,
|
|
name,
|
|
unit,
|
|
targetLow: targetLow ?? null,
|
|
targetHigh: targetHigh ?? null,
|
|
decimals: decimals ?? null,
|
|
description: description ?? null,
|
|
},
|
|
});
|
|
|
|
await auditLog("customMetric.create", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { customMetricId: created.id },
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "custom-metric.metric.create" },
|
|
meta: { customMetricId: created.id, revived: existing !== null },
|
|
});
|
|
|
|
// A freshly created metric has no values; a revived one may retain entries
|
|
// logged before it was soft-deleted, so resolve the latest + count for it.
|
|
let latest: { value: number; unit: string; measuredAt: Date } | null = null;
|
|
let entryCount = 0;
|
|
if (existing) {
|
|
const [latestEntry, count] = await Promise.all([
|
|
prisma.customMetricEntry.findFirst({
|
|
where: { userId: user.id, customMetricId: created.id },
|
|
orderBy: { measuredAt: "desc" },
|
|
select: { value: true, unit: true, measuredAt: true },
|
|
}),
|
|
prisma.customMetricEntry.count({
|
|
where: { userId: user.id, customMetricId: created.id },
|
|
}),
|
|
]);
|
|
latest = latestEntry ?? null;
|
|
entryCount = count;
|
|
}
|
|
|
|
return apiSuccess(
|
|
serialiseCustomMetricWithLatest({ ...created, latest, entryCount }),
|
|
201,
|
|
);
|
|
}
|