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
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Medikamenten-Erinnerung",
|
||||
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
|
||||
"eventMeasurementAnomaly": "Auffälliger Messwert",
|
||||
"eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.",
|
||||
"eventComplianceLow": "Niedrige Therapietreue",
|
||||
"eventComplianceLowDesc": "Wird gesendet, wenn deine Therapietreue in den letzten Tagen unter 80 % fällt.",
|
||||
"eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen",
|
||||
"eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.",
|
||||
"eventSystemAlert": "Systemmeldung",
|
||||
"eventSystemAlertDesc": "Allgemeine Mitteilungen vom HealthLog-Server, etwa Wartungsfenster oder Konto-Hinweise.",
|
||||
"eventPersonalRecord": "Persönlicher Rekord",
|
||||
"eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.",
|
||||
"eventMoodReminder": "Stimmungs-Erinnerung",
|
||||
"eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren.",
|
||||
"eventCyclePeriodSoon": "Periode steht bevor",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Medication reminder",
|
||||
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
|
||||
"eventMeasurementAnomaly": "Unusual measurement",
|
||||
"eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.",
|
||||
"eventComplianceLow": "Low adherence",
|
||||
"eventComplianceLowDesc": "Sent when your adherence over the last few days drops below 80 %.",
|
||||
"eventWithingsSyncFailed": "Withings sync failed",
|
||||
"eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.",
|
||||
"eventSystemAlert": "System notice",
|
||||
"eventSystemAlertDesc": "General messages from the HealthLog server, e.g. maintenance windows or account notes.",
|
||||
"eventPersonalRecord": "Personal record",
|
||||
"eventPersonalRecordDesc": "Sent when a reading beats your previous best.",
|
||||
"eventMoodReminder": "Mood reminder",
|
||||
"eventMoodReminderDesc": "Daily nudge to log your current mood.",
|
||||
"eventCyclePeriodSoon": "Period coming up",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Recordatorio de medicación",
|
||||
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
|
||||
"eventMeasurementAnomaly": "Medición inusual",
|
||||
"eventMeasurementAnomalyDesc": "Se envía cuando una lectura está claramente fuera de tu rango habitual.",
|
||||
"eventComplianceLow": "Cumplimiento bajo",
|
||||
"eventComplianceLowDesc": "Se envía cuando tu tasa de toma de los últimos días baja del 80 %.",
|
||||
"eventWithingsSyncFailed": "Error de sincronización con Withings",
|
||||
"eventWithingsSyncFailedDesc": "Se envía cuando la sincronización con Withings falla varios intentos seguidos.",
|
||||
"eventSystemAlert": "Aviso del sistema",
|
||||
"eventSystemAlertDesc": "Mensajes generales del servidor HealthLog, por ejemplo ventanas de mantenimiento o avisos de cuenta.",
|
||||
"eventPersonalRecord": "Récord personal",
|
||||
"eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.",
|
||||
"eventMoodReminder": "Recordatorio de ánimo",
|
||||
"eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual.",
|
||||
"eventCyclePeriodSoon": "Período próximo",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Rappel de médicament",
|
||||
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
|
||||
"eventMeasurementAnomaly": "Mesure inhabituelle",
|
||||
"eventMeasurementAnomalyDesc": "Envoyé quand une valeur sort nettement de votre plage habituelle.",
|
||||
"eventComplianceLow": "Observance faible",
|
||||
"eventComplianceLowDesc": "Envoyé quand votre taux de prise des derniers jours passe sous les 80 %.",
|
||||
"eventWithingsSyncFailed": "Échec de la synchronisation Withings",
|
||||
"eventWithingsSyncFailedDesc": "Envoyé quand la synchronisation Withings échoue plusieurs fois d'affilée.",
|
||||
"eventSystemAlert": "Notification système",
|
||||
"eventSystemAlertDesc": "Messages généraux du serveur HealthLog, par exemple maintenance ou avis de compte.",
|
||||
"eventPersonalRecord": "Record personnel",
|
||||
"eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.",
|
||||
"eventMoodReminder": "Rappel d'humeur",
|
||||
"eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment.",
|
||||
"eventCyclePeriodSoon": "Règles à venir",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Promemoria farmaco",
|
||||
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
|
||||
"eventMeasurementAnomaly": "Misurazione insolita",
|
||||
"eventMeasurementAnomalyDesc": "Inviato quando un valore esce nettamente dal tuo intervallo abituale.",
|
||||
"eventComplianceLow": "Aderenza bassa",
|
||||
"eventComplianceLowDesc": "Inviato quando il tuo tasso di assunzione degli ultimi giorni scende sotto l'80 %.",
|
||||
"eventWithingsSyncFailed": "Sincronizzazione Withings non riuscita",
|
||||
"eventWithingsSyncFailedDesc": "Inviato quando la sincronizzazione Withings fallisce per più tentativi consecutivi.",
|
||||
"eventSystemAlert": "Avviso di sistema",
|
||||
"eventSystemAlertDesc": "Messaggi generali dal server HealthLog, ad esempio finestre di manutenzione o note sull'account.",
|
||||
"eventPersonalRecord": "Record personale",
|
||||
"eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.",
|
||||
"eventMoodReminder": "Promemoria umore",
|
||||
"eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale.",
|
||||
"eventCyclePeriodSoon": "Ciclo in arrivo",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "복약 알림",
|
||||
"eventMedicationReminderDesc": "예정된 복용 시각마다 보내요.",
|
||||
"eventMeasurementAnomaly": "이상 측정값",
|
||||
"eventMeasurementAnomalyDesc": "측정값이 평소 범위를 크게 벗어나면 보내요.",
|
||||
"eventComplianceLow": "낮은 복약 순응도",
|
||||
"eventComplianceLowDesc": "최근 며칠 복약 순응도가 80% 아래로 떨어지면 보내요.",
|
||||
"eventWithingsSyncFailed": "Withings 동기화 실패",
|
||||
"eventWithingsSyncFailedDesc": "Withings 동기화가 여러 번 연달아 실패하면 보내요.",
|
||||
"eventSystemAlert": "시스템 공지",
|
||||
"eventSystemAlertDesc": "HealthLog 서버에서 오는 일반 메시지예요. 예를 들어 점검 시간이나 계정 안내.",
|
||||
"eventPersonalRecord": "개인 기록",
|
||||
"eventPersonalRecordDesc": "측정값이 이전 최고 기록을 넘으면 보내요.",
|
||||
"eventMoodReminder": "기분 알림",
|
||||
"eventMoodReminderDesc": "지금 기분을 기록하도록 매일 가볍게 알려줘요.",
|
||||
"eventCyclePeriodSoon": "생리 예정",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Medicatieherinnering",
|
||||
"eventMedicationReminderDesc": "Wordt verstuurd op elk gepland innamemoment.",
|
||||
"eventMeasurementAnomaly": "Ongebruikelijke meting",
|
||||
"eventMeasurementAnomalyDesc": "Wordt verstuurd als een meetwaarde ver buiten je gebruikelijke bereik ligt.",
|
||||
"eventComplianceLow": "Lage therapietrouw",
|
||||
"eventComplianceLowDesc": "Wordt verstuurd als je therapietrouw over de afgelopen dagen onder 80% zakt.",
|
||||
"eventWithingsSyncFailed": "Withings-synchronisatie mislukt",
|
||||
"eventWithingsSyncFailedDesc": "Wordt verstuurd als de Withings-synchronisatie meerdere keren achter elkaar mislukt.",
|
||||
"eventSystemAlert": "Systeembericht",
|
||||
"eventSystemAlertDesc": "Algemene berichten van de HealthLog-server, bijv. onderhoudsvensters of opmerkingen over je account.",
|
||||
"eventPersonalRecord": "Persoonlijk record",
|
||||
"eventPersonalRecordDesc": "Wordt verstuurd als een meetwaarde je vorige beste overtreft.",
|
||||
"eventMoodReminder": "Stemmingsherinnering",
|
||||
"eventMoodReminderDesc": "Dagelijks seintje om je huidige stemming vast te leggen.",
|
||||
"eventCyclePeriodSoon": "Menstruatie op komst",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Przypomnienie o leku",
|
||||
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
|
||||
"eventMeasurementAnomaly": "Nietypowy pomiar",
|
||||
"eventMeasurementAnomalyDesc": "Wysyłane, gdy odczyt znacznie odbiega od Twojego zwykłego zakresu.",
|
||||
"eventComplianceLow": "Niska zgodność",
|
||||
"eventComplianceLowDesc": "Wysyłane, gdy odsetek przyjętych dawek z ostatnich dni spadnie poniżej 80 %.",
|
||||
"eventWithingsSyncFailed": "Synchronizacja Withings nie powiodła się",
|
||||
"eventWithingsSyncFailedDesc": "Wysyłane, gdy synchronizacja Withings nie powiedzie się kilka razy z rzędu.",
|
||||
"eventSystemAlert": "Powiadomienie systemowe",
|
||||
"eventSystemAlertDesc": "Ogólne komunikaty z serwera HealthLog, na przykład okna serwisowe lub uwagi do konta.",
|
||||
"eventPersonalRecord": "Rekord osobisty",
|
||||
"eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.",
|
||||
"eventMoodReminder": "Przypomnienie o nastroju",
|
||||
"eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój.",
|
||||
"eventCyclePeriodSoon": "Zbliża się okres",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Lembrete de medicamento",
|
||||
"eventMedicationReminderDesc": "Enviado em cada horário programado de dose.",
|
||||
"eventMeasurementAnomaly": "Medição incomum",
|
||||
"eventMeasurementAnomalyDesc": "Enviado quando uma leitura está bem fora da sua faixa habitual.",
|
||||
"eventComplianceLow": "Adesão baixa",
|
||||
"eventComplianceLowDesc": "Enviado quando sua adesão nos últimos dias cai abaixo de 80%.",
|
||||
"eventWithingsSyncFailed": "Falha na sincronização do Withings",
|
||||
"eventWithingsSyncFailedDesc": "Enviado quando a sincronização do Withings falha várias tentativas seguidas.",
|
||||
"eventSystemAlert": "Aviso do sistema",
|
||||
"eventSystemAlertDesc": "Mensagens gerais do servidor do HealthLog, como janelas de manutenção ou notas da conta.",
|
||||
"eventPersonalRecord": "Recorde pessoal",
|
||||
"eventPersonalRecordDesc": "Enviado quando uma leitura supera seu recorde anterior.",
|
||||
"eventMoodReminder": "Lembrete de humor",
|
||||
"eventMoodReminderDesc": "Lembrete diário para registrar seu humor atual.",
|
||||
"eventCyclePeriodSoon": "Menstruação se aproximando",
|
||||
|
||||
@@ -9314,16 +9314,8 @@
|
||||
},
|
||||
"eventMedicationReminder": "Напоминание о лекарстве",
|
||||
"eventMedicationReminderDesc": "Отправляется в каждое запланированное время приёма.",
|
||||
"eventMeasurementAnomaly": "Необычное измерение",
|
||||
"eventMeasurementAnomalyDesc": "Отправляется, когда измерение заметно выходит за ваш обычный диапазон.",
|
||||
"eventComplianceLow": "Низкая приверженность",
|
||||
"eventComplianceLowDesc": "Отправляется, когда приверженность за последние несколько дней падает ниже 80 %.",
|
||||
"eventWithingsSyncFailed": "Сбой синхронизации Withings",
|
||||
"eventWithingsSyncFailedDesc": "Отправляется, когда синхронизация Withings не удаётся несколько раз подряд.",
|
||||
"eventSystemAlert": "Системное уведомление",
|
||||
"eventSystemAlertDesc": "Общие сообщения от сервера HealthLog, например об окнах обслуживания или заметки об аккаунте.",
|
||||
"eventPersonalRecord": "Личный рекорд",
|
||||
"eventPersonalRecordDesc": "Отправляется, когда измерение превосходит ваш прежний лучший результат.",
|
||||
"eventMoodReminder": "Напоминание о настроении",
|
||||
"eventMoodReminderDesc": "Ежедневное напоминание отметить текущее настроение.",
|
||||
"eventCyclePeriodSoon": "Скоро менструация",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -131,3 +131,49 @@ describe("event defaults: the matrix shows what delivery does", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("events nothing sends", () => {
|
||||
it("are listed for nobody, and their stored rows are not served", async () => {
|
||||
const userId = await seedAccount(false);
|
||||
const prisma = getPrismaClient();
|
||||
const channel = await prisma.notificationChannel.findFirstOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
// Rows written while the four switches were still offered.
|
||||
await prisma.notificationPreference.createMany({
|
||||
data: [
|
||||
{ channelId: channel.id, eventType: "PERSONAL_RECORD", enabled: true },
|
||||
{
|
||||
channelId: channel.id,
|
||||
eventType: "MEASUREMENT_ANOMALY",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
channelId: channel.id,
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/notifications/preferences/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
eventTypes: string[];
|
||||
preferences: { eventType: string; enabled: boolean }[];
|
||||
};
|
||||
};
|
||||
const { UNSENT_EVENT_TYPES } = await import("@/lib/notifications/types");
|
||||
for (const unsent of UNSENT_EVENT_TYPES) {
|
||||
expect(body.data.eventTypes).not.toContain(unsent);
|
||||
}
|
||||
expect(body.data.preferences).toEqual([
|
||||
expect.objectContaining({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
enabled: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user