mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The cycle/illness/records note DTOs, the biomarker context, and the custom mood/cycle labels each degraded an undecryptable value to a blank/generic fallback with no signal — a key-rotation gap read as 'left blank' and the catch was silent. Keep the fail-soft read posture (one bad row never 500s the list) but emit a wide-event on each swallowed decrypt so a systemic key gap surfaces. The visible per-field 'unreadable' marker in the UI is the deferred remainder.
313 lines
10 KiB
TypeScript
313 lines
10 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 { getEvent } from "@/lib/logging/context";
|
|
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;
|
|
/** Whether the day's BBT is marked disturbed (excluded from the engine). */
|
|
temperatureExcluded: boolean;
|
|
ovulationTest: string | null;
|
|
cervicalMucus: string | null;
|
|
/** Cervix observation signs (symptothermal secondary indicator). */
|
|
cervixPosition: string | null;
|
|
cervixFirmness: string | null;
|
|
cervixOpening: 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 (err) {
|
|
// A note WAS stored but is undecryptable (key-rotation gap / corruption).
|
|
// Fail soft to null so one bad row never throws the day-log read, but log
|
|
// it (F-CRYPTO-2) so a systemic key gap surfaces instead of silently
|
|
// reading as an empty note.
|
|
getEvent()?.addWarning(
|
|
`cycle day-log note decrypt failed: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
);
|
|
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,
|
|
temperatureExcluded: row.temperatureExcluded,
|
|
ovulationTest: row.ovulationTest,
|
|
cervicalMucus: row.cervicalMucus,
|
|
cervixPosition: row.cervixPosition,
|
|
cervixFirmness: row.cervixFirmness,
|
|
cervixOpening: row.cervixOpening,
|
|
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;
|
|
/** Whether `predictedOvulation` was confirmed by a signal layer (BBT / symptothermal). */
|
|
ovulationConfirmed: boolean;
|
|
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,
|
|
// Fertile-window + predicted ovulation are suppressed both when the goal
|
|
// forbids them AND while still learning (<3 cycles): below that we would be
|
|
// emitting a population-prior guess, not a data-grounded estimate. Making
|
|
// the gate structural here (not just in the web panels / calendar grid)
|
|
// means iOS — and any client reading `prediction.*` directly — cannot paint
|
|
// a fertile window the rest of the app refuses to show. `ovulationConfirmed`
|
|
// stays goal-gated only: a confirmed shift is observed data, not a prior.
|
|
fertileWindowStart:
|
|
goalAllowsFertile && !result.stillLearning
|
|
? result.fertileWindowStart
|
|
: null,
|
|
fertileWindowEnd:
|
|
goalAllowsFertile && !result.stillLearning
|
|
? result.fertileWindowEnd
|
|
: null,
|
|
predictedOvulation:
|
|
goalAllowsFertile && !result.stillLearning
|
|
? result.predictedOvulation
|
|
: null,
|
|
ovulationConfirmed: goalAllowsFertile ? result.ovulationConfirmed : false,
|
|
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;
|
|
/** Symptothermal secondary symptom — MUCUS (default) or CERVIX. */
|
|
secondarySymptom: string;
|
|
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,
|
|
secondarySymptom: row.secondarySymptom,
|
|
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;
|
|
secondarySymptom: 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";
|
|
}
|