mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.
- Migration 0369 (idempotent): symptom_definitions and symptom_events,
intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
eight active definitions per record (a hidden one frees its slot,
showing it again re-checks), hide or purge, events create/list/edit/
delete, a new link must name an open episode of the same record
(422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
plan; both carried at both backup ends with occurrences nested in their
definition, plaintext in a portable file and ciphertext in a recovery
file, and a link to an episode the file lacks dropped and named
(symptomEpisodeReference).
- OpenAPI regenerated.
290 lines
9.4 KiB
TypeScript
290 lines
9.4 KiB
TypeScript
/**
|
|
* Person-defined symptoms, with both backup ends in one file (v1.40).
|
|
*
|
|
* Same arrangement as `ecg-backup.ts` and `reminders-backup.ts`: a reader
|
|
* asking "is this carried at both ends?" answers it here, and a reader who
|
|
* greps only the restore route gets a false negative because the route
|
|
* delegates.
|
|
*
|
|
* ## Events ride inside their definition
|
|
*
|
|
* An occurrence means nothing without the symptom it is an occurrence of, so
|
|
* the file nests each definition's events under it, the way a medication's
|
|
* side effects ride inside the medication. A nested create binds the child to
|
|
* whatever id the parent row actually got, so there is no definition reference
|
|
* to resolve and none that can dangle.
|
|
*
|
|
* ## Free text follows the note contract
|
|
*
|
|
* A disaster-recovery payload carries the label and note ciphertext verbatim
|
|
* as base64 (the same instance's key reads it back unchanged). A portable
|
|
* export decrypts both, because a portable file exists to be readable by the
|
|
* person who owns it and to restore under another instance's key. A label this
|
|
* instance cannot decrypt travels as the visible unreadable marker rather than
|
|
* as nothing, the rule `records-backup.ts` states for notes: the definition is
|
|
* still the person's, and an empty name would read as one they never gave.
|
|
*
|
|
* ## The one reference that needs care
|
|
*
|
|
* `episodeId` addresses an illness episode and is a real foreign key. A
|
|
* portable export omits soft-deleted episodes, so an occurrence filed against
|
|
* an episode the person has since deleted names a row the file does not carry.
|
|
* The restore resolves the reference against the episodes it actually wrote
|
|
* and NULLS a miss (reported as `symptomEpisodeReference`) rather than writing
|
|
* it and rolling the whole restore back. The occurrence is the record; the
|
|
* episode link is a cross-reference.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
|
|
|
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import {
|
|
recordUnknownKeys,
|
|
type RestoreSkipLog,
|
|
} from "@/lib/export/restore-skips";
|
|
|
|
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
|
|
|
|
export interface SymptomsBackupOptions {
|
|
purpose?: "portable-export" | "disaster-recovery";
|
|
}
|
|
|
|
/** One occurrence, nested under its definition. */
|
|
export interface SymptomEventBackupEntry {
|
|
/** Present in a disaster-recovery payload. */
|
|
id?: string;
|
|
occurredAt: string;
|
|
intensity: number;
|
|
/** Plaintext; present on a portable payload. */
|
|
note?: string | null;
|
|
/** Ciphertext as base64; present on a disaster-recovery payload. */
|
|
noteEncrypted?: string | null;
|
|
episodeId: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** One definition and every occurrence of it. */
|
|
export interface SymptomDefinitionBackupEntry {
|
|
/** Present in a disaster-recovery payload. */
|
|
id?: string;
|
|
/** Plaintext; present on a portable payload. */
|
|
label?: string;
|
|
/** Ciphertext as base64; present on a disaster-recovery payload. */
|
|
labelEncrypted?: string;
|
|
icon: string | null;
|
|
sortOrder: number;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
events: SymptomEventBackupEntry[];
|
|
}
|
|
|
|
export interface SymptomsBackupSection {
|
|
symptomDefinitions: SymptomDefinitionBackupEntry[];
|
|
}
|
|
|
|
export interface SymptomsBackupCounts {
|
|
symptomDefinitions: number;
|
|
symptomEvents: number;
|
|
}
|
|
|
|
function base64(buf: Uint8Array): string {
|
|
return Buffer.from(buf).toString("base64");
|
|
}
|
|
|
|
function readTextForExport(buf: Uint8Array, field: string): string {
|
|
try {
|
|
return decryptFromBytes(buf);
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`symptom ${field} decrypt failed: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
);
|
|
return UNREADABLE_EXPORT_MARKER;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the symptoms slice of a user's full backup. The tables carry no
|
|
* tombstone, so both purposes read the same rows and differ only in how the
|
|
* free text travels.
|
|
*/
|
|
export async function buildSymptomsBackupSection(
|
|
prisma: Pick<PrismaClient, "symptomDefinition">,
|
|
userId: string,
|
|
options: SymptomsBackupOptions = {},
|
|
): Promise<SymptomsBackupSection> {
|
|
const disasterRecovery = options.purpose === "disaster-recovery";
|
|
const rows = await prisma.symptomDefinition.findMany({
|
|
where: { userId },
|
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
|
|
include: {
|
|
events: { orderBy: [{ occurredAt: "asc" }, { id: "asc" }] },
|
|
},
|
|
});
|
|
|
|
return {
|
|
symptomDefinitions: rows.map((row) => ({
|
|
...(disasterRecovery
|
|
? { id: row.id, labelEncrypted: base64(row.labelEncrypted) }
|
|
: { label: readTextForExport(row.labelEncrypted, "label") }),
|
|
icon: row.icon,
|
|
sortOrder: row.sortOrder,
|
|
isActive: row.isActive,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
events: row.events.map((event) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: event.id,
|
|
noteEncrypted: event.noteEncrypted
|
|
? base64(event.noteEncrypted)
|
|
: null,
|
|
}
|
|
: {
|
|
note: event.noteEncrypted
|
|
? readTextForExport(event.noteEncrypted, "note")
|
|
: null,
|
|
}),
|
|
occurredAt: event.occurredAt.toISOString(),
|
|
intensity: event.intensity,
|
|
episodeId: event.episodeId,
|
|
createdAt: event.createdAt.toISOString(),
|
|
updatedAt: event.updatedAt.toISOString(),
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function countSymptomsBackupSection(
|
|
section: SymptomsBackupSection,
|
|
): SymptomsBackupCounts {
|
|
return {
|
|
symptomDefinitions: section.symptomDefinitions.length,
|
|
symptomEvents: section.symptomDefinitions.reduce(
|
|
(sum, d) => sum + d.events.length,
|
|
0,
|
|
),
|
|
};
|
|
}
|
|
|
|
/** Counts the symptoms restore wiped, for the audit trail. */
|
|
export interface SymptomsRestoreCleared {
|
|
symptomDefinitions: number;
|
|
}
|
|
|
|
/**
|
|
* What the parser hands over, which is looser than what the builder writes:
|
|
* an older file carries no key at all, and optional columns arrive as
|
|
* `undefined` rather than `null`.
|
|
*/
|
|
export interface SymptomsRestoreInput {
|
|
symptomDefinitions: Array<{
|
|
id?: string | undefined;
|
|
label?: string | undefined;
|
|
labelEncrypted?: string | undefined;
|
|
icon?: string | null | undefined;
|
|
sortOrder: number;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
events: Array<{
|
|
id?: string | undefined;
|
|
occurredAt: string;
|
|
intensity: number;
|
|
note?: string | null | undefined;
|
|
noteEncrypted?: string | null | undefined;
|
|
episodeId?: string | null | undefined;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}>;
|
|
}>;
|
|
}
|
|
|
|
function decodeBytes(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 symptom definitions and their occurrences.
|
|
*
|
|
* Delete-then-recreate inside the caller's transaction, like every other
|
|
* section; deleting a definition cascades its events. MUST run AFTER the
|
|
* illness episodes are restored: `episodeIds` is the set the restore actually
|
|
* wrote, passed in rather than re-queried so this stays a pure function of the
|
|
* transaction it was handed.
|
|
*/
|
|
export async function restoreSymptomsData(
|
|
tx: Prisma.TransactionClient,
|
|
ownerId: string,
|
|
payload: SymptomsRestoreInput,
|
|
episodeIds: ReadonlySet<string>,
|
|
skips: RestoreSkipLog,
|
|
): Promise<SymptomsRestoreCleared> {
|
|
const cleared = await tx.symptomDefinition.deleteMany({
|
|
where: { userId: ownerId },
|
|
});
|
|
|
|
const droppedEpisodeRefs: string[] = [];
|
|
for (const definition of payload.symptomDefinitions) {
|
|
const labelEncrypted =
|
|
definition.labelEncrypted !== undefined
|
|
? decodeBytes(definition.labelEncrypted)
|
|
: encryptToBytes(definition.label ?? "");
|
|
await tx.symptomDefinition.create({
|
|
data: {
|
|
...(definition.id ? { id: definition.id } : {}),
|
|
userId: ownerId,
|
|
labelEncrypted,
|
|
icon: definition.icon ?? null,
|
|
sortOrder: definition.sortOrder,
|
|
isActive: definition.isActive,
|
|
createdAt: new Date(definition.createdAt),
|
|
updatedAt: new Date(definition.updatedAt),
|
|
events: {
|
|
create: definition.events.map((event) => {
|
|
let episodeId = event.episodeId ?? null;
|
|
if (episodeId && !episodeIds.has(episodeId)) {
|
|
droppedEpisodeRefs.push(episodeId);
|
|
episodeId = null;
|
|
}
|
|
return {
|
|
...(event.id ? { id: event.id } : {}),
|
|
userId: ownerId,
|
|
occurredAt: new Date(event.occurredAt),
|
|
intensity: event.intensity,
|
|
noteEncrypted:
|
|
event.noteEncrypted !== undefined
|
|
? event.noteEncrypted === null
|
|
? null
|
|
: decodeBytes(event.noteEncrypted)
|
|
: event.note
|
|
? encryptToBytes(event.note)
|
|
: null,
|
|
episodeId,
|
|
createdAt: new Date(event.createdAt),
|
|
updatedAt: new Date(event.updatedAt),
|
|
};
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
recordUnknownKeys(
|
|
skips,
|
|
"symptomEpisodeReference",
|
|
[...new Set(droppedEpisodeRefs)],
|
|
droppedEpisodeRefs,
|
|
);
|
|
|
|
return { symptomDefinitions: cleared.count };
|
|
}
|