Files
HealthLog/src/lib/openapi/routes/custom-metrics.ts
T
Marc-André Bombeck ab6c1cd917 fix(api): put a ceiling under the single-record writes
The batch endpoints have been capped at 60 calls a minute since they
were written. The per-record siblings a naive or older client loops
over — the measurement create, the mood entry, both medication-intake
writes, a lab result, a custom-metric entry, a cycle day-log, a
vaccination, an allergy, a biomarker and an encounter — had no ceiling
at all. That is backwards from the risk: the batch endpoint is the one
a well-behaved client uses, and the unlimited path is the one a broken
loop finds.

All eleven share one generous per-account bucket, `record-write`, at
300 requests a minute. Five writes a second sustained for a full minute
is beyond any hand-driven session and any sync burst, and every one of
these routes is already wrapped in `withIdempotency`, so an honest
retry storm is deduplicated before it is counted — the bucket exists to
cut off the pathological loop, not to shape traffic. It keys on the
ACTING account, the precedent the delegable medication routes set, so a
delegate burns their own allowance rather than locking the record's
owner out of their own log.

The eleven operations publish the bucket name and the cap instead of
the generic refusal, on the shared 429 that already declares
`Retry-After` and the rate-limit triple. A test pulls the key and the
window out of the query the limiter runs and holds the published
sentence to them, so the paragraph cannot drift away from the bucket it
describes.
2026-09-10 07:42:07 +02:00

409 lines
13 KiB
TypeScript

