mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
339 lines
13 KiB
TypeScript
339 lines
13 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 {
|
|
QUICK_ACTION_IDS,
|
|
type QuickActionId,
|
|
} from "@/lib/notifications/action-token";
|
|
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.",
|
|
});
|
|
|
|
const notificationActionResult = z
|
|
.object({
|
|
logged: z.literal(true),
|
|
action: z
|
|
.enum(QUICK_ACTION_IDS as [QuickActionId, ...QuickActionId[]])
|
|
.describe(
|
|
"The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the five-point mood scale.",
|
|
),
|
|
})
|
|
.meta({
|
|
id: "NotificationActionResult",
|
|
description: "A quick answer from a notification, logged as a mood entry.",
|
|
});
|
|
|
|
export const notificationChannelPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
|
{
|
|
"/api/notifications/action/{token}": {
|
|
post: {
|
|
tags: ["Notifications"],
|
|
summary: "Log a quick answer tapped inside a notification",
|
|
description:
|
|
"Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is the only credential. The token is bound to one user and one answer, expires six hours after the reminder was sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off.",
|
|
requestParams: {
|
|
path: z.object({
|
|
token: z
|
|
.string()
|
|
.describe("Signed action token from the notification's URL."),
|
|
}),
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "The answer was logged.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
notificationActionResult,
|
|
"NotificationActionResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
"401": {
|
|
description: "Invalid token (forged, altered, or unknown user).",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"403": {
|
|
description: "The mood module is turned off for this account.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"409": {
|
|
description: "This reminder was already answered.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"410": {
|
|
description: "The answer expired.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"/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 } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|