mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(notifications): drop the four switches nothing sends
The notification matrix listed "Unusual measurement", "Low adherence", "Withings sync failed" and "Personal record" with a switch per channel, three of them on by default, yet nothing dispatches any of them: a reading far outside its range or a week of missed doses produced no message. eventTypesForRole now leaves them out for every role, next to the admin-only filter, and the preferences read no longer serves rows stored for them. Their labels in all locales and their tap targets are deleted. The types stay in EVENT_TYPES, so the preference write and the stored rows keep parsing; no migration. The label and tap-target tests walked EVENT_TYPES and so pinned the four in place; they now walk the event types that are sent.
This commit is contained in:
1 parent
aae029d94e
commit
8057bb20bc
20 files changed
+207
-140
No files matched your search
@@ -2606,7 +2606,7 @@
|
||||
"lib/notifications/senders/telegram.ts": 4,
|
||||
"lib/notifications/senders/web-push.ts": 5,
|
||||
"lib/notifications/senders/webhook.ts": 1,
|
||||
"lib/notifications/types.ts": 21,
|
||||
"lib/notifications/types.ts": 20,
|
||||
"lib/notifications/web-push-clear.ts": 1,
|
||||
"lib/nutrients/__tests__/catalog.test.ts": 1,
|
||||
"lib/nutrients/catalog.ts": 8,
|
||||
|
||||
@@ -84,20 +84,25 @@ export const GET = apiHandler(async () => {
|
||||
globallyEnabled: globalToggles[ch.type] ?? true,
|
||||
}));
|
||||
|
||||
// Admin-only events (backup health) are listed for admins only, and an
|
||||
// event nothing sends is listed for nobody: a switch for either would
|
||||
// switch nothing. A stored row for an unlisted event stays in the table and
|
||||
// is not served.
|
||||
const eventTypes: string[] = eventTypesForRole(user.role);
|
||||
const preferences = channels.flatMap((ch) =>
|
||||
ch.preferences.map((p) => ({
|
||||
channelId: p.channelId,
|
||||
eventType: p.eventType,
|
||||
enabled: p.enabled,
|
||||
})),
|
||||
ch.preferences
|
||||
.filter((p) => eventTypes.includes(p.eventType))
|
||||
.map((p) => ({
|
||||
channelId: p.channelId,
|
||||
eventType: p.eventType,
|
||||
enabled: p.enabled,
|
||||
})),
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
channels: channelData,
|
||||
preferences,
|
||||
// Admin-only events (backup health) are listed for admins only: nobody
|
||||
// else is ever sent one, so a switch for it would switch nothing.
|
||||
eventTypes: eventTypesForRole(user.role),
|
||||
eventTypes,
|
||||
// What an event does on a channel with no stored row, resolved for this
|
||||
// account by the same function the dispatcher asks. A client renders a
|
||||
// switch without a row from this map, never from its own copy of the
|
||||
|
||||
@@ -143,8 +143,8 @@ describe("NotificationsPage", () => {
|
||||
},
|
||||
],
|
||||
preferences: [],
|
||||
eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"],
|
||||
eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false },
|
||||
eventTypes: ["MOOD_REMINDER", "DAILY_BRIEFING"],
|
||||
eventDefaults: { MOOD_REMINDER: true, DAILY_BRIEFING: false },
|
||||
};
|
||||
const html = render();
|
||||
const state = (label: string) =>
|
||||
@@ -152,10 +152,10 @@ describe("NotificationsPage", () => {
|
||||
`aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`,
|
||||
).exec(html);
|
||||
const mood = state("Mood reminder");
|
||||
const record = state("Personal record");
|
||||
const briefing = state("Daily briefing");
|
||||
expect(mood, "mood switch rendered").not.toBeNull();
|
||||
expect(mood?.[1] ?? mood?.[2]).toBe("checked");
|
||||
expect(record?.[1] ?? record?.[2]).toBe("unchecked");
|
||||
expect(briefing?.[1] ?? briefing?.[2]).toBe("unchecked");
|
||||
});
|
||||
|
||||
it("a stored row still wins over the default", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Every event type the /notifications matrix renders has words to render.
|
||||
*
|
||||
* The matrix builds its rows from `EVENT_TYPES` and derives each row's label
|
||||
* The matrix builds its rows from the event list the preferences read serves
|
||||
* (`eventTypesForRole`) and derives each row's label
|
||||
* and description key from the event name, so a new event type silently gets a
|
||||
* row whether or not anybody wrote the two strings for it. That is not a
|
||||
* theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key
|
||||
@@ -20,7 +21,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
UNSENT_EVENT_TYPES,
|
||||
eventTypesForRole,
|
||||
} from "@/lib/notifications/types";
|
||||
import {
|
||||
eventDescriptionTranslationKey,
|
||||
eventTranslationKey,
|
||||
@@ -60,14 +64,16 @@ describe("notification event labels", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("has a label and a description for every event type, in every locale", () => {
|
||||
it("has a label and a description for every listed event type, in every locale", () => {
|
||||
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
|
||||
expect(EVENT_TYPES.length).toBeGreaterThan(10);
|
||||
// ADMIN sees the widest list: every event that has a switch at all.
|
||||
const listed = eventTypesForRole("ADMIN");
|
||||
expect(listed.length).toBeGreaterThan(10);
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const locale of LOCALES) {
|
||||
const root = bundle(locale);
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
for (const eventType of listed) {
|
||||
for (const key of [
|
||||
eventTranslationKey(eventType),
|
||||
eventDescriptionTranslationKey(eventType),
|
||||
@@ -82,6 +88,24 @@ describe("notification event labels", () => {
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps no label for an event nothing sends", () => {
|
||||
const leftover: string[] = [];
|
||||
for (const locale of LOCALES) {
|
||||
const root = bundle(locale);
|
||||
for (const eventType of UNSENT_EVENT_TYPES) {
|
||||
for (const key of [
|
||||
eventTranslationKey(eventType),
|
||||
eventDescriptionTranslationKey(eventType),
|
||||
]) {
|
||||
if (resolve(root, key) !== undefined) {
|
||||
leftover.push(`${locale} still has ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the delegated-dose push copy", () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The event types a person is shown a switch for.
|
||||
*
|
||||
* The preferences read lists `eventTypesForRole(role)` as the matrix rows and
|
||||
* serves stored rows only for those. An event nothing sends is listed for
|
||||
* nobody; backup health for admins only.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
EVENT_TYPES,
|
||||
UNSENT_EVENT_TYPES,
|
||||
eventTypesForRole,
|
||||
isSentEventType,
|
||||
} from "@/lib/notifications/types";
|
||||
|
||||
describe("eventTypesForRole", () => {
|
||||
it.each(["USER", "ADMIN"])("lists no event nothing sends (%s)", (role) => {
|
||||
const listed: string[] = eventTypesForRole(role);
|
||||
expect(listed).toContain("MEDICATION_REMINDER");
|
||||
for (const unsent of UNSENT_EVENT_TYPES) {
|
||||
expect(listed).not.toContain(unsent);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists backup health for an admin only", () => {
|
||||
expect(eventTypesForRole("USER")).not.toContain("BACKUP_HEALTH");
|
||||
expect(eventTypesForRole("ADMIN")).toContain("BACKUP_HEALTH");
|
||||
});
|
||||
|
||||
it("keeps the declaration order", () => {
|
||||
expect(eventTypesForRole("ADMIN")).toEqual(
|
||||
EVENT_TYPES.filter((type) => isSentEventType(type)),
|
||||
);
|
||||
});
|
||||
|
||||
it("knows no event outside the union", () => {
|
||||
expect(isSentEventType("NOT_AN_EVENT")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The event → tap-target map and its resolver.
|
||||
*
|
||||
* Walks `EVENT_TYPES` rather than the map's own keys, so an event type added
|
||||
* to the union without a decision about where it leads fails here (the
|
||||
* `Record<EventType, …>` type already refuses to compile without one; this
|
||||
* Walks the sent event types rather than the map's own keys, so an event type
|
||||
* added to the union without a decision about where it leads fails here (the
|
||||
* `Record<SentEventType, …>` type already refuses to compile without one; this
|
||||
* pins that every entry is also a usable path).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -19,6 +19,8 @@ import { buildNtfyMessage } from "@/lib/notifications/senders/ntfy";
|
||||
import { buildWebhookBody } from "@/lib/notifications/senders/webhook";
|
||||
import {
|
||||
EVENT_TYPES,
|
||||
UNSENT_EVENT_TYPES,
|
||||
isSentEventType,
|
||||
type EventType,
|
||||
type NotificationPayload,
|
||||
} from "@/lib/notifications/types";
|
||||
@@ -35,6 +37,8 @@ import {
|
||||
/** Events never shown to the user, so they carry no target. */
|
||||
const SILENT: ReadonlySet<EventType> = new Set(["MEDICATION_INTAKE_SYNC"]);
|
||||
|
||||
const SENT = EVENT_TYPES.filter(isSentEventType);
|
||||
|
||||
const ORIGINAL = {
|
||||
APP_URL: process.env.APP_URL,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
@@ -48,9 +52,9 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NOTIFICATION_TAP_PATHS", () => {
|
||||
it("names a target for every event type", () => {
|
||||
expect(EVENT_TYPES.length).toBeGreaterThan(0);
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
it("names a target for every sent event type", () => {
|
||||
expect(SENT.length).toBeGreaterThan(0);
|
||||
for (const eventType of SENT) {
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(NOTIFICATION_TAP_PATHS, eventType),
|
||||
`${eventType} has no tap target`,
|
||||
@@ -68,17 +72,20 @@ describe("NOTIFICATION_TAP_PATHS", () => {
|
||||
it("keeps the targets the issue names", () => {
|
||||
expect(NOTIFICATION_TAP_PATHS.MOOD_REMINDER).toBe("/mood?add=1");
|
||||
expect(NOTIFICATION_TAP_PATHS.DAILY_BRIEFING).toBe("/");
|
||||
expect(NOTIFICATION_TAP_PATHS.WITHINGS_SYNC_FAILED).toBe(
|
||||
"/settings/integrations",
|
||||
);
|
||||
expect(NOTIFICATION_TAP_PATHS.MEDICATION_REMINDER).toBe("/medications");
|
||||
});
|
||||
|
||||
it("has no entry for an event type outside the union", () => {
|
||||
it("has no entry for an event type outside the sent set", () => {
|
||||
expect(Object.keys(NOTIFICATION_TAP_PATHS).sort()).toEqual(
|
||||
[...EVENT_TYPES].sort(),
|
||||
[...SENT].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("gives an event nothing sends no target", () => {
|
||||
for (const eventType of UNSENT_EVENT_TYPES) {
|
||||
expect(resolveTapPath({ eventType }), eventType).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTapPath", () => {
|
||||
@@ -212,7 +219,7 @@ describe("tap target on every kind and channel", () => {
|
||||
it.each(variants)("$name", async ({ extra, neutral }) => {
|
||||
process.env.APP_URL = "https://health.example";
|
||||
let checked = 0;
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
for (const eventType of SENT) {
|
||||
const payload = await withDeliveryLinks(
|
||||
{ userId: "u1", title: "t", message: "m", eventType, ...extra },
|
||||
"guardian-1",
|
||||
@@ -244,6 +251,6 @@ describe("tap target on every kind and channel", () => {
|
||||
).toBe(url);
|
||||
checked += 1;
|
||||
}
|
||||
expect(checked).toBe(EVENT_TYPES.length - SILENT.size);
|
||||
expect(checked).toBe(SENT.length - SILENT.size);
|
||||
});
|
||||
});
|
||||
@@ -13,29 +13,30 @@
|
||||
* - Webhook → `url` in the body (absolute URL)
|
||||
* - Email → a link line under the body (absolute URL)
|
||||
*
|
||||
* The map is typed `Record<EventType, …>`, so a new event type without a
|
||||
* target does not compile, and `tap-target.test.ts` walks `EVENT_TYPES` so a
|
||||
* target that stops resolving fails there too.
|
||||
* The map is typed `Record<SentEventType, …>`, so a new event type without a
|
||||
* target does not compile, and `tap-target.test.ts` walks the sent event
|
||||
* types so a target that stops resolving fails there too. An event type
|
||||
* nothing sends (`UNSENT_EVENT_TYPES`) has no entry and resolves to no target.
|
||||
*
|
||||
* A producer may name a more precise path in `metadata.url` (the low-stock
|
||||
* alert opens the medication's stock tab, the Coach nudge opens its own
|
||||
* conversation). That override is accepted only as a same-origin path, so a
|
||||
* value that names another host can never become a click target.
|
||||
*/
|
||||
import type { EventType, NotificationPayload } from "@/lib/notifications/types";
|
||||
import type {
|
||||
EventType,
|
||||
NotificationPayload,
|
||||
SentEventType,
|
||||
} from "@/lib/notifications/types";
|
||||
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
|
||||
|
||||
/**
|
||||
* The default in-app path per event type. `null` means the event is never
|
||||
* shown to the user (a silent background push), so it carries no target.
|
||||
*/
|
||||
export const NOTIFICATION_TAP_PATHS: Record<EventType, string | null> = {
|
||||
export const NOTIFICATION_TAP_PATHS: Record<SentEventType, string | null> = {
|
||||
MEDICATION_REMINDER: "/medications",
|
||||
MEASUREMENT_ANOMALY: "/measurements",
|
||||
COMPLIANCE_LOW: "/insights/medications",
|
||||
WITHINGS_SYNC_FAILED: "/settings/integrations",
|
||||
SYSTEM_ALERT: "/",
|
||||
PERSONAL_RECORD: "/achievements",
|
||||
// `?add=1` opens the mood entry sheet on the first paint, and only in the
|
||||
// recipient's own record: a session switched into another record opens the
|
||||
// page without the sheet (`src/app/mood/page-client.tsx`). No other target
|
||||
@@ -97,7 +98,9 @@ export function resolveTapPath(
|
||||
"eventType" | "metadata" | "discreet" | "recordUserId" | "recipientUserId"
|
||||
>,
|
||||
): string | null {
|
||||
const fallback = NOTIFICATION_TAP_PATHS[payload.eventType];
|
||||
const paths: Partial<Record<EventType, string | null>> =
|
||||
NOTIFICATION_TAP_PATHS;
|
||||
const fallback = paths[payload.eventType];
|
||||
if (fallback === null || fallback === undefined) return null;
|
||||
if (isManagedGuardianDelivery(payload)) return NEUTRAL_TAP_PATH;
|
||||
if (payload.discreet) return NEUTRAL_TAP_PATH;
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
import type { NotificationQuickAction } from "@/lib/notifications/action-token";
|
||||
|
||||
/**
|
||||
* Notification system type definitions.
|
||||
* Channels: Telegram, ntfy, Web Push
|
||||
* Events: medication reminders, anomalies, compliance, etc.
|
||||
* Notification system type definitions: the channels a notification travels
|
||||
* on and the event types it can carry.
|
||||
*/
|
||||
|
||||
export const CHANNEL_TYPES = [
|
||||
@@ -30,13 +29,12 @@ export type ChannelType = (typeof CHANNEL_TYPES)[number];
|
||||
|
||||
export const EVENT_TYPES = [
|
||||
"MEDICATION_REMINDER",
|
||||
// The next three and PERSONAL_RECORD are never sent; see
|
||||
// `UNSENT_EVENT_TYPES`.
|
||||
"MEASUREMENT_ANOMALY",
|
||||
"COMPLIANCE_LOW",
|
||||
"WITHINGS_SYNC_FAILED",
|
||||
"SYSTEM_ALERT",
|
||||
// v1.4.25 — 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",
|
||||
// v0.5.4 ios-coord — daily mood-reminder nudge. Default OFF on the
|
||||
// per-event policy (see DEFAULT_EVENT_PREFS) and additionally gated
|
||||
@@ -112,8 +110,7 @@ export const EVENT_TYPES = [
|
||||
// a deterministic floor). It is a nudge, NEVER the anomaly channel: it is
|
||||
// dispatched non-urgent, fired at most once per user per local day (the
|
||||
// `push_attempts` ledger is the frequency anchor), and only inside a local
|
||||
// morning window, so a concerning reading always stays on
|
||||
// `MEASUREMENT_ANOMALY` and this never bypasses Focus.
|
||||
// morning window, so it never bypasses Focus.
|
||||
"DAILY_BRIEFING",
|
||||
// v1.36.x — somebody with a write grant on this record marked one of the
|
||||
// owner's doses. The only notification a delegated write produces: every
|
||||
@@ -154,11 +151,9 @@ export type ManagedGuardianFanoutEvent =
|
||||
* 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.
|
||||
* Events default to ON (the original opt-out model); the ones that
|
||||
* default OFF say why beside their entry. The entries for
|
||||
* `UNSENT_EVENT_TYPES` decide nothing, since nothing dispatches them.
|
||||
*/
|
||||
export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
|
||||
MEDICATION_REMINDER: true,
|
||||
@@ -229,11 +224,38 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
|
||||
/** Event types only an admin is ever sent, and only an admin is shown. */
|
||||
export const ADMIN_ONLY_EVENT_TYPES: readonly EventType[] = ["BACKUP_HEALTH"];
|
||||
|
||||
/**
|
||||
* Event types nothing sends. They stay in `EVENT_TYPES` because the
|
||||
* preference write and stored preference rows still name them, but nobody is
|
||||
* shown a switch for them and they carry no tap target: a switch for an event
|
||||
* nobody emits would switch nothing. Withings failures reach admins as
|
||||
* `SYSTEM_ALERT`.
|
||||
*/
|
||||
export const UNSENT_EVENT_TYPES = [
|
||||
"MEASUREMENT_ANOMALY",
|
||||
"COMPLIANCE_LOW",
|
||||
"WITHINGS_SYNC_FAILED",
|
||||
"PERSONAL_RECORD",
|
||||
] as const satisfies readonly EventType[];
|
||||
|
||||
/** The event types something sends. */
|
||||
export type SentEventType = Exclude<
|
||||
EventType,
|
||||
(typeof UNSENT_EVENT_TYPES)[number]
|
||||
>;
|
||||
|
||||
export function isSentEventType(type: string): type is SentEventType {
|
||||
return (
|
||||
(EVENT_TYPES as readonly string[]).includes(type) &&
|
||||
!(UNSENT_EVENT_TYPES as readonly string[]).includes(type)
|
||||
);
|
||||
}
|
||||
|
||||
/** The event types a person with `role` can receive, in matrix order. */
|
||||
export function eventTypesForRole(role: string): EventType[] {
|
||||
return role === "ADMIN"
|
||||
? [...EVENT_TYPES]
|
||||
: EVENT_TYPES.filter((type) => !ADMIN_ONLY_EVENT_TYPES.includes(type));
|
||||
export function eventTypesForRole(role: string): SentEventType[] {
|
||||
return EVENT_TYPES.filter(isSentEventType).filter(
|
||||
(type) => role === "ADMIN" || !ADMIN_ONLY_EVENT_TYPES.includes(type),
|
||||
);
|
||||
}
|
||||
|
||||
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
|
||||
|
||||
@@ -76,7 +76,7 @@ const notificationPreferencesResponse = z
|
||||
eventTypes: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Every event category the server knows, in declaration order — the list a client renders rows for.",
|
||||
"The event categories this account is shown a switch for, in declaration order — the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.",
|
||||
),
|
||||
eventDefaults: z
|
||||
.record(eventTypeEnum, z.boolean())
|
||||
@@ -87,7 +87,7 @@ const notificationPreferencesResponse = z
|
||||
.meta({
|
||||
id: "NotificationPreferencesResponse",
|
||||
description:
|
||||
"The caller's notification channels, their stored per-event overrides, and the full event vocabulary.",
|
||||
"The caller's notification channels, their stored per-event overrides, and the event categories they can switch.",
|
||||
});
|
||||
|
||||
const notificationPreferenceRequest = notificationPreferenceSchema.meta({
|
||||
|
||||
Reference in new issue
Block a user