/**
* OpenAPI route table for the user-defined custom-metric store
* (`/api/custom-metrics`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies reuse the runtime Zod schemas from `@/lib/validations/custom-metrics`
* so the wire contract stays single-source. The response shapes are declared
* here.
*
* Custom metrics are a separate generic store from the closed measurement
* system. A definition can explicitly opt into the bounded daily correlation
* behaviour-channel scan. All fields are plaintext.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
createCustomMetricEntrySchema,
createCustomMetricSchema,
updateCustomMetricEntrySchema,
updateCustomMetricSchema,
} from "@/lib/validations/custom-metrics";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const createCustomMetricRequest = createCustomMetricSchema.meta({
id: "CreateCustomMetricRequest",
description:
"Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, an optional `description`, and explicit `correlationEnabled` opt-in. The name is unique per user. Logging a value later snapshots its unit.",
});
const updateCustomMetricRequest = updateCustomMetricSchema.meta({
id: "UpdateCustomMetricRequest",
description:
"Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. `correlationEnabled` explicitly controls use as a bounded behaviour channel. A conflicting rename is rejected 409.",
});
const createCustomMetricEntryRequest = createCustomMetricEntrySchema.meta({
id: "CreateCustomMetricEntryRequest",
description:
"Log a value against a custom metric: numeric `value`, ISO 8601 `measuredAt`, optional free-text `note`. The metric's current unit is snapshotted onto the entry server-side.",
});
const updateCustomMetricEntryRequest = updateCustomMetricEntrySchema.meta({
id: "UpdateCustomMetricEntryRequest",
description:
"Partial edit of a logged value. An omitted key leaves the column untouched; an explicit `null` on `note` clears it.",
});
const latestValue = z
.object({
value: z.number(),
unit: z.string(),
measuredAt: z.string(),
})
.nullable();
const customMetricRow = z
.object({
id: z.string(),
name: z.string(),
unit: z.string(),
targetLow: z.number().nullable(),
targetHigh: z.number().nullable(),
decimals: z.number().nullable(),
description: z.string().nullable(),
correlationEnabled: z.boolean(),
latest: latestValue,
entryCount: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "CustomMetric",
description:
"A user-defined custom metric. `latest` is the most recently logged value (or null); `entryCount` is the total logged values. The optional target window is the user's own good range, charted as a reference band.",
});
const entryRow = z
.object({
id: z.string(),
customMetricId: z.string(),
value: z.number(),
unit: z.string(),
measuredAt: z.string(),
note: z.string().nullable(),
createdAt: z.string(),
})
.meta({
id: "CustomMetricEntry",
description:
"A single logged value for a custom metric. `unit` is a snapshot of the metric's unit at write time.",
});
const listResponse = z
.object({ customMetrics: z.array(customMetricRow) })
.meta({ id: "ListCustomMetricsResponse" });
const entriesResponse = z
.object({
entries: z.array(entryRow),
meta: z.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
}),
})
.meta({ id: "ListCustomMetricEntriesResponse" });
const conflict = {
"409": {
description:
"A custom metric with this name already exists for the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const notFound = {
"404": {
description: "Custom metric not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
const entryNotFound = {
"404": {
description: "Custom metric entry not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/custom-metrics": {
get: {
tags: ["Custom metrics"],
summary: "List the caller's custom metrics",
description:
"Returns every custom metric the caller has defined, name-ordered, each with its latest logged value and total value count.",
responses: {
...recordRefusal(),
"200": {
description: "Custom-metric catalog.",
content: {
"application/json": {
schema: dataEnvelope(listResponse, "ListCustomMetricsEnvelope"),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Custom metrics"],
summary: "Define a custom metric",
description:
"Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that definition. Audits as `customMetric.create`.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createCustomMetricRequest } },
},
responses: {
...idempotentWrite(),
"201": {
description: "Created custom metric.",
content: {
"application/json": {
schema: dataEnvelope(
customMetricRow,
"CreateCustomMetricResponse",
),
},
},
},
...conflict,
...stdResponses,
},
},
},
"/api/custom-metrics/{id}": {
get: {
tags: ["Custom metrics"],
summary: "Fetch a single custom metric",
description:
"Returns the custom-metric definition. Cross-user rows surface as 404.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
"200": {
description: "Custom-metric detail.",
content: {
"application/json": {
schema: dataEnvelope(customMetricRow, "GetCustomMetricResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
patch: {
tags: ["Custom metrics"],
summary: "Edit a custom metric",
description:
"Partial edit; omitted fields are untouched, an explicit null clears a target bound / decimals / description. Audits as `customMetric.update`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateCustomMetricRequest } },
},
responses: {
"200": {
description: "Updated custom metric.",
content: {
"application/json": {
schema: dataEnvelope(
customMetricRow,
"UpdateCustomMetricResponse",
),
},
},
},
...conflict,
...notFound,
...stdResponses,
},
},
delete: {
tags: ["Custom metrics"],
summary: "Delete a custom metric",
description:
"Soft-deletes the metric (stamps `deletedAt`); its logged values are retained and re-creating the name revives it. Audits as `customMetric.delete`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteCustomMetricResponse",
),
},
},
},
...notFound,
...stdResponses,
},
},
},
"/api/custom-metrics/{id}/entries": {
get: {
tags: ["Custom metrics"],
summary: "List a custom metric's logged values",
description:
"Offset-paginated value feed for one custom metric (the chart + history read). Cross-user / unknown metric ids surface as 404.",
requestParams: {
path: z.object({ id: z.string() }),
query: z.object({
limit: z.coerce.number().int().min(1).max(500).optional(),
offset: z.coerce.number().int().min(0).optional(),
sortDir: z.enum(["asc", "desc"]).optional(),
}),
},
responses: {
...recordRefusal(),
"200": {
description: "Logged values.",
content: {
"application/json": {
schema: dataEnvelope(
entriesResponse,
"ListCustomMetricEntriesEnvelope",
),
},
},
},
...notFound,
...stdResponses,
},
},
post: {
tags: ["Custom metrics"],
summary: "Log a value",
description:
"Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
"application/json": { schema: createCustomMetricEntryRequest },
},
},
responses: {
...idempotentWrite(),
"201": {
description: "Created value.",
content: {
"application/json": {
schema: dataEnvelope(entryRow, "CreateCustomMetricEntryResponse"),
},
},
},
...notFound,
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
"/api/custom-metrics/{id}/entries/{entryId}": {
patch: {
tags: ["Custom metrics"],
summary: "Edit a logged value",
description:
"Partial edit of a value; omitted fields untouched, explicit null clears `note`. Audits as `customMetricEntry.update`.",
requestParams: {
path: z.object({ id: z.string(), entryId: z.string() }),
},
requestBody: {
required: true,
content: {
"application/json": { schema: updateCustomMetricEntryRequest },
},
},
responses: {
"200": {
description: "Updated value.",
content: {
"application/json": {
schema: dataEnvelope(entryRow, "UpdateCustomMetricEntryResponse"),
},
},
},
...entryNotFound,
...stdResponses,
},
},
delete: {
tags: ["Custom metrics"],
summary: "Delete a logged value",
description:
"Tombstones the value (soft delete, v1.37.20); the sibling entries/restore endpoint clears the tombstone. Audits as `customMetricEntry.delete`.",
requestParams: {
path: z.object({ id: z.string(), entryId: z.string() }),
},
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteCustomMetricEntryResponse",
),
},
},
},
...entryNotFound,
...stdResponses,
},
},
},
"/api/custom-metrics/{id}/entries/restore": {
post: {
tags: ["Custom metrics"],
summary: "Restore tombstoned values (v1.37.20)",
description:
"Clears deletedAt on every owned, currently-tombstoned entry of this metric named in ids (1..200). A foreign, live or mismatched-parent id is a silent no-op, never an existence leak. Backs the delete toast's Undo. Audits as `customMetricEntry.restore`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": {
schema: z
.object({ ids: z.array(z.string().min(1)).min(1).max(200) })
.meta({ id: "RestoreCustomMetricEntriesRequest" }),
},
},
},
responses: {
"200": {
description: "Count of entries restored.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ restored: z.number().int() }),
"RestoreCustomMetricEntriesResponse",
),
},
},
},
...stdResponses,
},
},
},
};