mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
MenstrualCycle rows were only ever written by the one-tap period boundary. Every other way of recording bleeding wrote a day-log and stopped there: the log sheet's flow chips, the bulk drain, an Apple Health export full of menstrual-flow samples. The engine reads cycle rows, so a person could log every period they had and the module still held that none had happened. No cycle length, no forecast, an empty chart, and an offer to log a first period. An Apple Health user who never found the one-tap button had the whole module dead. A first bleeding day now opens the cycle it belongs to, through one shared seam in the day-log write helper, so the single capture, the bulk drain and the Apple Health fold all infer a start the same way and the one-tap boundary's own write lands on a date that already has a cycle, which the rule declines. The rule is conservative, because a false start corrupts every length derived after it. Spotting does not open a cycle: it is as often the tail or the herald of a period as its first day. Bleeding flagged as between periods does not, since that flag exists to say so. Neither does a bleeding day within the hard physiological minimum of an existing start, which is what keeps day three of a period, or a forgotten day two, from starting a cycle of its own without needing a contiguity check. A genuinely short cycle is not inferred from flow alone; the one-tap start still opens one on any date. An edit is symmetric. Setting a flow opens the cycle; clearing it, or flagging it as between periods, removes the cycle that day opened and re-opens the one before it. A cycle that an edit can create and not undo leaves the record holding a period nothing on the day says happened. - the period route's start arm and the flow inference now share one boundary helper, so a start means the same thing however it is entered - back-dating re-anchors both neighbours, as the one-tap start does - integration: eleven cases through the real routes, of which five are about what must NOT open a cycle
636 lines
22 KiB
TypeScript
636 lines
22 KiB
TypeScript
/**
|
|
* Cycle day-log write helper — the shared upsert behind the single POST,
|
|
* the PATCH, and the bulk drain (ios-contract §2.A / §2.B).
|
|
*
|
|
* UPSERT key: `(userId, source, externalId)` when `externalId` is present
|
|
* (the NULL-distinct cross-device dedup key, like MoodEntry), else the
|
|
* canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM).
|
|
*
|
|
* Sensitive-category encryption: when the resolved profile flag is ON the
|
|
* five intent-revealing fields (sexualActivity, protectedSex,
|
|
* pregnancyTest, progesteroneTest, contraceptive) are encrypted into the
|
|
* single `sensitiveEncrypted` JSON envelope and the plaintext columns are
|
|
* NULLed — they then drop out of the rollup / correlation tier (the trade
|
|
* for at-rest secrecy). When OFF the plaintext columns are written as
|
|
* before and the envelope is NULL.
|
|
*
|
|
* Partial merge: a re-post that omits a field never nulls a previously
|
|
* stored value — the UPDATE branch only overwrites the fields actually
|
|
* present in the input.
|
|
*
|
|
* Collision-safe: on a `(userId, source, externalId)`-keyed create that
|
|
* collides with an existing canonical `(userId, date)` row (Prisma P2002),
|
|
* the existing row is adopted and UPDATEd in place — one canonical row per
|
|
* date regardless of provenance.
|
|
*
|
|
* Returns the upserted row id + whether a row already existed (so the
|
|
* caller can map `inserted`/`updated`/`duplicate`) plus whether any field
|
|
* actually changed on a re-post (drives the bulk `updated` vs `duplicate`
|
|
* distinction).
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { encrypt, decrypt } from "@/lib/crypto";
|
|
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
|
import { ensureCycleForBleedingDay } from "@/lib/cycle/cycle-boundaries";
|
|
import type { CycleDayLogInput } from "@/lib/validations/cycle";
|
|
import type {
|
|
CervicalMucus,
|
|
CervixFirmness,
|
|
CervixOpening,
|
|
CervixPosition,
|
|
ContraceptiveKind,
|
|
FlowLevel,
|
|
HomeTestResult,
|
|
OvulationTest,
|
|
Prisma,
|
|
} from "@/generated/prisma/client";
|
|
|
|
export interface DayLogWriteResult {
|
|
id: string;
|
|
existed: boolean;
|
|
/** A pre-existing row whose persisted fields differ from the re-post. */
|
|
changed: boolean;
|
|
}
|
|
|
|
/** The five intent-revealing fields the encryption envelope carries. */
|
|
interface SensitiveFields {
|
|
sexualActivity: boolean;
|
|
protectedSex: boolean | null;
|
|
pregnancyTest: HomeTestResult | null;
|
|
progesteroneTest: HomeTestResult | null;
|
|
contraceptive: ContraceptiveKind | null;
|
|
}
|
|
|
|
/** A symptom selection carrying its catalog key + optional 1-4 severity. */
|
|
export interface SymptomSelection {
|
|
key: string;
|
|
severity?: number | null;
|
|
}
|
|
|
|
/**
|
|
* Resolve catalog symptom keys → `{ key, id }`, dropping unknown keys
|
|
* silently. Returns the pairs (not bare ids) so the caller can carry the
|
|
* per-link severity through to the join write.
|
|
*/
|
|
export async function resolveSymptomIds(
|
|
userId: string,
|
|
keys: readonly string[],
|
|
): Promise<{ key: string; id: string }[]> {
|
|
if (keys.length === 0) return [];
|
|
const unique = Array.from(new Set(keys));
|
|
// Catalog rows (userId null) plus this user's own custom symptoms.
|
|
const rows = await prisma.cycleSymptom.findMany({
|
|
where: {
|
|
key: { in: unique },
|
|
isActive: true,
|
|
OR: [{ userId: null }, { userId }],
|
|
},
|
|
select: { id: true, key: true },
|
|
});
|
|
return rows.map((r) => ({ key: r.key, id: r.id }));
|
|
}
|
|
|
|
/**
|
|
* Replace the symptom-link set for a day-log. Shared by the upsert helper
|
|
* and the PATCH route so both resolve + write the join identically. Each
|
|
* selection's optional 1-4 severity is persisted on the link (NULL = a
|
|
* plain presence link).
|
|
*/
|
|
export async function replaceSymptomLinks(
|
|
userId: string,
|
|
dayLogId: string,
|
|
selections: readonly SymptomSelection[],
|
|
): Promise<void> {
|
|
const resolved = await resolveSymptomIds(
|
|
userId,
|
|
selections.map((s) => s.key),
|
|
);
|
|
// Map each resolved id back to its requested severity by key.
|
|
const severityByKey = new Map(
|
|
selections.map((s) => [
|
|
s.key,
|
|
typeof s.severity === "number" ? s.severity : null,
|
|
]),
|
|
);
|
|
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId } });
|
|
if (resolved.length > 0) {
|
|
await prisma.cycleSymptomLink.createMany({
|
|
data: resolved.map(({ key, id }) => ({
|
|
dayLogId,
|
|
symptomId: id,
|
|
severity: severityByKey.get(key) ?? null,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt the sensitive envelope fail-soft. Returns `{ ok: true, values }` on a
|
|
* clean decrypt, `{ ok: false }` when an envelope is present but undecryptable
|
|
* (rotation gap, GCM corruption). The caller MUST treat `ok: false` as
|
|
* "do not re-write this column" — never as "the field is empty".
|
|
*/
|
|
function decryptSensitive(
|
|
envelope: string,
|
|
): { ok: true; values: Partial<SensitiveFields> } | { ok: false } {
|
|
try {
|
|
return {
|
|
ok: true,
|
|
values: JSON.parse(decrypt(envelope)) as Partial<SensitiveFields>,
|
|
};
|
|
} catch {
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
/** Decrypt a note fail-soft (null on missing / undecryptable). */
|
|
function decryptNoteSoft(notesEncrypted: string | null): string | null {
|
|
if (!notesEncrypted) return null;
|
|
try {
|
|
return decrypt(notesEncrypted);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
type ExistingRow = {
|
|
id: string;
|
|
date: string;
|
|
flow: FlowLevel | null;
|
|
intermenstrualBleeding: boolean;
|
|
basalBodyTempC: number | null;
|
|
temperatureExcluded: boolean;
|
|
ovulationTest: OvulationTest | null;
|
|
cervicalMucus: CervicalMucus | null;
|
|
cervixPosition: CervixPosition | null;
|
|
cervixFirmness: CervixFirmness | null;
|
|
cervixOpening: CervixOpening | null;
|
|
sexualActivity: boolean;
|
|
protectedSex: boolean | null;
|
|
pregnancyTest: HomeTestResult | null;
|
|
progesteroneTest: HomeTestResult | null;
|
|
contraceptive: ContraceptiveKind | null;
|
|
sensitiveEncrypted: string | null;
|
|
notesEncrypted: string | null;
|
|
deletedAt: Date | null;
|
|
};
|
|
|
|
const EXISTING_SELECT = {
|
|
id: true,
|
|
date: true,
|
|
flow: true,
|
|
intermenstrualBleeding: true,
|
|
basalBodyTempC: true,
|
|
temperatureExcluded: true,
|
|
ovulationTest: true,
|
|
cervicalMucus: true,
|
|
cervixPosition: true,
|
|
cervixFirmness: true,
|
|
cervixOpening: true,
|
|
sexualActivity: true,
|
|
protectedSex: true,
|
|
pregnancyTest: true,
|
|
progesteroneTest: true,
|
|
contraceptive: true,
|
|
sensitiveEncrypted: true,
|
|
notesEncrypted: true,
|
|
deletedAt: true,
|
|
} as const;
|
|
|
|
/**
|
|
* Upsert one cycle day-log. `tz` anchors the date string. When
|
|
* `cycleId` is supplied the row is attributed to that cycle span.
|
|
*
|
|
* `encryptSensitiveFlag` lets a batch caller (bulk drain / Apple-Health import
|
|
* flush) resolve the invariant `sensitiveCategoryEncryption` flag ONCE and
|
|
* thread it in, instead of this helper re-reading the profile per row (a
|
|
* per-day round-trip on a multi-year import — QA M-3). The single POST/PATCH
|
|
* paths omit it and keep the lazy read.
|
|
*/
|
|
export async function upsertCycleDayLog(
|
|
userId: string,
|
|
entry: CycleDayLogInput,
|
|
tz: string | null,
|
|
cycleId: string | null = null,
|
|
encryptSensitiveFlag?: boolean,
|
|
): Promise<DayLogWriteResult> {
|
|
const source = entry.source;
|
|
|
|
// Resolve the sensitive-category encryption flag once per write (or reuse the
|
|
// flag the batch caller already resolved).
|
|
const encryptSensitive =
|
|
encryptSensitiveFlag ??
|
|
(await getOrCreateCycleProfile(userId)).sensitiveCategoryEncryption;
|
|
|
|
const where: Prisma.CycleDayLogWhereUniqueInput = entry.externalId
|
|
? {
|
|
userId_source_externalId: {
|
|
userId,
|
|
source,
|
|
externalId: entry.externalId,
|
|
},
|
|
}
|
|
: { userId_date: { userId, date: entry.date } };
|
|
|
|
// Probe so the caller can distinguish inserted / updated / duplicate
|
|
// and (for an externalId re-post) detect whether a field changed.
|
|
const existing = (await prisma.cycleDayLog.findUnique({
|
|
where,
|
|
select: EXISTING_SELECT,
|
|
})) as ExistingRow | null;
|
|
|
|
const result = await writeDayLog({
|
|
userId,
|
|
entry,
|
|
tz,
|
|
cycleId,
|
|
source,
|
|
where,
|
|
existing,
|
|
encryptSensitive,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
interface WriteArgs {
|
|
userId: string;
|
|
entry: CycleDayLogInput;
|
|
tz: string | null;
|
|
cycleId: string | null;
|
|
source: CycleDayLogInput["source"];
|
|
where: Prisma.CycleDayLogWhereUniqueInput;
|
|
existing: ExistingRow | null;
|
|
encryptSensitive: boolean;
|
|
}
|
|
|
|
async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
|
|
const {
|
|
userId,
|
|
entry,
|
|
tz,
|
|
cycleId,
|
|
source,
|
|
where,
|
|
existing,
|
|
encryptSensitive,
|
|
} = args;
|
|
|
|
// Decrypt the existing note (fail-soft) for the change/duplicate split —
|
|
// re-encrypting the same plaintext yields a different ciphertext (random
|
|
// GCM IV), so a ciphertext compare would always read "changed". This is a
|
|
// DISPLAY-ONLY read (a `null` here only affects the change flag, never the
|
|
// written column — see the note-write below), so soft-decrypt is safe.
|
|
const existingNote = decryptNoteSoft(existing?.notesEncrypted ?? null);
|
|
|
|
// The resolved plaintext sensitive values for THIS write, after partial
|
|
// merge against what's already stored (decrypted from the envelope when
|
|
// the stored row was encrypted). `storedDecryptable` is false when the
|
|
// existing envelope could not be decrypted — in that case the merged
|
|
// values are DEFAULTS, not the real stored data, so they must never be
|
|
// re-encrypted back over the column (that would wipe the envelope).
|
|
const merged = existing
|
|
? mergeStoredSensitive(existing)
|
|
: {
|
|
values: {
|
|
sexualActivity: false,
|
|
protectedSex: null,
|
|
pregnancyTest: null,
|
|
progesteroneTest: null,
|
|
contraceptive: null,
|
|
},
|
|
decryptable: true,
|
|
};
|
|
const storedSensitive: SensitiveFields = merged.values;
|
|
const storedSensitiveDecryptable = merged.decryptable;
|
|
|
|
const resolvedSensitive: SensitiveFields = {
|
|
sexualActivity:
|
|
entry.sexualActivity !== undefined
|
|
? entry.sexualActivity
|
|
: storedSensitive.sexualActivity,
|
|
protectedSex:
|
|
entry.protectedSex !== undefined
|
|
? (entry.protectedSex ?? null)
|
|
: storedSensitive.protectedSex,
|
|
pregnancyTest:
|
|
entry.pregnancyTest !== undefined
|
|
? (entry.pregnancyTest ?? null)
|
|
: storedSensitive.pregnancyTest,
|
|
progesteroneTest:
|
|
entry.progesteroneTest !== undefined
|
|
? (entry.progesteroneTest ?? null)
|
|
: storedSensitive.progesteroneTest,
|
|
contraceptive:
|
|
entry.contraceptive !== undefined
|
|
? (entry.contraceptive ?? null)
|
|
: storedSensitive.contraceptive,
|
|
};
|
|
|
|
// Note resolution with partial merge. When `note` is supplied, encrypt it
|
|
// (an explicit empty/null clears the column). When `note` is OMITTED, the
|
|
// existing ciphertext is preserved VERBATIM — never decrypt→re-encrypt, and
|
|
// never collapse an undecryptable existing value to null. A soft-decrypt that
|
|
// returns null on an undecryptable column (rotation gap, GCM corruption) must
|
|
// not be allowed to permanently wipe the stored note when an unrelated field
|
|
// is the only thing being edited (mirrors the v1.18.1 53-column rotation fix:
|
|
// a write path never re-writes an `*Encrypted` column from a soft-decrypt
|
|
// result).
|
|
const notesEncrypted: string | null =
|
|
entry.note !== undefined
|
|
? entry.note
|
|
? encrypt(entry.note)
|
|
: null
|
|
: (existing?.notesEncrypted ?? null);
|
|
|
|
// Note change detection for the `changed`/`duplicate` split. An omitted
|
|
// `note` preserves the column verbatim, so it can never count as a change.
|
|
// A supplied `note` is compared against the soft-decrypted existing value
|
|
// (display-only) — a ciphertext compare would always read "changed" (random
|
|
// GCM IV).
|
|
const incomingNote = entry.note !== undefined ? (entry.note ?? null) : null;
|
|
const noteChanged = entry.note !== undefined && incomingNote !== existingNote;
|
|
|
|
// Non-sensitive fields, partial-merged against the stored row.
|
|
const flow =
|
|
entry.flow !== undefined ? (entry.flow ?? null) : (existing?.flow ?? null);
|
|
const intermenstrualBleeding =
|
|
entry.intermenstrualBleeding !== undefined
|
|
? entry.intermenstrualBleeding
|
|
: (existing?.intermenstrualBleeding ?? false);
|
|
const basalBodyTempC =
|
|
entry.basalBodyTempC !== undefined
|
|
? (entry.basalBodyTempC ?? null)
|
|
: (existing?.basalBodyTempC ?? null);
|
|
const temperatureExcluded =
|
|
entry.temperatureExcluded !== undefined
|
|
? entry.temperatureExcluded
|
|
: (existing?.temperatureExcluded ?? false);
|
|
const ovulationTest =
|
|
entry.ovulationTest !== undefined
|
|
? (entry.ovulationTest ?? null)
|
|
: (existing?.ovulationTest ?? null);
|
|
const cervicalMucus =
|
|
entry.cervicalMucus !== undefined
|
|
? (entry.cervicalMucus ?? null)
|
|
: (existing?.cervicalMucus ?? null);
|
|
const cervixPosition =
|
|
entry.cervixPosition !== undefined
|
|
? (entry.cervixPosition ?? null)
|
|
: (existing?.cervixPosition ?? null);
|
|
const cervixFirmness =
|
|
entry.cervixFirmness !== undefined
|
|
? (entry.cervixFirmness ?? null)
|
|
: (existing?.cervixFirmness ?? null);
|
|
const cervixOpening =
|
|
entry.cervixOpening !== undefined
|
|
? (entry.cervixOpening ?? null)
|
|
: (existing?.cervixOpening ?? null);
|
|
|
|
// A first bleeding day opens the cycle it belongs to. Cycle rows used to
|
|
// come only from the one-tap period boundary, so a person who logged every
|
|
// period as flow had a module that held none had ever happened — no lengths,
|
|
// no forecast, an empty chart. This is the one shared seam behind the single
|
|
// capture, the bulk drain, the Apple Health fold and the boundary itself, so
|
|
// all four infer a start the same way (the boundary's own write lands on a
|
|
// date that already has a cycle, which the detector declines). The day is
|
|
// then attributed to the cycle it just opened rather than the one before it.
|
|
const openedCycleId = await ensureCycleForBleedingDay(
|
|
userId,
|
|
entry.date,
|
|
tz,
|
|
{
|
|
flow,
|
|
intermenstrualBleeding,
|
|
},
|
|
);
|
|
const attributedCycleId = openedCycleId ?? cycleId;
|
|
|
|
// Was any of the five sensitive fields actually supplied in THIS update?
|
|
// Used to decide whether an undecryptable existing envelope may be preserved
|
|
// verbatim (an unrelated-field edit) or must be rewritten (the user is
|
|
// explicitly setting sensitive data).
|
|
const sensitiveFieldSupplied =
|
|
entry.sexualActivity !== undefined ||
|
|
entry.protectedSex !== undefined ||
|
|
entry.pregnancyTest !== undefined ||
|
|
entry.progesteroneTest !== undefined ||
|
|
entry.contraceptive !== undefined;
|
|
|
|
// Split the sensitive fields between plaintext columns and the envelope
|
|
// depending on the flag. When encrypting, the plaintext columns are NULL
|
|
// (drop out of rollup/correlation) and the envelope carries the JSON.
|
|
//
|
|
// Data-loss guard: when the stored envelope could NOT be decrypted, the
|
|
// merged `resolvedSensitive` carries DEFAULTS, not the real stored values.
|
|
// Re-encrypting them over the column would permanently wipe the user's
|
|
// sensitive data on an edit of an unrelated field. So when encrypting, the
|
|
// existing envelope is undecryptable, and no sensitive field is supplied in
|
|
// this update, preserve the existing ciphertext VERBATIM (mirrors the
|
|
// v1.18.1 53-column rotation fix). An explicit sensitive-field write still
|
|
// re-encrypts the user's intent.
|
|
const preserveExistingEnvelope =
|
|
encryptSensitive && !storedSensitiveDecryptable && !sensitiveFieldSupplied;
|
|
const sensitiveEncrypted = encryptSensitive
|
|
? preserveExistingEnvelope
|
|
? (existing?.sensitiveEncrypted ?? null)
|
|
: encrypt(JSON.stringify(resolvedSensitive))
|
|
: null;
|
|
const sensitivePlaintext = encryptSensitive
|
|
? {
|
|
sexualActivity: false,
|
|
protectedSex: null,
|
|
pregnancyTest: null,
|
|
progesteroneTest: null,
|
|
contraceptive: null,
|
|
}
|
|
: { ...resolvedSensitive };
|
|
|
|
const baseData: Prisma.CycleDayLogUncheckedUpdateInput = {
|
|
flow,
|
|
intermenstrualBleeding,
|
|
basalBodyTempC,
|
|
temperatureExcluded,
|
|
ovulationTest,
|
|
cervicalMucus,
|
|
cervixPosition,
|
|
cervixFirmness,
|
|
cervixOpening,
|
|
sexualActivity: sensitivePlaintext.sexualActivity,
|
|
protectedSex: sensitivePlaintext.protectedSex,
|
|
pregnancyTest: sensitivePlaintext.pregnancyTest,
|
|
progesteroneTest: sensitivePlaintext.progesteroneTest,
|
|
contraceptive: sensitivePlaintext.contraceptive,
|
|
sensitiveEncrypted,
|
|
notesEncrypted,
|
|
};
|
|
|
|
// Did anything actually change vs the stored row? Compare the resolved
|
|
// PLAINTEXT values (note + sensitive), never ciphertext.
|
|
let changed = !existing;
|
|
if (existing) {
|
|
changed =
|
|
(existing.flow ?? null) !== flow ||
|
|
existing.intermenstrualBleeding !== intermenstrualBleeding ||
|
|
(existing.basalBodyTempC ?? null) !== basalBodyTempC ||
|
|
existing.temperatureExcluded !== temperatureExcluded ||
|
|
(existing.ovulationTest ?? null) !== ovulationTest ||
|
|
(existing.cervicalMucus ?? null) !== cervicalMucus ||
|
|
(existing.cervixPosition ?? null) !== cervixPosition ||
|
|
(existing.cervixFirmness ?? null) !== cervixFirmness ||
|
|
(existing.cervixOpening ?? null) !== cervixOpening ||
|
|
// When the stored envelope was undecryptable and we preserve it
|
|
// verbatim (no sensitive field supplied), the sensitive values cannot
|
|
// be compared meaningfully — skip them. An explicit sensitive write
|
|
// compares the resolved values against the (default) stored ones.
|
|
(!preserveExistingEnvelope &&
|
|
(storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity ||
|
|
storedSensitive.protectedSex !== resolvedSensitive.protectedSex ||
|
|
storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest ||
|
|
storedSensitive.progesteroneTest !==
|
|
resolvedSensitive.progesteroneTest ||
|
|
storedSensitive.contraceptive !== resolvedSensitive.contraceptive)) ||
|
|
noteChanged ||
|
|
existing.deletedAt !== null ||
|
|
(Boolean(entry.externalId) && existing.date !== entry.date);
|
|
}
|
|
|
|
const updateData: Prisma.CycleDayLogUncheckedUpdateInput = {
|
|
...baseData,
|
|
...(entry.externalId ? { date: entry.date } : {}),
|
|
...(attributedCycleId !== null ? { cycleId: attributedCycleId } : {}),
|
|
deletedAt: null,
|
|
// Only bump syncVersion when a field actually changed — an unchanged
|
|
// re-post must be a true no-op on the /api/sync/changes feed so other
|
|
// devices don't re-pull an identical row.
|
|
...(changed ? { syncVersion: { increment: 1 } } : {}),
|
|
};
|
|
|
|
let rowId: string;
|
|
let existed = existing !== null;
|
|
try {
|
|
const row = await prisma.cycleDayLog.upsert({
|
|
where,
|
|
create: {
|
|
userId,
|
|
date: entry.date,
|
|
tz,
|
|
source,
|
|
externalId: entry.externalId ?? null,
|
|
cycleId: attributedCycleId,
|
|
flow,
|
|
intermenstrualBleeding,
|
|
basalBodyTempC,
|
|
temperatureExcluded,
|
|
ovulationTest,
|
|
cervicalMucus,
|
|
cervixPosition,
|
|
cervixFirmness,
|
|
cervixOpening,
|
|
sexualActivity: sensitivePlaintext.sexualActivity,
|
|
protectedSex: sensitivePlaintext.protectedSex,
|
|
pregnancyTest: sensitivePlaintext.pregnancyTest,
|
|
progesteroneTest: sensitivePlaintext.progesteroneTest,
|
|
contraceptive: sensitivePlaintext.contraceptive,
|
|
sensitiveEncrypted,
|
|
notesEncrypted,
|
|
},
|
|
update: updateData,
|
|
select: { id: true },
|
|
});
|
|
rowId = row.id;
|
|
} catch (err: unknown) {
|
|
// Dual-unique collision: an externalId-keyed create whose date already
|
|
// has a canonical (userId, date) row. Adopt the existing row and UPDATE
|
|
// it in place — one canonical row per date regardless of provenance.
|
|
if (isUniqueViolation(err) && entry.externalId) {
|
|
const canonical = await prisma.cycleDayLog.findUnique({
|
|
where: { userId_date: { userId, date: entry.date } },
|
|
select: { id: true },
|
|
});
|
|
if (!canonical) throw err;
|
|
const adopted = await prisma.cycleDayLog.update({
|
|
where: { id: canonical.id },
|
|
data: {
|
|
...updateData,
|
|
source,
|
|
externalId: entry.externalId,
|
|
...(changed ? {} : { syncVersion: { increment: 1 } }),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
rowId = adopted.id;
|
|
existed = true;
|
|
changed = true;
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Replace the symptom-link set when symptoms were supplied. Absent
|
|
// `symptoms` leaves existing links untouched.
|
|
if (entry.symptoms !== undefined) {
|
|
await replaceSymptomLinks(userId, rowId, entry.symptoms);
|
|
}
|
|
|
|
return { id: rowId, existed, changed };
|
|
}
|
|
|
|
/** Prisma P2002 (unique constraint) detector without importing the runtime. */
|
|
function isUniqueViolation(err: unknown): boolean {
|
|
return (
|
|
typeof err === "object" &&
|
|
err !== null &&
|
|
"code" in err &&
|
|
(err as { code?: unknown }).code === "P2002"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the stored plaintext sensitive fields (envelope OR columns).
|
|
* `decryptable` is false ONLY when an envelope is present but could not be
|
|
* decrypted — the returned `values` are then DEFAULTS and the caller must not
|
|
* re-encrypt them back over the column.
|
|
*/
|
|
function mergeStoredSensitive(existing: ExistingRow): {
|
|
values: SensitiveFields;
|
|
decryptable: boolean;
|
|
} {
|
|
if (existing.sensitiveEncrypted) {
|
|
const dec = decryptSensitive(existing.sensitiveEncrypted);
|
|
if (!dec.ok) {
|
|
return {
|
|
values: {
|
|
sexualActivity: false,
|
|
protectedSex: null,
|
|
pregnancyTest: null,
|
|
progesteroneTest: null,
|
|
contraceptive: null,
|
|
},
|
|
decryptable: false,
|
|
};
|
|
}
|
|
return {
|
|
values: {
|
|
sexualActivity: dec.values.sexualActivity ?? false,
|
|
protectedSex: dec.values.protectedSex ?? null,
|
|
pregnancyTest: dec.values.pregnancyTest ?? null,
|
|
progesteroneTest: dec.values.progesteroneTest ?? null,
|
|
contraceptive: dec.values.contraceptive ?? null,
|
|
},
|
|
decryptable: true,
|
|
};
|
|
}
|
|
return {
|
|
values: {
|
|
sexualActivity: existing.sexualActivity,
|
|
protectedSex: existing.protectedSex,
|
|
pregnancyTest: existing.pregnancyTest,
|
|
progesteroneTest: existing.progesteroneTest,
|
|
contraceptive: existing.contraceptive,
|
|
},
|
|
decryptable: true,
|
|
};
|
|
}
|