diff --git a/CHANGELOG.md b/CHANGELOG.md index e51619328..9c8d22234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.4.47.6] — 2026-05-22 — APNs per-channel test endpoint wired + +The Settings notification-status card's "Test senden" button was unwired for the APNS channel — the `TEST_ENDPOINTS` map at `notification-status-card.tsx:60-64` had TELEGRAM / NTFY / WEB_PUSH but not APNS. Clicking the button on an APNS row resolved to `undefined` and the fetch fired against an empty URL, silently failing. The operator had no first-class way to verify the v1.4.47.5 auto-detect path; only the natural channel-state-machine cron retry could exercise it. + +This release adds the missing endpoint + map entry. + +### Added + +- `src/app/api/notifications/apns/test/route.ts` (new) — per-channel APNS self-test endpoint. Mirrors the web-push test shape: `requireAuth` + 5/min rate-limit + calls `sendViaApns(user.id, …)` with a localised "test notification" body. Returns `{ ok, reason }` so the UI can surface the actual APNs reason on failure. + +### Changed + +- `src/components/settings/notification-status-card.tsx:60-65` — added the `APNS` row to `TEST_ENDPOINTS`. The button now actually fires. + +### Effect + +- Operator can verify APNs delivery without waiting for the channel-state-machine's exponential-backoff cron retry. The v1.4.47.5 auto-detect path runs inside `sendViaApns`, so clicking "Test senden" on the APNS row will exercise it directly. + +### Risk + +Zero. Additive endpoint + one new map key. Existing channels keep working. + ## [1.4.47.5] — 2026-05-22 — APNs gateway auto-detect on `BadEnvironmentKeyInToken` v1.4.47.4 + the Coolify `APNS_PRODUCTION` env-var deletion got JWT signing past Apple's gate, but the next push still failed with `BadEnvironmentKeyInToken` — the server's per-device gateway routing didn't match the actual token environment. The iOS client picks the gateway env when it registers, but in practice the client's reported env can mismatch the token's true environment (e.g. a DEBUG build that registered itself as "production" by accident, or a TestFlight-then-Debug installation chain). diff --git a/package.json b/package.json index 16f19598c..e62fe3d04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.47.5", + "version": "1.4.47.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/notifications/apns/test/route.ts b/src/app/api/notifications/apns/test/route.ts new file mode 100644 index 000000000..23a940667 --- /dev/null +++ b/src/app/api/notifications/apns/test/route.ts @@ -0,0 +1,77 @@ +/** + * v1.4.47.6 — per-channel APNs test endpoint. + * + * Mirrors `/api/notifications/web-push/test`: fires a single self-test + * push to the calling user's iOS devices via `sendViaApns`. The + * `notification-status-card` UI on the Settings page maps the + * "Test senden" button on the APNS channel row to this endpoint. + * + * Why a separate endpoint per channel instead of `/api/admin/notifications/test`: + * - The admin test endpoint requires admin role; the per-channel test + * is available to any authenticated user who owns the channel. + * - The admin variant fans out to ALL channels at once; the per-channel + * variant lets the user verify a single channel after a fix without + * spamming the other channels too. + */ +import type { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { sendViaApns } from "@/lib/notifications/senders/apns"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +function isLocale(value: string | null | undefined): value is Locale { + return ( + value === "de" || + value === "en" || + value === "fr" || + value === "es" || + value === "it" || + value === "pl" + ); +} + +export const POST = apiHandler(async (request: NextRequest) => { + void request; + const { user } = await requireAuth(); + annotate({ action: { name: "notifications.apns.test" } }); + + const rl = await checkRateLimit(`apns-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const localeRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { locale: true }, + }); + const locale: Locale = isLocale(localeRow?.locale) + ? localeRow.locale + : defaultLocale; + const t = getServerTranslator(locale).t; + + const result = await sendViaApns(user.id, { + title: t("notifications.admin.testNotificationTitle"), + message: t("notifications.admin.testNotificationBody"), + eventType: "SYSTEM_ALERT", + }); + + annotate({ + meta: { + apns_test_ok: result.ok, + apns_test_reason: result.ok ? undefined : result.reason, + }, + }); + + return apiSuccess({ + ok: result.ok, + reason: result.ok ? undefined : result.reason, + }); +}); diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx index 0d27b151b..ef93a4068 100644 --- a/src/components/settings/notification-status-card.tsx +++ b/src/components/settings/notification-status-card.tsx @@ -35,7 +35,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH"; +type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS"; type ChannelState = | "active" @@ -61,6 +61,7 @@ const TEST_ENDPOINTS: Record = { TELEGRAM: "/api/settings/telegram/test", NTFY: "/api/settings/ntfy/test", WEB_PUSH: "/api/notifications/web-push/test", + APNS: "/api/notifications/apns/test", }; export function NotificationStatusCard() {