diff --git a/src/app/api/cycle/all/route.ts b/src/app/api/cycle/all/route.ts index 3f4616321..c5d508430 100644 --- a/src/app/api/cycle/all/route.ts +++ b/src/app/api/cycle/all/route.ts @@ -4,12 +4,14 @@ * 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. + * menstrual cycles, predictions, the cycle audit trail, and the cycle + * reminder-delivery rows in the push-attempts ledger — so no dated + * reproductive trace survives in Postgres (the post-Dobbs threat model). The + * `CycleProfile` row is left in place (gate + settings keep working). * - * Gated (`cycle.disabled` 403) and owner-scoped. Audited. + * Gated (`cycle.disabled` 403) and owner-scoped. The purge action itself IS + * audited (written after the transaction, so it survives) — its `details` + * carry only counts, no reproductive content. */ import { NextRequest } from "next/server"; @@ -39,10 +41,34 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const cycles = await tx.menstrualCycle.deleteMany({ where: { userId: user.id }, }); + // The cycle audit trail carries dated reproductive-adjacent details + // (period boundaries, the AVOID_PREGNANCY goal-nudge, goal changes). A + // purge that promises "nothing reproductive persists" must clear it too. + // The purge's own audit row is written AFTER this transaction, so it + // survives (counts only, no reproductive content). + const auditRows = await tx.auditLog.deleteMany({ + where: { + userId: user.id, + OR: [ + { action: { startsWith: "cycle." } }, + { action: "user.cycle-prefs.update" }, + ], + }, + }); + // The shared push-attempts ledger holds dated cycle reminder-delivery + // rows (eventType + createdAt) for up to 90 days — drop the cycle ones. + const pushRows = await tx.pushAttempt.deleteMany({ + where: { + userId: user.id, + eventType: { in: ["CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM"] }, + }, + }); return { dayLogs: dayLogs.count, predictions: predictions.count, cycles: cycles.count, + auditRows: auditRows.count, + pushRows: pushRows.count, }; }); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index fdeb41e54..e4bfa86c2 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -6,8 +6,10 @@ * `{ profile, prediction, days }`. The synchronous engine call is cheap * (pure stats); the heavy part is the cache persist, which is debounced * stale-while-revalidate and fire-and-forget so it never blocks the GET. - * Fertile-window fields are goal-gated (null unless TRYING_TO_CONCEIVE), - * suppressed server-side. + * Fertile-window fields are goal-gated (surfaced for TRYING_TO_CONCEIVE and + * AVOID_PREGNANCY — the latter with the stronger "not a contraceptive method" + * disclaimer; suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF), + * resolved server-side. * * Default range: current cycle − 90d … +180d forward. */ @@ -26,6 +28,7 @@ import { import { toCyclePredictionDTO, goalAllowsFertileWindow, + cycleDisclaimerKey, } from "@/lib/cycle/dto"; import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; @@ -158,7 +161,9 @@ export const GET = apiHandler(async (request: NextRequest) => { userLocale: user.locale ?? undefined, }); const t = getServerTranslator(locale); - const disclaimer = t.t("cycle.prediction.disclaimer"); + // AVOID_PREGNANCY surfaces the fertile window, so it must carry the stronger + // "not a contraceptive method" caveat (QA H-1). + const disclaimer = t.t(cycleDisclaimerKey(profile.goal)); annotate({ action: { name: "cycle.calendar.read" }, diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index abe2f53f8..084dd5318 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -24,12 +24,12 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleBulkSchema, MAX_CYCLE_BULK_ENTRIES } from "@/lib/validations/cycle"; import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; -import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; const BATCH_RATE_LIMIT_MAX = 60; @@ -92,6 +92,25 @@ async function postBulk(request: NextRequest): Promise { const { entries } = parsed.data; const tz = user.timezone ?? DEFAULT_TIMEZONE; + // Hoist cycle attribution + the invariant encryption flag out of the loop: + // load the user's cycle start dates once and resolve the owning span in + // memory (latest start <= date), instead of a findOwningCycleId query per + // entry (up to 500) + a profile read per entry (QA M-3 / round-1 N+1). + const cycleRows = await prisma.menstrualCycle.findMany({ + where: { userId: user.id, 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; + }; + const encryptSensitive = gate.profile.sensitiveCategoryEncryption; + const results: EntryResult[] = []; let inserted = 0; let updated = 0; @@ -101,8 +120,14 @@ async function postBulk(request: NextRequest): Promise { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; try { - const cycleId = await findOwningCycleId(user.id, entry.date); - const r = await upsertCycleDayLog(user.id, entry, tz, cycleId); + const cycleId = owningCycleId(entry.date); + const r = await upsertCycleDayLog( + user.id, + entry, + tz, + cycleId, + encryptSensitive, + ); let status: EntryStatus; if (!r.existed) { @@ -124,9 +149,10 @@ async function postBulk(request: NextRequest): Promise { }); } 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. + // driver message (it can carry column / constraint / value fragments). + // `annotate` meta is NOT run through the central redactor (only + // setError/setHttp are), so the raw message must not be attached here: + // record the stable code + the Prisma error code only (QA M-sec1). const code = typeof err === "object" && err !== null && "code" in err ? (err as { code?: unknown }).code @@ -142,7 +168,7 @@ async function postBulk(request: NextRequest): Promise { meta: { index: i, reason, - error: err instanceof Error ? err.message : String(err), + error_code: typeof code === "string" ? code : "unknown", }, }); skipped += 1; diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 31a11a0e1..908ea889c 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -95,6 +95,27 @@ async function postPeriod(request: NextRequest): Promise { syncVersion: { increment: 1 }, }, }); + + // Re-anchor the FOLLOWING neighbour: when this start is back-filled + // between two existing cycles, the new cycle's end + length are already + // known from its successor's start. Without this the inserted cycle keeps + // endDate/lengthDays = null even though the next start exists, and the + // iOS mirror + history surfaces read a stale open cycle (QA M-1). + const next = await db.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { gt: date } }, + orderBy: { startDate: "asc" }, + select: { startDate: true }, + }); + if (next) { + await db.menstrualCycle.update({ + where: { id: cycle.id }, + data: { + endDate: addDays(next.startDate, -1), + lengthDays: dayDiff(next.startDate, date), + syncVersion: { increment: 1 }, + }, + }); + } return { cycleId: cycle.id as string | null }; } diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 00cc32e33..ccc514337 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -90,7 +90,18 @@ export function CycleView() { const loading = calendar.isLoading && !calendar.data; const calendarError = calendar.isError && !calendar.data; const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined; - const disclaimerText = t("cycle.prediction.disclaimer"); + // AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger + // "not a contraceptive method" caveat. Prefer the server-resolved + // prediction.disclaimer (already goal-correct); fall back to the goal-derived + // key for the no-prediction calendar tab (QA H-1). + const goal = calendar.data?.profile.goal; + const disclaimerText = + calendar.data?.prediction?.disclaimer ?? + t( + goal === "AVOID_PREGNANCY" + ? "cycle.disclaimer" + : "cycle.prediction.disclaimer", + ); return (
diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 2675385e5..5fb63635c 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -173,18 +173,27 @@ const EXISTING_SELECT = { /** * Upsert one cycle day-log. `tz` anchors the date string. When * `cycleId` is supplied the row is attributed to that cycle span. + * + * `encryptSensitiveFlag` lets a batch caller (bulk drain / Apple-Health import + * flush) resolve the invariant `sensitiveCategoryEncryption` flag ONCE and + * thread it in, instead of this helper re-reading the profile per row (a + * per-day round-trip on a multi-year import — QA M-3). The single POST/PATCH + * paths omit it and keep the lazy read. */ export async function upsertCycleDayLog( userId: string, entry: CycleDayLogInput, tz: string | null, cycleId: string | null = null, + encryptSensitiveFlag?: boolean, ): Promise { const source = entry.source; - // Resolve the sensitive-category encryption flag once per write. - const profile = await getOrCreateCycleProfile(userId); - const encryptSensitive = profile.sensitiveCategoryEncryption; + // Resolve the sensitive-category encryption flag once per write (or reuse the + // flag the batch caller already resolved). + const encryptSensitive = + encryptSensitiveFlag ?? + (await getOrCreateCycleProfile(userId)).sensitiveCategoryEncryption; const where: Prisma.CycleDayLogWhereUniqueInput = entry.externalId ? { diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 2f66fe5fd..781b59a2e 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -247,3 +247,17 @@ export function goalAllowsFertileWindow(goal: string): boolean { // hidden for GENERAL_HEALTH / PERIMENOPAUSE / OFF. return goal === "TRYING_TO_CONCEIVE" || goal === "AVOID_PREGNANCY"; } + +/** + * The i18n key for the disclaimer a goal must show. AVOID_PREGNANCY surfaces the + * fertile window, so it MUST carry the stronger "not a contraceptive method — + * never safe to assume unprotected sex" copy (`cycle.disclaimer`); every other + * goal gets the standard "estimates, not medical advice" line + * (`cycle.prediction.disclaimer`). Shared by the server route and the client so + * web/iOS/engine show the same caveat. (algorithm.md §4 — safety-relevant.) + */ +export function cycleDisclaimerKey(goal: string): string { + return goal === "AVOID_PREGNANCY" + ? "cycle.disclaimer" + : "cycle.prediction.disclaimer"; +} diff --git a/src/lib/cycle/import-accumulator.ts b/src/lib/cycle/import-accumulator.ts index a95ad8ece..c81d58578 100644 --- a/src/lib/cycle/import-accumulator.ts +++ b/src/lib/cycle/import-accumulator.ts @@ -208,6 +208,15 @@ export class CycleImportAccumulator { return id; }; + // Resolve the invariant encryption flag once for the whole flush, instead + // of re-reading the profile per upserted day (QA M-3). Default ON if the + // profile somehow vanished between the gate check and here. + const encProfile = await prisma.cycleProfile.findUnique({ + where: { userId: this.userId }, + select: { sensitiveCategoryEncryption: true }, + }); + const encryptSensitive = encProfile?.sensitiveCategoryEncryption ?? true; + for (const [dayKey, bucket] of this.byDay.entries()) { const symptoms = bucket.symptomKeys.size > 0 @@ -232,6 +241,7 @@ export class CycleImportAccumulator { entry, this.userTimezone, cycleId, + encryptSensitive, ); stats.daysUpserted += 1; if (!result.existed) stats.daysInserted += 1; diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index d7a887fa7..c057e21dd 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -618,7 +618,14 @@ export async function sendViaApns( alert: { title: payload.title, body: stripHtml(payload.message) }, data: { eventType: routingEvent, - ...pickIosMetadata(payload.metadata), + // In discreet mode also drop cycle-semantic metadata (e.g. `phase`) + // so a future cycle push can't name a cycle concept on the wire even + // though the allowlist permits `phase` (QA LOW). + ...pickIosMetadata( + payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, + ), }, threadId: routingEvent, // The iOS app registers a `UNNotificationCategory` per event-type @@ -769,3 +776,21 @@ function pickIosMetadata( } return out; } + +/** Cycle-semantic metadata keys that must never ride a discreet payload. */ +const CYCLE_METADATA_KEYS = new Set(["phase"]); + +/** + * Drop cycle-semantic metadata (e.g. `phase`) so a discreet cycle push can't + * name a cycle concept on the wire even though the allowlist permits the key. + */ +function stripCycleMetadata( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) return metadata; + const out: Record = {}; + for (const key of Object.keys(metadata)) { + if (!CYCLE_METADATA_KEYS.has(key)) out[key] = metadata[key]; + } + return out; +} diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 923248eb8..c7a13f81f 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -79,7 +79,11 @@ export async function sendViaWebPush( const pushPayload = JSON.stringify({ title: payload.title, body: payload.message.replace(/<[^>]*>/g, ""), - tag: payload.eventType, + // Discreet mode (cycle privacy): the coalescing `tag` must not name the + // event. Web-Push payloads are VAPID-encrypted (only the SW sees this), + // but the discreet contract still requires no event name on the wire — + // mirror the APNs GENERIC_EVENT collapse (QA M-sec3). + tag: payload.discreet ? "REMINDER" : payload.eventType, url: "/", }); diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index 6f2bd55dc..1ee5992e8 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -53,6 +53,18 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ /insurance/i, /insurer/i, /kvnr/i, + // v1.15.0 — reproductive-health intent fields (the post-Dobbs threat + // model's highest-sensitivity category). These are encrypted at rest in the + // sensitive-category envelope and no cycle route currently routes its body + // through `buildPayloadDiagnostic`, so this is defence-in-depth: if a future + // change ever excerpts a cycle request body, the intent values redact rather + // than land verbatim. (`flow`/`note` are too generic to add safely.) + /sexualactivity/i, + /protectedsex/i, + /pregnancytest/i, + /progesteronetest/i, + /contraceptive/i, + /cervicalmucus/i, ]; const REDACTED = "[redacted]";