refactor(types): restore Prisma's type-checker on the cycle + PR write paths

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 02:09:33 +02:00
1 parent d978c2b662
commit a7c82eb582
3 files changed
+117 -74

No files matched your search

+58 -30
View File
@@ -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<FlowLevel>([
"NONE",
"SPOTTING",
"LIGHT",
"MEDIUM",
"HEAVY",
]);
const OVULATION_TESTS = new Set<OvulationTest>([
"NEGATIVE",
"POSITIVE_LH_SURGE",
"ESTROGEN_SURGE",
"INDETERMINATE",
]);
const CERVICAL_MUCUS = new Set([
const CERVICAL_MUCUS = new Set<CervicalMucus>([
"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<SecondarySymptom>(["MUCUS", "CERVIX"]);
const CERVIX_POSITIONS = new Set<CervixPosition>(["LOW", "HIGH"]);
const CERVIX_FIRMNESSES = new Set<CervixFirmness>(["FIRM", "SOFT"]);
const CERVIX_OPENINGS = new Set<CervixOpening>(["CLOSED", "OPEN"]);
const HOME_TESTS = new Set<HomeTestResult>([
"NEGATIVE",
"POSITIVE",
"INDETERMINATE",
]);
const CONTRACEPTIVES = new Set<ContraceptiveKind>([
"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<MeasurementSource>([
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
]);
const CYCLE_GOALS = new Set<CycleTrackingGoal>([
"GENERAL_HEALTH",
"AVOID_PREGNANCY",
"TRYING_TO_CONCEIVE",
@@ -191,9 +220,11 @@ const CYCLE_GOALS = new Set([
function enumOrNull<T extends string>(
value: string | null | undefined,
allow: Set<string>,
allow: ReadonlySet<T>,
): T | null {
return value != null && allow.has(value) ? (value as T) : null;
return value != null && (allow as ReadonlySet<string>).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
+44 -34
View File
@@ -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<DayLogWriteResult> {
: { ...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<DayLogWriteResult> {
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,
},
+15 -10
View File
@@ -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<string, unknown>,
where: Prisma.WorkoutWhereInput,
slot: WorkoutSlotDefinition,
): Promise<WorkoutCandidate | null> {
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,