feat(notifications): personal-records push opt-in toggle (default OFF)

Adds the PERSONAL_RECORD event type to the dispatcher vocabulary
and flips its default policy to opt-in. Without an explicit
preference row the dispatcher now suppresses PR pushes — so a
multi-year Apple Health backfill writes the records but does not
saturate the lock-screen on first sync. Other events keep the
opt-out default they always had.

The /notifications matrix renders the new event automatically
because it iterates EVENT_TYPES; the per-event default helper
keeps the toggle in the right initial state across all six
locales (EN, DE, FR, ES, IT, PL).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:51:05 +02:00
1 parent 7b7a896c44
commit 15a72bb329
10 files changed
+313 -14

No files matched your search

+9 -2
View File
@@ -8,6 +8,10 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
} from "@/lib/notifications/types";
interface ChannelInfo {
id: string;
@@ -115,8 +119,11 @@ export default function NotificationsPage() {
const pref = data?.preferences?.find(
(p) => p.channelId === channelId && p.eventType === eventType,
);
// Opt-out model: default is ON when no preference row exists
return pref ? pref.enabled : true;
if (pref) return pref.enabled;
// v1.4.25 W16c — per-event default. Most events stay opt-out
// (no row = ON); PERSONAL_RECORD flips to opt-in (no row = OFF)
// so a backfill doesn't fire hundreds of pushes on first sync.
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
}
if (authLoading || isLoading) {
@@ -309,6 +309,80 @@ describe("dispatchNotification — cooldown skip", () => {
});
});
describe("dispatchNotification — per-event default policy (v1.4.25 W16c)", () => {
it("PERSONAL_RECORD defaults OFF when no preference row exists", async () => {
const channel = makeChannel({
type: "NTFY",
config: JSON.stringify({
serverUrl: "https://ntfy.example",
topic: "t",
}),
preferences: [],
});
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
channel,
] as never);
await dispatchNotification({
eventType: "PERSONAL_RECORD",
userId: "u-1",
title: "PR",
message: "Best ever",
});
// No row → opt-in for this event type → sender NOT called.
expect(sendViaNtfyMock).not.toHaveBeenCalled();
});
it("PERSONAL_RECORD fires when the user has explicitly opted in", async () => {
const channel = makeChannel({
type: "NTFY",
config: JSON.stringify({
serverUrl: "https://ntfy.example",
topic: "t",
}),
preferences: [{ eventType: "PERSONAL_RECORD", enabled: true }],
});
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
channel,
] as never);
sendViaNtfyMock.mockResolvedValueOnce({ ok: true });
await dispatchNotification({
eventType: "PERSONAL_RECORD",
userId: "u-1",
title: "PR",
message: "Best ever",
});
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
});
it("legacy events (MEDICATION_REMINDER) still default ON when no row exists", async () => {
const channel = makeChannel({
type: "NTFY",
config: JSON.stringify({
serverUrl: "https://ntfy.example",
topic: "t",
}),
preferences: [],
});
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
channel,
] as never);
sendViaNtfyMock.mockResolvedValueOnce({ ok: true });
await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: "u-1",
title: "Reminder",
message: "Take it",
});
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
});
});
describe("dispatchNotification — Telegram hard reject", () => {
it("auto-disables on 'chat not found'", async () => {
const channel = makeChannel({
+15 -2
View File
@@ -5,7 +5,9 @@ import type {
TelegramChannelConfig,
NtfyChannelConfig,
ChannelType,
EventType,
} from "@/lib/notifications/types";
import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
@@ -113,10 +115,21 @@ export async function dispatchNotification(
channels.sort((a, b) => channelPriority(a.type) - channelPriority(b.type));
const now = new Date();
// v1.4.25 W16c — per-event default policy. Most events stay
// opt-out (no row = enabled); PERSONAL_RECORD flips to opt-in
// (no row = disabled) so the iOS backfill doesn't saturate the
// lock-screen on first launch. The user enables it explicitly
// from /settings/notifications once they've seen the badge a few
// times and decided they want push.
const defaultEnabled =
EVENT_DEFAULT_ENABLED[payload.eventType as EventType] ?? true;
for (const channel of channels) {
// Opt-out model: if no preference row exists, default to enabled
const pref = channel.preferences[0];
if (pref && !pref.enabled) continue;
if (pref) {
if (!pref.enabled) continue;
} else if (!defaultEnabled) {
continue;
}
// Backoff cooldown — skip the channel until `nextRetryAt`. Without
// this guard, a flapping upstream would burn API quota every
+25
View File
@@ -13,6 +13,10 @@ export const EVENT_TYPES = [
"COMPLIANCE_LOW",
"WITHINGS_SYNC_FAILED",
"SYSTEM_ALERT",
// v1.4.25 W16c — PR detection. Default OFF (see DEFAULT_EVENT_PREFS
// below) so a multi-year Apple Health backfill doesn't fire hundreds
// of pushes during initial sync.
"PERSONAL_RECORD",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -22,6 +26,27 @@ export const EVENT_TYPE_LABELS: Record<EventType, string> = {
COMPLIANCE_LOW: "Low compliance",
WITHINGS_SYNC_FAILED: "Withings sync failed",
SYSTEM_ALERT: "System alerts",
PERSONAL_RECORD: "Personal records",
};
/**
* Per-event default policy when no `NotificationPreference` row
* exists. The dispatcher reads this to decide whether to fire on a
* channel that has never been explicitly toggled for the event.
*
* Every event defaults to ON (the original opt-out model) EXCEPT
* `PERSONAL_RECORD`: PRs are silent by default and require the user
* to explicitly enable them in `/settings/notifications`. Rationale —
* a fresh iOS install backfilling years of HealthKit history would
* otherwise saturate the user's lock-screen on first launch.
*/
export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
MEDICATION_REMINDER: true,
MEASUREMENT_ANOMALY: true,
COMPLIANCE_LOW: true,
WITHINGS_SYNC_FAILED: true,
SYSTEM_ALERT: true,
PERSONAL_RECORD: false,
};
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {