mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #1135 from MBombeck/fix/cycle-backup-and-med-put
fix(backup): carry encrypted free text readable in portable backups
This commit is contained in:
31 files changed
+2336
-612
No files matched your search
@@ -36645,6 +36645,54 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
sensitive:
|
||||
anyOf:
|
||||
- anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
sexualActivity:
|
||||
type: boolean
|
||||
protectedSex:
|
||||
anyOf:
|
||||
- type: boolean
|
||||
- type: "null"
|
||||
pregnancyTest:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- NEGATIVE
|
||||
- POSITIVE
|
||||
- INDETERMINATE
|
||||
- type: "null"
|
||||
progesteroneTest:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- NEGATIVE
|
||||
- POSITIVE
|
||||
- INDETERMINATE
|
||||
- type: "null"
|
||||
contraceptive:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- NONE
|
||||
- UNSPECIFIED
|
||||
- IMPLANT
|
||||
- INJECTION
|
||||
- IUD
|
||||
- INTRAVAGINAL_RING
|
||||
- ORAL
|
||||
- PATCH
|
||||
- EMERGENCY
|
||||
- type: "null"
|
||||
additionalProperties: false
|
||||
- type: string
|
||||
- type: "null"
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
@@ -36748,6 +36796,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- key
|
||||
- labelKey
|
||||
@@ -36784,6 +36836,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
@@ -36841,6 +36897,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
scaleMin:
|
||||
default: 1
|
||||
type: integer
|
||||
@@ -38428,6 +38488,10 @@ components:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -38495,6 +38559,18 @@ components:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
reason:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
outcome:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
bodySite:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
laterality:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -38651,6 +38727,10 @@ components:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
|
||||
+5
-1
@@ -7566,7 +7566,11 @@
|
||||
"restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.",
|
||||
"restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.",
|
||||
"previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)",
|
||||
"restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)",
|
||||
"restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)",
|
||||
"restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
|
||||
+5
-1
@@ -7566,7 +7566,11 @@
|
||||
"restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.",
|
||||
"restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.",
|
||||
"previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)",
|
||||
"restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)",
|
||||
"restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)",
|
||||
"restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
|
||||
+5
-1
@@ -7566,7 +7566,11 @@
|
||||
"restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.",
|
||||
"restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.",
|
||||
"previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)",
|
||||
"restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)",
|
||||
"restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)",
|
||||
"restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
|
||||
+5
-1
@@ -7566,7 +7566,11 @@
|
||||
"restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.",
|
||||
"restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.",
|
||||
"previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)",
|
||||
"restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)",
|
||||
"restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)",
|
||||
"restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
|
||||
+5
-1
@@ -7566,7 +7566,11 @@
|
||||
"restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.",
|
||||
"restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.",
|
||||
"previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)",
|
||||
"restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)",
|
||||
"restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)",
|
||||
"restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
|
||||
+5
-1
@@ -7545,7 +7545,11 @@
|
||||
"restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
|
||||
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
|
||||
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
|
||||
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)"
|
||||
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
|
||||
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
|
||||
"restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)",
|
||||
"restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)",
|
||||
"restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
|
||||
+5
-1
@@ -7566,7 +7566,11 @@
|
||||
"restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.",
|
||||
"restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.",
|
||||
"previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)",
|
||||
"restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)",
|
||||
"restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)",
|
||||
"restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
|
||||
@@ -114,6 +114,11 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// The PUT writes inside one interactive transaction; run its callback
|
||||
// against the same mocked client.
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
// v1.40 — the category and course helpers' defaults, which
|
||||
// `resetAllMocks` clears.
|
||||
vi.mocked(resolveCourseFields).mockResolvedValue(new Map());
|
||||
@@ -203,9 +208,9 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
// v1.40 (#1024) — the window goes through the course writer, which
|
||||
// writes the row and the latest course in one transaction; the plain
|
||||
// row update no longer carries it.
|
||||
// v1.40 (#1024) — the window goes through the course writer, inside the
|
||||
// same transaction as the row update; the plain row update no longer
|
||||
// carries it.
|
||||
const call = lastUpdateCall();
|
||||
expect(call.data).not.toHaveProperty("endsOn");
|
||||
expect(setCurrentWindow).toHaveBeenCalledWith(
|
||||
@@ -213,6 +218,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
medicationId: "m1",
|
||||
endsOn: new Date("2026-12-31"),
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -667,7 +667,12 @@ export const PUT = apiHandler(
|
||||
// Tombstone today's and future open pending rows of this medication.
|
||||
// Runs on a schedule replace (the old anchors are stale) and when intake
|
||||
// tracking is switched off (nothing is due any more).
|
||||
const tombstoneOpenSlots = async (): Promise<void> => {
|
||||
// The rollup days the tombstoned slots sat on, recomputed once the
|
||||
// transaction below has committed the tombstones.
|
||||
const staleRollupDays = new Set<string>();
|
||||
const tombstoneOpenSlots = async (
|
||||
db: Prisma.TransactionClient,
|
||||
): Promise<void> => {
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
@@ -689,12 +694,12 @@ export const PUT = apiHandler(
|
||||
scheduledFor: { gte: todayStart },
|
||||
} as const;
|
||||
// Capture the affected slots BEFORE the tombstone so the compliance
|
||||
// rollups for those days can be recomputed immediately below —
|
||||
// rollups for those days can be recomputed after the commit —
|
||||
// `updateMany` does not return the touched rows. Without the
|
||||
// recompute a tombstoned pending kept counting as `scheduled` in
|
||||
// the rollup row until the next event-driven write for that day,
|
||||
// depressing the rate the cards read in the meantime.
|
||||
const tombstoned = await prisma.medicationIntakeEvent.findMany({
|
||||
const tombstoned = await db.medicationIntakeEvent.findMany({
|
||||
where: tombstoneWhere,
|
||||
select: { scheduledFor: true },
|
||||
});
|
||||
@@ -708,331 +713,337 @@ export const PUT = apiHandler(
|
||||
tombstonedTo = row.scheduledFor;
|
||||
}
|
||||
}
|
||||
await prisma.medicationIntakeEvent.updateMany({
|
||||
await db.medicationIntakeEvent.updateMany({
|
||||
where: tombstoneWhere,
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
// Recompute each affected rollup day (deduped via the user-tz day
|
||||
// key). Best-effort like every other rollup write-hook: a failure
|
||||
// is annotated, never blocks the schedule replace.
|
||||
const staleDayKeys = new Set(
|
||||
tombstoned.map((row) =>
|
||||
dayKeyForScheduledFor(row.scheduledFor, userTz),
|
||||
),
|
||||
);
|
||||
for (const dayKey of staleDayKeys) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
user.id,
|
||||
id,
|
||||
dayKey,
|
||||
userTz,
|
||||
);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_failed: true,
|
||||
medication_compliance_rollup_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Deduped via the user-tz day key.
|
||||
for (const row of tombstoned) {
|
||||
staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
|
||||
}
|
||||
};
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
// v1.39.1 (#1033) — a stretch with intake tracking off expected
|
||||
// nothing, so it archives with an empty payload whatever the rows
|
||||
// said; and a tracking transition is itself an era boundary even when
|
||||
// the rows stay the same.
|
||||
const archivedEntries = wasTracked ? previousEntries : [];
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
(trackingChanged ||
|
||||
!wasTracked ||
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
))
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: archivedEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
await tombstoneOpenSlots();
|
||||
await prisma.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
} else if (trackingChanged) {
|
||||
// v1.39.1 (#1033) — intake tracking switched without a schedule
|
||||
// replace. Close the current era at this instant: switching OFF
|
||||
// archives the schedule that was in force (the tracked stretch keeps
|
||||
// its expected doses); switching back ON archives the untracked
|
||||
// stretch with an empty payload, so compliance never counts it as
|
||||
// missed and the live schedule, the projector and the reminder worker
|
||||
// all resume from now.
|
||||
const liveRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
if (liveRows.length > 0) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const archivedEntries = wasTracked
|
||||
? liveRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
track_intake: willTrack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
|
||||
// the open placeholders of today and later go the way a schedule
|
||||
// replace sends them (a replace in the same request already did it).
|
||||
if (trackingChanged && !willTrack && !schedules) {
|
||||
await tombstoneOpenSlots();
|
||||
}
|
||||
|
||||
const baseUpdateData = {
|
||||
...(name !== undefined && { name }),
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
|
||||
// valid explicit value (clears the override → user-level default).
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
// (deactivate tracking / clear the per-med restriction).
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.7.0 — iOS reminder flags, field-by-field.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Field-by-field; `null` is a valid explicit value (clears the code).
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
// `startsOn` / `endsOn` are `Date | null | undefined` (the
|
||||
// schema lets the user clear them explicitly with null).
|
||||
// v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the
|
||||
// course writer below writes them together with the course they
|
||||
// project, in one transaction, so the row and its courses cannot
|
||||
// disagree if one of the two writes fails.
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const withoutNotifications = { ...baseUpdateData } as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete withoutNotifications.notificationsEnabled;
|
||||
const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0;
|
||||
const hasNotificationsPatch = notificationsEnabled !== undefined;
|
||||
|
||||
const updateCandidates: Array<Record<string, unknown>> = [
|
||||
{ ...baseUpdateData, ...pausedAtPatch },
|
||||
];
|
||||
if (hasPausedAtPatch) {
|
||||
updateCandidates.push(baseUpdateData);
|
||||
}
|
||||
if (hasNotificationsPatch) {
|
||||
updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch });
|
||||
if (hasPausedAtPatch) {
|
||||
updateCandidates.push(withoutNotifications);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.40 — everything the PUT writes to the medication, its schedule and
|
||||
// its course commits as one transaction. The course write can still be
|
||||
// refused after the check above (another request wrote a course in
|
||||
// between); with the row update committed first, the client got the
|
||||
// refusal while the name, the schedule and `oneShot` were already
|
||||
// changed. Now a refusal rolls all of it back. The rollup recompute and
|
||||
// the grace bridge stay outside: both read or write what this commit
|
||||
// settles, and neither may undo it.
|
||||
let medication;
|
||||
let lastUpdateErr: unknown;
|
||||
for (const candidate of updateCandidates) {
|
||||
try {
|
||||
medication = await prisma.medication.update({
|
||||
try {
|
||||
medication = await prisma.$transaction(async (tx) => {
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await tx.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
// v1.39.1 (#1033) — a stretch with intake tracking off expected
|
||||
// nothing, so it archives with an empty payload whatever the rows
|
||||
// said; and a tracking transition is itself an era boundary even when
|
||||
// the rows stay the same.
|
||||
const archivedEntries = wasTracked ? previousEntries : [];
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
(trackingChanged ||
|
||||
!wasTracked ||
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
))
|
||||
) {
|
||||
const lastRevision = await tx.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: archivedEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await tx.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
await tombstoneOpenSlots(tx);
|
||||
await tx.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
} else if (trackingChanged) {
|
||||
// v1.39.1 (#1033) — intake tracking switched without a schedule
|
||||
// replace. Close the current era at this instant: switching OFF
|
||||
// archives the schedule that was in force (the tracked stretch keeps
|
||||
// its expected doses); switching back ON archives the untracked
|
||||
// stretch with an empty payload, so compliance never counts it as
|
||||
// missed and the live schedule, the projector and the reminder worker
|
||||
// all resume from now.
|
||||
const liveRows = await tx.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
if (liveRows.length > 0) {
|
||||
const lastRevision = await tx.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const archivedEntries = wasTracked
|
||||
? liveRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
track_intake: willTrack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
|
||||
// the open placeholders of today and later go the way a schedule
|
||||
// replace sends them (a replace in the same request already did it).
|
||||
if (trackingChanged && !willTrack && !schedules) {
|
||||
await tombstoneOpenSlots(tx);
|
||||
}
|
||||
|
||||
const baseUpdateData = {
|
||||
...(name !== undefined && { name }),
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
|
||||
// valid explicit value (clears the override → user-level default).
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
// (deactivate tracking / clear the per-med restriction).
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.7.0 — iOS reminder flags, field-by-field.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Field-by-field; `null` is a valid explicit value (clears the code).
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
// `startsOn` / `endsOn` are `Date | null | undefined` (the
|
||||
// schema lets the user clear them explicitly with null).
|
||||
// v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the
|
||||
// course writer below writes them together with the course they
|
||||
// project, in one transaction, so the row and its courses cannot
|
||||
// disagree if one of the two writes fails.
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const updated = await tx.medication.update({
|
||||
where: { id },
|
||||
data: candidate,
|
||||
data: { ...baseUpdateData, ...pausedAtPatch },
|
||||
include: { schedules: true },
|
||||
});
|
||||
break;
|
||||
} catch (updateErr) {
|
||||
lastUpdateErr = updateErr;
|
||||
}
|
||||
}
|
||||
if (!medication) throw lastUpdateErr;
|
||||
|
||||
// v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a
|
||||
// single live marker that resume clears, so the paused window is
|
||||
// irrecoverable once resumed and every paused day's slot collapses to
|
||||
// "missed" in the compliance denominator. Mirror the live transition
|
||||
// onto an additive era record the rate paths read: a pause opens an era
|
||||
// (resumedAt null), a resume closes the latest open era. `userId` comes
|
||||
// from the authenticated session; the data object is built field-by-field.
|
||||
if (active === false && existing.active) {
|
||||
await prisma.medicationPauseEra.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
userId: user.id,
|
||||
pausedAt: new Date(),
|
||||
resumedAt: null,
|
||||
},
|
||||
});
|
||||
} else if (active === true && !existing.active) {
|
||||
await prisma.medicationPauseEra.updateMany({
|
||||
where: { medicationId: id, userId: user.id, resumedAt: null },
|
||||
data: { resumedAt: new Date() },
|
||||
// v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a
|
||||
// single live marker that resume clears, so the paused window is
|
||||
// irrecoverable once resumed and every paused day's slot collapses to
|
||||
// "missed" in the compliance denominator. Mirror the live transition
|
||||
// onto an additive era record the rate paths read: a pause opens an era
|
||||
// (resumedAt null), a resume closes the latest open era. `userId` comes
|
||||
// from the authenticated session; the data object is built field-by-field.
|
||||
if (active === false && existing.active) {
|
||||
await tx.medicationPauseEra.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
userId: user.id,
|
||||
pausedAt: new Date(),
|
||||
resumedAt: null,
|
||||
},
|
||||
});
|
||||
} else if (active === true && !existing.active) {
|
||||
await tx.medicationPauseEra.updateMany({
|
||||
where: { medicationId: id, userId: user.id, resumedAt: null },
|
||||
data: { resumedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// v1.40 (#1024) — write the window onto the latest course and
|
||||
// project it back, then serve the projected row.
|
||||
if (touchesWindow) {
|
||||
const projected = await setCurrentWindow(
|
||||
{
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: courseTz,
|
||||
...nextWindow,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return { ...updated, ...projected };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (err) {
|
||||
// A concurrent course write landed between the check above and the
|
||||
// course write. The transaction rolled back: nothing was written.
|
||||
if (err instanceof CourseWriteError) {
|
||||
const r = courseRefusalResponse(err.refusal);
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Recompute each rollup day the tombstoned slots sat on, now that the
|
||||
// tombstones are committed. Best-effort like every other rollup
|
||||
// write-hook: a failure is annotated, never fails the edit.
|
||||
for (const dayKey of staleRollupDays) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
user.id,
|
||||
id,
|
||||
dayKey,
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_failed: true,
|
||||
medication_compliance_rollup_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the v1.5.5 primary-schedule grace bridge after the
|
||||
@@ -1055,29 +1066,6 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.40 (#1024) — write the window onto the latest course and project it
|
||||
// back, then serve the projected row.
|
||||
if (touchesWindow) {
|
||||
let projected;
|
||||
try {
|
||||
projected = await setCurrentWindow({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: courseTz,
|
||||
...nextWindow,
|
||||
});
|
||||
} catch (err) {
|
||||
// A concurrent course write landed between the check above and this
|
||||
// transaction. Nothing of the window was written.
|
||||
if (err instanceof CourseWriteError) {
|
||||
const r = courseRefusalResponse(err.refusal);
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
medication = { ...medication, ...projected };
|
||||
}
|
||||
|
||||
// v1.40 (#1041) — an iPhone app build that predates custom categories
|
||||
// decodes an unknown category as OTHER and sends it back on every edit,
|
||||
// which would overwrite the person's own category with OTHER. A native
|
||||
|
||||
@@ -464,6 +464,18 @@ function catalogueLabel(
|
||||
if (catalogue === "symptomEpisodeReference") {
|
||||
return t("admin.section.backups.restoreSkippedSymptomEpisodeReference");
|
||||
}
|
||||
if (catalogue === "visitCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedVisitCiphertext");
|
||||
}
|
||||
if (catalogue === "vaccinationCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedVaccinationCiphertext");
|
||||
}
|
||||
if (catalogue === "moodLabelCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedMoodLabelCiphertext");
|
||||
}
|
||||
if (catalogue === "cycleCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedCycleCiphertext");
|
||||
}
|
||||
if (catalogue === "medicationTarget") {
|
||||
return t("admin.section.backups.restoreSkippedMedicationTarget");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
|
||||
import { buildCycleBackupSection } from "../backup";
|
||||
|
||||
@@ -236,3 +239,87 @@ describe("a symptom the account created survives the round trip", () => {
|
||||
expect(section.customSymptoms).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a portable file carries the cycle free text readable", () => {
|
||||
// Ciphertext in a portable file is noise on any host with another key, so
|
||||
// the note, the sensitive envelope and a custom label travel opened, and
|
||||
// the restore seals them under the receiving host's key.
|
||||
const savedKey = process.env.ENCRYPTION_KEY;
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEY = "ab".repeat(32);
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = savedKey;
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function client(dayLog: Record<string, unknown>, label: string | null) {
|
||||
return {
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ date: "2026-07-02", symptomLinks: [], ...dayLog },
|
||||
]),
|
||||
},
|
||||
cycleSymptom: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
key: "custom_ache",
|
||||
labelKey: "cycle.symptom.custom_ache",
|
||||
categoryId: "cat-1",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: label,
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
it("opens each sealed value and carries no ciphertext", async () => {
|
||||
const section = await buildCycleBackupSection(
|
||||
client(
|
||||
{
|
||||
notesEncrypted: encrypt("Cramps at four"),
|
||||
sensitiveEncrypted: encrypt(
|
||||
JSON.stringify({ sexualActivity: true, contraceptive: "ORAL" }),
|
||||
),
|
||||
},
|
||||
encrypt("Back ache"),
|
||||
),
|
||||
"user-1",
|
||||
{},
|
||||
);
|
||||
const day = section.cycleDayLogs[0];
|
||||
expect(day.note).toBe("Cramps at four");
|
||||
expect(day.sensitive).toEqual({
|
||||
sexualActivity: true,
|
||||
contraceptive: "ORAL",
|
||||
});
|
||||
expect(day).not.toHaveProperty("notesEncrypted");
|
||||
expect(day).not.toHaveProperty("sensitiveEncrypted");
|
||||
expect(section.customSymptoms[0].label).toBe("Back ache");
|
||||
expect(section.customSymptoms[0]).not.toHaveProperty("labelEncrypted");
|
||||
});
|
||||
|
||||
it("writes the unreadable marker where this host cannot open a value", async () => {
|
||||
const section = await buildCycleBackupSection(
|
||||
client(
|
||||
{
|
||||
notesEncrypted: "v9.bm90LWEtcmVhbC1jaXBoZXJ0ZXh0LWF0LWFsbC1yZWFsbHk=",
|
||||
},
|
||||
null,
|
||||
),
|
||||
"user-1",
|
||||
{},
|
||||
);
|
||||
expect(section.cycleDayLogs[0].note).toBe(UNREADABLE_EXPORT_MARKER);
|
||||
expect(section.cycleDayLogs[0].sensitive).toBeNull();
|
||||
expect(section.customSymptoms[0].label).toBeNull();
|
||||
});
|
||||
});
|
||||
+113
-22
@@ -5,10 +5,25 @@
|
||||
* never drift. Mirrors the `backupPayloadSchema` cycle shapes.
|
||||
*
|
||||
* Reads are scoped to `userId` + `deletedAt: null` and exclude predicted
|
||||
* (forecast) cycle rows — only observed history round-trips. `notesEncrypted`
|
||||
* is carried as the ciphertext envelope VERBATIM: the backup never decrypts
|
||||
* the day-log note, so a wrong-surface plaintext leak is impossible and the
|
||||
* owner's note round-trips encrypted at rest.
|
||||
* (forecast) cycle rows — only observed history round-trips.
|
||||
*
|
||||
* ## Free text follows the note contract
|
||||
*
|
||||
* Three columns are sealed: the day-log note, the sensitive-category envelope
|
||||
* and a custom symptom's label. A disaster-recovery file carries them as the
|
||||
* stored ciphertext, which the same instance's key reads back. A portable file
|
||||
* carries them readable (`note`, `sensitive`, `label`), and the restore seals
|
||||
* them under the receiving host's key, because a portable file exists to be
|
||||
* moved, and ciphertext from one host is noise on another.
|
||||
*
|
||||
* Until v1.40 the portable file carried the ciphertext too, so a note restored
|
||||
* onto a host with a different key was unreadable for good. Such a file is
|
||||
* still accepted: every ciphertext value in it is opened with this host's keys
|
||||
* before it is written, and one that does not open is kept out of the row and
|
||||
* named in the skip report (`cycleCiphertext`), never written back as
|
||||
* ciphertext nobody can read. The same check covers a disaster-recovery file,
|
||||
* which is why `RESTORE_SELF_VERIFIED_SECTIONS` lets these sections past the
|
||||
* restore's key preflight.
|
||||
*/
|
||||
import type {
|
||||
CervicalMucus,
|
||||
@@ -25,6 +40,12 @@ import type {
|
||||
PrismaClient,
|
||||
SecondarySymptom,
|
||||
} from "@/generated/prisma/client";
|
||||
import {
|
||||
openSealedForExport,
|
||||
sealForRestore,
|
||||
UNOPENED,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
@@ -91,8 +112,17 @@ export interface CycleBackupSection {
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
notesEncrypted: string | null;
|
||||
/** Ciphertext; present on a disaster-recovery payload. */
|
||||
sensitiveEncrypted?: string | null;
|
||||
/** Ciphertext; present on a disaster-recovery payload. */
|
||||
notesEncrypted?: string | null;
|
||||
/** Plaintext; present on a portable payload. */
|
||||
note?: string | null;
|
||||
/**
|
||||
* The sensitive-category envelope opened; present on a portable payload.
|
||||
* The unreadable marker when this instance could not open it.
|
||||
*/
|
||||
sensitive?: CycleSensitiveFields | string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
tz: string | null;
|
||||
@@ -142,18 +172,40 @@ export interface CycleBackupSection {
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
/**
|
||||
* The user's own words for the symptom, carried as ciphertext verbatim.
|
||||
* The user's own words for the symptom: ciphertext on a disaster-recovery
|
||||
* payload, plaintext (`label`) on a portable one.
|
||||
*
|
||||
* The builder has always emitted it and this interface did not declare it,
|
||||
* which held while the payload was assembled key by key and stopped holding
|
||||
* the moment the section is spread onto the wire: an undeclared field is a
|
||||
* field the wire type says is not there. Declared, so the type describes
|
||||
* the file.
|
||||
* Declared rather than left to the spread, because an undeclared field is
|
||||
* a field the wire type says is not there.
|
||||
*/
|
||||
labelEncrypted: string | null;
|
||||
labelEncrypted?: string | null;
|
||||
label?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The five intent fields the sensitive-category envelope holds. */
|
||||
export interface CycleSensitiveFields {
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: string | null;
|
||||
progesteroneTest?: string | null;
|
||||
contraceptive?: string | null;
|
||||
}
|
||||
|
||||
/** The sensitive envelope opened for a portable file, or the marker. */
|
||||
function openSensitiveForExport(
|
||||
sealed: string | null,
|
||||
): CycleSensitiveFields | string | null {
|
||||
if (!sealed) return null;
|
||||
const opened = openSealedForExport(sealed, "cycle sensitive envelope");
|
||||
if (opened === UNREADABLE_EXPORT_MARKER) return opened;
|
||||
try {
|
||||
return JSON.parse(opened) as CycleSensitiveFields;
|
||||
} catch {
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cycle section of a user's full backup. Accepts any client with
|
||||
* the cycle delegates (the route's global client OR the worker's local
|
||||
@@ -259,8 +311,17 @@ export async function buildCycleBackupSection(
|
||||
pregnancyTest: d.pregnancyTest,
|
||||
progesteroneTest: d.progesteroneTest,
|
||||
contraceptive: d.contraceptive,
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
}
|
||||
: {
|
||||
sensitive: openSensitiveForExport(d.sensitiveEncrypted),
|
||||
note: d.notesEncrypted
|
||||
? openSealedForExport(d.notesEncrypted, "cycle day-log note")
|
||||
: null,
|
||||
}),
|
||||
source: d.source,
|
||||
externalId: d.externalId,
|
||||
tz: d.tz,
|
||||
@@ -277,7 +338,13 @@ export async function buildCycleBackupSection(
|
||||
icon: sym.icon,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
labelEncrypted: sym.labelEncrypted,
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: sym.labelEncrypted }
|
||||
: {
|
||||
label: sym.labelEncrypted
|
||||
? openSealedForExport(sym.labelEncrypted, "cycle symptom label")
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -383,8 +450,9 @@ function enumOrNull<T extends string>(
|
||||
* inside an existing transaction. Delete-then-recreate, matching the
|
||||
* measurement/mood restore contract. Symptom links are re-resolved against
|
||||
* the seeded catalogue by key, and each one gets back the 1-4 intensity the
|
||||
* file recorded for it, or none if the file recorded none. `notesEncrypted` is
|
||||
* written back as the ciphertext envelope verbatim — never re-encrypted.
|
||||
* file recorded for it, or none if the file recorded none. Sealed free text
|
||||
* comes back as the file-header comment describes: readable values sealed
|
||||
* under this host's key, ciphertext written back only when this host opens it.
|
||||
*
|
||||
* A key that resolves against nothing is dropped from the day's links and
|
||||
* recorded on `skips`. It is a required argument rather than an optional one:
|
||||
@@ -400,6 +468,9 @@ export async function restoreCycleData(
|
||||
payload: BackupPayload,
|
||||
skips: RestoreSkipLog,
|
||||
): Promise<CycleRestoreCleared> {
|
||||
// Sealed values from the file this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
// Wipe (child links cascade off the day-log delete).
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
@@ -488,7 +559,12 @@ export async function restoreCycleData(
|
||||
icon: sym.icon ?? null,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
labelEncrypted: sym.labelEncrypted ?? null,
|
||||
labelEncrypted: sealForRestore(
|
||||
sym.labelEncrypted,
|
||||
sym.label,
|
||||
`customSymptoms.${sym.key}.labelEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide. Leave the
|
||||
// catalogue row alone: the account's links resolve against it either way.
|
||||
@@ -591,9 +667,22 @@ export async function restoreCycleData(
|
||||
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,
|
||||
sensitiveEncrypted: sealForRestore(
|
||||
d.sensitiveEncrypted,
|
||||
d.sensitive === undefined || d.sensitive === null
|
||||
? d.sensitive
|
||||
: typeof d.sensitive === "string"
|
||||
? UNOPENED
|
||||
: JSON.stringify(d.sensitive),
|
||||
`cycleDayLogs.${d.date}.sensitiveEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
notesEncrypted: sealForRestore(
|
||||
d.notesEncrypted,
|
||||
d.note,
|
||||
`cycleDayLogs.${d.date}.notesEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL",
|
||||
externalId: d.externalId ?? null,
|
||||
tz: d.tz ?? null,
|
||||
@@ -608,6 +697,8 @@ export async function restoreCycleData(
|
||||
});
|
||||
}
|
||||
|
||||
recordUnknownKeys(skips, "cycleCiphertext", unopened, unopened);
|
||||
|
||||
return {
|
||||
cycles: cycles.count,
|
||||
cycleDayLogs: dayLogs.count,
|
||||
|
||||
@@ -138,6 +138,30 @@ describe("assessBackupKeys", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the cycle sections to the restore, which opens each value itself", () => {
|
||||
// A same-id impostor in a cycle note must neither refuse the file nor be
|
||||
// picked as the sample that proves the key for the sections that need it.
|
||||
const fine = encrypt("a measurement note under the real key");
|
||||
useKeys({ old: K3, cur: K2 }, "old");
|
||||
const impostor = encrypt("x");
|
||||
useKeys({ old: K1, cur: K2 }, "cur");
|
||||
const file = {
|
||||
measurements: [{ notesEncrypted: fine }],
|
||||
cycleDayLogs: [
|
||||
{ notesEncrypted: impostor },
|
||||
{ notesEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` },
|
||||
],
|
||||
customSymptoms: [
|
||||
{ labelEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` },
|
||||
],
|
||||
};
|
||||
const collector = new BackupKeyIdCollector();
|
||||
collector.visit(file);
|
||||
const verdict = assessBackupKeys(collector);
|
||||
expect(verdict.missing).toEqual([]);
|
||||
expect(verdict.unreadable).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches an id that is configured with different key material", () => {
|
||||
const file = fileWrittenUnderOld();
|
||||
useKeys({ old: K3, cur: K2 }, "cur");
|
||||
|
||||
@@ -97,6 +97,32 @@ export function isEncryptedMember(name: string): boolean {
|
||||
return name.endsWith("Encrypted") && name.length > "Encrypted".length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections whose restore opens every inner value with this host's keys itself,
|
||||
* and keeps out (and names in the skip report) any value it cannot open.
|
||||
*
|
||||
* The cycle day-logs, custom cycle symptoms, custom mood tags and mood
|
||||
* categories, practitioners, visits and vaccinations are here because a
|
||||
* portable file written before v1.40 carried their free text as the stored
|
||||
* ciphertext,
|
||||
* not as readable text. Such a file is still restorable onto another host:
|
||||
* what that host can open comes back, what it cannot stays out of the row and
|
||||
* is reported. Refusing the whole file over a note would cost every other
|
||||
* part of the account. The trade, stated: a disaster-recovery copy whose ONLY
|
||||
* missing key sits in these sections restores with those values reported as
|
||||
* skipped instead of being refused; any other section needing the key still
|
||||
* refuses it, which in practice is every real copy.
|
||||
*/
|
||||
export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet<string> = new Set([
|
||||
"cycleDayLogs",
|
||||
"customSymptoms",
|
||||
"customMoodTags",
|
||||
"customMoodTagCategories",
|
||||
"practitioners",
|
||||
"encounters",
|
||||
"vaccinations",
|
||||
]);
|
||||
|
||||
interface KeyUse {
|
||||
/** How many values need this key. */
|
||||
count: number;
|
||||
@@ -127,7 +153,10 @@ export class BackupKeyIdCollector {
|
||||
}
|
||||
use.count += 1;
|
||||
use.sections.add(section);
|
||||
// A sample from a self-verified section proves nothing the restore needs:
|
||||
// a value there that does not open is skipped, not fatal.
|
||||
if (
|
||||
!RESTORE_SELF_VERIFIED_SECTIONS.has(section) &&
|
||||
value.length <= MAX_SAMPLE_CHARS &&
|
||||
(!use.sample || value.length < use.sample.value.length)
|
||||
) {
|
||||
@@ -257,7 +286,9 @@ function openSample(sample: { value: string; form: InnerCiphertextForm }) {
|
||||
* key to prove the key under that id is the right one.
|
||||
*
|
||||
* `ignoreSections` leaves out sections the caller will not write: the
|
||||
* instance settings, when the operator has not asked for them back.
|
||||
* instance settings, when the operator has not asked for them back. The
|
||||
* sections in {@link RESTORE_SELF_VERIFIED_SECTIONS} are always left out,
|
||||
* because their restore checks each value itself.
|
||||
*/
|
||||
export function assessBackupKeys(
|
||||
collector: BackupKeyIdCollector,
|
||||
@@ -274,7 +305,14 @@ export function assessBackupKeys(
|
||||
for (const [keyId, use] of [...collector.entries()].sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if ([...use.sections].every((section) => ignore.has(section))) continue;
|
||||
if (
|
||||
[...use.sections].every(
|
||||
(section) =>
|
||||
ignore.has(section) || RESTORE_SELF_VERIFIED_SECTIONS.has(section),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
keyIds.push(keyId);
|
||||
configured ??= new Set(getConfiguredKeyIds());
|
||||
if (!configured.has(keyId)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { openSealedForExport } from "@/lib/export/sealed-text";
|
||||
import { iterateMeasurementPages } from "@/lib/export/paged-measurements";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import {
|
||||
@@ -1284,8 +1285,18 @@ export async function buildFullBackupPayload(
|
||||
icon: category.icon,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
// Ciphertext verbatim, like the tag label beside it.
|
||||
labelEncrypted: category.labelEncrypted,
|
||||
// The person's own words: ciphertext in a disaster-recovery payload,
|
||||
// readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: category.labelEncrypted }
|
||||
: {
|
||||
label: category.labelEncrypted
|
||||
? openSealedForExport(
|
||||
category.labelEncrypted,
|
||||
"mood category label",
|
||||
)
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
hiddenMoodTags: hiddenMoodTags.map((hidden) => ({
|
||||
key: hidden.moodTag.key,
|
||||
@@ -1300,9 +1311,15 @@ export async function buildFullBackupPayload(
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon,
|
||||
sortOrder: tag.sortOrder,
|
||||
// Ciphertext verbatim, like every other *Encrypted column in a DR
|
||||
// payload: the same instance's key reads it back unchanged.
|
||||
labelEncrypted: tag.labelEncrypted,
|
||||
// Ciphertext in a disaster-recovery payload (the same instance's key
|
||||
// reads it back), readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: tag.labelEncrypted }
|
||||
: {
|
||||
label: tag.labelEncrypted
|
||||
? openSealedForExport(tag.labelEncrypted, "mood tag label")
|
||||
: null,
|
||||
}),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
|
||||
+125
-102
@@ -66,6 +66,7 @@ import {
|
||||
listTenantEdges,
|
||||
} from "@/lib/export/tenant-boundary";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { sealForRestore } from "@/lib/export/sealed-text";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
@@ -1514,6 +1515,130 @@ export async function restoreBackup(
|
||||
}
|
||||
|
||||
reportSection("mood");
|
||||
// The account's own categories, tags and hidden set are written
|
||||
// whether or not the file carries a mood entry: an account that set
|
||||
// up its mood vocabulary before logging a single entry has them too,
|
||||
// and they used to be written only inside the entries branch.
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
// Labels this host's keys do not open, by file path.
|
||||
const unopenedMoodLabels: string[] = [];
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: sealForRestore(
|
||||
category.labelEncrypted,
|
||||
category.label,
|
||||
`customMoodTagCategories.${category.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
// "Unknown mood factor keys" — the file was complete and unusable.
|
||||
for (const tag of payload.customMoodTags) {
|
||||
await tx.moodTag.upsert({
|
||||
where: { key: tag.key },
|
||||
create: {
|
||||
...(tag.id ? { id: tag.id } : {}),
|
||||
userId: ownerId,
|
||||
key: tag.key,
|
||||
labelKey: tag.labelKey,
|
||||
categoryId: tag.categoryId,
|
||||
kind: tag.kind,
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder,
|
||||
labelEncrypted: sealForRestore(
|
||||
tag.labelEncrypted,
|
||||
tag.label,
|
||||
`customMoodTags.${tag.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide.
|
||||
// Leave the catalogue row alone; the links resolve either way.
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodLabelCiphertext",
|
||||
unopenedMoodLabels,
|
||||
unopenedMoodLabels,
|
||||
);
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.moodEntries.length > 0) {
|
||||
const referencedFactorKeys = payload.moodEntries.flatMap((entry) =>
|
||||
entry.factors.map((factor) => factor.key),
|
||||
@@ -1523,108 +1648,6 @@ export async function restoreBackup(
|
||||
(entry) => entry.structuredTags,
|
||||
);
|
||||
const tagKeys = [...new Set(referencedTagKeys)];
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: category.labelEncrypted ?? null,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
// "Unknown mood factor keys" — the file was complete and unusable.
|
||||
for (const tag of payload.customMoodTags) {
|
||||
await tx.moodTag.upsert({
|
||||
where: { key: tag.key },
|
||||
create: {
|
||||
...(tag.id ? { id: tag.id } : {}),
|
||||
userId: ownerId,
|
||||
key: tag.key,
|
||||
labelKey: tag.labelKey,
|
||||
categoryId: tag.categoryId,
|
||||
kind: tag.kind,
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder,
|
||||
labelEncrypted: tag.labelEncrypted ?? null,
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide.
|
||||
// Leave the catalogue row alone; the links resolve either way.
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
const factorRows =
|
||||
factorKeys.length === 0
|
||||
? []
|
||||
|
||||
@@ -152,6 +152,25 @@
|
||||
* real foreign key, so the POINTER is dropped and the occurrence restores
|
||||
* unlinked. A portable export omits soft-deleted episodes, which is the
|
||||
* ordinary way an occurrence ends up naming one the file does not carry.
|
||||
*
|
||||
* The sixteenth, `cycleCiphertext`, is not a reference at all. It names a
|
||||
* sealed cycle value (a day-log note, the sensitive-category envelope, a
|
||||
* custom symptom's label) the file carried as ciphertext this host's keys do
|
||||
* not open — a portable file written before v1.40 on a host with another key.
|
||||
* Writing it back would store a value no reader can open, so the FIELD is kept
|
||||
* out and the day or the symptom restores without it. Its key is the path in
|
||||
* the file (`cycleDayLogs.<date>.notesEncrypted`), never the value.
|
||||
*
|
||||
* The seventeenth, `moodLabelCiphertext`, is the same for the label of a mood
|
||||
* tag or mood category the account created
|
||||
* (`customMoodTags.<key>.labelEncrypted`): the tag or category restores, under
|
||||
* its key, without the person's own name for it.
|
||||
*
|
||||
* The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`,
|
||||
* are the same again for the free text of the visits record (a practitioner's
|
||||
* note, address and phone; a visit's reason, outcome and body site) and of a
|
||||
* vaccination's note (`vaccinations.<id>.noteEncrypted`). The row restores
|
||||
* without the field.
|
||||
*/
|
||||
export type SkippedCatalogue =
|
||||
| "cycleSymptom"
|
||||
@@ -172,7 +191,11 @@ export type SkippedCatalogue =
|
||||
| "scheduleRevisionLink"
|
||||
| "checkupClosure"
|
||||
| "accountSetting"
|
||||
| "symptomEpisodeReference";
|
||||
| "symptomEpisodeReference"
|
||||
| "cycleCiphertext"
|
||||
| "moodLabelCiphertext"
|
||||
| "visitCiphertext"
|
||||
| "vaccinationCiphertext";
|
||||
|
||||
/** One key this instance does not know, and the links it cost. */
|
||||
export interface SkippedCatalogueKey {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Both ends of a sealed free-text column in a backup section: the string
|
||||
* codec (`<keyId>.<base64>` in a text column — the cycle day-log note and
|
||||
* sensitive envelope, custom cycle symptom and mood labels) and the shared
|
||||
* bytes codec (the practitioner note, the visit's reason, outcome and body
|
||||
* site, the vaccination note).
|
||||
*
|
||||
* The split is the one every other section uses. A disaster-recovery file
|
||||
* carries the stored ciphertext, which the same instance's key reads back. A
|
||||
* portable file carries the readable value (or the unreadable marker when
|
||||
* the writing host could not open it), and the restore seals it under the
|
||||
* receiving host's active key, because ciphertext from one host is noise on
|
||||
* another.
|
||||
*
|
||||
* Portable files written before v1.40 carried these columns as ciphertext.
|
||||
* They still restore: a ciphertext value is written back only when this
|
||||
* host's keys open it. One that does not open is kept out of the row and its
|
||||
* file path is recorded, so the caller can name it in the skip report rather
|
||||
* than store a value nobody can read. That per-value check is why the key
|
||||
* preflight leaves these sections out (`RESTORE_SELF_VERIFIED_SECTIONS`).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Open a sealed value for a portable file, or the unreadable marker. */
|
||||
export function openSealedForExport(sealed: string, field: string): string {
|
||||
try {
|
||||
return decrypt(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** A readable value the file could not supply: the writer could not open it. */
|
||||
export const UNOPENED: unique symbol = Symbol("unopened");
|
||||
|
||||
/**
|
||||
* The sealed column value for one free-text field from the file.
|
||||
*
|
||||
* Ciphertext is written back only when this host opens it — a value sealed
|
||||
* under a key this host does not hold (or the same key id over other key
|
||||
* material) would be a row nobody can read. A readable value is sealed under
|
||||
* this host's active key. Anything kept out is recorded under `path`.
|
||||
*/
|
||||
export function sealForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined | typeof UNOPENED,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): string | null {
|
||||
if (sealed) {
|
||||
try {
|
||||
decrypt(sealed);
|
||||
return sealed;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (readable === UNOPENED) {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
return readable ? encrypt(readable) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Bytes` twin of {@link openSealedForExport}, for columns written with
|
||||
* the shared bytes codec (`encryptToBytes`): the practitioner note, the
|
||||
* visit's reason, outcome and body site, the vaccination note.
|
||||
*/
|
||||
export function openSealedBytesForExport(
|
||||
sealed: Uint8Array | null,
|
||||
field: string,
|
||||
): string | null {
|
||||
if (!sealed || sealed.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored `Bytes` ciphertext as a disaster-recovery file carries it. */
|
||||
export function encodeSealedBytes(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
function decodeBase64(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Bytes` twin of {@link sealForRestore}. `sealed` is the base64 a
|
||||
* disaster-recovery (or pre-v1.40 portable) file carries; it is written back
|
||||
* only when this host opens it. A readable value is sealed under this host's
|
||||
* active key.
|
||||
*/
|
||||
export function sealBytesForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (sealed) {
|
||||
const bytes = decodeBase64(sealed);
|
||||
try {
|
||||
decryptFromBytes(bytes);
|
||||
return bytes;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return readable ? encryptToBytes(readable) : null;
|
||||
}
|
||||
@@ -35,7 +35,11 @@
|
||||
* guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`
|
||||
* holds.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
encodeSealedBytes,
|
||||
openSealedBytesForExport,
|
||||
sealBytesForRestore,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { VaccinationSite } from "@/generated/prisma/client";
|
||||
|
||||
@@ -72,8 +76,10 @@ export interface VaccinationBackupEntry {
|
||||
* lacks the reminder.
|
||||
*/
|
||||
reminderId: string | null;
|
||||
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
|
||||
noteEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
noteEncrypted?: string | null;
|
||||
/** The note readable; portable payloads only. */
|
||||
note?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
@@ -96,12 +102,6 @@ export interface VaccinationsBackupCounts {
|
||||
vaccinationLinks: number;
|
||||
}
|
||||
|
||||
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
|
||||
function encodeCiphertext(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Named select constants, one per model — a structural matcher binds a model
|
||||
* to the literal beside its delegate call, and an inline object inside a
|
||||
@@ -172,7 +172,14 @@ export async function buildVaccinationsBackupSection(
|
||||
practitionerId: row.practitionerId,
|
||||
encounterId: row.encounterId,
|
||||
reminderId: row.reminderId,
|
||||
noteEncrypted: encodeCiphertext(row.noteEncrypted),
|
||||
...(disasterRecovery
|
||||
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
|
||||
: {
|
||||
note: openSealedBytesForExport(
|
||||
row.noteEncrypted,
|
||||
"vaccination note",
|
||||
),
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
...(disasterRecovery
|
||||
@@ -240,16 +247,10 @@ export type RestoredVaccination = Pick<
|
||||
| "encounterId"
|
||||
| "reminderId"
|
||||
| "noteEncrypted"
|
||||
| "note"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's immunization log and its document links.
|
||||
*
|
||||
@@ -307,6 +308,8 @@ export async function restoreVaccinationsData(
|
||||
const droppedPractitioners: string[] = [];
|
||||
const droppedEncounters: string[] = [];
|
||||
const droppedReminders: string[] = [];
|
||||
// Sealed notes this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
if (payload.vaccinations.length > 0) {
|
||||
await tx.vaccinationRecord.createMany({
|
||||
@@ -349,10 +352,12 @@ export async function restoreVaccinationsData(
|
||||
practitionerId,
|
||||
encounterId,
|
||||
reminderId,
|
||||
noteEncrypted:
|
||||
entry.noteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.noteEncrypted),
|
||||
noteEncrypted: sealBytesForRestore(
|
||||
entry.noteEncrypted,
|
||||
entry.note,
|
||||
`vaccinations.${entry.id}.noteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
createdAt: new Date(entry.createdAt),
|
||||
updatedAt: new Date(entry.updatedAt),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
@@ -379,6 +384,7 @@ export async function restoreVaccinationsData(
|
||||
[...new Set(droppedReminders)],
|
||||
droppedReminders,
|
||||
);
|
||||
recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened);
|
||||
|
||||
const restoredVaccinations = new Set(
|
||||
payload.vaccinations.map((entry) => entry.id),
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
* result ids, so a lab link in one cannot resolve; a disaster-recovery
|
||||
* payload carries every id and they all do.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type {
|
||||
EncounterKind,
|
||||
@@ -42,6 +41,11 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
encodeSealedBytes,
|
||||
openSealedBytesForExport,
|
||||
sealBytesForRestore,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import { readPractitionerContact } from "@/lib/practitioners/dto";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
@@ -71,8 +75,10 @@ export interface PractitionerBackupEntry {
|
||||
locationEncrypted?: string | null;
|
||||
/** Base64 ciphertext of the phone number, disaster-recovery payloads only. */
|
||||
phoneEncrypted?: string | null;
|
||||
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
|
||||
noteEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
noteEncrypted?: string | null;
|
||||
/** The note readable; portable payloads only. */
|
||||
note?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
@@ -87,10 +93,15 @@ export interface EncounterBackupEntry {
|
||||
status: EncounterStatus;
|
||||
kind: EncounterKind;
|
||||
practitionerId: string | null;
|
||||
reasonEncrypted: string | null;
|
||||
outcomeEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
reasonEncrypted?: string | null;
|
||||
outcomeEncrypted?: string | null;
|
||||
/** The procedure's body site, ciphertext like the two above (v1.39.1). */
|
||||
bodySiteEncrypted: string | null;
|
||||
bodySiteEncrypted?: string | null;
|
||||
/** The three readable; portable payloads only. */
|
||||
reason?: string | null;
|
||||
outcome?: string | null;
|
||||
bodySite?: string | null;
|
||||
laterality: Laterality | null;
|
||||
/**
|
||||
* The appointment's reminder row, which restores in the same run since
|
||||
@@ -124,12 +135,6 @@ export interface VisitsBackupCounts {
|
||||
encounterLinks: number;
|
||||
}
|
||||
|
||||
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
|
||||
function encodeCiphertext(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Named select constants, one per model.
|
||||
*
|
||||
@@ -256,10 +261,10 @@ export async function buildVisitsBackupSection(
|
||||
? {
|
||||
location: null,
|
||||
phone: null,
|
||||
locationEncrypted: encodeCiphertext(
|
||||
locationEncrypted: encodeSealedBytes(
|
||||
row.locationEncrypted ?? encryptNote(row.location),
|
||||
),
|
||||
phoneEncrypted: encodeCiphertext(
|
||||
phoneEncrypted: encodeSealedBytes(
|
||||
row.phoneEncrypted ?? encryptNote(row.phone),
|
||||
),
|
||||
}
|
||||
@@ -275,7 +280,14 @@ export async function buildVisitsBackupSection(
|
||||
"phone",
|
||||
),
|
||||
}),
|
||||
noteEncrypted: encodeCiphertext(row.noteEncrypted),
|
||||
...(disasterRecovery
|
||||
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
|
||||
: {
|
||||
note: openSealedBytesForExport(
|
||||
row.noteEncrypted,
|
||||
"practitioner note",
|
||||
),
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
...(disasterRecovery
|
||||
@@ -288,9 +300,26 @@ export async function buildVisitsBackupSection(
|
||||
status: row.status,
|
||||
kind: row.kind,
|
||||
practitionerId: row.practitionerId,
|
||||
reasonEncrypted: encodeCiphertext(row.reasonEncrypted),
|
||||
outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted),
|
||||
bodySiteEncrypted: encodeCiphertext(row.bodySiteEncrypted),
|
||||
...(disasterRecovery
|
||||
? {
|
||||
reasonEncrypted: encodeSealedBytes(row.reasonEncrypted),
|
||||
outcomeEncrypted: encodeSealedBytes(row.outcomeEncrypted),
|
||||
bodySiteEncrypted: encodeSealedBytes(row.bodySiteEncrypted),
|
||||
}
|
||||
: {
|
||||
reason: openSealedBytesForExport(
|
||||
row.reasonEncrypted,
|
||||
"visit reason",
|
||||
),
|
||||
outcome: openSealedBytesForExport(
|
||||
row.outcomeEncrypted,
|
||||
"visit outcome",
|
||||
),
|
||||
bodySite: openSealedBytesForExport(
|
||||
row.bodySiteEncrypted,
|
||||
"visit body site",
|
||||
),
|
||||
}),
|
||||
laterality: row.laterality,
|
||||
reminderId: row.reminderId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -380,6 +409,7 @@ export type RestoredPractitioner = Pick<
|
||||
| "locationEncrypted"
|
||||
| "phoneEncrypted"
|
||||
| "noteEncrypted"
|
||||
| "note"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
@@ -394,18 +424,14 @@ export type RestoredEncounter = Pick<
|
||||
| "reasonEncrypted"
|
||||
| "outcomeEncrypted"
|
||||
| "bodySiteEncrypted"
|
||||
| "reason"
|
||||
| "outcome"
|
||||
| "bodySite"
|
||||
| "laterality"
|
||||
| "reminderId"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's visits, address book and link rows.
|
||||
*
|
||||
@@ -440,6 +466,9 @@ export async function restoreVisitsData(
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
// Sealed values this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
if (payload.practitioners.length > 0) {
|
||||
await tx.practitioner.createMany({
|
||||
data: payload.practitioners.map((entry) => ({
|
||||
@@ -451,18 +480,24 @@ export async function restoreVisitsData(
|
||||
// v1.39.4 — ciphertext wins when a disaster-recovery file carried it;
|
||||
// a portable file's (or an older file's) readable value is sealed
|
||||
// under this instance's key. The readable columns are never written.
|
||||
locationEncrypted:
|
||||
entry.locationEncrypted != null
|
||||
? decodeCiphertext(entry.locationEncrypted)
|
||||
: encryptNote(entry.location),
|
||||
phoneEncrypted:
|
||||
entry.phoneEncrypted != null
|
||||
? decodeCiphertext(entry.phoneEncrypted)
|
||||
: encryptNote(entry.phone),
|
||||
noteEncrypted:
|
||||
entry.noteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.noteEncrypted),
|
||||
locationEncrypted: sealBytesForRestore(
|
||||
entry.locationEncrypted,
|
||||
entry.location,
|
||||
`practitioners.${entry.id}.locationEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
phoneEncrypted: sealBytesForRestore(
|
||||
entry.phoneEncrypted,
|
||||
entry.phone,
|
||||
`practitioners.${entry.id}.phoneEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
noteEncrypted: sealBytesForRestore(
|
||||
entry.noteEncrypted,
|
||||
entry.note,
|
||||
`practitioners.${entry.id}.noteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
createdAt: new Date(entry.createdAt),
|
||||
updatedAt: new Date(entry.updatedAt),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
@@ -517,20 +552,26 @@ export async function restoreVisitsData(
|
||||
status: entry.status,
|
||||
kind: entry.kind,
|
||||
practitionerId,
|
||||
reasonEncrypted:
|
||||
entry.reasonEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.reasonEncrypted),
|
||||
outcomeEncrypted:
|
||||
entry.outcomeEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.outcomeEncrypted),
|
||||
reasonEncrypted: sealBytesForRestore(
|
||||
entry.reasonEncrypted,
|
||||
entry.reason,
|
||||
`encounters.${entry.id}.reasonEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
outcomeEncrypted: sealBytesForRestore(
|
||||
entry.outcomeEncrypted,
|
||||
entry.outcome,
|
||||
`encounters.${entry.id}.outcomeEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
// Absent in a file written before v1.39.1: the visit comes back
|
||||
// with no site, which is what it had.
|
||||
bodySiteEncrypted:
|
||||
entry.bodySiteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.bodySiteEncrypted),
|
||||
bodySiteEncrypted: sealBytesForRestore(
|
||||
entry.bodySiteEncrypted,
|
||||
entry.bodySite,
|
||||
`encounters.${entry.id}.bodySiteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
laterality: entry.laterality ?? null,
|
||||
reminderId,
|
||||
createdAt: new Date(entry.createdAt),
|
||||
@@ -553,6 +594,7 @@ export async function restoreVisitsData(
|
||||
[...new Set(droppedReminders)],
|
||||
droppedReminders,
|
||||
);
|
||||
recordUnknownKeys(skips, "visitCiphertext", unopened, unopened);
|
||||
|
||||
const restoredEncounters = new Set(payload.encounters.map((e) => e.id));
|
||||
|
||||
|
||||
@@ -323,57 +323,69 @@ function windowAsCourse(
|
||||
* Clearing both on a medication with one course deletes it (chronic again,
|
||||
* the pre-course meaning of no dates); on one with several it is refused,
|
||||
* because it would silently drop the history.
|
||||
*
|
||||
* `tx` runs the write inside the caller's transaction, so the PUT's row
|
||||
* update and this course write commit or roll back together: a refusal here
|
||||
* then leaves nothing of the PUT behind.
|
||||
*/
|
||||
export async function setCurrentWindow(
|
||||
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
||||
tx?: Tx,
|
||||
): Promise<ProjectedWindow> {
|
||||
return tx
|
||||
? writeCurrentWindow(tx, args)
|
||||
: prisma.$transaction((own) => writeCurrentWindow(own, args));
|
||||
}
|
||||
|
||||
async function writeCurrentWindow(
|
||||
tx: Tx,
|
||||
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
||||
): Promise<ProjectedWindow> {
|
||||
const todayKey = userDayKey(args.now ?? new Date(), args.timeZone);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
|
||||
if (args.startsOn === null && args.endsOn === null) {
|
||||
if (!latest) return project(tx, args.medicationId);
|
||||
if (courses.length > 1) throw new CourseWriteError("windowRequired");
|
||||
await tx.medicationCourse.delete({ where: { id: latest.id } });
|
||||
return project(tx, args.medicationId);
|
||||
}
|
||||
|
||||
const next = windowAsCourse(
|
||||
args.startsOn,
|
||||
args.endsOn,
|
||||
latest,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
|
||||
if (latest) {
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? next : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: next,
|
||||
});
|
||||
} else {
|
||||
refuseIfInvalid([next], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...next,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (args.startsOn === null && args.endsOn === null) {
|
||||
if (!latest) return project(tx, args.medicationId);
|
||||
if (courses.length > 1) throw new CourseWriteError("windowRequired");
|
||||
await tx.medicationCourse.delete({ where: { id: latest.id } });
|
||||
return project(tx, args.medicationId);
|
||||
});
|
||||
}
|
||||
|
||||
const next = windowAsCourse(
|
||||
args.startsOn,
|
||||
args.endsOn,
|
||||
latest,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
|
||||
if (latest) {
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? next : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: next,
|
||||
});
|
||||
} else {
|
||||
refuseIfInvalid([next], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...next,
|
||||
},
|
||||
});
|
||||
}
|
||||
return project(tx, args.medicationId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -625,6 +625,15 @@ const cycleSymptomSeveritySchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** The sensitive-category envelope's five fields, as a portable file carries them. */
|
||||
const cycleSensitiveFieldsSchema = z.object({
|
||||
sexualActivity: z.boolean().optional(),
|
||||
protectedSex: z.boolean().nullable().optional(),
|
||||
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
|
||||
});
|
||||
|
||||
const cycleDayLogSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
@@ -644,8 +653,20 @@ const cycleDayLogSchema = z
|
||||
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
|
||||
// Free text travels like every other note: a disaster-recovery file
|
||||
// carries the ciphertext (`*Encrypted`), a portable file the readable
|
||||
// value (`note`, `sensitive`), which the restore seals under the
|
||||
// receiving host's key. A portable file written before v1.40 carries the
|
||||
// ciphertext too; the restore keeps only what this host can open.
|
||||
sensitiveEncrypted: z.string().nullable().optional(),
|
||||
notesEncrypted: z.string().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
// The five intent fields the sensitive-category envelope holds, or the
|
||||
// unreadable marker when the writing host could not open the envelope.
|
||||
sensitive: z
|
||||
.union([cycleSensitiveFieldsSchema, z.string()])
|
||||
.nullable()
|
||||
.optional(),
|
||||
source: z.enum(MeasurementSource).optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
tz: z.string().nullable().optional(),
|
||||
@@ -696,10 +717,12 @@ const customMoodTagSchema = z
|
||||
isActive: z.boolean().default(true),
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
// The user's own words for the tag, encrypted at rest and carried
|
||||
// verbatim. Without it the tag comes back as a bare key and the person
|
||||
// who named it "Migräne" gets `custom:cm3x9…` instead.
|
||||
// The user's own words for the tag. Without it the tag comes back as a
|
||||
// bare key and the person who named it "Migräne" gets `custom:cm3x9…`
|
||||
// instead. Ciphertext on a disaster-recovery file, readable `label` on a
|
||||
// portable one; the restore seals the readable value under its own key.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
// The scale a RATED factor was recorded on. `inverse` marks a factor
|
||||
// where a HIGH value is bad (stress, conflict). Dropping it does not lose
|
||||
// a label, it silently reverses the meaning of every rating already
|
||||
@@ -752,7 +775,10 @@ const customCycleSymptomSchema = z
|
||||
sortOrder: z.number().int().default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
// Same reasoning as the mood tag's: the user's own words live here.
|
||||
// Ciphertext on a disaster-recovery file, readable `label` on a portable
|
||||
// one (the day-log note's split).
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -1181,8 +1207,9 @@ const symptomDefinitionBackupSchema = z
|
||||
*
|
||||
* `id` is required on both records, unlike the lab result above: a link
|
||||
* addresses an encounter and its far side by id and nothing else on either row
|
||||
* is unique enough to rebuild the reference from. The ciphertext columns ride
|
||||
* verbatim as base64 — a visit note is never decrypted into the file.
|
||||
* is unique enough to rebuild the reference from. Free text follows the note
|
||||
* contract: ciphertext as base64 on a disaster-recovery file, the readable
|
||||
* value on a portable one (sealed under the receiving host's key on restore).
|
||||
*/
|
||||
const practitionerBackupSchema = z
|
||||
.object({
|
||||
@@ -1196,6 +1223,7 @@ const practitionerBackupSchema = z
|
||||
locationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
phoneEncrypted: base64BytesSchema.nullable().optional(),
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
@@ -1214,6 +1242,10 @@ const encounterBackupSchema = z
|
||||
// v1.39.1 — the procedure's body site and side. Optional so a file written
|
||||
// before they existed still parses; the visit restores with neither.
|
||||
bodySiteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
// The readable reason, outcome and body site of a portable file.
|
||||
reason: z.string().nullable().optional(),
|
||||
outcome: z.string().nullable().optional(),
|
||||
bodySite: z.string().nullable().optional(),
|
||||
laterality: z.enum(Laterality).nullable().optional(),
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
@@ -1255,7 +1287,9 @@ const vaccinationBackupSchema = z
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
reminderId: z.string().nullable().optional(),
|
||||
// Ciphertext on a disaster-recovery file, readable on a portable one.
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
@@ -1618,7 +1652,9 @@ const customMoodTagCategorySchema = z
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
// Same split as the custom tag's label.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -391,8 +391,10 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
cervixFirmness: "FIRM",
|
||||
cervixOpening: "CLOSED",
|
||||
sexualActivity: false,
|
||||
sensitiveEncrypted: "sensitive-cycle-dr",
|
||||
notesEncrypted: "notes-cycle-dr",
|
||||
// Real ciphertext: the restore writes a sealed value back only when
|
||||
// this host opens it.
|
||||
sensitiveEncrypted: encrypt(JSON.stringify({ sexualActivity: false })),
|
||||
notesEncrypted: encrypt("notes-cycle-dr"),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "cycle-day-external-dr",
|
||||
tz: "Europe/London",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* A portable file's cycle free text survives a move to a host with other keys.
|
||||
*
|
||||
* The day-log note, the sensitive-category envelope and a custom symptom's
|
||||
* label are sealed under the writing host's key. A portable file used to carry
|
||||
* those three as the stored ciphertext, so a restore onto a host with a
|
||||
* different key either refused the whole file at the key check or, with a
|
||||
* matching key id over different key material, wrote rows nobody could open.
|
||||
*
|
||||
* Both ends and the pipe: the file is built by the real `buildFullBackupPayload`
|
||||
* under key A, stored and restored through the real route under key B, and
|
||||
* the restored rows are opened under key B.
|
||||
*
|
||||
* The second case is the file written before the fix, which still carries
|
||||
* ciphertext. A value this host's keys open comes back; a value they cannot
|
||||
* open is kept out of the row and named in the skip report, rather than
|
||||
* written back as ciphertext no reader can open.
|
||||
*
|
||||
* Mutation: restore the verbatim copy of `notesEncrypted` in
|
||||
* `restoreCycleData` and the legacy case writes the foreign ciphertext back.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "./restore-job-driver";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
|
||||
/** Make this process the host holding exactly `keys`, writing under `active`. */
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
process.env.ENCRYPTION_KEY = "";
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
const HOST_A = { a: "11".repeat(32) };
|
||||
const HOST_B = { b: "22".repeat(32) };
|
||||
/** Same key id as host B, different key material. */
|
||||
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
|
||||
|
||||
const NOTE = "Cramps woke me at four";
|
||||
const SENSITIVE = {
|
||||
sexualActivity: true,
|
||||
protectedSex: false,
|
||||
pregnancyTest: "NEGATIVE",
|
||||
progesteroneTest: null,
|
||||
contraceptive: "ORAL",
|
||||
};
|
||||
const LABEL = "Lower back ache";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
||||
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "cycle-cross-key-owner",
|
||||
email: "cycle-cross-key-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Store `payload` as a backup on this host and run the real restore. */
|
||||
async function restore(ownerId: string, payload: unknown) {
|
||||
const prisma = getPrismaClient();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "CYCLE_CROSS_KEY_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("cycle free text in a portable file restored under another key", () => {
|
||||
it("comes back readable under the receiving host's key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
/* ── host A writes the account and exports it ─────────────────────── */
|
||||
useKeys(HOST_A, "a");
|
||||
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: "custom:cross-key-ache",
|
||||
labelKey: "cycle.symptom.custom",
|
||||
labelEncrypted: encrypt(LABEL),
|
||||
},
|
||||
});
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
date: "2026-06-02",
|
||||
flow: "MEDIUM",
|
||||
notesEncrypted: encrypt(NOTE),
|
||||
sensitiveEncrypted: encrypt(JSON.stringify(SENSITIVE)),
|
||||
},
|
||||
});
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as unknown;
|
||||
|
||||
/* ── host B, which has never held key A, restores it ──────────────── */
|
||||
useKeys(HOST_B, "b");
|
||||
// Host B is another database: the account's own symptom row from host A
|
||||
// is not there (the restore upserts by key and keeps an existing row).
|
||||
await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } });
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const day = await prisma.cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: owner.id },
|
||||
});
|
||||
expect(day.flow).toBe("MEDIUM");
|
||||
expect(day.notesEncrypted).not.toBeNull();
|
||||
expect(decrypt(day.notesEncrypted!)).toBe(NOTE);
|
||||
expect(day.sensitiveEncrypted).not.toBeNull();
|
||||
expect(JSON.parse(decrypt(day.sensitiveEncrypted!))).toEqual(SENSITIVE);
|
||||
|
||||
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
|
||||
where: { key: "custom:cross-key-ache" },
|
||||
});
|
||||
expect(decrypt(symptom.labelEncrypted!)).toBe(LABEL);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a legacy file's ciphertext only where this host can open it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
// Written under host B's own key: opens here.
|
||||
useKeys(HOST_B, "b");
|
||||
const ownNote = encrypt(NOTE);
|
||||
// Written elsewhere: an id host B does not hold, and host B's id over
|
||||
// different key material.
|
||||
useKeys(HOST_A, "a");
|
||||
const foreignNote = encrypt("written under key a");
|
||||
const foreignLabel = encrypt("label under key a");
|
||||
useKeys(HOST_B_IMPOSTOR, "b");
|
||||
const impostorSensitive = encrypt(JSON.stringify(SENSITIVE));
|
||||
|
||||
// The shape a portable file had before the fix: every free-text field as
|
||||
// the stored ciphertext, no plaintext beside it.
|
||||
useKeys(HOST_B, "b");
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
|
||||
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
file.customSymptoms = [
|
||||
{
|
||||
key: "custom:legacy-foreign",
|
||||
labelKey: "cycle.symptom.custom",
|
||||
categoryId: category.id,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: foreignLabel,
|
||||
},
|
||||
];
|
||||
const day = (
|
||||
date: string,
|
||||
notes: string | null,
|
||||
sensitive: string | null,
|
||||
) => ({
|
||||
date,
|
||||
flow: "LIGHT",
|
||||
intermenstrualBleeding: false,
|
||||
basalBodyTempC: null,
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
sensitiveEncrypted: sensitive,
|
||||
notesEncrypted: notes,
|
||||
source: "MANUAL",
|
||||
externalId: null,
|
||||
tz: null,
|
||||
symptomKeys: [],
|
||||
symptomSeverities: [],
|
||||
});
|
||||
file.cycleDayLogs = [
|
||||
day("2026-06-01", ownNote, null),
|
||||
day("2026-06-02", foreignNote, impostorSensitive),
|
||||
];
|
||||
// Still a file this release accepts.
|
||||
parseBackupPayload(JSON.stringify(file));
|
||||
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const days = await prisma.cycleDayLog.findMany({
|
||||
where: { userId: owner.id },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
expect(days.map((d) => d.date)).toEqual(["2026-06-01", "2026-06-02"]);
|
||||
expect(decrypt(days[0].notesEncrypted!)).toBe(NOTE);
|
||||
// The day itself comes back; only what cannot be opened stays out.
|
||||
expect(days[1].flow).toBe("LIGHT");
|
||||
expect(days[1].notesEncrypted).toBeNull();
|
||||
expect(days[1].sensitiveEncrypted).toBeNull();
|
||||
|
||||
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
|
||||
where: { key: "custom:legacy-foreign" },
|
||||
});
|
||||
expect(symptom.labelEncrypted).toBeNull();
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
skipped: {
|
||||
links: number;
|
||||
catalogueKeys: Array<{
|
||||
catalogue: string;
|
||||
key: string;
|
||||
links: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.skipped.catalogueKeys).toEqual([
|
||||
{
|
||||
catalogue: "cycleCiphertext",
|
||||
key: "customSymptoms.custom:legacy-foreign.labelEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "cycleCiphertext",
|
||||
key: "cycleDayLogs.2026-06-02.notesEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "cycleCiphertext",
|
||||
key: "cycleDayLogs.2026-06-02.sensitiveEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
]);
|
||||
expect(body.data.skipped.links).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* A portable file's custom mood labels survive a move to a host with other
|
||||
* keys.
|
||||
*
|
||||
* The labels of the mood tags and mood tag categories a person created are
|
||||
* sealed under the writing host's key. A portable file used to carry them as
|
||||
* the stored ciphertext, so a restore onto a host with a different key
|
||||
* refused the file at the key check, or with a matching key id over other key
|
||||
* material wrote labels nobody could open.
|
||||
*
|
||||
* Both ends and the pipe: built by the real `buildFullBackupPayload` under key
|
||||
* A, restored through the real route under key B, opened under key B. The
|
||||
* second case is a file written before the fix: a label this host opens comes
|
||||
* back, one it cannot open stays out and is named in the skip report.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "./restore-job-driver";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
|
||||
/** Make this process the host holding exactly `keys`, writing under `active`. */
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
process.env.ENCRYPTION_KEY = "";
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
const HOST_A = { a: "11".repeat(32) };
|
||||
const HOST_B = { b: "22".repeat(32) };
|
||||
/** Same key id as host B, different key material. */
|
||||
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
|
||||
|
||||
const CATEGORY_LABEL = "Evenings";
|
||||
const TAG_LABEL = "Migraine";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
||||
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-cross-key-owner",
|
||||
email: "mood-cross-key-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Store `payload` as a backup on this host and run the real restore. */
|
||||
async function restore(ownerId: string, payload: unknown) {
|
||||
const prisma = getPrismaClient();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "MOOD_CROSS_KEY_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("custom mood labels in a portable file restored under another key", () => {
|
||||
// Neither case logs a mood entry: an account's own tags and categories
|
||||
// travel whether or not it has entries (they used to be written only inside
|
||||
// the restore's entries branch, and a vocabulary set up before the first
|
||||
// entry did not come back).
|
||||
it("come back readable under the receiving host's key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_A, "a");
|
||||
const category = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
key: "customcat:cross-key",
|
||||
labelKey: "mood.category.custom",
|
||||
labelEncrypted: encrypt(CATEGORY_LABEL),
|
||||
},
|
||||
});
|
||||
await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: "custom:cross-key",
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "BINARY",
|
||||
labelEncrypted: encrypt(TAG_LABEL),
|
||||
},
|
||||
});
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as unknown;
|
||||
|
||||
// Host B is another database: the rows host A wrote are not there.
|
||||
useKeys(HOST_B, "b");
|
||||
await prisma.moodTag.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.moodTagCategory.deleteMany({ where: { userId: owner.id } });
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const restoredCategory = await prisma.moodTagCategory.findUniqueOrThrow({
|
||||
where: { key: "customcat:cross-key" },
|
||||
});
|
||||
expect(decrypt(restoredCategory.labelEncrypted!)).toBe(CATEGORY_LABEL);
|
||||
const restoredTag = await prisma.moodTag.findUniqueOrThrow({
|
||||
where: { key: "custom:cross-key" },
|
||||
});
|
||||
expect(decrypt(restoredTag.labelEncrypted!)).toBe(TAG_LABEL);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a legacy file's label ciphertext only where this host can open it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_B, "b");
|
||||
const ownLabel = encrypt(TAG_LABEL);
|
||||
useKeys(HOST_B_IMPOSTOR, "b");
|
||||
const impostorLabel = encrypt(CATEGORY_LABEL);
|
||||
|
||||
// The shape a portable file had before the fix: labels as ciphertext.
|
||||
useKeys(HOST_B, "b");
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
|
||||
file.customMoodTagCategories = [
|
||||
{
|
||||
id: "legacy-category-id",
|
||||
key: "customcat:legacy",
|
||||
labelKey: "mood.category.custom",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: impostorLabel,
|
||||
},
|
||||
];
|
||||
file.customMoodTags = [
|
||||
{
|
||||
key: "custom:legacy",
|
||||
labelKey: "mood.tag.custom",
|
||||
categoryId: "legacy-category-id",
|
||||
kind: "BINARY",
|
||||
isActive: true,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
labelEncrypted: ownLabel,
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
},
|
||||
];
|
||||
parseBackupPayload(JSON.stringify(file));
|
||||
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const category = await prisma.moodTagCategory.findUniqueOrThrow({
|
||||
where: { key: "customcat:legacy" },
|
||||
});
|
||||
expect(category.labelEncrypted).toBeNull();
|
||||
const tag = await prisma.moodTag.findUniqueOrThrow({
|
||||
where: { key: "custom:legacy" },
|
||||
});
|
||||
expect(decrypt(tag.labelEncrypted!)).toBe(TAG_LABEL);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
skipped: {
|
||||
links: number;
|
||||
catalogueKeys: Array<{
|
||||
catalogue: string;
|
||||
key: string;
|
||||
links: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.skipped.catalogueKeys).toEqual([
|
||||
{
|
||||
catalogue: "moodLabelCiphertext",
|
||||
key: "customMoodTagCategories.customcat:legacy.labelEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
|
||||
import {
|
||||
TWO_ENDED_MODELS,
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
@@ -3694,6 +3695,12 @@ function lostColumns(
|
||||
return { lost: lost.sort(), compared };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections a portable file may carry stored ciphertext in, each with the
|
||||
* reason. Empty: every sealed column travels readable in a portable file.
|
||||
*/
|
||||
const PORTABLE_CIPHERTEXT_ALLOWED: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
describe("every column of every two-ended model survives a real restore", () => {
|
||||
it("brings each column back with the value it left with", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -3758,6 +3765,58 @@ describe("every column of every two-ended model survives a real restore", () =>
|
||||
},
|
||||
{ purpose: "disaster-recovery", exportedAt },
|
||||
);
|
||||
|
||||
// ── No ciphertext in a portable file ──────────────────────────────────
|
||||
//
|
||||
// Every sealed column is filled at this point, so the portable file of
|
||||
// this account is the one place a column still exported as stored
|
||||
// ciphertext shows up. A portable file is read and restored on hosts
|
||||
// with other keys; ciphertext in it is noise there, and a restore either
|
||||
// refuses the file or writes values nobody can open. That class was
|
||||
// fixed one section at a time (cycle, mood labels, visits, vaccinations)
|
||||
// and kept coming back, because each builder decides its own encoding.
|
||||
// This reads what the builders actually wrote, rather than how their
|
||||
// source is spelt. Limit: it sees members named `…Encrypted` only, the
|
||||
// naming the key preflight relies on too.
|
||||
const sealedIn = (text: string) => {
|
||||
const collector = new BackupKeyIdCollector();
|
||||
collector.visit(JSON.parse(text));
|
||||
const sections = new Map<string, number>();
|
||||
for (const [, use] of collector.entries()) {
|
||||
for (const section of use.sections) {
|
||||
sections.set(section, (sections.get(section) ?? 0) + use.count);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
};
|
||||
// The floor: the same matcher over the disaster-recovery file of the same
|
||||
// account, which carries every sealed column as ciphertext. A matcher
|
||||
// that finds nothing there would make the portable check pass vacuously.
|
||||
const drSealed = sealedIn(json);
|
||||
expect(
|
||||
[...drSealed.values()].reduce((a, b) => a + b, 0),
|
||||
).toBeGreaterThanOrEqual(40);
|
||||
expect(drSealed.size).toBeGreaterThanOrEqual(15);
|
||||
let portableJson = "";
|
||||
await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER_ID,
|
||||
(chunk) => {
|
||||
portableJson += chunk;
|
||||
},
|
||||
{ purpose: "portable-export", exportedAt },
|
||||
);
|
||||
const portableSealed = [...sealedIn(portableJson).keys()].filter(
|
||||
(section) => !PORTABLE_CIPHERTEXT_ALLOWED.has(section),
|
||||
);
|
||||
expect(
|
||||
portableSealed,
|
||||
"a portable file carries these sections' sealed columns as stored " +
|
||||
"ciphertext. Carry them readable (`src/lib/export/sealed-text.ts`) " +
|
||||
"and seal them again on restore, or name the section in " +
|
||||
"PORTABLE_CIPHERTEXT_ALLOWED with the reason",
|
||||
).toEqual([]);
|
||||
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
const backup = await prisma.dataBackup.create({
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* A portable file's visit and vaccination free text survives a move to a host
|
||||
* with other keys.
|
||||
*
|
||||
* A practitioner's note, a visit's reason, outcome and body site and a
|
||||
* vaccination's note are sealed under the writing host's key. A portable file
|
||||
* used to carry them as the stored ciphertext, so a restore onto a host with
|
||||
* a different key refused the file at the key check, or with a matching key id
|
||||
* over other key material wrote values nobody could open.
|
||||
*
|
||||
* Both ends and the pipe: built by the real `buildFullBackupPayload` under key
|
||||
* A, restored through the real route under key B, opened under key B. The
|
||||
* second case is a file written before the fix: a value this host opens comes
|
||||
* back, one it cannot open stays out and is named in the skip report.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "./restore-job-driver";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
|
||||
/** Make this process the host holding exactly `keys`, writing under `active`. */
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
process.env.ENCRYPTION_KEY = "";
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
const HOST_A = { a: "11".repeat(32) };
|
||||
const HOST_B = { b: "22".repeat(32) };
|
||||
/** Same key id as host B, different key material. */
|
||||
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
|
||||
|
||||
const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
||||
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "visits-cross-key-owner",
|
||||
email: "visits-cross-key-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Store `payload` as a backup on this host and run the real restore. */
|
||||
async function restore(ownerId: string, payload: unknown) {
|
||||
const prisma = getPrismaClient();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "VISITS_CROSS_KEY_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("visit and vaccination free text restored under another key", () => {
|
||||
it("comes back readable under the receiving host's key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_A, "a");
|
||||
const practitioner = await prisma.practitioner.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
name: "Practice",
|
||||
noteEncrypted: encryptToBytes("ring the upper bell"),
|
||||
locationEncrypted: encryptToBytes("Main street 1"),
|
||||
},
|
||||
});
|
||||
const encounter = await prisma.encounter.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
occurredAt: new Date("2026-06-30T08:00:00.000Z"),
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitionerId: practitioner.id,
|
||||
reasonEncrypted: encryptToBytes("annual check"),
|
||||
outcomeEncrypted: encryptToBytes("all fine"),
|
||||
bodySiteEncrypted: encryptToBytes("left knee"),
|
||||
},
|
||||
});
|
||||
const vaccination = await prisma.vaccinationRecord.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
occurredAt: new Date("2026-05-14T00:00:00.000Z"),
|
||||
vaccineName: "Tetanus",
|
||||
noteEncrypted: encryptToBytes("sore arm for a day"),
|
||||
},
|
||||
});
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as unknown;
|
||||
|
||||
useKeys(HOST_B, "b");
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const p = await prisma.practitioner.findUniqueOrThrow({
|
||||
where: { id: practitioner.id },
|
||||
});
|
||||
expect(decryptFromBytes(p.noteEncrypted!)).toBe("ring the upper bell");
|
||||
expect(decryptFromBytes(p.locationEncrypted!)).toBe("Main street 1");
|
||||
const e = await prisma.encounter.findUniqueOrThrow({
|
||||
where: { id: encounter.id },
|
||||
});
|
||||
expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check");
|
||||
expect(decryptFromBytes(e.outcomeEncrypted!)).toBe("all fine");
|
||||
expect(decryptFromBytes(e.bodySiteEncrypted!)).toBe("left knee");
|
||||
const v = await prisma.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id: vaccination.id },
|
||||
});
|
||||
expect(decryptFromBytes(v.noteEncrypted!)).toBe("sore arm for a day");
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a legacy file's ciphertext only where this host can open it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_B, "b");
|
||||
const ownReason = b64(encryptToBytes("annual check"));
|
||||
useKeys(HOST_A, "a");
|
||||
const foreignNote = b64(encryptToBytes("written under key a"));
|
||||
useKeys(HOST_B_IMPOSTOR, "b");
|
||||
const impostorOutcome = b64(encryptToBytes("same id, other key"));
|
||||
const impostorVaccination = b64(encryptToBytes("same id, other key"));
|
||||
|
||||
// The shape a portable file had before the fix: ciphertext, no text.
|
||||
useKeys(HOST_B, "b");
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
|
||||
const at = "2026-06-30T08:00:00.000Z";
|
||||
file.practitioners = [
|
||||
{
|
||||
id: "legacy-practitioner",
|
||||
name: "Practice",
|
||||
specialty: null,
|
||||
practice: null,
|
||||
location: null,
|
||||
phone: null,
|
||||
noteEncrypted: foreignNote,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
},
|
||||
];
|
||||
file.encounters = [
|
||||
{
|
||||
id: "legacy-encounter",
|
||||
occurredAt: at,
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitionerId: "legacy-practitioner",
|
||||
reasonEncrypted: ownReason,
|
||||
outcomeEncrypted: impostorOutcome,
|
||||
bodySiteEncrypted: null,
|
||||
laterality: null,
|
||||
reminderId: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
},
|
||||
];
|
||||
file.vaccinations = [
|
||||
{
|
||||
id: "legacy-vaccination",
|
||||
occurredAt: at,
|
||||
vaccineName: "Tetanus",
|
||||
noteEncrypted: impostorVaccination,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
},
|
||||
];
|
||||
parseBackupPayload(JSON.stringify(file));
|
||||
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const p = await prisma.practitioner.findUniqueOrThrow({
|
||||
where: { id: "legacy-practitioner" },
|
||||
});
|
||||
expect(p.noteEncrypted).toBeNull();
|
||||
const e = await prisma.encounter.findUniqueOrThrow({
|
||||
where: { id: "legacy-encounter" },
|
||||
});
|
||||
expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check");
|
||||
expect(e.outcomeEncrypted).toBeNull();
|
||||
const v = await prisma.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id: "legacy-vaccination" },
|
||||
});
|
||||
expect(v.vaccineName).toBe("Tetanus");
|
||||
expect(v.noteEncrypted).toBeNull();
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
skipped: {
|
||||
links: number;
|
||||
catalogueKeys: Array<{
|
||||
catalogue: string;
|
||||
key: string;
|
||||
links: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.skipped.catalogueKeys).toEqual([
|
||||
{
|
||||
catalogue: "vaccinationCiphertext",
|
||||
key: "vaccinations.legacy-vaccination.noteEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "visitCiphertext",
|
||||
key: "encounters.legacy-encounter.outcomeEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "visitCiphertext",
|
||||
key: "practitioners.legacy-practitioner.noteEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@
|
||||
* into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled,
|
||||
* merging same-day samples into one row with first-write-wins re-import
|
||||
* - the full-backup cycle section round-trips through
|
||||
* `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted`
|
||||
* preserved verbatim and symptom links re-resolved by key
|
||||
* `buildCycleBackupSection` → `restoreCycleData` with the note carried
|
||||
* readable, re-sealed on restore, and symptom links re-resolved by key
|
||||
*/
|
||||
import { writeFileSync, mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
|
||||
import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
@@ -163,7 +164,7 @@ describe("cycle import — reproductive HK samples route to CycleDayLog", () =>
|
||||
});
|
||||
|
||||
describe("cycle backup — round-trips through build + restore", () => {
|
||||
it("preserves notesEncrypted ciphertext and symptom links by key", async () => {
|
||||
it("carries the note readable and re-seals it, with symptom links by key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const cycle = await prisma.menstrualCycle.create({
|
||||
@@ -184,7 +185,7 @@ describe("cycle backup — round-trips through build + restore", () => {
|
||||
cervixPosition: "HIGH",
|
||||
cervixFirmness: "SOFT",
|
||||
cervixOpening: "OPEN",
|
||||
notesEncrypted: "v1:abc123:cipher-envelope",
|
||||
notesEncrypted: encrypt("Cramps in the evening"),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
@@ -207,9 +208,9 @@ describe("cycle backup — round-trips through build + restore", () => {
|
||||
const section = await buildCycleBackupSection(prisma, USER_ID);
|
||||
expect(section.cycles).toHaveLength(1);
|
||||
expect(section.cycleDayLogs).toHaveLength(1);
|
||||
expect(section.cycleDayLogs[0].notesEncrypted).toBe(
|
||||
"v1:abc123:cipher-envelope",
|
||||
);
|
||||
// A portable section carries the note readable, never the ciphertext.
|
||||
expect(section.cycleDayLogs[0].note).toBe("Cramps in the evening");
|
||||
expect(section.cycleDayLogs[0]).not.toHaveProperty("notesEncrypted");
|
||||
expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]);
|
||||
|
||||
const payload = parseBackupPayload({
|
||||
@@ -234,7 +235,7 @@ describe("cycle backup — round-trips through build + restore", () => {
|
||||
include: { symptomLinks: { include: { symptom: true } } },
|
||||
});
|
||||
expect(restored).toHaveLength(1);
|
||||
expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope");
|
||||
expect(decrypt(restored[0].notesEncrypted!)).toBe("Cramps in the evening");
|
||||
expect(restored[0].cervixPosition).toBe("HIGH");
|
||||
expect(restored[0].cervixFirmness).toBe("SOFT");
|
||||
expect(restored[0].cervixOpening).toBe("OPEN");
|
||||
|
||||
@@ -343,7 +343,10 @@ describe("the backup", () => {
|
||||
const entry = section.encounters.find((row) => row.id === created.id);
|
||||
expect(entry?.kind).toBe("PROCEDURE");
|
||||
expect(entry?.laterality).toBe("BOTH");
|
||||
expect(entry?.bodySiteEncrypted).toEqual(expect.any(String));
|
||||
// A portable section carries the site readable, never the ciphertext; the
|
||||
// restore seals it under this host's key.
|
||||
expect(entry?.bodySite).toBe("Hüfte");
|
||||
expect(entry).not.toHaveProperty("bodySiteEncrypted");
|
||||
|
||||
// Through the file schema, as a restore reads it: a field the schema does
|
||||
// not know would be dropped here and come back as null.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* A medication PUT the course writer refuses changes nothing.
|
||||
*
|
||||
* The PUT checks the window against the other courses first, then writes the
|
||||
* row, then writes the course. A course written by another request between
|
||||
* the check and the course write makes the course write refuse. If the row
|
||||
* write had already committed, the client got an error while the name, the
|
||||
* schedule and `oneShot` were changed anyway: a one-time medication with a
|
||||
* window that spans more than its one day.
|
||||
*
|
||||
* The race is reproduced by letting the up-front check pass (it is the check
|
||||
* the other request outran), so the real course writer meets the conflicting
|
||||
* courses and refuses, through the shipped route and a real Postgres.
|
||||
*
|
||||
* Mutation: move the row update back out of the transaction the course write
|
||||
* runs in, and the name and `oneShot` assertions turn red.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const OWNER = "user-med-put-race";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// The check the concurrent request outran: it saw no conflict.
|
||||
vi.mock("@/lib/medications/courses", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/medications/courses")>()),
|
||||
checkCurrentWindow: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER,
|
||||
username: OWNER,
|
||||
email: `${OWNER}@example.test`,
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function req(method: string, path: string, body?: unknown) {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("a medication PUT refused by the course writer", () => {
|
||||
it("leaves the medication row, its schedule and its courses as they were", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { POST } = await import("@/app/api/medications/route");
|
||||
const created = await POST(
|
||||
req("POST", "/api/medications", {
|
||||
name: "Tamiflu",
|
||||
dose: "75 mg",
|
||||
startsOn: "2026-03-01",
|
||||
endsOn: "2026-03-07",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const { id } = (await created.json()).data as { id: string };
|
||||
|
||||
// The course the other request wrote.
|
||||
const { POST: postCourse } =
|
||||
await import("@/app/api/medications/[id]/courses/route");
|
||||
const course = await postCourse(
|
||||
req("POST", `/api/medications/${id}/courses`, {
|
||||
startsOn: "2026-06-10",
|
||||
endsOn: "2026-06-16",
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(course.status).toBe(201);
|
||||
|
||||
const before = await prisma.medication.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } },
|
||||
});
|
||||
const revisionsBefore = await prisma.medicationScheduleRevision.count({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
|
||||
// A one-time medication cannot carry two courses: the writer refuses.
|
||||
const { PUT } = await import("@/app/api/medications/[id]/route");
|
||||
const res = await PUT(
|
||||
req("PUT", `/api/medications/${id}`, {
|
||||
name: "Oseltamivir",
|
||||
oneShot: true,
|
||||
startsOn: "2026-03-05",
|
||||
schedules: [{ windowStart: "20:00", windowEnd: "21:00" }],
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).meta.errorCode).toMatch(/^medications\.course\./);
|
||||
|
||||
const after = await prisma.medication.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } },
|
||||
});
|
||||
expect(after.name).toBe("Tamiflu");
|
||||
expect(after.oneShot).toBe(false);
|
||||
expect(after.startsOn).toEqual(before.startsOn);
|
||||
expect(after.endsOn).toEqual(before.endsOn);
|
||||
expect(after.updatedAt).toEqual(before.updatedAt);
|
||||
expect(after.schedules.map((s) => s.id)).toEqual(
|
||||
before.schedules.map((s) => s.id),
|
||||
);
|
||||
expect(after.schedules.map((s) => s.windowStart)).toEqual(["08:00"]);
|
||||
expect(after.courses.map((c) => [c.startsOn, c.endsOn])).toEqual(
|
||||
before.courses.map((c) => [c.startsOn, c.endsOn]),
|
||||
);
|
||||
expect(
|
||||
await prisma.medicationScheduleRevision.count({
|
||||
where: { medicationId: id },
|
||||
}),
|
||||
).toBe(revisionsBefore);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user