mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(symptoms): add person-defined symptoms and timestamped occurrences
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.
This commit is contained in:
1 parent
7cd323499e
commit
d6571c4e9c
30 files changed
+3091
-16
No files matched your search
@@ -426,6 +426,7 @@ function makePrisma() {
|
||||
// an account whose watch has never taken one is the ordinary case the
|
||||
// builder must still answer for.
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
|
||||
// against these, so the assertions below exercise the builder rather than
|
||||
// a stand-in that would agree with whatever the payload happened to do.
|
||||
|
||||
@@ -312,6 +312,7 @@ function sourceClient() {
|
||||
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -95,6 +95,10 @@ export const BACKED_UP_MODELS = [
|
||||
"IllnessEpisode",
|
||||
"IllnessDayLog",
|
||||
"IllnessSymptomLink",
|
||||
// v1.40 — the person's own symptoms and every occurrence of them. Nothing
|
||||
// else holds either; a restore without them loses the history outright.
|
||||
"SymptomDefinition",
|
||||
"SymptomEvent",
|
||||
"EcgRecording",
|
||||
"UserHealthProfile",
|
||||
"HealthProfileFactRevision",
|
||||
@@ -250,6 +254,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
|
||||
// because that reference is a pointer between two independently carried
|
||||
// tables and not a parent-child ride.
|
||||
"src/lib/export/ecg-backup.ts",
|
||||
// The person's own symptoms, both ends beside each other; the occurrences
|
||||
// ride inside their definition (`include: { events }` out, `events: {
|
||||
// create }` back), the way a medication's side effects do.
|
||||
"src/lib/export/symptoms-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -268,6 +276,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
"src/lib/export/environment-backup.ts",
|
||||
"src/lib/export/onboarding-backup.ts",
|
||||
"src/lib/export/ecg-backup.ts",
|
||||
"src/lib/export/symptoms-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -308,6 +317,8 @@ export const TWO_ENDED_MODELS = [
|
||||
"IllnessEpisode",
|
||||
"IllnessDayLog",
|
||||
"IllnessSymptomLink",
|
||||
"SymptomDefinition",
|
||||
"SymptomEvent",
|
||||
"UserHealthProfile",
|
||||
"HealthProfileFactRevision",
|
||||
"CycleProfile",
|
||||
|
||||
@@ -106,6 +106,12 @@ import {
|
||||
type EcgBackupCounts,
|
||||
type EcgBackupSection,
|
||||
} from "@/lib/export/ecg-backup";
|
||||
import {
|
||||
buildSymptomsBackupSection,
|
||||
countSymptomsBackupSection,
|
||||
type SymptomsBackupCounts,
|
||||
type SymptomsBackupSection,
|
||||
} from "@/lib/export/symptoms-backup";
|
||||
import {
|
||||
buildIntradayProfileBackupSection,
|
||||
countIntradayProfileBackupSection,
|
||||
@@ -140,7 +146,8 @@ export interface FullBackupCounts
|
||||
AwardsBackupCounts,
|
||||
EnvironmentBackupCounts,
|
||||
OnboardingBackupCounts,
|
||||
EcgBackupCounts {
|
||||
EcgBackupCounts,
|
||||
SymptomsBackupCounts {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
@@ -710,6 +717,7 @@ export async function buildFullBackupPayload(
|
||||
environment,
|
||||
onboardingRecord,
|
||||
ecg,
|
||||
symptoms,
|
||||
nutrientDays,
|
||||
accountSettings,
|
||||
] = await Promise.all([
|
||||
@@ -886,6 +894,11 @@ export async function buildFullBackupPayload(
|
||||
buildEcgBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// The person's own symptoms with their occurrences nested inside. Both
|
||||
// ends live in `src/lib/export/symptoms-backup.ts`.
|
||||
buildSymptomsBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// Nutrient day totals were absent from every export path, which
|
||||
// contradicted the schema's own reason for denormalising the unit column
|
||||
// ("rows stay self-describing in exports even if the catalog ever drifts").
|
||||
@@ -939,6 +952,7 @@ export async function buildFullBackupPayload(
|
||||
const awardsSection: AwardsBackupSection = awards;
|
||||
const environmentSection: EnvironmentBackupSection = environment;
|
||||
const ecgSection: EcgBackupSection = ecg;
|
||||
const symptomsSection: SymptomsBackupSection = symptoms;
|
||||
const accountSettingsSection: AccountSettingsBackupSection = accountSettings;
|
||||
|
||||
// The three unbounded tables, either read into arrays or left as markers
|
||||
@@ -1306,6 +1320,7 @@ export async function buildFullBackupPayload(
|
||||
...awardsSection,
|
||||
...environmentSection,
|
||||
...ecgSection,
|
||||
...symptomsSection,
|
||||
nutrientDays: nutrientDays.map((n) => ({
|
||||
day: n.day,
|
||||
nutrient: n.nutrient,
|
||||
@@ -1373,6 +1388,7 @@ export async function buildFullBackupPayload(
|
||||
...countAwardsBackupSection(awards),
|
||||
...countEnvironmentBackupSection(environment),
|
||||
...countEcgBackupSection(ecg),
|
||||
...countSymptomsBackupSection(symptoms),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -106,6 +106,7 @@ import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
|
||||
import { restoreAwardsData } from "@/lib/export/awards-backup";
|
||||
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
|
||||
import { restoreEcgData } from "@/lib/export/ecg-backup";
|
||||
import { restoreSymptomsData } from "@/lib/export/symptoms-backup";
|
||||
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
|
||||
import {
|
||||
ensureMedicationCategoryTable,
|
||||
@@ -179,6 +180,7 @@ export interface RestoreResponse {
|
||||
environmentContexts: number;
|
||||
environmentTravelLocations: number;
|
||||
ecgRecordings: number;
|
||||
symptomDefinitions: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2079,6 +2081,18 @@ export async function restoreBackup(
|
||||
}
|
||||
}
|
||||
|
||||
// The person's own symptoms and their occurrences. After the episodes,
|
||||
// because an occurrence may point at one; the id set is the one this
|
||||
// branch just wrote. Both ends live in
|
||||
// `src/lib/export/symptoms-backup.ts`.
|
||||
const symptomsCleared = await restoreSymptomsData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
episodeIds,
|
||||
skips,
|
||||
);
|
||||
|
||||
reportSection("allergies");
|
||||
for (const allergy of payload.allergies) {
|
||||
await tx.allergy.create({
|
||||
@@ -2460,6 +2474,7 @@ export async function restoreBackup(
|
||||
environmentTravelLocations:
|
||||
environmentCleared.environmentTravelLocations,
|
||||
ecgRecordings: ecgCleared.ecgRecordings,
|
||||
symptomDefinitions: symptomsCleared.symptomDefinitions,
|
||||
};
|
||||
assertNoNewForeignReferences(
|
||||
foreignBefore,
|
||||
|
||||
@@ -146,6 +146,12 @@
|
||||
* know, a threshold out of range, an avatar the upload would refuse. Its key
|
||||
* is the column (or `column.part` for one band or one site), never the value.
|
||||
* The account keeps what it had for that setting; everything else comes back.
|
||||
*
|
||||
* The fifteenth, `symptomEpisodeReference`, names the illness episode a
|
||||
* symptom occurrence was filed against that the restore did not put back. A
|
||||
* 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.
|
||||
*/
|
||||
export type SkippedCatalogue =
|
||||
| "cycleSymptom"
|
||||
@@ -165,7 +171,8 @@ export type SkippedCatalogue =
|
||||
| "medicationTarget"
|
||||
| "scheduleRevisionLink"
|
||||
| "checkupClosure"
|
||||
| "accountSetting";
|
||||
| "accountSetting"
|
||||
| "symptomEpisodeReference";
|
||||
|
||||
/** One key this instance does not know, and the links it cost. */
|
||||
export interface SkippedCatalogueKey {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in new issue
Block a user