From a7c82eb5828ef0e6ddccc2c8372bd8a7a51a47e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:49:27 +0200 Subject: [PATCH] refactor(types): restore Prisma's type-checker on the cycle + PR write paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle write path and the PR worker cast Prisma inputs to `never`, which switches off the compiler exactly where a schema/enum drift would otherwise be caught. - day-log-write: `ExistingRow` and `SensitiveFields` widened every enum column to `string`, forcing 21 `as never` writes. Type them to the generated enums (FlowLevel, OvulationTest, CervicalMucus, Cervix*, HomeTestResult, ContraceptiveKind) — the Zod input enums already match member-for-member — and build the create row from the typed locals instead of re-casting the update-input shape. - cycle/backup: thread the enum element type through `enumOrNull` and pin each allow-set to its enum, so the 12 restore writes type-check against the generated create input rather than `never`. - pr-detection-worker: type the candidate `source` as MeasurementSource (both Workout and PersonalRecord use it) and give `findBestWorkout` a real `Prisma.WorkoutWhereInput` + `WorkoutOrderByWithRelationInput`. No behaviour change; the writes are now compiler-checked. No latent drift surfaced — every enum set lines up with the schema. --- src/lib/cycle/backup.ts | 88 ++++++++++++------- src/lib/cycle/day-log-write.ts | 78 +++++++++------- .../personal-records/pr-detection-worker.ts | 25 +++--- 3 files changed, 117 insertions(+), 74 deletions(-) diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index a5a76ff50..bad576278 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -10,7 +10,21 @@ * the day-log note, so a wrong-surface plaintext leak is impossible and the * owner's note round-trips encrypted at rest. */ -import type { PrismaClient, Prisma } from "@/generated/prisma/client"; +import type { + CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, + ContraceptiveKind, + CycleTrackingGoal, + FlowLevel, + HomeTestResult, + MeasurementSource, + OvulationTest, + Prisma, + PrismaClient, + SecondarySymptom, +} from "@/generated/prisma/client"; import type { BackupPayload } from "@/lib/validations/backup"; /** The cycle slice of a backup payload. */ @@ -150,26 +164,36 @@ export interface CycleRestoreCleared { // few enum columns up-front (mirrors the measurement-type guard in the // restore route). Unknown values are coerced to null rather than failing // the whole restore — a single drifted field shouldn't strand the rest. -const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]); -const OVULATION_TESTS = new Set([ +const FLOW_LEVELS = new Set([ + "NONE", + "SPOTTING", + "LIGHT", + "MEDIUM", + "HEAVY", +]); +const OVULATION_TESTS = new Set([ "NEGATIVE", "POSITIVE_LH_SURGE", "ESTROGEN_SURGE", "INDETERMINATE", ]); -const CERVICAL_MUCUS = new Set([ +const CERVICAL_MUCUS = new Set([ "DRY", "STICKY", "CREAMY", "WATERY", "EGG_WHITE", ]); -const SECONDARY_SYMPTOMS = new Set(["MUCUS", "CERVIX"]); -const CERVIX_POSITIONS = new Set(["LOW", "HIGH"]); -const CERVIX_FIRMNESSES = new Set(["FIRM", "SOFT"]); -const CERVIX_OPENINGS = new Set(["CLOSED", "OPEN"]); -const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]); -const CONTRACEPTIVES = new Set([ +const SECONDARY_SYMPTOMS = new Set(["MUCUS", "CERVIX"]); +const CERVIX_POSITIONS = new Set(["LOW", "HIGH"]); +const CERVIX_FIRMNESSES = new Set(["FIRM", "SOFT"]); +const CERVIX_OPENINGS = new Set(["CLOSED", "OPEN"]); +const HOME_TESTS = new Set([ + "NEGATIVE", + "POSITIVE", + "INDETERMINATE", +]); +const CONTRACEPTIVES = new Set([ "NONE", "UNSPECIFIED", "IMPLANT", @@ -180,8 +204,13 @@ const CONTRACEPTIVES = new Set([ "PATCH", "EMERGENCY", ]); -const CYCLE_SOURCES = new Set(["MANUAL", "WITHINGS", "IMPORT", "APPLE_HEALTH"]); -const CYCLE_GOALS = new Set([ +const CYCLE_SOURCES = new Set([ + "MANUAL", + "WITHINGS", + "IMPORT", + "APPLE_HEALTH", +]); +const CYCLE_GOALS = new Set([ "GENERAL_HEALTH", "AVOID_PREGNANCY", "TRYING_TO_CONCEIVE", @@ -191,9 +220,11 @@ const CYCLE_GOALS = new Set([ function enumOrNull( value: string | null | undefined, - allow: Set, + allow: ReadonlySet, ): T | null { - return value != null && allow.has(value) ? (value as T) : null; + return value != null && (allow as ReadonlySet).has(value) + ? (value as T) + : null; } /** @@ -228,13 +259,13 @@ export async function restoreCycleData( await tx.cycleProfile.create({ data: { userId: ownerId, - goal: (enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH") as never, + goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH", cycleTrackingEnabled: p.cycleTrackingEnabled ?? null, typicalCycleLength: p.typicalCycleLength ?? null, typicalPeriodLength: p.typicalPeriodLength ?? null, lutealPhaseLength: p.lutealPhaseLength ?? null, - secondarySymptom: (enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ?? - "MUCUS") as never, + secondarySymptom: + enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ?? "MUCUS", predictionEnabled: p.predictionEnabled ?? true, rawChartMode: p.rawChartMode ?? false, discreetNotifications: p.discreetNotifications ?? false, @@ -302,27 +333,24 @@ export async function restoreCycleData( userId: ownerId, date: d.date, cycleId: owningCycleId(d.date), - flow: enumOrNull(d.flow, FLOW_LEVELS) as never, + flow: enumOrNull(d.flow, FLOW_LEVELS), intermenstrualBleeding: d.intermenstrualBleeding ?? false, basalBodyTempC: d.basalBodyTempC ?? null, temperatureExcluded: d.temperatureExcluded ?? false, - ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never, - cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never, - cervixPosition: enumOrNull(d.cervixPosition, CERVIX_POSITIONS) as never, - cervixFirmness: enumOrNull( - d.cervixFirmness, - CERVIX_FIRMNESSES, - ) as never, - cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS) as never, + ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS), + cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS), + cervixPosition: enumOrNull(d.cervixPosition, CERVIX_POSITIONS), + cervixFirmness: enumOrNull(d.cervixFirmness, CERVIX_FIRMNESSES), + cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS), sexualActivity: d.sexualActivity ?? false, protectedSex: d.protectedSex ?? null, - pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never, - progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS) as never, - contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES) as never, + pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS), + progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS), + contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES), // 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, + source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL", externalId: d.externalId ?? null, tz: d.tz ?? null, ...(symptomIds.length > 0 diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 10f7bc324..8d447daf9 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -32,7 +32,17 @@ import { prisma } from "@/lib/db"; 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"; +import type { + CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, + ContraceptiveKind, + FlowLevel, + HomeTestResult, + OvulationTest, + Prisma, +} from "@/generated/prisma/client"; export interface DayLogWriteResult { id: string; @@ -45,9 +55,9 @@ export interface DayLogWriteResult { interface SensitiveFields { sexualActivity: boolean; protectedSex: boolean | null; - pregnancyTest: string | null; - progesteroneTest: string | null; - contraceptive: string | null; + pregnancyTest: HomeTestResult | null; + progesteroneTest: HomeTestResult | null; + contraceptive: ContraceptiveKind | null; } /** A symptom selection carrying its catalog key + optional 1-4 severity. */ @@ -146,20 +156,20 @@ function decryptNoteSoft(notesEncrypted: string | null): string | null { type ExistingRow = { id: string; date: string; - flow: string | null; + flow: FlowLevel | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; temperatureExcluded: boolean; - ovulationTest: string | null; - cervicalMucus: string | null; - cervixPosition: string | null; - cervixFirmness: string | null; - cervixOpening: string | null; + ovulationTest: OvulationTest | null; + cervicalMucus: CervicalMucus | null; + cervixPosition: CervixPosition | null; + cervixFirmness: CervixFirmness | null; + cervixOpening: CervixOpening | null; sexualActivity: boolean; protectedSex: boolean | null; - pregnancyTest: string | null; - progesteroneTest: string | null; - contraceptive: string | null; + pregnancyTest: HomeTestResult | null; + progesteroneTest: HomeTestResult | null; + contraceptive: ContraceptiveKind | null; sensitiveEncrypted: string | null; notesEncrypted: string | null; deletedAt: Date | null; @@ -418,20 +428,20 @@ async function writeDayLog(args: WriteArgs): Promise { : { ...resolvedSensitive }; const baseData: Prisma.CycleDayLogUncheckedUpdateInput = { - flow: flow as never, + flow, intermenstrualBleeding, basalBodyTempC, temperatureExcluded, - ovulationTest: ovulationTest as never, - cervicalMucus: cervicalMucus as never, - cervixPosition: cervixPosition as never, - cervixFirmness: cervixFirmness as never, - cervixOpening: cervixOpening as never, + ovulationTest, + cervicalMucus, + cervixPosition, + cervixFirmness, + cervixOpening, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, - pregnancyTest: sensitivePlaintext.pregnancyTest as never, - progesteroneTest: sensitivePlaintext.progesteroneTest as never, - contraceptive: sensitivePlaintext.contraceptive as never, + pregnancyTest: sensitivePlaintext.pregnancyTest, + progesteroneTest: sensitivePlaintext.progesteroneTest, + contraceptive: sensitivePlaintext.contraceptive, sensitiveEncrypted, notesEncrypted, }; @@ -489,20 +499,20 @@ async function writeDayLog(args: WriteArgs): Promise { source, externalId: entry.externalId ?? null, cycleId, - flow: baseData.flow as never, - intermenstrualBleeding: baseData.intermenstrualBleeding as never, - basalBodyTempC: baseData.basalBodyTempC as never, - temperatureExcluded: baseData.temperatureExcluded as never, - ovulationTest: baseData.ovulationTest as never, - cervicalMucus: baseData.cervicalMucus as never, - cervixPosition: baseData.cervixPosition as never, - cervixFirmness: baseData.cervixFirmness as never, - cervixOpening: baseData.cervixOpening as never, + flow, + intermenstrualBleeding, + basalBodyTempC, + temperatureExcluded, + ovulationTest, + cervicalMucus, + cervixPosition, + cervixFirmness, + cervixOpening, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, - pregnancyTest: sensitivePlaintext.pregnancyTest as never, - progesteroneTest: sensitivePlaintext.progesteroneTest as never, - contraceptive: sensitivePlaintext.contraceptive as never, + pregnancyTest: sensitivePlaintext.pregnancyTest, + progesteroneTest: sensitivePlaintext.progesteroneTest, + contraceptive: sensitivePlaintext.contraceptive, sensitiveEncrypted, notesEncrypted, }, diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index a50fd1767..644215d0c 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -45,7 +45,9 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { getPRDirection, isPRTrackable } from "./pr-direction"; import { + Prisma, PersonalRecordDirection, + type MeasurementSource, type MeasurementType, type PrismaClient, } from "@/generated/prisma/client"; @@ -93,7 +95,7 @@ interface MeasurementCandidate { value: number; unit: string; measuredAt: Date; - source: string; + source: MeasurementSource; externalId: string | null; } @@ -103,7 +105,7 @@ interface WorkoutCandidate { startedAt: Date; durationSec: number; totalDistanceM: number | null; - source: string; + source: MeasurementSource; externalId: string | null; } @@ -298,7 +300,7 @@ export async function detectPersonalRecordsForUser( unit: best.unit, achievedAt: best.measuredAt, sourceMeasurementId: best.id, - source: best.source as never, + source: best.source, externalId: best.externalId, }, ], @@ -384,7 +386,7 @@ export async function detectPersonalRecordsForUser( unit: slot.unit, achievedAt: best.startedAt, sourceMeasurementId: null, - source: best.source as never, + source: best.source, externalId: best.externalId, }, ], @@ -614,15 +616,18 @@ async function findBestCumulativeDay( async function findBestWorkout( prisma: PrismaClient, - where: Record, + where: Prisma.WorkoutWhereInput, slot: WorkoutSlotDefinition, ): Promise { + const dir: Prisma.SortOrder = + slot.direction === PersonalRecordDirection.MAX ? "desc" : "asc"; + const orderBy: Prisma.WorkoutOrderByWithRelationInput = + slot.field === "durationSec" + ? { durationSec: dir } + : { totalDistanceM: dir }; const row = await prisma.workout.findFirst({ - where: where as never, - orderBy: - slot.direction === PersonalRecordDirection.MAX - ? ({ [slot.field]: "desc" } as never) - : ({ [slot.field]: "asc" } as never), + where, + orderBy, select: { id: true, sportType: true,