mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user