diff --git a/README.md b/README.md index 510606216..b52e88a11 100644 --- a/README.md +++ b/README.md @@ -595,7 +595,8 @@ The full documentation set lives under [`docs/`](docs/) (also published at [docs | Area | Where | What it covers | | ---- | ----- | -------------- | -| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling | +| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling, [reproductive-health data on your own server](docs/self-hosting/reproductive-health-data.md) | +| Threat model | [`docs/security/cycle-data-threat-model.md`](docs/security/cycle-data-threat-model.md) | What self-hosting cycle data does and does not protect against | | Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning | | API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset | | Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers | diff --git a/docs/security/cycle-data-threat-model.md b/docs/security/cycle-data-threat-model.md new file mode 100644 index 000000000..83c148430 --- /dev/null +++ b/docs/security/cycle-data-threat-model.md @@ -0,0 +1,52 @@ +# Cycle-data threat model + +A compact statement of what self-hosting HealthLog's cycle tracking +protects against and what it does not. Pair this with the operator +guidance in +[`self-hosting/reproductive-health-data.md`](../self-hosting/reproductive-health-data.md). + +The scope here is reproductive-health data specifically (menstrual, +fertility, pregnancy, contraception, sexual-activity fields), because +that is the category where the "who can reach my data" question carries +the highest stakes. + +## What self-hosting + encryption-at-rest protects against + +- **A cloud vendor holding your data.** There is no SaaS account and no + vendor key escrow. No third party can be served process for data it + does not have. +- **A stolen disk or a leaked backup, while the key is held separately.** + The intent-revealing fields are AES-256-GCM encrypted at rest; a disk + image without the `ENCRYPTION_KEYS` value does not yield them. +- **Casual lock-screen disclosure.** Discreet notifications collapse + every cycle reminder to a generic "HealthLog reminder" so the event is + never named on a lock screen. +- **Third-party analytics / ad-tech leakage.** HealthLog ships no + telemetry and no third-party trackers; cycle data is never sent to an + analytics or advertising endpoint. + +## What it does NOT protect against + +- **Seizure or compulsion of your own host.** If the running machine is + seized, imaged, or compelled, the data is on it. Encryption at rest + does not help against a live host or a key stored beside the data. +- **Your hosting provider.** A VPS provider can snapshot the disk and + can be served process. Self-hosting on rented infrastructure narrows + the set of parties that hold the data; it does not eliminate them. +- **Your own backups.** Any `pg_dump` or off-host export carries the + same data and must be protected as part of the same surface. +- **A compromised or unlocked client device.** A phone or laptop with a + live session or cached client holds data locally. Server-side + protection does nothing for an unlocked device. +- **Re-identification from "plaintext" fields.** Flow, basal body + temperature, ovulation tests, cervical mucus, and cycle dates stay in + plaintext so the engines can read them. They are health observations, + not the disclosure-sensitive intent fields — but they are still + health data on your host. + +## The one-line version + +Self-hosting means **no cloud vendor to subpoena**. It does **not** mean +the data is beyond reach: your own infrastructure, your provider, your +backups, and your devices are all still part of the surface. Size your +operator-side hardening to your own threat model. diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md new file mode 100644 index 000000000..bb30fd909 --- /dev/null +++ b/docs/self-hosting/reproductive-health-data.md @@ -0,0 +1,103 @@ +# Reproductive-health data on your own server + +HealthLog's cycle tracking stores menstrual, fertility, and related +reproductive-health data the same way it stores every other metric: in +the PostgreSQL database that runs on the host you control, encrypted at +rest for the intent-revealing fields. This page explains what that +arrangement does and does not change about who can reach that data, in +plain terms and without overstating the protection. + +## What self-hosting changes + +A consumer cycle-tracking app runs on the vendor's servers. Your +reproductive-health data lives in their database, under their keys, in +their jurisdiction. If that vendor receives a subpoena, a civil +discovery request, or a law-enforcement demand it judges valid, the +vendor — not you — decides how to respond, and it holds the data and the +keys needed to comply. Several vendors state in their own privacy +policies that they comply with valid legal process; that is a factual +description of where the data sits, not an accusation. + +Self-hosting removes that vendor from the picture. There is no cloud +account holding your cycle data, no vendor key escrow, and no third +party that can be served process to hand over data it does not have. The +honest framing is narrow and worth stating precisely: + +> Self-hosting means there is no cloud vendor to subpoena for your +> reproductive-health data. It does **not** make the data +> unreachable. + +The encryption-at-rest layer is real: the intent-revealing day-log +fields (free-text notes always; pregnancy, contraception, and +sexual-activity fields by default for this category) are encrypted with +AES-256-GCM under a key you supply via `ENCRYPTION_KEYS`, and the +application fails closed rather than ever writing those fields in +plaintext. Flow, basal body temperature, ovulation-test results, +cervical-mucus observations, and the cycle dates stay in plaintext +because the prediction and correlation engines read them; they are +health observations, not the disclosure-sensitive intent fields. + +## The residual threat (read this part) + +Self-hosting moves the data onto a box you own. That box can still be +reached: + +- **Physical or legal seizure of your own host.** If the machine + running PostgreSQL — your VPS, your home server, your NAS — is seized, + imaged, or compelled, the data is on it. Encryption at rest protects + against a stolen disk or a leaked backup only while the key is *not* + also available; if the host is running, or the key is stored next to + the data, that protection is gone. +- **Your hosting provider.** A VPS provider can snapshot your disk and + can be served process the same way a SaaS vendor can. Self-hosting on + rented infrastructure narrows the set of parties who hold your data; + it does not reduce it to zero. +- **Your own backups.** Off-host backups (the optional S3 export, or any + `pg_dump` you take) carry the same data. Encrypt them, control where + they land, and treat them as part of the same threat surface. +- **The device that talks to the server.** A phone or laptop with a + live session, a cached PWA, or the native client holds data locally + too. Protecting the server does nothing for an unlocked device. + +The promise is therefore specific: **no cloud vendor to subpoena**, not +**data nobody can ever reach**. If your threat model includes seizure of +your own infrastructure, encryption at rest plus a key you keep off the +host is the floor, and the threat-model note below covers the rest. + +## Public record + +The reason this distinction matters is documented in public legal +record, not speculation: + +- In 2021 the U.S. Federal Trade Commission settled with the maker of a + period- and fertility-tracking app over allegations that it shared + users' health data with third-party analytics and advertising + services despite privacy promises. The settlement is public record. +- In 2025 a federal jury found that the same app's maker violated a + California privacy statute over the sharing of menstrual-tracking + data; the verdict and the underlying class action are public record. + +These are cited as factual public record to explain *why* the +"who holds the data" question is the one that matters for this category. +HealthLog makes no claim about any other product's current practices. + +## Practical hardening + +If reproductive-health privacy is the reason you self-host, the +operator-side steps that matter most: + +- Keep `ENCRYPTION_KEYS` off the host that runs PostgreSQL where your + threat model allows it (an injected secret at boot, not a file next to + the database). See `docs/ops/encryption-key-rotation.md`. +- Turn off any integration you do not need. The fewer outbound paths, + the smaller the surface. +- Encrypt and control your backups. The off-host backup feature uses + AES-GCM; the destination bucket and its access are yours to lock down. +- Use the in-app **discreet notifications** toggle so cycle reminders + never name the event on a lock screen, and the **client-managed + reminders** path if you want the device, not the server, to own those + notifications. +- Lock the devices that hold a live session. + +See also the [threat-model note](../security/cycle-data-threat-model.md) +for a compact "protects against / does not protect against" summary. diff --git a/messages/de.json b/messages/de.json index eb288c858..bdafdb4d7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Mittlere Sicherheit", "confidenceHigh": "Hohe Sicherheit" } - } + }, + "disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson." + }, + "cycleReminders": { + "periodSoonTitle": "Periode steht wahrscheinlich bevor", + "periodSoonBody": "Deine nächste Periode wird in etwa zwei Tagen erwartet.", + "periodConfirmTitle": "Hat deine Periode begonnen?", + "periodConfirmBody": "Deine Periode wurde für etwa jetzt vorhergesagt. Trage sie ein, damit die Vorhersagen genau bleiben.", + "discreetTitle": "HealthLog-Erinnerung", + "discreetBody": "Du hast eine Erinnerung in HealthLog." } } diff --git a/messages/en.json b/messages/en.json index 585a03828..dccfc63de 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Medium confidence", "confidenceHigh": "High confidence" } - } + }, + "disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional." + }, + "cycleReminders": { + "periodSoonTitle": "Period likely soon", + "periodSoonBody": "Your next period is predicted in about two days.", + "periodConfirmTitle": "Did your period start?", + "periodConfirmBody": "Your period was predicted around now. Log it to keep predictions accurate.", + "discreetTitle": "HealthLog reminder", + "discreetBody": "You have a reminder in HealthLog." } } diff --git a/messages/es.json b/messages/es.json index c08a026c9..a37f55d4e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Confianza media", "confidenceHigh": "Confianza alta" } - } + }, + "disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario." + }, + "cycleReminders": { + "periodSoonTitle": "El periodo llegará pronto", + "periodSoonBody": "Se prevé tu próximo periodo en unos dos días.", + "periodConfirmTitle": "¿Ha comenzado tu periodo?", + "periodConfirmBody": "Tu periodo estaba previsto para ahora. Regístralo para mantener las predicciones precisas.", + "discreetTitle": "Recordatorio de HealthLog", + "discreetBody": "Tienes un recordatorio en HealthLog." } } diff --git a/messages/fr.json b/messages/fr.json index 1f56a7aba..b0f4ea28f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Confiance moyenne", "confidenceHigh": "Confiance élevée" } - } + }, + "disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé." + }, + "cycleReminders": { + "periodSoonTitle": "Règles probablement bientôt", + "periodSoonBody": "Vos prochaines règles sont prévues dans environ deux jours.", + "periodConfirmTitle": "Vos règles ont-elles commencé ?", + "periodConfirmBody": "Vos règles étaient prévues pour maintenant. Enregistrez-les pour garder des prévisions précises.", + "discreetTitle": "Rappel HealthLog", + "discreetBody": "Vous avez un rappel dans HealthLog." } } diff --git a/messages/it.json b/messages/it.json index 5114e3a61..ea0d1780c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Affidabilità media", "confidenceHigh": "Affidabilità alta" } - } + }, + "disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario." + }, + "cycleReminders": { + "periodSoonTitle": "Ciclo probabilmente in arrivo", + "periodSoonBody": "Il tuo prossimo ciclo è previsto tra circa due giorni.", + "periodConfirmTitle": "Il ciclo è iniziato?", + "periodConfirmBody": "Il tuo ciclo era previsto per adesso. Registralo per mantenere accurate le previsioni.", + "discreetTitle": "Promemoria HealthLog", + "discreetBody": "Hai un promemoria in HealthLog." } } diff --git a/messages/pl.json b/messages/pl.json index c10fdef07..830347919 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Średnia pewność", "confidenceHigh": "Wysoka pewność" } - } + }, + "disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia." + }, + "cycleReminders": { + "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", + "periodSoonBody": "Twoja następna miesiączka jest przewidywana za około dwa dni.", + "periodConfirmTitle": "Czy miesiączka się zaczęła?", + "periodConfirmBody": "Twoja miesiączka była przewidywana mniej więcej teraz. Zapisz ją, aby prognozy pozostały dokładne.", + "discreetTitle": "Przypomnienie HealthLog", + "discreetBody": "Masz przypomnienie w HealthLog." } } diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 8de3e2566..b6ba3810f 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -90,6 +90,7 @@ describe("GET /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); }); @@ -109,6 +110,7 @@ describe("GET /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); }); @@ -130,6 +132,7 @@ describe("GET /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); }); }); @@ -162,6 +165,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -170,6 +174,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, }, }); @@ -182,10 +187,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => { previous: { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, next: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, changed: ["medication"], }), @@ -246,6 +253,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, }, }); @@ -270,6 +278,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -278,6 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, }, }); @@ -305,6 +315,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 9 }, + cycle: { clientManaged: false }, }, }, }); diff --git a/src/components/cycle/cycle-prediction-disclaimer.tsx b/src/components/cycle/cycle-prediction-disclaimer.tsx new file mode 100644 index 000000000..90c3e7793 --- /dev/null +++ b/src/components/cycle/cycle-prediction-disclaimer.tsx @@ -0,0 +1,41 @@ +/** + * Fixed non-medical disclaimer for every fertility / prediction surface. + * + * Mirrors the GLP-1 drug-level-estimate disclaimer pattern + * (`DrugLevelChart`): a styled, always-attached note rendered as plain + * React text children (no markdown), so it cannot be dismissed or styled + * away and carries no XSS surface. + * + * The copy is deliberate and load-bearing: predictions are descriptive + * estimates from the user's own logged data — never a contraceptive + * method, never medical advice, and never a "safe to have unprotected + * sex" signal. Drop this under any calendar, wheel, fertile-window, or + * predicted-period surface. The copy lives in `messages/.json` + * under `cycle.disclaimer` and is translated for all six locales. + */ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +export interface CyclePredictionDisclaimerProps { + /** Optional extra classes for layout (margins, width) at the call site. */ + className?: string; +} + +export function CyclePredictionDisclaimer({ + className, +}: CyclePredictionDisclaimerProps) { + const { t } = useTranslations(); + return ( +

+ {t("cycle.disclaimer")} +

+ ); +} diff --git a/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts new file mode 100644 index 000000000..6e06f9ed7 --- /dev/null +++ b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts @@ -0,0 +1,50 @@ +/** + * v1.15 — cycle-reminder queue registration guard. + * + * Same source-text-grep approach as the mood-reminder / drain / mean- + * consolidation registration guards: assert the cycle-reminder queue is + * declared, listed in `allQueues` (the v1.4.37 dead-queue lesson — an + * unregistered queue is never provisioned and the schedule silently + * no-ops), scheduled on its cron, and wired to a `boss.work` handler that + * delegates to `runCycleReminderTick`. No pg-boss / Prisma boot. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); +const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); + +describe("reminder-worker — cycle-reminder wiring", () => { + it("imports the cycle-reminder tick runner", () => { + expect(source).toMatch(/from\s*["']@\/lib\/jobs\/cycle-reminder["']/); + expect(source).toMatch(/\brunCycleReminderTick\b/); + }); + + it("declares the queue + cron constants", () => { + expect(source).toMatch( + /CYCLE_REMINDER_QUEUE\s*=\s*["']cycle-reminder-check["']/, + ); + expect(source).toMatch(/CYCLE_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/); + }); + + it("registers the cycle-reminder queue in the allQueues loop", () => { + const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bCYCLE_REMINDER_QUEUE\b/); + }); + + it("schedules the cycle-reminder cron", () => { + expect(source).toMatch(/\[CYCLE_REMINDER_QUEUE,\s*CYCLE_REMINDER_CRON\]/); + }); + + it("registers a boss.work handler that runs the cycle-reminder tick", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}CYCLE_REMINDER_QUEUE[\s\S]{0,200}handleCycleReminderCheck/, + ); + expect(source).toMatch( + /handleCycleReminderCheck[\s\S]{0,400}runCycleReminderTick/, + ); + }); +}); diff --git a/src/lib/jobs/__tests__/cycle-reminder.test.ts b/src/lib/jobs/__tests__/cycle-reminder.test.ts new file mode 100644 index 000000000..fd32fbf0a --- /dev/null +++ b/src/lib/jobs/__tests__/cycle-reminder.test.ts @@ -0,0 +1,464 @@ +/** + * v1.15 — cycle-reminder dispatcher unit tests. + * + * Pins the windowing + suppression contract: + * - Window gating: only fires inside the 09:00 local-time hour. + * - PERIOD_SOON fires exactly `PERIOD_SOON_LEAD_DAYS` before the + * predicted next-period start. + * - PERIOD_CONFIRM fires on/after the predicted start, inside the grace + * window, while no observed period is logged — and goes quiet once a + * period IS logged near the prediction. + * - Gate: a cycle-disabled account never receives a push. + * - predictionEnabled=false suppresses everything. + * - clientManaged suppression: skips the server push (iOS owns it). + * - discreetNotifications: swaps the body to the generic "HealthLog + * reminder" so no cycle event is named on the lock screen. + * - Idempotency: an existing `ok` push_attempt for the event today + * blocks a second push. + * - Locale: title + body resolve from the user's locale. + * - Per-user try-wrapper: one bad row never aborts the tick. + * + * The tests stub the Prisma surface manually (no testcontainer boot). + */ +import { describe, it, expect, vi } from "vitest"; + +import { + buildCycleReminderPayload, + cycleReminderLocalDate, + evaluateCycleReminder, + runCycleReminderTick, + CYCLE_REMINDER_LOCAL_HOUR, + PERIOD_SOON_LEAD_DAYS, + PERIOD_CONFIRM_GRACE_DAYS, +} from "../cycle-reminder"; +import type { NotificationPayload } from "@/lib/notifications/types"; +import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; + +type DispatchFn = (payload: NotificationPayload) => Promise; + +const OK: DispatchOutcome = { + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, +}; +const NO_CHANNEL: DispatchOutcome = { + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, +}; + +interface FakeUser { + id: string; + gender: string | null; + timezone: string; + locale: string | null; + notificationPrefs?: unknown; + cycleProfile: { + cycleTrackingEnabled: boolean | null; + predictionEnabled: boolean; + discreetNotifications: boolean; + } | null; +} + +interface FakeState { + predictions: Array<{ userId: string; nextPeriodStart: string; user: FakeUser }>; + /** Observed (non-predicted) cycle start dates per userId. */ + observedStarts: Record; + /** Existing `ok` push attempts: `${userId}:${eventType}` already sent today. */ + alreadyOk: Set; +} + +function makePrisma(state: FakeState) { + return { + cyclePrediction: { + findMany: vi.fn(async () => state.predictions), + }, + pushAttempt: { + findFirst: vi.fn( + async ({ + where, + }: { + where: { userId: string; eventType: string }; + }) => { + const key = `${where.userId}:${where.eventType}`; + return state.alreadyOk.has(key) ? { id: "pa-1" } : null; + }, + ), + }, + menstrualCycle: { + findMany: vi.fn( + async ({ where }: { where: { userId: string } }) => { + const starts = state.observedStarts[where.userId] ?? []; + return starts.map((startDate) => ({ startDate })); + }, + ), + }, + }; +} + +/** 09:00 Europe/Berlin (CEST in May → 07:00Z). */ +const AT_0900_BERLIN = new Date("2026-05-17T07:00:00Z"); + +function baseUser(over: Partial = {}): FakeUser { + return { + id: "u1", + gender: "FEMALE", + timezone: "Europe/Berlin", + locale: "en", + notificationPrefs: null, + cycleProfile: { + cycleTrackingEnabled: null, + predictionEnabled: true, + discreetNotifications: false, + }, + ...over, + }; +} + +describe("cycleReminderLocalDate", () => { + it("returns the local date inside the 09:00 hour", () => { + expect(cycleReminderLocalDate(AT_0900_BERLIN, "Europe/Berlin")).toBe( + "2026-05-17", + ); + expect(CYCLE_REMINDER_LOCAL_HOUR).toBe(9); + }); + + it("returns null at 08:59 and 10:00 local time", () => { + expect( + cycleReminderLocalDate(new Date("2026-05-17T06:59:00Z"), "Europe/Berlin"), + ).toBeNull(); + expect( + cycleReminderLocalDate(new Date("2026-05-17T08:00:00Z"), "Europe/Berlin"), + ).toBeNull(); + }); + + it("respects a non-Berlin timezone", () => { + // 09:00 America/New_York (EDT) = 13:00Z + expect( + cycleReminderLocalDate(new Date("2026-05-17T13:00:00Z"), "America/New_York"), + ).toBe("2026-05-17"); + }); +}); + +describe("evaluateCycleReminder", () => { + it("fires PERIOD_SOON exactly lead-days before the predicted start", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-18", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe("CYCLE_PERIOD_SOON"); + expect(PERIOD_SOON_LEAD_DAYS).toBe(2); + }); + + it("does not fire PERIOD_SOON one day off the lead", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-19", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe(null); // 1 day before → neither soon (lead=2) nor confirm (not yet due) + }); + + it("fires PERIOD_CONFIRM on the predicted day when no period logged", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-20", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe("CYCLE_PERIOD_CONFIRM"); + }); + + it("fires PERIOD_CONFIRM within the grace window after the predicted day", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-23", // +3 days, grace=3 + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe("CYCLE_PERIOD_CONFIRM"); + expect(PERIOD_CONFIRM_GRACE_DAYS).toBe(3); + }); + + it("stops PERIOD_CONFIRM past the grace window", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-24", // +4 days, grace=3 + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe(null); + }); + + it("suppresses PERIOD_CONFIRM once a period is logged", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-20", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: true, + }), + ).toBe(null); + }); + + it("suppresses PERIOD_SOON once a period is logged (early period)", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-18", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: true, + }), + ).toBe(null); + }); +}); + +describe("buildCycleReminderPayload", () => { + it("names the event when not discreet", () => { + const soon = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", false); + expect(soon.title.length).toBeGreaterThan(0); + expect(soon.title.toLowerCase()).toContain("period"); + const confirm = buildCycleReminderPayload( + "CYCLE_PERIOD_CONFIRM", + "en", + false, + ); + expect(confirm.title).not.toBe(soon.title); + }); + + it("collapses to the generic HealthLog body when discreet", () => { + const soon = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", true); + const confirm = buildCycleReminderPayload( + "CYCLE_PERIOD_CONFIRM", + "en", + true, + ); + expect(soon).toEqual(confirm); // identical generic copy regardless of event + expect(soon.title.toLowerCase()).not.toContain("period"); + expect(soon.title).toContain("HealthLog"); + }); + + it("resolves a non-default locale", () => { + const de = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "de", false); + const en = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", false); + expect(de.title).not.toBe(en.title); + }); +}); + +describe("runCycleReminderTick", () => { + function run(state: FakeState, dispatch: DispatchFn, now = AT_0900_BERLIN) { + const prisma = makePrisma(state); + return runCycleReminderTick( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + now, + { dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification }, + ); + } + + it("dispatches PERIOD_SOON two days before the predicted start", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodSoon).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_PERIOD_SOON"); + }); + + it("dispatches PERIOD_CONFIRM on the predicted day with no logged period", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-17", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodConfirm).toBe(1); + expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_PERIOD_CONFIRM"); + }); + + it("suppresses PERIOD_CONFIRM once a period is logged near the prediction", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-17", user: baseUser() }, + ], + observedStarts: { u1: ["2026-05-17"] }, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodConfirm).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedAlreadyLogged).toBe(1); + }); + + it("gates entirely on cycle tracking — a MALE account never gets a push", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ gender: "MALE", cycleProfile: { cycleTrackingEnabled: null, predictionEnabled: true, discreetNotifications: false } }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.dispatchedPeriodSoon).toBe(0); + }); + + it("opts a non-FEMALE account in via the explicit cycleTrackingEnabled flag", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + gender: "MALE", + cycleProfile: { cycleTrackingEnabled: true, predictionEnabled: true, discreetNotifications: false }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodSoon).toBe(1); + }); + + it("suppresses everything when predictionEnabled is false", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + cycleProfile: { cycleTrackingEnabled: null, predictionEnabled: false, discreetNotifications: false }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedPredictionDisabled).toBe(1); + }); + + it("suppresses the server push when clientManaged is true", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + notificationPrefs: { cycle: { clientManaged: true } }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.suppressedClientManaged).toBe(1); + }); + + it("swaps to the generic body when discreetNotifications is true", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + cycleProfile: { cycleTrackingEnabled: null, predictionEnabled: true, discreetNotifications: true }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodSoon).toBe(1); + expect(summary.suppressedDiscreet).toBe(1); + const payload = dispatch.mock.calls[0][0]; + // Generic body — no cycle event named on the lock screen. + expect(payload.title.toLowerCase()).not.toContain("period"); + expect(payload.title).toContain("HealthLog"); + // The eventType is still the real one (so prefs gating + ledger work). + expect(payload.eventType).toBe("CYCLE_PERIOD_SOON"); + }); + + it("does not double-fire when an ok attempt already landed today", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(["u1:CYCLE_PERIOD_SOON"]), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedAlreadyNotified).toBe(1); + }); + + it("counts a no-channel dispatch as skipped (free to retry)", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => NO_CHANNEL); + const summary = await run(state, dispatch); + expect(summary.skippedNoChannel).toBe(1); + expect(summary.dispatchedPeriodSoon).toBe(0); + }); + + it("skips outside the 09:00 window", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch, new Date("2026-05-17T20:00:00Z")); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedOutsideWindow).toBe(1); + }); + + it("isolates a per-user failure from the rest of the tick", async () => { + const state: FakeState = { + predictions: [ + { userId: "bad", nextPeriodStart: "not-a-date", user: baseUser({ id: "bad" }) }, + { userId: "u2", nextPeriodStart: "2026-05-19", user: baseUser({ id: "u2" }) }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.failed).toBe(1); + expect(summary.dispatchedPeriodSoon).toBe(1); // u2 still processed + }); +}); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts new file mode 100644 index 000000000..ee402181d --- /dev/null +++ b/src/lib/jobs/cycle-reminder.ts @@ -0,0 +1,421 @@ +/** + * v1.15 — cycle reminder dispatcher (period-soon + period-start-confirm). + * + * Mirrors `mood-reminder.ts` and the medication-reminder cron: a cron tick + * fires every 15 minutes, and when it lands inside the user's reminder hour + * in their local timezone this module scans every cycle-enabled user with a + * cached `CyclePrediction`, decides who is in-window, and dispatches at most + * one push per event per local day through the existing + * APNs → Telegram → ntfy → Web-Push cascade (`dispatchNotification`), which + * records every attempt in `push_attempts`. + * + * Two events, both default-OFF and gated TWICE (per-event default + the + * `CycleProfile.cycleTrackingEnabled` flag): + * + * - CYCLE_PERIOD_SOON — a couple of days before the predicted next-period + * start (`PERIOD_SOON_LEAD_DAYS`). + * - CYCLE_PERIOD_CONFIRM — a gentle "did your period start?" on/after the + * predicted start while no observed cycle is logged, + * for up to `PERIOD_CONFIRM_GRACE_DAYS`. + * + * Two suppression rules ride on top of the dispatcher cascade: + * + * - `notificationPrefs.cycle.clientManaged` — when true, iOS owns the local + * reminders, so the server skips its push and emits a + * `cycle_reminder.suppressed_client_managed` wide-event annotation (the + * medication `clientManaged` precedent). + * - `CycleProfile.discreetNotifications` — when true, the push body is the + * generic "HealthLog reminder" so no cycle event is named on the lock + * screen. The decision is made here, where the localised body is composed. + * + * Idempotency is anchored on the `push_attempts` ledger itself (no new table): + * before dispatching, the runner checks whether an `ok` attempt for the same + * event already landed for the user's local day. The cron worker runs + * single-flight (`localConcurrency: 1`) so two ticks never race the + * fire-and-forget ledger write. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales } from "@/lib/i18n/config"; +import { getLocalDateParts, getUserTodayBounds } from "@/lib/timezone"; +import { dayDiff } from "@/lib/cycle/day-math"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import type { EventType } from "@/lib/notifications/types"; +import { isCycleReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { getEvent } from "@/lib/logging/context"; + +/** + * Local-time hour (0–23) at which the daily cycle reminder fires. 09:00 is + * a calm morning slot that does not collide with the medication-reminder + * hot path (top of the hour) or the mood reminder (22:00). Fixed for v1.15 + * (no per-user override yet); exported so the tests can pin the window. + */ +export const CYCLE_REMINDER_LOCAL_HOUR = 9; + +/** Days before the predicted next-period start that PERIOD_SOON fires. */ +export const PERIOD_SOON_LEAD_DAYS = 2; + +/** + * Days on/after the predicted next-period start that PERIOD_CONFIRM keeps + * nudging while no period is logged. Day 0 (the predicted start) through + * this many days inclusive; after that the runner gives up for the cycle. + */ +export const PERIOD_CONFIRM_GRACE_DAYS = 3; + +export type CycleReminderEvent = "CYCLE_PERIOD_SOON" | "CYCLE_PERIOD_CONFIRM"; + +export interface CycleReminderSummary { + candidatesScanned: number; + inWindow: number; + dispatchedPeriodSoon: number; + dispatchedPeriodConfirm: number; + suppressedClientManaged: number; + suppressedDiscreet: number; + skippedAlreadyNotified: number; + skippedNoChannel: number; + skippedOutsideWindow: number; + skippedPredictionDisabled: number; + skippedAlreadyLogged: number; + failed: number; +} + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +function emptySummary(): CycleReminderSummary { + return { + candidatesScanned: 0, + inWindow: 0, + dispatchedPeriodSoon: 0, + dispatchedPeriodConfirm: 0, + suppressedClientManaged: 0, + suppressedDiscreet: 0, + skippedAlreadyNotified: 0, + skippedNoChannel: 0, + skippedOutsideWindow: 0, + skippedPredictionDisabled: 0, + skippedAlreadyLogged: 0, + failed: 0, + }; +} + +/** + * Compute the user's local `YYYY-MM-DD` date when `now` falls inside the + * cycle-reminder hour; `null` otherwise (outside the window). Pulled out so + * the unit tests can pin the boundary (08:59 → null, 09:00 → date, + * 09:59 → date, 10:00 → null) without a DB. + */ +export function cycleReminderLocalDate( + now: Date, + timezone: string, +): string | null { + const parts = getLocalDateParts(now, timezone || "Europe/Berlin"); + if (parts.hour !== CYCLE_REMINDER_LOCAL_HOUR) return null; + return `${parts.year.toString().padStart(4, "0")}-${parts.month + .toString() + .padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`; +} + +/** + * Pure decision predicate: given the user's local date and their cached + * forecast, which cycle reminder (if any) should fire today? + * + * Returns the event to dispatch, or `null` to skip. Kept DB-free so the + * windowing contract is unit-testable in isolation: + * + * - PERIOD_SOON when `dayDiff(nextPeriodStart, today) === lead` and no + * period has already been logged near the prediction. + * - PERIOD_CONFIRM when today is on/after the predicted start and within + * the grace window AND no observed cycle has been logged yet + * (`periodAlreadyLogged === false`). + * + * Either nudge is suppressed once a period IS logged near the prediction — + * an early period makes the "soon" nudge stale and the "confirm" nudge moot. + */ +export function evaluateCycleReminder( + args: { + today: string; + nextPeriodStart: string; + periodAlreadyLogged: boolean; + }, + leadDays: number = PERIOD_SOON_LEAD_DAYS, + graceDays: number = PERIOD_CONFIRM_GRACE_DAYS, +): CycleReminderEvent | null { + // A logged period near the prediction silences both nudges — the forecast + // is already confirmed (the cache regenerates to the next cycle shortly). + if (args.periodAlreadyLogged) return null; + + const daysUntil = dayDiff(args.nextPeriodStart, args.today); + + if (daysUntil === leadDays) { + return "CYCLE_PERIOD_SOON"; + } + + // On/after the predicted start (daysUntil <= 0), within the grace window + // → nudge to confirm (the already-logged case returned null above). + if (daysUntil <= 0 && daysUntil >= -graceDays) { + return "CYCLE_PERIOD_CONFIRM"; + } + + return null; +} + +/** + * Build the localised title + body for a cycle reminder push. When + * `discreet` is true, both collapse to the generic "HealthLog reminder" + * copy so no cycle event is named on the lock screen (the §6.4 discreet + * mode). Pulled out so a template change is testable without the runner. + */ +export function buildCycleReminderPayload( + event: CycleReminderEvent, + locale: string | null | undefined, + discreet: boolean, +): { title: string; body: string } { + const t = getServerTranslator(resolveLocale(locale)).t; + if (discreet) { + return { + title: t("cycleReminders.discreetTitle"), + body: t("cycleReminders.discreetBody"), + }; + } + if (event === "CYCLE_PERIOD_SOON") { + return { + title: t("cycleReminders.periodSoonTitle"), + body: t("cycleReminders.periodSoonBody"), + }; + } + return { + title: t("cycleReminders.periodConfirmTitle"), + body: t("cycleReminders.periodConfirmBody"), + }; +} + +/** + * Has an `ok` push attempt for `event` already landed for the user today + * (their local day window)? The `push_attempts` ledger doubles as the + * idempotency anchor so no extra table is needed. Best-effort: a DB error + * biases toward "not yet notified" so a transient blip never silently nukes + * the day's nudge (the at-most-once trade the mood reminder also makes). + */ +async function alreadyNotifiedToday( + prisma: PrismaClient, + userId: string, + event: EventType, + localDayStartUtc: Date, + localDayEndUtc: Date, +): Promise { + try { + const row = await prisma.pushAttempt.findFirst({ + where: { + userId, + eventType: event, + result: "ok", + createdAt: { gte: localDayStartUtc, lt: localDayEndUtc }, + }, + select: { id: true }, + }); + return row !== null; + } catch { + return false; + } +} + +/** + * Run one cycle-reminder cron tick. Iterates every user whose cycle + * tracking is enabled and who has a cached forecast, checks the local-time + * window + the prediction window, and dispatches at most one push per event + * per local day, honouring `clientManaged` and `discreetNotifications`. + * + * `dispatch` is injectable so the unit tests exercise the decision + + * suppression logic without a live dispatcher. + */ +export async function runCycleReminderTick( + prisma: PrismaClient, + now: Date, + options: { + dispatch?: typeof dispatchNotification; + } = {}, +): Promise { + const dispatchImpl = options.dispatch ?? dispatchNotification; + const summary = emptySummary(); + + // Only users with a cached forecast can be in-window; the join also + // narrows to the cohort that has logged enough to predict. + const predictions = await prisma.cyclePrediction.findMany({ + select: { + userId: true, + nextPeriodStart: true, + user: { + select: { + id: true, + gender: true, + timezone: true, + locale: true, + notificationPrefs: true, + cycleProfile: { + select: { + cycleTrackingEnabled: true, + predictionEnabled: true, + discreetNotifications: true, + }, + }, + }, + }, + }, + }); + + for (const pred of predictions) { + summary.candidatesScanned += 1; + const user = pred.user; + try { + const profile = user.cycleProfile; + + // Gate ENTIRELY on cycle tracking being enabled (gender-derived or + // explicit opt-in) — a disabled account never receives a cycle push. + if (!isCycleEnabled(user.gender, profile)) { + summary.skippedOutsideWindow += 1; + continue; + } + + // No predictions when the user opted out of algorithmic interpretation. + if (profile && profile.predictionEnabled === false) { + summary.skippedPredictionDisabled += 1; + continue; + } + + const timezone = user.timezone || "Europe/Berlin"; + const today = cycleReminderLocalDate(now, timezone); + if (!today) { + summary.skippedOutsideWindow += 1; + continue; + } + + summary.inWindow += 1; + + // Has the user already logged an observed (non-predicted) cycle + // starting on/around the predicted day? If so, the confirm nudge is + // moot. We look for any observed cycle whose start is within the + // grace window of the prediction. + const periodAlreadyLogged = await hasObservedPeriodNearPrediction( + prisma, + user.id, + pred.nextPeriodStart, + ); + + const event = evaluateCycleReminder({ + today, + nextPeriodStart: pred.nextPeriodStart, + periodAlreadyLogged, + }); + + if (!event) { + if (periodAlreadyLogged) summary.skippedAlreadyLogged += 1; + else summary.skippedOutsideWindow += 1; + continue; + } + + // clientManaged suppression — iOS owns the local reminders. Mirror the + // medication path: skip the server push, emit the annotation. + if (isCycleReminderClientManaged(user.notificationPrefs)) { + summary.suppressedClientManaged += 1; + getEvent()?.addMeta( + "cycle_reminder_suppressed_client_managed", + `${event}:${today}`, + ); + getEvent()?.addMeta("cycle_reminder_suppressed_meta", { + user_id: user.id, + event, + local_date: today, + }); + continue; + } + + // Idempotency: one push per event per local day. Compute the local + // day's UTC bounds for the ledger lookup. `end` is the last + // millisecond of the day, so add 1 ms to make the range half-open. + const { start: dayStartUtc, end: dayEndInclusive } = getUserTodayBounds( + now, + timezone, + ); + const seen = await alreadyNotifiedToday( + prisma, + user.id, + event, + dayStartUtc, + new Date(dayEndInclusive.getTime() + 1), + ); + if (seen) { + summary.skippedAlreadyNotified += 1; + continue; + } + + const discreet = profile?.discreetNotifications === true; + if (discreet) summary.suppressedDiscreet += 1; + + const { title, body } = buildCycleReminderPayload( + event, + user.locale, + discreet, + ); + + const outcome = await dispatchImpl({ + eventType: event, + userId: user.id, + title, + message: body, + metadata: { + scheduledAt: now.toISOString(), + localDate: today, + }, + }); + + if (!outcome.dispatched) { + summary.skippedNoChannel += 1; + continue; + } + + if (event === "CYCLE_PERIOD_SOON") summary.dispatchedPeriodSoon += 1; + else summary.dispatchedPeriodConfirm += 1; + } catch (err: unknown) { + summary.failed += 1; + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning( + `cycle-reminder per-user dispatch failed for ${user.id}: ${message}`, + ); + } + } + + return summary; +} + +/** + * Observed (non-predicted, non-deleted) period logged within the grace + * window of the predicted start? Used to suppress the confirm nudge once + * the user has logged their period. A query failure biases toward "not + * logged" so the nudge still fires rather than silently going quiet. + */ +async function hasObservedPeriodNearPrediction( + prisma: PrismaClient, + userId: string, + predictedStart: string, +): Promise { + try { + const candidates = await prisma.menstrualCycle.findMany({ + where: { userId, isPredicted: false, deletedAt: null }, + select: { startDate: true }, + orderBy: { startDate: "desc" }, + take: 12, + }); + return candidates.some((c) => { + const delta = dayDiff(c.startDate, predictedStart); + return delta >= -PERIOD_CONFIRM_GRACE_DAYS && delta <= PERIOD_SOON_LEAD_DAYS; + }); + } catch { + return false; + } +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index af50e25d6..e80b6fac7 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -223,6 +223,7 @@ import { getPhaseKeyboard, } from "@/lib/jobs/reminder-phases"; import { runMoodReminderTick } from "@/lib/jobs/mood-reminder"; +import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder"; import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs"; import { withBackgroundEvent } from "@/lib/logging/background"; import { assertSubsystemEnabled } from "@/lib/process-type"; @@ -393,6 +394,16 @@ const PUSH_ATTEMPT_RETENTION_DAYS = 90; // and the drain (03:45) inside the existing 03:xx maintenance window. const MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE = "measurement-tombstone-cleanup"; const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; +// v1.15 — daily cycle reminder cron (period-soon + period-start-confirm). +// +// Runs every 15 minutes for the same reason as the mood reminder: the +// handler short-circuits unless the candidate user's local time is the +// cycle-reminder hour (09:00), so the 15-min cadence picks up every IANA +// timezone crossing that hour without one cron entry per zone. At most one +// push per event per user per local day — the `push_attempts` ledger is the +// idempotency anchor inside the handler. +const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; +const CYCLE_REMINDER_CRON = "*/15 * * * *"; // v1.4.38 — the per-sample cutoff hours constant now lives on the // helper module so the worker, the admin route, and the CLI all read // the same source of truth. Re-export pulled in alongside @@ -497,6 +508,10 @@ interface MoodReminderPayload { triggeredAt: string; } +interface CycleReminderPayload { + triggeredAt: string; +} + interface TlsPinMonitorPayload { triggeredAt: string; } @@ -1659,6 +1674,39 @@ async function handleMoodReminderCheck(jobs: Job[]) { }); } +/** + * v1.15 — daily cycle-reminder dispatcher (period-soon + period-start-confirm). + * + * Thin shim around `runCycleReminderTick` in `cycle-reminder.ts` so the + * unit tests exercise the windowing + suppression logic without pg-boss. + */ +async function handleCycleReminderCheck(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.cycle_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const summary = await runCycleReminderTick(prisma, new Date()); + evt.setBackground({ + task_name: "job.cycle_reminder", + result: { + candidates_scanned: summary.candidatesScanned, + in_window: summary.inWindow, + dispatched_period_soon: summary.dispatchedPeriodSoon, + dispatched_period_confirm: summary.dispatchedPeriodConfirm, + suppressed_client_managed: summary.suppressedClientManaged, + suppressed_discreet: summary.suppressedDiscreet, + skipped_already_notified: summary.skippedAlreadyNotified, + skipped_outside_window: summary.skippedOutsideWindow, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + async function handleRateLimitCleanup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.rate_limit_cleanup", async (evt) => { @@ -2427,6 +2475,11 @@ export async function startReminderWorker() { // dispatcher never fires. MOOD_REMINDER_QUEUE, MOOD_REMINDER_CLEANUP_QUEUE, + // v1.15 — cycle-reminder cron tick (period-soon + period-start-confirm). + // Same pg-boss v12 createQueue contract as the mood-reminder queue; + // without this entry the every-15-min schedule silently no-ops and the + // cycle dispatcher never fires (the v1.4.37 dead-queue class). + CYCLE_REMINDER_QUEUE, // v1.4.49 — push-attempt ledger cleanup. Same createQueue contract // as the other cleanup jobs; the daily schedule below would // silently no-op without this entry. @@ -2575,6 +2628,11 @@ export async function startReminderWorker() { // per tick for the entire opted-in cohort. [MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON], [MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON], + // v1.15 — every-15-min tick for the daily cycle reminder. The handler + // short-circuits unless the candidate user's local time is the 09:00 + // hour, so the cron costs ~one prediction-row scan per tick for the + // opted-in cohort. + [CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON], // v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts. [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight @@ -2801,6 +2859,14 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleMoodReminderCleanup, ); + // v1.15 — single-flight cycle-reminder worker. localConcurrency=1 keeps + // two ticks from racing the fire-and-forget `push_attempts` ledger that + // anchors the per-day idempotency, exactly like the mood-reminder worker. + await boss.work( + CYCLE_REMINDER_QUEUE, + { localConcurrency: 1 }, + handleCycleReminderCheck, + ); // v1.4.49 — daily prune of the push-attempt ledger. Single-flight // matches every other cleanup queue; two ticks racing on the same // DELETE statement is wasted work and the second tick's payload diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 158d62302..f28bbe534 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -23,6 +23,17 @@ export const EVENT_TYPES = [ // in twice before the server starts nudging them about an // emotionally-loaded surface. "MOOD_REMINDER", + // v1.15 — cycle reminders. Both default OFF (see EVENT_DEFAULT_ENABLED) + // and are gated again on `CycleProfile.cycleTrackingEnabled`, so a user + // opts in twice before the server nudges about an intent-revealing, + // privacy-sensitive surface. `CYCLE_PERIOD_SOON` fires a couple of days + // before the predicted next-period start; `CYCLE_PERIOD_CONFIRM` is a + // gentle "did your period start?" on/after the predicted start while no + // period is logged. When `CycleProfile.discreetNotifications` is true the + // push body is the generic "HealthLog reminder" so no cycle event is + // named on the lock screen. + "CYCLE_PERIOD_SOON", + "CYCLE_PERIOD_CONFIRM", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -49,6 +60,11 @@ export const EVENT_DEFAULT_ENABLED: Record = { // `User.moodReminderEnabled`, this per-event default also has to be // flipped on before the dispatcher will surface the channel. MOOD_REMINDER: false, + // v1.15 — cycle reminders default OFF. Even if a future surface forgets + // to gate on `CycleProfile.cycleTrackingEnabled`, this per-event default + // also has to be flipped on before the dispatcher surfaces the channel. + CYCLE_PERIOD_SOON: false, + CYCLE_PERIOD_CONFIRM: false, }; export const CHANNEL_TYPE_LABELS: Record = { diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index fccf733e5..f199b12d3 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MOOD_REMINDER_HOUR, DEFAULT_NOTIFICATION_PREFS, + isCycleReminderClientManaged, isMedicationReminderClientManaged, notificationPrefsSchema, parseNotificationPrefs, @@ -89,6 +90,7 @@ describe("parseNotificationPrefs", () => { ).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -96,6 +98,7 @@ describe("parseNotificationPrefs", () => { expect(parseNotificationPrefs({ medication: {} })).toEqual({ medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -105,6 +108,7 @@ describe("parseNotificationPrefs", () => { ).toEqual({ medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -130,6 +134,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -143,6 +148,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -153,6 +159,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -163,6 +170,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -174,6 +182,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 8 }, + cycle: { clientManaged: false }, }); }); @@ -217,6 +226,34 @@ describe("isMedicationReminderClientManaged — cron-skip gate", () => { }); }); +describe("isCycleReminderClientManaged — cron-skip gate", () => { + it("returns false for a null / undefined row (server-managed default)", () => { + expect(isCycleReminderClientManaged(null)).toBe(false); + expect(isCycleReminderClientManaged(undefined)).toBe(false); + }); + + it("returns false when the user has not opted in", () => { + expect( + isCycleReminderClientManaged({ cycle: { clientManaged: false } }), + ).toBe(false); + }); + + it("returns true when the iOS app owns local cycle reminders", () => { + expect( + isCycleReminderClientManaged({ cycle: { clientManaged: true } }), + ).toBe(true); + }); + + it("is independent of the medication delivery default", () => { + // The medication `deliveryDefault: client` must NOT suppress cycle pushes. + expect( + isCycleReminderClientManaged({ + medication: { deliveryDefault: "client" }, + }), + ).toBe(false); + }); +}); + describe("resolveMoodReminderHour — cron hour gate", () => { it("returns the default hour for a null row", () => { expect(resolveMoodReminderHour(null)).toBe(DEFAULT_MOOD_REMINDER_HOUR); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 76f482038..f42c1f183 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -10,7 +10,8 @@ * Shape (additive — future categories slot in next to `medication`): * { * "medication": { "clientManaged": boolean }, - * "mood": { "reminderHour": 0..23 } + * "mood": { "reminderHour": 0..23 }, + * "cycle": { "clientManaged": boolean } * } * * The schema is intentionally additive — every new category grows the @@ -55,6 +56,20 @@ const moodPrefsSchema = z }) .partial(); +/** + * v1.15 — cycle category schema. `clientManaged` mirrors the medication + * gate: when the iOS app owns local cycle reminders (period-soon / + * period-start-confirm), it flips this to `true` and the server-side + * cycle cron suppresses its APNs send for that user, exactly as the + * medication path does. Sub-object form keeps the layout open for future + * cycle-notification knobs. + */ +const cyclePrefsSchema = z + .object({ + clientManaged: z.boolean(), + }) + .partial(); + /** * v1.7.0 — per-device delivery override schema for the device PATCH. * NULL clears the override (the device inherits the user-level roaming @@ -72,6 +87,7 @@ export const notificationPrefsSchema = z .object({ medication: medicationPrefsSchema, mood: moodPrefsSchema, + cycle: cyclePrefsSchema, }) .partial(); @@ -92,6 +108,14 @@ export interface NotificationPrefs { /** v1.7.0 — local-time hour (0–23) for the daily mood reminder. */ reminderHour: number; }; + cycle: { + /** + * v1.15 — when true the iOS app owns local cycle reminders and the + * server-side cycle cron suppresses its APNs send (the medication + * `clientManaged` precedent). + */ + clientManaged: boolean; + }; } /** @@ -114,6 +138,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR, }, + cycle: { + clientManaged: false, + }, }; /** @@ -150,6 +177,10 @@ export function resolveNotificationPrefs( ...base.mood, ...(incoming.mood ?? {}), }, + cycle: { + ...base.cycle, + ...(incoming.cycle ?? {}), + }, }); } @@ -174,6 +205,7 @@ function applyDeliveryDefaultMapping(prefs: NotificationPrefs): NotificationPref clientManaged, }, mood: { ...prefs.mood }, + cycle: { ...prefs.cycle }, }; } @@ -191,6 +223,17 @@ export function isMedicationReminderClientManaged(raw: unknown): boolean { ); } +/** + * Cron-side helper. Returns `true` when the user has opted in to + * client-managed CYCLE reminders (iOS owns the local period-soon / + * period-start-confirm nudges) and the server-side push should be + * suppressed. Tolerates a null / missing prefs row. Mirrors + * `isMedicationReminderClientManaged`. + */ +export function isCycleReminderClientManaged(raw: unknown): boolean { + return parseNotificationPrefs(raw).cycle.clientManaged === true; +} + /** * v1.7.0 — resolve the effective delivery channel for a device. The * per-device override wins; otherwise the user-level roaming default; @@ -223,6 +266,7 @@ function cloneDefaults(): NotificationPrefs { return { medication: { ...DEFAULT_NOTIFICATION_PREFS.medication }, mood: { ...DEFAULT_NOTIFICATION_PREFS.mood }, + cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle }, }; } @@ -238,5 +282,9 @@ function mergeOverDefaults( ...DEFAULT_NOTIFICATION_PREFS.mood, ...(input.mood ?? {}), }, + cycle: { + ...DEFAULT_NOTIFICATION_PREFS.cycle, + ...(input.cycle ?? {}), + }, }); }