Files
HealthLog/src/lib/illness/day-log-write.ts
T
Marc-André Bombeck cf903f5022 fix(crypto): stop day-log upserts wiping undecryptable encrypted fields
A partial-update upsert soft-decrypted the existing encrypted column,
returned null on failure, then re-wrote it — so editing an unrelated
field permanently nulled an undecryptable note or sensitive envelope.

Preserve the existing ciphertext verbatim when the encrypted field is
absent from the update; only re-encrypt on an explicit field write.
Mirrors the v1.18.1 rotation fix.
2026-06-20 16:38:09 +02:00

179 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Illness day-log write helper (v1.18.1) — the upsert behind
* `POST .../episodes/{id}/day-logs`.
*
* UPSERT key: the canonical `(episodeId, date)`. `note` → `noteEncrypted`
* (AES-256-GCM via the shared Bytes codec). Symptom links carry a 0–3
* Jackson/WURSS severity (NULL = a plain presence link).
*
* 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. An explicit `null` clears.
*
* Symptom-key resolution is scoped to the global seeded catalog (illness
* symptoms have no per-user custom rows in the MVP); unknown keys are
* dropped silently.
*/
import { prisma } from "@/lib/db";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import type { IllnessDayLogInput } from "@/lib/validations/illness";
import { dayLogSymptomInclude, toIllnessDayLogDTO } from "@/lib/illness/dto";
import type { IllnessDayLogDTO } from "@/lib/illness/dto";
import type { Prisma } from "@/generated/prisma/client";
export interface IllnessDayLogWriteResult {
id: string;
existed: boolean;
/** The full DTO of the written row — no second round-trip needed. */
dto: IllnessDayLogDTO;
}
/** A symptom selection carrying its catalog key + optional 0–3 severity. */
export interface IllnessSymptomSelection {
key: string;
severity?: number | null;
}
/** A Prisma client OR an interactive-transaction client. */
type Db = typeof prisma | Prisma.TransactionClient;
/**
* Resolve catalog symptom keys → `{ key, id }`, dropping unknown / inactive
* keys silently.
*/
export async function resolveIllnessSymptomIds(
keys: readonly string[],
db: Db = prisma,
): Promise<{ key: string; id: string }[]> {
if (keys.length === 0) return [];
const unique = Array.from(new Set(keys));
const rows = await db.illnessSymptom.findMany({
where: { key: { in: unique }, isActive: true },
select: { id: true, key: true },
});
return rows.map((r) => ({ key: r.key, id: r.id }));
}
/**
* Replace the symptom-link set for a day-log. Each selection's optional
* 1–3 severity is persisted on the link (NULL = a plain presence link).
* Runs on the caller's client so it can join the day-log upsert in one
* transaction (no torn state between the delete + create halves).
*/
export async function replaceIllnessSymptomLinks(
dayLogId: string,
selections: readonly IllnessSymptomSelection[],
db: Db = prisma,
): Promise<void> {
const resolved = await resolveIllnessSymptomIds(
selections.map((s) => s.key),
db,
);
const severityByKey = new Map(
selections.map((s) => [
s.key,
typeof s.severity === "number" ? s.severity : null,
]),
);
await db.illnessSymptomLink.deleteMany({ where: { dayLogId } });
if (resolved.length > 0) {
await db.illnessSymptomLink.createMany({
data: resolved.map(({ key, id }) => ({
dayLogId,
symptomId: id,
severity: severityByKey.get(key) ?? null,
})),
skipDuplicates: true,
});
}
}
/**
* Upsert one illness day-log on the `(episodeId, date)` key. The caller is
* responsible for verifying the episode is owned + live before calling.
* `tz` anchors the date string.
*/
export async function upsertIllnessDayLog(
userId: string,
episodeId: string,
entry: IllnessDayLogInput,
tz: string | null,
): Promise<IllnessDayLogWriteResult> {
const where = { episodeId_date: { episodeId, date: entry.date } };
// The upsert, the symptom-link replacement, and the final include-read all
// run in ONE interactive transaction: no torn state between the link delete
// + create halves, and no second round-trip — the written row (with its
// flattened symptom links) comes straight back as the DTO.
const { row, existed } = await prisma.$transaction(async (tx) => {
const existing = await tx.illnessDayLog.findUnique({
where,
select: {
id: true,
functionalImpact: true,
feverC: true,
noteEncrypted: true,
},
});
// Partial merge against the stored row.
const functionalImpact =
entry.functionalImpact !== undefined
? (entry.functionalImpact ?? null)
: (existing?.functionalImpact ?? null);
const feverC =
entry.feverC !== undefined
? (entry.feverC ?? null)
: (existing?.feverC ?? null);
// 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 BYTE-FOR-BYTE — 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 noteEncrypted =
entry.note !== undefined
? entry.note
? encryptToBytes(entry.note)
: null
: (existing?.noteEncrypted ?? null);
const upserted = await tx.illnessDayLog.upsert({
where,
create: {
userId,
episodeId,
date: entry.date,
tz,
functionalImpact,
feverC,
noteEncrypted,
},
update: {
functionalImpact,
feverC,
noteEncrypted,
deletedAt: null,
},
select: { id: true },
});
if (entry.symptoms !== undefined) {
await replaceIllnessSymptomLinks(upserted.id, entry.symptoms, tx);
}
const full = await tx.illnessDayLog.findUniqueOrThrow({
where: { id: upserted.id },
include: dayLogSymptomInclude,
});
return { row: full, existed: existing !== null };
});
return { id: row.id, existed, dto: toIllnessDayLogDTO(row) };
}