diff --git a/messages/de.json b/messages/de.json index 49891b4d7..cdbfcc74b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5097,7 +5097,16 @@ "deleteData": "Meine Zyklusdaten löschen", "saved": "Einstellungen gespeichert", "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", - "save": "Speichern" + "save": "Speichern", + "purgeTitle": "Alle Zyklusdaten löschen", + "purgeDescription": "Entfernt dauerhaft jede Periode, jeden Tageseintrag und jede Vorhersage von diesem Konto. Dies kann nicht rückgängig gemacht werden.", + "purgeButton": "Alle Zyklusdaten löschen", + "purgeConfirmTitle": "Alle Zyklusdaten löschen?", + "purgeConfirmBody": "Dies löscht dauerhaft jede Periode, jeden Tageseintrag, jedes Symptom und jede Vorhersage. Dies kann nicht rückgängig gemacht werden.", + "purgeConfirm": "Alles löschen", + "purgeCancel": "Abbrechen", + "purgeSuccess": "Alle Zyklusdaten gelöscht.", + "purgeError": "Zyklusdaten konnten nicht gelöscht werden. Bitte erneut versuchen." } }, "cycleReminders": { diff --git a/messages/en.json b/messages/en.json index e55e0d75b..61bb42df8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5097,7 +5097,16 @@ "deleteData": "Delete my cycle data", "saved": "Settings saved", "saveError": "Could not save. Try again.", - "save": "Save" + "save": "Save", + "purgeTitle": "Delete all cycle data", + "purgeDescription": "Permanently remove every period, day-log, and prediction from this account. This cannot be undone.", + "purgeButton": "Delete all cycle data", + "purgeConfirmTitle": "Delete all cycle data?", + "purgeConfirmBody": "This permanently deletes every period, day-log, symptom, and prediction. This cannot be undone.", + "purgeConfirm": "Delete everything", + "purgeCancel": "Cancel", + "purgeSuccess": "All cycle data deleted.", + "purgeError": "Could not delete cycle data. Try again." } }, "cycleReminders": { diff --git a/messages/es.json b/messages/es.json index c39d085e6..842109078 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5097,7 +5097,16 @@ "deleteData": "Eliminar mis datos del ciclo", "saved": "Ajustes guardados", "saveError": "No se pudo guardar. Inténtalo de nuevo.", - "save": "Guardar" + "save": "Guardar", + "purgeTitle": "Eliminar todos los datos del ciclo", + "purgeDescription": "Elimina de forma permanente todos los periodos, registros diarios y predicciones de esta cuenta. No se puede deshacer.", + "purgeButton": "Eliminar todos los datos del ciclo", + "purgeConfirmTitle": "¿Eliminar todos los datos del ciclo?", + "purgeConfirmBody": "Esto elimina de forma permanente todos los periodos, registros diarios, síntomas y predicciones. No se puede deshacer.", + "purgeConfirm": "Eliminar todo", + "purgeCancel": "Cancelar", + "purgeSuccess": "Todos los datos del ciclo eliminados.", + "purgeError": "No se pudieron eliminar los datos del ciclo. Inténtalo de nuevo." } }, "cycleReminders": { diff --git a/messages/fr.json b/messages/fr.json index c6e315957..7d6d9e5f3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5097,7 +5097,16 @@ "deleteData": "Supprimer mes données de cycle", "saved": "Réglages enregistrés", "saveError": "Enregistrement impossible. Réessaie.", - "save": "Enregistrer" + "save": "Enregistrer", + "purgeTitle": "Supprimer toutes les données de cycle", + "purgeDescription": "Supprime définitivement chaque règle, journal quotidien et prédiction de ce compte. Cette action est irréversible.", + "purgeButton": "Supprimer toutes les données de cycle", + "purgeConfirmTitle": "Supprimer toutes les données de cycle ?", + "purgeConfirmBody": "Cela supprime définitivement chaque règle, journal quotidien, symptôme et prédiction. Cette action est irréversible.", + "purgeConfirm": "Tout supprimer", + "purgeCancel": "Annuler", + "purgeSuccess": "Toutes les données de cycle ont été supprimées.", + "purgeError": "Impossible de supprimer les données de cycle. Réessayez." } }, "cycleReminders": { diff --git a/messages/it.json b/messages/it.json index faf92c4cb..9bfa8463d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5097,7 +5097,16 @@ "deleteData": "Elimina i miei dati del ciclo", "saved": "Impostazioni salvate", "saveError": "Impossibile salvare. Riprova.", - "save": "Salva" + "save": "Salva", + "purgeTitle": "Elimina tutti i dati del ciclo", + "purgeDescription": "Rimuove definitivamente ogni mestruazione, registro giornaliero e previsione da questo account. L'operazione è irreversibile.", + "purgeButton": "Elimina tutti i dati del ciclo", + "purgeConfirmTitle": "Eliminare tutti i dati del ciclo?", + "purgeConfirmBody": "Questo elimina definitivamente ogni mestruazione, registro giornaliero, sintomo e previsione. L'operazione è irreversibile.", + "purgeConfirm": "Elimina tutto", + "purgeCancel": "Annulla", + "purgeSuccess": "Tutti i dati del ciclo eliminati.", + "purgeError": "Impossibile eliminare i dati del ciclo. Riprova." } }, "cycleReminders": { diff --git a/messages/pl.json b/messages/pl.json index f451696bf..bb141b4c8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5097,7 +5097,16 @@ "deleteData": "Usuń moje dane cyklu", "saved": "Ustawienia zapisane", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", - "save": "Zapisz" + "save": "Zapisz", + "purgeTitle": "Usuń wszystkie dane cyklu", + "purgeDescription": "Trwale usuwa każdą miesiączkę, wpis dzienny i prognozę z tego konta. Tej operacji nie można cofnąć.", + "purgeButton": "Usuń wszystkie dane cyklu", + "purgeConfirmTitle": "Usunąć wszystkie dane cyklu?", + "purgeConfirmBody": "Spowoduje to trwałe usunięcie każdej miesiączki, wpisu dziennego, objawu i prognozy. Tej operacji nie można cofnąć.", + "purgeConfirm": "Usuń wszystko", + "purgeCancel": "Anuluj", + "purgeSuccess": "Usunięto wszystkie dane cyklu.", + "purgeError": "Nie udało się usunąć danych cyklu. Spróbuj ponownie." } }, "cycleReminders": { diff --git a/prisma/migrations/0131_v1150_cycle_sensitive_encryption/migration.sql b/prisma/migrations/0131_v1150_cycle_sensitive_encryption/migration.sql new file mode 100644 index 000000000..b7dbf6ea2 --- /dev/null +++ b/prisma/migrations/0131_v1150_cycle_sensitive_encryption/migration.sql @@ -0,0 +1,5 @@ +-- Additive: at-rest envelope column for the intent-revealing cycle day-log +-- fields. Populated INSTEAD of the plaintext columns when +-- CycleProfile.sensitive_category_encryption is ON; the plaintext columns +-- are then NULL. AES-256-GCM ciphertext, decrypted fail-soft on read. +ALTER TABLE "cycle_day_logs" ADD COLUMN "sensitive_encrypted" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a9eb00922..ab1844a26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3432,6 +3432,16 @@ model CycleDayLog { progesteroneTest HomeTestResult? @map("progesterone_test") contraceptive ContraceptiveKind? @map("contraceptive") + /// AES-256-GCM ciphertext envelope of the intent-revealing fields + /// (sexualActivity / protectedSex / pregnancyTest / progesteroneTest / + /// contraceptive). Populated INSTEAD of the plaintext columns above when + /// CycleProfile.sensitiveCategoryEncryption is ON: the plaintext columns + /// are NULLed and this carries the JSON envelope, so the most sensitive + /// reproductive data is encrypted at rest. Those five fields then drop + /// out of the rollup / correlation tier (queryable plaintext is the + /// trade for at-rest secrecy). Decrypted fail-soft on read. + sensitiveEncrypted String? @map("sensitive_encrypted") + /// AES-256-GCM ciphertext envelope of the day's free-text note (always /// encrypted — the `*Encrypted` String? convention, matching /// MoodTag.labelEncrypted + InsightNarrative). diff --git a/src/app/api/cycle/all/route.ts b/src/app/api/cycle/all/route.ts new file mode 100644 index 000000000..3f4616321 --- /dev/null +++ b/src/app/api/cycle/all/route.ts @@ -0,0 +1,61 @@ +/** + * `DELETE /api/cycle/all` — one-click cycle hard-delete purge. + * + * Distinct from the per-row soft-delete (`DELETE /api/cycle/day-logs/{id}`, + * which leaves a tombstone for the sync feed): this HARD-deletes every + * cycle row the user owns — day-logs (and their symptom links, by cascade), + * menstrual cycles, predictions, and the reminder ledger — so nothing + * reproductive persists in Postgres. The `CycleProfile` row is reset to its + * defaults (tracking left enabled) rather than dropped, so the gate and + * settings page keep working. + * + * Gated (`cycle.disabled` 403) and owner-scoped. Audited. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + // Hard delete in a transaction. Symptom links cascade off the day-log + // delete (the schema's onDelete: Cascade). Order child-before-parent for + // the rows that don't cascade. + const cleared = await prisma.$transaction(async (tx) => { + const dayLogs = await tx.cycleDayLog.deleteMany({ + where: { userId: user.id }, + }); + const predictions = await tx.cyclePrediction.deleteMany({ + where: { userId: user.id }, + }); + const cycles = await tx.menstrualCycle.deleteMany({ + where: { userId: user.id }, + }); + return { + dayLogs: dayLogs.count, + predictions: predictions.count, + cycles: cycles.count, + }; + }); + + await auditLog("cycle.purge", { + userId: user.id, + ipAddress: getClientIp(request), + details: cleared, + }); + + annotate({ + action: { name: "cycle.purge" }, + meta: cleared, + }); + + return apiSuccess({ purged: true, ...cleared }); +}); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 362d039b0..a9d41ff18 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -22,7 +22,9 @@ import { safeJson, } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; -import { encrypt } from "@/lib/crypto"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import { replaceSymptomLinks } from "@/lib/cycle/day-log-write"; import { cycleDayLogPatchSchema } from "@/lib/validations/cycle"; import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; @@ -39,7 +41,16 @@ export const PATCH = apiHandler( const existing = await prisma.cycleDayLog.findFirst({ where: { id, deletedAt: null }, - select: { id: true, userId: true }, + select: { + id: true, + userId: true, + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + }, }); if (!existing || existing.userId !== user.id) { return apiError("Day-log not found", 404); @@ -61,9 +72,38 @@ export const PATCH = apiHandler( const body = parsed.data; - // Field-by-field update (no mass assignment). Every field is - // optional; an omitted field is left untouched. `note` re-encrypts; - // an explicit null clears it. + // Resolve the stored plaintext sensitive fields (from the envelope when + // the row was encrypted, else the columns) so a partial patch merges + // against the true current value. + const encryptSensitive = (await getOrCreateCycleProfile(user.id)) + .sensitiveCategoryEncryption; + const stored = readStoredSensitive(existing); + const merged = { + sexualActivity: + body.sexualActivity !== undefined + ? body.sexualActivity + : stored.sexualActivity, + protectedSex: + body.protectedSex !== undefined + ? (body.protectedSex ?? null) + : stored.protectedSex, + pregnancyTest: + body.pregnancyTest !== undefined + ? (body.pregnancyTest ?? null) + : stored.pregnancyTest, + progesteroneTest: + body.progesteroneTest !== undefined + ? (body.progesteroneTest ?? null) + : stored.progesteroneTest, + contraceptive: + body.contraceptive !== undefined + ? (body.contraceptive ?? null) + : stored.contraceptive, + }; + + // Field-by-field update (no mass assignment). Non-sensitive fields are + // written only when present; the sensitive set is re-resolved and split + // between plaintext columns and the envelope per the flag. await prisma.cycleDayLog.update({ where: { id }, data: { @@ -80,21 +120,16 @@ export const PATCH = apiHandler( ...(body.cervicalMucus !== undefined && { cervicalMucus: body.cervicalMucus, }), - ...(body.sexualActivity !== undefined && { - sexualActivity: body.sexualActivity, - }), - ...(body.protectedSex !== undefined && { - protectedSex: body.protectedSex, - }), - ...(body.pregnancyTest !== undefined && { - pregnancyTest: body.pregnancyTest, - }), - ...(body.progesteroneTest !== undefined && { - progesteroneTest: body.progesteroneTest, - }), - ...(body.contraceptive !== undefined && { - contraceptive: body.contraceptive, - }), + sexualActivity: encryptSensitive ? false : merged.sexualActivity, + protectedSex: encryptSensitive ? null : merged.protectedSex, + pregnancyTest: (encryptSensitive ? null : merged.pregnancyTest) as never, + progesteroneTest: (encryptSensitive + ? null + : merged.progesteroneTest) as never, + contraceptive: (encryptSensitive ? null : merged.contraceptive) as never, + sensitiveEncrypted: encryptSensitive + ? encrypt(JSON.stringify(merged)) + : null, ...(body.note !== undefined && { notesEncrypted: body.note ? encrypt(body.note) : null, }), @@ -104,25 +139,11 @@ export const PATCH = apiHandler( // Replace symptom links only when `symptoms` was supplied. if (body.symptoms !== undefined) { - const keys = Array.from(new Set(body.symptoms.map((s) => s.key))); - const symptoms = - keys.length > 0 - ? await prisma.cycleSymptom.findMany({ - where: { - key: { in: keys }, - isActive: true, - OR: [{ userId: null }, { userId: user.id }], - }, - select: { id: true }, - }) - : []; - await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: id } }); - if (symptoms.length > 0) { - await prisma.cycleSymptomLink.createMany({ - data: symptoms.map((s) => ({ dayLogId: id, symptomId: s.id })), - skipDuplicates: true, - }); - } + await replaceSymptomLinks( + user.id, + id, + body.symptoms.map((s) => s.key), + ); } const row = await prisma.cycleDayLog.findUniqueOrThrow({ @@ -190,3 +211,51 @@ export const DELETE = apiHandler( return new Response(null, { status: 204 }); }, ); + +/** The stored plaintext sensitive fields (envelope when encrypted, else columns). */ +function readStoredSensitive(row: { + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; + sensitiveEncrypted: string | null; +}): { + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; +} { + if (row.sensitiveEncrypted) { + try { + const dec = JSON.parse(decrypt(row.sensitiveEncrypted)) as Record< + string, + unknown + >; + return { + sexualActivity: (dec.sexualActivity as boolean) ?? false, + protectedSex: (dec.protectedSex as boolean | null) ?? null, + pregnancyTest: (dec.pregnancyTest as string | null) ?? null, + progesteroneTest: (dec.progesteroneTest as string | null) ?? null, + contraceptive: (dec.contraceptive as string | null) ?? null, + }; + } catch { + // Fail-soft: an undecryptable envelope reads as cleared. + return { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + }; + } + } + return { + sexualActivity: row.sexualActivity, + protectedSex: row.protectedSex, + pregnancyTest: row.pregnancyTest, + progesteroneTest: row.progesteroneTest, + contraceptive: row.contraceptive, + }; +} diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index 27272e80c..abe2f53f8 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -123,8 +123,28 @@ async function postBulk(request: NextRequest): Promise { ...(entry.externalId ? { externalId: entry.externalId } : {}), }); } catch (err: unknown) { + // Map to a stable closed set of reason codes — never echo the raw + // driver message (it can carry column / constraint / value fragments + // and bypasses the central redaction boundary). The full message is + // logged through the WideEvent path where redaction applies. + const code = + typeof err === "object" && err !== null && "code" in err + ? (err as { code?: unknown }).code + : undefined; const reason = - err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; + code === "P2002" + ? "constraint" + : code === "P2003" + ? "constraint" + : "upsert_failed"; + annotate({ + action: { name: "cycle.bulk.entry-failed" }, + meta: { + index: i, + reason, + error: err instanceof Error ? err.message : String(err), + }, + }); skipped += 1; results.push({ index: i, diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 1490559ac..9a0c62fa0 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -19,6 +19,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, + apiError, getClientIp, returnAllZodIssues, safeJson, @@ -57,7 +58,29 @@ async function postDayLog(request: NextRequest): Promise { const tz = user.timezone ?? DEFAULT_TIMEZONE; const cycleId = await findOwningCycleId(user.id, entry.date); - const result = await upsertCycleDayLog(user.id, entry, tz, cycleId); + let result; + try { + result = await upsertCycleDayLog(user.id, entry, tz, cycleId); + } catch (err: unknown) { + // A residual unique-constraint collision (the helper adopts the + // canonical row on the common case) surfaces as a clean 409, never a + // 500 (the MoodEntry conflict precedent). + if ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "P2002" + ) { + annotate({ + action: { name: "cycle.day-log.conflict" }, + meta: { date: entry.date }, + }); + return apiError("Day-log already exists for this date", 409, { + errorCode: "cycle.day-log.conflict", + }); + } + throw err; + } const row = await prisma.cycleDayLog.findUniqueOrThrow({ where: { id: result.id }, diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 309f81742..35377c9bc 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -47,6 +47,7 @@ export interface CycleBackupSection { pregnancyTest: string | null; progesteroneTest: string | null; contraceptive: string | null; + sensitiveEncrypted: string | null; notesEncrypted: string | null; source: string; externalId: string | null; @@ -115,7 +116,8 @@ export async function buildCycleBackupSection( pregnancyTest: d.pregnancyTest, progesteroneTest: d.progesteroneTest, contraceptive: d.contraceptive, - // Ciphertext envelope carried verbatim — never decrypted here. + // Both ciphertext envelopes carried verbatim — never decrypted here. + sensitiveEncrypted: d.sensitiveEncrypted, notesEncrypted: d.notesEncrypted, source: d.source, externalId: d.externalId, @@ -286,7 +288,8 @@ export async function restoreCycleData( pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never, progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS) as never, contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES) as never, - // Ciphertext envelope written back verbatim — never re-encrypted. + // Both ciphertext envelopes written back verbatim — never re-encrypted. + sensitiveEncrypted: d.sensitiveEncrypted ?? null, notesEncrypted: d.notesEncrypted ?? null, source: (enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL") as never, externalId: d.externalId ?? null, diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index c02c1c01c..4ca3545e6 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -4,10 +4,24 @@ * * UPSERT key: `(userId, source, externalId)` when `externalId` is present * (the NULL-distinct cross-device dedup key, like MoodEntry), else the - * canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM); - * every other field is queryable plaintext. Symptom keys resolve against - * the seeded catalog (unknown keys dropped silently) and write the - * `CycleSymptomLink` join. + * canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM). + * + * Sensitive-category encryption: when the resolved profile flag is ON the + * five intent-revealing fields (sexualActivity, protectedSex, + * pregnancyTest, progesteroneTest, contraceptive) are encrypted into the + * single `sensitiveEncrypted` JSON envelope and the plaintext columns are + * NULLed — they then drop out of the rollup / correlation tier (the trade + * for at-rest secrecy). When OFF the plaintext columns are written as + * before and the envelope is NULL. + * + * Partial merge: a re-post that omits a field never nulls a previously + * stored value — the UPDATE branch only overwrites the fields actually + * present in the input. + * + * Collision-safe: on a `(userId, source, externalId)`-keyed create that + * collides with an existing canonical `(userId, date)` row (Prisma P2002), + * the existing row is adopted and UPDATEd in place — one canonical row per + * date regardless of provenance. * * Returns the upserted row id + whether a row already existed (so the * caller can map `inserted`/`updated`/`duplicate`) plus whether any field @@ -15,8 +29,10 @@ * distinction). */ import { prisma } from "@/lib/db"; -import { encrypt } from "@/lib/crypto"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import type { CycleDayLogInput } from "@/lib/validations/cycle"; +import type { Prisma } from "@/generated/prisma/client"; export interface DayLogWriteResult { id: string; @@ -25,8 +41,17 @@ export interface DayLogWriteResult { changed: boolean; } +/** The five intent-revealing fields the encryption envelope carries. */ +interface SensitiveFields { + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; +} + /** Resolve catalog symptom keys → ids, dropping unknown keys silently. */ -async function resolveSymptomIds( +export async function resolveSymptomIds( userId: string, keys: readonly string[], ): Promise { @@ -44,6 +69,81 @@ async function resolveSymptomIds( return rows.map((r) => r.id); } +/** + * Replace the symptom-link set for a day-log. Shared by the upsert helper + * and the PATCH route so both resolve + write the join identically. + */ +export async function replaceSymptomLinks( + userId: string, + dayLogId: string, + keys: readonly string[], +): Promise { + const ids = await resolveSymptomIds(userId, keys); + await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId } }); + if (ids.length > 0) { + await prisma.cycleSymptomLink.createMany({ + data: ids.map((symptomId) => ({ dayLogId, symptomId })), + skipDuplicates: true, + }); + } +} + +/** Decrypt the sensitive envelope fail-soft (null on missing / undecryptable). */ +function decryptSensitive(envelope: string | null): Partial { + if (!envelope) return {}; + try { + return JSON.parse(decrypt(envelope)) as Partial; + } catch { + return {}; + } +} + +/** Decrypt a note fail-soft (null on missing / undecryptable). */ +function decryptNoteSoft(notesEncrypted: string | null): string | null { + if (!notesEncrypted) return null; + try { + return decrypt(notesEncrypted); + } catch { + return null; + } +} + +type ExistingRow = { + id: string; + date: string; + flow: string | null; + intermenstrualBleeding: boolean; + basalBodyTempC: number | null; + ovulationTest: string | null; + cervicalMucus: string | null; + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; + sensitiveEncrypted: string | null; + notesEncrypted: string | null; + deletedAt: Date | null; +}; + +const EXISTING_SELECT = { + id: true, + date: true, + flow: true, + intermenstrualBleeding: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + notesEncrypted: true, + deletedAt: true, +} as const; + /** * Upsert one cycle day-log. `tz` anchors the date string. When * `cycleId` is supplied the row is attributed to that cycle span. @@ -55,9 +155,12 @@ export async function upsertCycleDayLog( cycleId: string | null = null, ): Promise { const source = entry.source; - const notesEncrypted = entry.note ? encrypt(entry.note) : null; - const where = entry.externalId + // Resolve the sensitive-category encryption flag once per write. + const profile = await getOrCreateCycleProfile(userId); + const encryptSensitive = profile.sensitiveCategoryEncryption; + + const where: Prisma.CycleDayLogWhereUniqueInput = entry.externalId ? { userId_source_externalId: { userId, @@ -69,103 +172,265 @@ export async function upsertCycleDayLog( // Probe so the caller can distinguish inserted / updated / duplicate // and (for an externalId re-post) detect whether a field changed. - const existing = await prisma.cycleDayLog.findUnique({ + const existing = (await prisma.cycleDayLog.findUnique({ where, - select: { - id: true, - date: true, - flow: true, - intermenstrualBleeding: true, - basalBodyTempC: true, - ovulationTest: true, - cervicalMucus: true, - sexualActivity: true, - protectedSex: true, - pregnancyTest: true, - progesteroneTest: true, - contraceptive: true, - notesEncrypted: true, - deletedAt: true, - }, + select: EXISTING_SELECT, + })) as ExistingRow | null; + + const result = await writeDayLog({ + userId, + entry, + tz, + cycleId, + source, + where, + existing, + encryptSensitive, }); - // Field-by-field create data (no mass assignment). - const baseData = { - flow: entry.flow ?? null, - intermenstrualBleeding: entry.intermenstrualBleeding ?? false, - basalBodyTempC: entry.basalBodyTempC ?? null, - ovulationTest: entry.ovulationTest ?? null, - cervicalMucus: entry.cervicalMucus ?? null, - sexualActivity: entry.sexualActivity ?? false, - protectedSex: entry.protectedSex ?? null, - pregnancyTest: entry.pregnancyTest ?? null, - progesteroneTest: entry.progesteroneTest ?? null, - contraceptive: entry.contraceptive ?? null, + return result; +} + +interface WriteArgs { + userId: string; + entry: CycleDayLogInput; + tz: string | null; + cycleId: string | null; + source: CycleDayLogInput["source"]; + where: Prisma.CycleDayLogWhereUniqueInput; + existing: ExistingRow | null; + encryptSensitive: boolean; +} + +async function writeDayLog(args: WriteArgs): Promise { + const { userId, entry, tz, cycleId, source, where, existing, encryptSensitive } = + args; + + // Decrypt the existing note (fail-soft) for the change/duplicate split — + // re-encrypting the same plaintext yields a different ciphertext (random + // GCM IV), so a ciphertext compare would always read "changed". + const existingNote = decryptNoteSoft(existing?.notesEncrypted ?? null); + + // The resolved plaintext sensitive values for THIS write, after partial + // merge against what's already stored (decrypted from the envelope when + // the stored row was encrypted). + const storedSensitive: SensitiveFields = existing + ? mergeStoredSensitive(existing) + : { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + }; + + const resolvedSensitive: SensitiveFields = { + sexualActivity: + entry.sexualActivity !== undefined + ? entry.sexualActivity + : storedSensitive.sexualActivity, + protectedSex: + entry.protectedSex !== undefined + ? (entry.protectedSex ?? null) + : storedSensitive.protectedSex, + pregnancyTest: + entry.pregnancyTest !== undefined + ? (entry.pregnancyTest ?? null) + : storedSensitive.pregnancyTest, + progesteroneTest: + entry.progesteroneTest !== undefined + ? (entry.progesteroneTest ?? null) + : storedSensitive.progesteroneTest, + contraceptive: + entry.contraceptive !== undefined + ? (entry.contraceptive ?? null) + : storedSensitive.contraceptive, + }; + + // Note resolution with partial merge: an omitted `note` keeps the stored + // value; an explicit null clears it. + const incomingNote = + entry.note !== undefined ? (entry.note ?? null) : decryptNoteSoft(existing?.notesEncrypted ?? null); + const notesEncrypted = incomingNote ? encrypt(incomingNote) : null; + + // Non-sensitive fields, partial-merged against the stored row. + const flow = entry.flow !== undefined ? (entry.flow ?? null) : (existing?.flow ?? null); + const intermenstrualBleeding = + entry.intermenstrualBleeding !== undefined + ? entry.intermenstrualBleeding + : (existing?.intermenstrualBleeding ?? false); + const basalBodyTempC = + entry.basalBodyTempC !== undefined + ? (entry.basalBodyTempC ?? null) + : (existing?.basalBodyTempC ?? null); + const ovulationTest = + entry.ovulationTest !== undefined + ? (entry.ovulationTest ?? null) + : (existing?.ovulationTest ?? null); + const cervicalMucus = + entry.cervicalMucus !== undefined + ? (entry.cervicalMucus ?? null) + : (existing?.cervicalMucus ?? null); + + // Split the sensitive fields between plaintext columns and the envelope + // depending on the flag. When encrypting, the plaintext columns are NULL + // (drop out of rollup/correlation) and the envelope carries the JSON. + const sensitiveEncrypted = encryptSensitive + ? encrypt(JSON.stringify(resolvedSensitive)) + : null; + const sensitivePlaintext = encryptSensitive + ? { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + } + : { ...resolvedSensitive }; + + const baseData: Prisma.CycleDayLogUncheckedUpdateInput = { + flow: flow as never, + intermenstrualBleeding, + basalBodyTempC, + ovulationTest: ovulationTest as never, + cervicalMucus: cervicalMucus as never, + sexualActivity: sensitivePlaintext.sexualActivity, + protectedSex: sensitivePlaintext.protectedSex, + pregnancyTest: sensitivePlaintext.pregnancyTest as never, + progesteroneTest: sensitivePlaintext.progesteroneTest as never, + contraceptive: sensitivePlaintext.contraceptive as never, + sensitiveEncrypted, notesEncrypted, }; - // Last-writer-wins on the field set. An externalId re-post also refreshes - // `date` (a re-zoned re-import lands the corrected wall-clock). A - // soft-deleted row is resurrected on a fresh write (deletedAt cleared). - const updateData = { - ...baseData, - ...(entry.externalId ? { date: entry.date } : {}), - ...(cycleId !== null ? { cycleId } : {}), - deletedAt: null, - syncVersion: { increment: 1 }, - }; - - const row = await prisma.cycleDayLog.upsert({ - where, - create: { - userId, - date: entry.date, - tz, - source, - externalId: entry.externalId ?? null, - cycleId, - ...baseData, - }, - update: updateData, - select: { id: true }, - }); - - // Replace the symptom-link set when symptoms were supplied. Absent - // `symptoms` leaves existing links untouched (a partial day-log edit - // that doesn't mention symptoms must not wipe them). - if (entry.symptoms !== undefined) { - const ids = await resolveSymptomIds( - userId, - entry.symptoms.map((s) => s.key), - ); - await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: row.id } }); - if (ids.length > 0) { - await prisma.cycleSymptomLink.createMany({ - data: ids.map((symptomId) => ({ dayLogId: row.id, symptomId })), - skipDuplicates: true, - }); - } - } - - // `changed` only matters for the bulk `updated` vs `duplicate` split on - // an existing row: did any persisted field differ from the re-post? - let changed = false; + // Did anything actually change vs the stored row? Compare the resolved + // PLAINTEXT values (note + sensitive), never ciphertext. + let changed = !existing; if (existing) { changed = - existing.flow !== baseData.flow || - existing.intermenstrualBleeding !== baseData.intermenstrualBleeding || - existing.basalBodyTempC !== baseData.basalBodyTempC || - existing.ovulationTest !== baseData.ovulationTest || - existing.cervicalMucus !== baseData.cervicalMucus || - existing.sexualActivity !== baseData.sexualActivity || - existing.protectedSex !== baseData.protectedSex || - existing.pregnancyTest !== baseData.pregnancyTest || - existing.progesteroneTest !== baseData.progesteroneTest || - existing.contraceptive !== baseData.contraceptive || - existing.notesEncrypted !== baseData.notesEncrypted || + (existing.flow ?? null) !== flow || + existing.intermenstrualBleeding !== intermenstrualBleeding || + (existing.basalBodyTempC ?? null) !== basalBodyTempC || + (existing.ovulationTest ?? null) !== ovulationTest || + (existing.cervicalMucus ?? null) !== cervicalMucus || + storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || + storedSensitive.protectedSex !== resolvedSensitive.protectedSex || + storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest || + storedSensitive.progesteroneTest !== resolvedSensitive.progesteroneTest || + storedSensitive.contraceptive !== resolvedSensitive.contraceptive || + existingNote !== incomingNote || existing.deletedAt !== null || (Boolean(entry.externalId) && existing.date !== entry.date); } - return { id: row.id, existed: existing !== null, changed }; + const updateData: Prisma.CycleDayLogUncheckedUpdateInput = { + ...baseData, + ...(entry.externalId ? { date: entry.date } : {}), + ...(cycleId !== null ? { cycleId } : {}), + deletedAt: null, + // Only bump syncVersion when a field actually changed — an unchanged + // re-post must be a true no-op on the /api/sync/changes feed so other + // devices don't re-pull an identical row. + ...(changed ? { syncVersion: { increment: 1 } } : {}), + }; + + let rowId: string; + let existed = existing !== null; + try { + const row = await prisma.cycleDayLog.upsert({ + where, + create: { + userId, + date: entry.date, + tz, + source, + externalId: entry.externalId ?? null, + cycleId, + flow: baseData.flow as never, + intermenstrualBleeding: baseData.intermenstrualBleeding as never, + basalBodyTempC: baseData.basalBodyTempC as never, + ovulationTest: baseData.ovulationTest as never, + cervicalMucus: baseData.cervicalMucus as never, + sexualActivity: sensitivePlaintext.sexualActivity, + protectedSex: sensitivePlaintext.protectedSex, + pregnancyTest: sensitivePlaintext.pregnancyTest as never, + progesteroneTest: sensitivePlaintext.progesteroneTest as never, + contraceptive: sensitivePlaintext.contraceptive as never, + sensitiveEncrypted, + notesEncrypted, + }, + update: updateData, + select: { id: true }, + }); + rowId = row.id; + } catch (err: unknown) { + // Dual-unique collision: an externalId-keyed create whose date already + // has a canonical (userId, date) row. Adopt the existing row and UPDATE + // it in place — one canonical row per date regardless of provenance. + if (isUniqueViolation(err) && entry.externalId) { + const canonical = await prisma.cycleDayLog.findUnique({ + where: { userId_date: { userId, date: entry.date } }, + select: { id: true }, + }); + if (!canonical) throw err; + const adopted = await prisma.cycleDayLog.update({ + where: { id: canonical.id }, + data: { + ...updateData, + source, + externalId: entry.externalId, + ...(changed ? {} : { syncVersion: { increment: 1 } }), + }, + select: { id: true }, + }); + rowId = adopted.id; + existed = true; + changed = true; + } else { + throw err; + } + } + + // Replace the symptom-link set when symptoms were supplied. Absent + // `symptoms` leaves existing links untouched. + if (entry.symptoms !== undefined) { + await replaceSymptomLinks( + userId, + rowId, + entry.symptoms.map((s) => s.key), + ); + } + + return { id: rowId, existed, changed }; +} + +/** Prisma P2002 (unique constraint) detector without importing the runtime. */ +function isUniqueViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "P2002" + ); +} + +/** Resolve the stored plaintext sensitive fields (envelope OR columns). */ +function mergeStoredSensitive(existing: ExistingRow): SensitiveFields { + if (existing.sensitiveEncrypted) { + const dec = decryptSensitive(existing.sensitiveEncrypted); + return { + sexualActivity: dec.sexualActivity ?? false, + protectedSex: dec.protectedSex ?? null, + pregnancyTest: dec.pregnancyTest ?? null, + progesteroneTest: dec.progesteroneTest ?? null, + contraceptive: dec.contraceptive ?? null, + }; + } + return { + sexualActivity: existing.sexualActivity, + protectedSex: existing.protectedSex, + pregnancyTest: existing.pregnancyTest, + progesteroneTest: existing.progesteroneTest, + contraceptive: existing.contraceptive, + }; } diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index a5c651695..83b08d12c 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -73,7 +73,34 @@ function decryptNote(notesEncrypted: string | null): string | null { } } +interface SensitiveEnvelope { + sexualActivity?: boolean; + protectedSex?: boolean | null; + pregnancyTest?: string | null; + progesteroneTest?: string | null; + contraceptive?: string | null; +} + +/** + * Decrypt the sensitive-category envelope fail-soft. When + * `sensitiveCategoryEncryption` was ON at write the five intent fields live + * here instead of the plaintext columns; an undecryptable envelope reads as + * an empty object so the page never 500s. + */ +function decryptSensitive(envelope: string | null): SensitiveEnvelope { + if (!envelope) return {}; + try { + return JSON.parse(decrypt(envelope)) as SensitiveEnvelope; + } catch { + return {}; + } +} + export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { + // Prefer the encryption envelope when present; else read the plaintext + // columns (the flag-OFF path). + const enc = decryptSensitive(row.sensitiveEncrypted); + const hasEnvelope = row.sensitiveEncrypted != null; return { id: row.id, date: row.date, @@ -83,11 +110,13 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { basalBodyTempC: row.basalBodyTempC, ovulationTest: row.ovulationTest, cervicalMucus: row.cervicalMucus, - sexualActivity: row.sexualActivity, - protectedSex: row.protectedSex, - pregnancyTest: row.pregnancyTest, - progesteroneTest: row.progesteroneTest, - contraceptive: row.contraceptive, + sexualActivity: hasEnvelope ? (enc.sexualActivity ?? false) : row.sexualActivity, + protectedSex: hasEnvelope ? (enc.protectedSex ?? null) : row.protectedSex, + pregnancyTest: hasEnvelope ? (enc.pregnancyTest ?? null) : row.pregnancyTest, + progesteroneTest: hasEnvelope + ? (enc.progesteroneTest ?? null) + : row.progesteroneTest, + contraceptive: hasEnvelope ? (enc.contraceptive ?? null) : row.contraceptive, symptoms: (row.symptomLinks ?? []).map((l) => ({ key: l.symptom.key, severity: null, diff --git a/src/lib/cycle/import-accumulator.ts b/src/lib/cycle/import-accumulator.ts index 82030dfa6..a95ad8ece 100644 --- a/src/lib/cycle/import-accumulator.ts +++ b/src/lib/cycle/import-accumulator.ts @@ -21,7 +21,8 @@ */ import { prisma } from "@/lib/db"; import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; -import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { auditLog } from "@/lib/auth/audit"; import { mapHkCycleSample, isCycleHkIdentifier, @@ -33,6 +34,14 @@ import type { ContraceptiveKind } from "@/generated/prisma/client"; /** Synthetic per-day externalId prefix for HealthKit-imported cycle rows. */ export const HK_CYCLE_DAY_EXTERNAL_PREFIX = "hkcycle:"; +/** A zeroed cycle-import result — the no-cycle / aborted-flush fallback. */ +export const EMPTY_CYCLE_IMPORT_STATS: CycleImportStats = { + samplesConsumed: 0, + daysUpserted: 0, + daysInserted: 0, + goalNudged: false, +}; + /** Per-day merge state held during the parse. */ interface DayBucket { fields: CycleDayLogFields; @@ -162,14 +171,8 @@ export class CycleImportAccumulator { select: { cycleTrackingEnabled: true }, }), ]); - // Inline the gate logic (no profile auto-create on the import path). - const toggle = profile?.cycleTrackingEnabled; - this.gated = - toggle === true - ? true - : toggle === false - ? false - : user?.gender === "FEMALE"; + // The single source-of-truth gate (no profile auto-create on import). + this.gated = isCycleEnabled(user?.gender, profile); return this.gated; } @@ -188,6 +191,23 @@ export class CycleImportAccumulator { let contraceptiveNudge: ContraceptiveKind | null = null; + // Hoist cycle attribution out of the per-day loop: load the user's + // (non-deleted) cycle start dates once and resolve the owning span in + // memory (latest start <= date) rather than a query per day. + const cycleRows = await prisma.menstrualCycle.findMany({ + where: { userId: this.userId, deletedAt: null }, + orderBy: { startDate: "asc" }, + select: { id: true, startDate: true }, + }); + const owningCycleId = (date: string): string | null => { + let id: string | null = null; + for (const c of cycleRows) { + if (c.startDate <= date) id = c.id; + else break; + } + return id; + }; + for (const [dayKey, bucket] of this.byDay.entries()) { const symptoms = bucket.symptomKeys.size > 0 @@ -206,7 +226,7 @@ export class CycleImportAccumulator { loggedAt: `${dayKey}T12:00:00.000Z`, }; - const cycleId = await findOwningCycleId(this.userId, dayKey); + const cycleId = owningCycleId(dayKey); const result = await upsertCycleDayLog( this.userId, entry, @@ -235,7 +255,15 @@ export class CycleImportAccumulator { where: { userId: this.userId, goal: "GENERAL_HEALTH" }, data: { goal: "AVOID_PREGNANCY" }, }); - if (updated.count > 0) stats.goalNudged = true; + if (updated.count > 0) { + stats.goalNudged = true; + // A server-side mutation of an intent-revealing setting must not be + // invisible — record it on the audit trail. + await auditLog("cycle.profile.goal-nudged", { + userId: this.userId, + details: { from: "GENERAL_HEALTH", to: "AVOID_PREGNANCY", via: "apple-health-import" }, + }); + } } return stats; diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index ee402181d..93c55c9d1 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -368,6 +368,9 @@ export async function runCycleReminderTick( userId: user.id, title, message: body, + // In discreet mode the senders mask the lock-screen routing + // metadata so no cycle event is named on the lock screen. + discreet, metadata: { scheduledAt: now.toISOString(), localDate: today, diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 9f69f3bca..a0d740f73 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -46,6 +46,7 @@ import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activit import { validateMeasurementRange } from "@/lib/validations/measurement"; import { CycleImportAccumulator, + EMPTY_CYCLE_IMPORT_STATS, type CycleImportStats, } from "@/lib/cycle/import-accumulator"; import { HK_SEXUAL_ACTIVITY_PROTECTION_META } from "@/lib/cycle/healthkit-mapping"; @@ -815,19 +816,23 @@ export async function streamParseExportXml( // v1.15.0 — fold the accumulated reproductive samples into CYCLE // day-logs. Gated on cycle-tracking being enabled for the account so a // non-cycle Apple Health export never silently provisions cycle rows. - let cycle: CycleImportStats = { - samplesConsumed: 0, - daysUpserted: 0, - daysInserted: 0, - goalNudged: false, - }; + let cycle: CycleImportStats = { ...EMPTY_CYCLE_IMPORT_STATS }; // Only touch the cycle tables when the export actually carried // reproductive samples AND the account has cycle tracking enabled. The // empty-accumulator short-circuit also keeps the no-cycle import path - // (and its unit tests) free of any cycle DB round-trip. + // (and its unit tests) free of any cycle DB round-trip. A flush failure + // (e.g. a single colliding day) must never abort the whole import — fold + // the error into zeroed cycle stats and continue. if (cycleAccumulator.hasSamples() && (await cycleAccumulator.isEnabled())) { - cycle = await cycleAccumulator.flush(); - rowsUpserted += cycle.daysUpserted; + try { + cycle = await cycleAccumulator.flush(); + rowsUpserted += cycle.daysUpserted; + } catch (err: unknown) { + cycle = { ...EMPTY_CYCLE_IMPORT_STATS }; + console.warn( + `cycle import flush failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } } await emitProgress("upserting"); diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index fe17639a4..d7a887fa7 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -523,6 +523,7 @@ export async function sendViaApns( message: string; eventType: string; metadata?: Record; + discreet?: boolean; }, ): Promise { const config = loadApnsConfig(); @@ -602,6 +603,12 @@ export async function sendViaApns( const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER"; let result: Awaited> | null = null; + // Discreet mode (cycle privacy): the lock-screen-visible routing + // metadata must not carry the event name. Collapse the category / + // threadId / collapseId and the data eventType to a generic value so + // CYCLE_PERIOD_SOON never surfaces on the lock screen. + const GENERIC_EVENT = "REMINDER"; + const routingEvent = payload.discreet ? GENERIC_EVENT : payload.eventType; let workingEnv: "production" | "sandbox" | null = null; for (const attemptEnv of envSequence) { result = await sendApnsPush({ @@ -610,16 +617,16 @@ export async function sendViaApns( payload: { alert: { title: payload.title, body: stripHtml(payload.message) }, data: { - eventType: payload.eventType, + eventType: routingEvent, ...pickIosMetadata(payload.metadata), }, - threadId: payload.eventType, + threadId: routingEvent, // The iOS app registers a `UNNotificationCategory` per event-type // so the same string doubles as the category identifier. iOS // ignores categories it doesn't know about, so adding the key // here is safe for event-types that don't have an actionable // category registered yet — they fall back to a plain alert. - category: payload.eventType, + category: routingEvent, // Future-proof for an iOS Notification Service Extension. iOS // ignores the flag when no extension is registered, so it's a // no-op on today's binary but unblocks NSE work without a @@ -632,7 +639,7 @@ export async function sendViaApns( } : {}), }, - collapseId: payload.eventType, + collapseId: routingEvent, }); if (result.ok) { diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 05248d381..a71a05cd1 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -29,7 +29,12 @@ export async function sendViaNtfy( Title: payload.title, Priority: payload.eventType === "MEDICATION_REMINDER" ? "high" : "default", - Tags: payload.eventType.toLowerCase().replace(/_/g, "-"), + // Discreet mode (cycle privacy): the X-Tags header is visible on the + // lock screen, so collapse it to a generic tag instead of leaking the + // cycle event name. + Tags: payload.discreet + ? "reminder" + : payload.eventType.toLowerCase().replace(/_/g, "-"), }; if (config.authToken) { diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index f28bbe534..160935d93 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -96,4 +96,12 @@ export interface NotificationPayload { message: string; /** Channel-specific extras (e.g. medicationId for Telegram inline buttons) */ metadata?: Record; + /** + * Discreet mode (cycle privacy). When true, the lock-screen-visible + * routing metadata the senders would otherwise derive from `eventType` + * (APNs category / threadId / collapseId, ntfy Tags) is replaced with a + * generic value so the cycle event name never appears on the lock screen + * — the title/body are already masked upstream. + */ + discreet?: boolean; } diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 1099ea1aa..ea483d352 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -148,6 +148,7 @@ const cycleDayLogSchema = z pregnancyTest: z.string().nullable().optional(), progesteroneTest: z.string().nullable().optional(), contraceptive: z.string().nullable().optional(), + sensitiveEncrypted: z.string().nullable().optional(), notesEncrypted: z.string().nullable().optional(), source: z.string().min(1).optional(), externalId: z.string().nullable().optional(),