Files
HealthLog/src/lib/cycle/dto.ts
T
Marc-André BombeckandClaude Opus 4.8 3375bbabac fix(cycle): second-audit safety, privacy, and write-path hardening
Address the second-round audit (server cluster):

- AVOID_PREGNANCY surfaces the fertile window, so it must carry the
  stronger "not a contraceptive method" disclaimer — wire the existing
  strong copy via a shared cycleDisclaimerKey(goal), server and client.
- Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so
  an inserted-between-two-cycles start no longer leaves a stale open cycle.
- Resolve the sensitive-category encryption flag once per bulk/import
  batch instead of re-reading the profile per day; hoist the bulk cycle
  attribution in-memory (no per-entry query).
- Drop the raw driver message from the bulk wide-event meta (annotate meta
  is not run through the central redactor); keep the stable reason + code.
- Hard-purge now clears the cycle audit trail and the cycle reminder rows
  in the push-attempts ledger, so no dated reproductive trace survives.
- Discreet mode collapses the Web-Push coalescing tag and drops cycle
  metadata (phase) from APNs, matching the existing APNs event collapse.
- Add the reproductive intent field names to the central redactor denylist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:15:22 +02:00

264 lines
8.5 KiB
TypeScript

/**
* Cycle row → wire-DTO mappers (ios-contract §2 + §3).
*
* The canonical `CycleDayLogDTO` and `MenstrualCycleDTO` shapes the iOS
* client mirrors. `notesEncrypted` is decrypted fail-soft on read (a
* key-rotation gap on one row reads `null`, never 500s the whole page).
* Symptom links are flattened to `[{ key, severity }]` using the catalog
* key; `severity` carries the persisted per-link 1-4 Likert intensity
* (NULL = a plain presence link).
*/
import { decrypt } from "@/lib/crypto";
import type { CyclePredictionResult } from "@/lib/cycle/types";
import type {
CycleDayLog,
CycleSymptomLink,
CycleSymptom,
MenstrualCycle,
} from "@/generated/prisma/client";
export interface CycleSymptomDTO {
key: string;
severity: number | null;
}
export interface CycleDayLogDTO {
id: string;
date: string;
cycleId: string | null;
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;
symptoms: CycleSymptomDTO[];
note: string | null;
source: string;
externalId: string | null;
syncVersion: number;
updatedAt: string;
deletedAt: string | null;
}
export interface MenstrualCycleDTO {
id: string;
startDate: string;
endDate: string | null;
periodEndDate: string | null;
lengthDays: number | null;
ovulationDate: string | null;
ovulationConfirmed: boolean;
isPredicted: boolean;
syncVersion: number;
updatedAt: string;
}
type DayLogWithLinks = CycleDayLog & {
symptomLinks?: (CycleSymptomLink & { symptom: Pick<CycleSymptom, "key"> })[];
};
/** Decrypt a day-log note fail-soft (null on missing / undecryptable). */
function decryptNote(notesEncrypted: string | null): string | null {
if (!notesEncrypted) return null;
try {
return decrypt(notesEncrypted);
} catch {
return 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,
cycleId: row.cycleId,
flow: row.flow,
intermenstrualBleeding: row.intermenstrualBleeding,
basalBodyTempC: row.basalBodyTempC,
ovulationTest: row.ovulationTest,
cervicalMucus: row.cervicalMucus,
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: l.severity ?? null,
})),
note: decryptNote(row.notesEncrypted),
source: row.source,
externalId: row.externalId,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
};
}
export function toMenstrualCycleDTO(row: MenstrualCycle): MenstrualCycleDTO {
return {
id: row.id,
startDate: row.startDate,
endDate: row.endDate,
periodEndDate: row.periodEndDate,
lengthDays: row.lengthDays,
ovulationDate: row.ovulationDate,
ovulationConfirmed: row.ovulationConfirmed,
isPredicted: row.isPredicted,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
};
}
/** The Prisma `include` that hydrates symptom links → DTO keys. */
export const dayLogSymptomInclude = {
symptomLinks: { include: { symptom: { select: { key: true } } } },
} as const;
export interface CyclePredictionDTO {
method: string;
nextPeriodStart: string;
nextPeriodStartLow: string;
nextPeriodStartHigh: string;
fertileWindowStart: string | null;
fertileWindowEnd: string | null;
predictedOvulation: string | null;
confidence: number;
cyclesObserved: number;
stillLearning: boolean;
disclaimer: string;
}
/**
* Map the engine result to the wire `CyclePredictionDTO`. Fertile-window
* fields (and the predicted ovulation) are suppressed unless the goal
* permits them (ios-contract §2.D — GENERAL_HEALTH / PERIMENOPAUSE / OFF
* never surface a fertile window; the suppression is server-side, never
* iOS).
*/
export function toCyclePredictionDTO(
result: CyclePredictionResult,
goalAllowsFertile: boolean,
disclaimer: string,
): CyclePredictionDTO {
return {
method: result.method,
nextPeriodStart: result.nextPeriodStart,
nextPeriodStartLow: result.nextPeriodStartLow,
nextPeriodStartHigh: result.nextPeriodStartHigh,
fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null,
fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null,
predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
stillLearning: result.stillLearning,
disclaimer,
};
}
/** The full profile read DTO (ios-contract §2.G). */
export interface CycleProfileDTO {
goal: string;
cycleTrackingEnabled: boolean;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
updatedAt: string;
}
export function toCycleProfileDTO(
row: MenstrualCycleProfileRow,
cycleTrackingEnabled: boolean,
): CycleProfileDTO {
return {
goal: row.goal,
cycleTrackingEnabled,
rawChartMode: row.rawChartMode,
predictionEnabled: row.predictionEnabled,
discreetNotifications: row.discreetNotifications,
sensitiveCategoryEncryption: row.sensitiveCategoryEncryption,
typicalCycleLength: row.typicalCycleLength,
typicalPeriodLength: row.typicalPeriodLength,
lutealPhaseLength: row.lutealPhaseLength,
updatedAt: row.updatedAt.toISOString(),
};
}
type MenstrualCycleProfileRow = {
goal: string;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
updatedAt: Date;
};
/**
* Whether a goal surfaces the fertile window. Per algorithm.md §4 the window is
* shown for TRYING_TO_CONCEIVE and AVOID_PREGNANCY (AVOID_PREGNANCY carries a
* stronger "estimate, not a contraceptive method" caveat in the copy), and
* suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF (the suppression is always
* server-side, never iOS).
*/
export function goalAllowsFertileWindow(goal: string): boolean {
// algorithm.md §4: shown for TRYING_TO_CONCEIVE AND AVOID_PREGNANCY (the
// latter carries a stronger "not a contraceptive method" caveat in copy),
// 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";
}