mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
7b7a896c44
commit
15a72bb329
10 files changed
+313
-14
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in new issue
Block a user