Files
HealthLog/src/lib/openapi/routes/notifications.ts
T
Marc-André Bombeck c8035fdd78 Describe the integration, notification and MCP surfaces in the contract
Sixteen routes the native client already calls were absent from the OpenAPI
registry, so the published spec said nothing about them. `openapi:check`
compares the registry against the YAML and never the routes against the
registry, which is why the gap survived.

Nightscout status / test / disconnect, the WHOOP credential and status reads,
and the Withings status / disconnect pair land in the integrations module. The
three per-channel configs under `/api/settings/*` join the settings module; the
notification preference and delivery-health surfaces get their own module, as do
the MCP connector connections and tokens.

These are credential-bearing surfaces, so the contract states what each response
actually carries. Every channel GET reduces its secret to a boolean, and the
descriptions say so, along with the consequence: a client cannot read the value
back, so an empty value on the way in preserves the stored one rather than
clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable
Bearer, and its description says outright that this is the only copy.

Two inline request schemas move to the validations modules they belong in, so
the registry documents the same object the handler parses rather than a
restatement of it.
2026-08-22 12:21:16 +02:00

276 lines
11 KiB
TypeScript

/**
* OpenAPI route table — notification channels: per-event preferences and
* per-channel delivery health.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies come from `src/lib/validations/notifications.ts` so the wire contract
* stays single-source; the response DTOs mirror the handlers under
* `src/app/api/notifications/*`.
*
* The channel and event vocabularies are read from
* `src/lib/notifications/types.ts` rather than restated, so a new channel type
* or event category cannot be added to the dispatcher and quietly go missing
* from the published contract.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { CHANNEL_TYPES, EVENT_TYPES } from "@/lib/notifications/types";
import {
notificationPreferenceSchema,
reEnableChannelSchema,
} from "@/lib/validations/notifications";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
const channelTypeEnum = z
.enum(CHANNEL_TYPES)
.describe("Delivery transport for a notification channel.");
const eventTypeEnum = z
.enum(EVENT_TYPES)
.describe("Notification category a per-channel preference can toggle.");
const notificationChannel = z
.object({
id: z.string(),
type: channelTypeEnum,
label: z
.string()
.describe(
"Human-readable channel name. Falls back to the raw `type` when no label is registered.",
),
enabled: z.boolean().describe("The user's own per-channel toggle."),
globallyEnabled: z
.boolean()
.describe(
"The operator's instance-wide toggle for this transport. Only TELEGRAM, NTFY and WEB_PUSH have one; every other transport reports `true`. A channel the user has enabled still sends nothing while this is false.",
),
})
.meta({
id: "NotificationChannelSummary",
description:
"One of the caller's notification channels, with both the per-user and the instance-wide toggle so a client can explain why an enabled channel is silent.",
});
const notificationPreference = z
.object({
channelId: z.string(),
eventType: eventTypeEnum,
enabled: z.boolean(),
})
.meta({
id: "NotificationPreference",
description:
"An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood and cycle reminders). Absence is therefore not 'disabled'.",
});
const notificationPreferencesResponse = z
.object({
channels: z.array(notificationChannel),
preferences: z.array(notificationPreference),
eventTypes: z
.array(z.string())
.describe(
"Every event category the server knows, in declaration order — the list a client renders rows for.",
),
})
.meta({
id: "NotificationPreferencesResponse",
description:
"The caller's notification channels, their stored per-event overrides, and the full event vocabulary.",
});
const notificationPreferenceRequest = notificationPreferenceSchema.meta({
id: "NotificationPreferenceRequest",
description:
"Set one per-(channel, event) override. `channelId` must belong to the caller — a channel id owned by somebody else answers 404, not 403. Upsert: the row is created when absent.",
});
const channelDeliveryStatus = z
.object({
id: z.string(),
type: channelTypeEnum,
label: z.string(),
enabled: z.boolean(),
state: z
.enum(["active", "auto_disabled", "manually_disabled", "sending_paused"])
.describe(
"Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects (`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled but inside a retry cooldown until `nextRetryAt`. `active` — enabled and sending.",
),
disabledReason: z
.string()
.nullable()
.describe(
"Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates `auto_disabled` from `manually_disabled`.",
),
consecutiveFailures: z.number().int(),
lastSuccessAt: z.iso.datetime({ offset: true }).nullable(),
lastFailureAt: z.iso.datetime({ offset: true }).nullable(),
lastFailureReason: z.string().nullable(),
nextRetryAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("End of the current cooldown; null when not paused."),
})
.meta({
id: "NotificationChannelDeliveryStatus",
description:
"Delivery health for one channel: the derived state plus the raw counters and timestamps behind it.",
});
const eventDeliveryStatus = z
.object({
lastDeliveredAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("Null when this category has never been delivered."),
})
.meta({ id: "NotificationEventDeliveryStatus" });
const notificationStatusResponse = z
.object({
channels: z.array(channelDeliveryStatus),
events: z
.record(z.string(), eventDeliveryStatus)
.describe(
"Keyed by event category. EVERY known category is present, with `lastDeliveredAt: null` when no data exists, so a client can render an empty row without conditional plumbing. Only categories with their own dispatch ledger can ever report a non-null instant — today that is `MOOD_REMINDER` alone; every other category reports null regardless of what was actually delivered.",
),
})
.meta({
id: "NotificationStatusResponse",
description:
"Per-channel delivery health plus a per-category last-delivered map.",
});
const reEnableChannelRequest = reEnableChannelSchema.meta({
id: "ReEnableNotificationChannelRequest",
description:
"Clear the auto-disable on one of the caller's own channels. A channel id the caller does not own answers 404.",
});
export const notificationChannelPaths: NonNullable<ZodOpenApiObject["paths"]> =
{
"/api/notifications/preferences": {
get: {
tags: ["Notifications"],
summary: "Read the caller's notification channels and preferences",
description:
"Returns every channel the caller has, its stored per-event overrides, and the full event vocabulary. Not a pure read: a user whose Telegram config still lives only on the legacy `User` columns has it migrated into a channel row as a side effect of this call, and that migration failing is swallowed as a warning rather than surfaced. Auth via cookie or Bearer.",
responses: {
"200": {
description: "Channels, stored overrides and event vocabulary.",
content: {
"application/json": {
schema: dataEnvelope(
notificationPreferencesResponse,
"GetNotificationPreferences",
),
},
},
},
...stdResponses,
},
},
put: {
tags: ["Notifications"],
summary: "Set one per-channel notification preference",
description:
"Upserts a single (channel, event) override and echoes the stored row. One preference per call — there is no batch arm. Body capped at 64 KiB. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {
"application/json": { schema: notificationPreferenceRequest },
},
},
responses: {
"200": {
description: "The stored preference.",
content: {
"application/json": {
schema: dataEnvelope(
notificationPreference,
"PutNotificationPreferenceResponse",
),
},
},
},
"404": {
description: "No such channel for this caller.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Body exceeds 64 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"Body is not parseable JSON, or failed validation. Note that unlike the routes wrapped in `safeJson`, a malformed body here is 422 rather than 400, and no Content-Type check runs.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/notifications/status": {
get: {
tags: ["Notifications"],
summary: "Read per-channel delivery health",
description:
"What the notification settings surface paints into its Active / Auto-disabled / Sending-paused badges, plus a per-category last-delivered map. Auth via cookie or Bearer.",
responses: {
"200": {
description: "Delivery health per channel and per category.",
content: {
"application/json": {
schema: dataEnvelope(
notificationStatusResponse,
"GetNotificationStatus",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Notifications"],
summary: "Re-enable an auto-disabled channel",
description:
"Clears `disabledReason`, `consecutiveFailures` and `nextRetryAt`, sets `enabled` true, and audits the change. Accepted on any of the caller's channels, not only auto-disabled ones — re-enabling an already-active channel is a no-op that still answers 200. Follow up with a test send if the client wants confirmation the transport works again. Body capped at 64 KiB. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {
"application/json": { schema: reEnableChannelRequest },
},
},
responses: {
"200": {
description: "The channel was re-enabled.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ ok: z.literal(true) }),
"ReEnableNotificationChannelResponse",
),
},
},
},
"404": {
description: "No such channel for this caller.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Body exceeds 64 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"Body is not parseable JSON, or failed validation. As above, a malformed body here is 422 rather than 400.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
};