mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d978c2b662
commit
a7c82eb582
3 files changed
+117
-74
No files matched your search
+58
-30
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user