Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 unit tests under `senders/__tests__/apns.test.ts` exercise:
* env-var loader — all-or-none guard, inline-PEM `\n` unescape, file
fallback path, cache, APNS_PRODUCTION override
* Provider lazy-init + per-gateway cache (sandbox vs production stay
isolated; APNS_PRODUCTION funnels every send through production)
* permanent-failure detection (`Unregistered`, `BadDeviceToken`,
`DeviceTokenNotForTopic` flip `shouldDisable: true`; transient
reasons stay false)
* `sendViaApns()` dispatcher entry — fan-out, dead-row cleanup,
hard-vs-soft channel-reject classification, HTML stripping
3 integration tests under `tests/integration/apns-dispatch.test.ts`
drive the real dispatcher against a Postgres testcontainer with a
stubbed `@parse/node-apn` Provider:
* Round-trip: notification fans out via APNs and the channel-state
machine records `lastSuccessAt` + clears any cooldown
* Failure path: `Unregistered` deletes the dead Device row,
auto-disables the channel with the captured reason, and writes the
`notification.channel.auto_disabled` audit-log entry
* Cascade: a disabled APNS channel falls through to Telegram on the
next dispatch — the apns send-mock stays untouched
Adds `senders/__mocks__/apns.ts` — a deterministic mock with queued
per-token responses + recorded calls so future test files can swap the
real sender for a fixture-driven double without touching `@parse/node-apn`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires `sendViaApns()` into `dispatcher.sendToChannel()` as the fourth
channel-type alongside Telegram, ntfy, and Web Push. Per-user channel
selection works exactly as before — a user with `APNS` enabled in
`NotificationChannel` and no preference override receives APNs alerts;
a user who pairs an iPhone but disables APNs in Settings doesn't.
Cascade order is now deterministic: APNs → Telegram → ntfy → Web Push.
The order matters because each channel runs best-effort and the user
sees notifications fan out in that sequence; sorting in `dispatchNotification`
keeps the delivery sequence stable across dispatches even when Postgres
returns the channel rows in physical order. Unknown channel types sort
last so a stale enum value can't preempt a real channel.
The `CHANNEL_TYPES` tuple, `CHANNEL_TYPE_LABELS` map, and
`ChannelConfig` union pick up `APNS` / `Apple Push (APNs)` /
`ApnsChannelConfig` (an empty record by design — the per-device APNs
token + environment live on the `Device` row, not on the channel
config). On `shouldDisable: true` from the sender, the dispatcher's
existing channel-state machine flips `disabledReason` and bumps
`consecutiveFailures` through `recordChannelHardReject()` — no new
state-machine code needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `src/lib/notifications/senders/apns.ts` so the dispatcher can fan
notifications out to a user's iPhone via Apple's push gateway. The
sender exposes two surfaces:
* `sendApnsPush({ deviceToken, environment, payload })` for one-shot
delivery — the helper the iOS-targeted reminder pipeline calls
directly.
* `sendViaApns(userId, payload)` mirrors `sendViaWebPush(userId,
payload)` so the dispatcher can route APNS through the same
`SendOutcome` contract: at-least-one-success → ok, every device
permanently rejected → hard channel reject + auto-disable, anything
else → soft failure that feeds the existing exponential-backoff
schedule.
Provider lifecycle is one `apn.Provider` per gateway (`sandbox` vs
`production`), lazy-initialised so the JWT-signing-key parse doesn't
happen at boot. `@parse/node-apn` regenerates the bearer JWT every
~50 minutes inside the Provider, so the singleton is safe — the
explicit `resetApnsForTesting()` helper exists so unit tests can
re-read env vars between cases.
Permanent failure detection covers `Unregistered`, `BadDeviceToken`,
and `DeviceTokenNotForTopic`; the dispatcher branch deletes the dead
Device row before returning, mirroring the web-push 410 cleanup. The
env-var loader is all-or-none with a one-shot warning when the
operator started configuring and stopped — half-finished Coolify env
blocks surface in the logs the moment they matter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.
Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
"blocked by the user") → channel auto-disabled, disabled_reason
captured, audit log notification.channel.auto_disabled written.
Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
[30s, 5min, 30min, 2h]. Counter consecutive_failures increments
per failure; after the 5th in-a-row the channel is auto-disabled
with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
channel without calling the sender — defeats the API-quota-burn
loop a flapping upstream would otherwise create.
Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
columns (recordChannelSuccess, recordChannelHardReject,
recordChannelTransientFailure, reEnableChannel,
isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
give-up. Sender-throws absorbed as transient failures.
Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).
Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:
- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json
Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated
Bump version to 1.0.1.
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.