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:
Marc-André Bombeck committed 2026-10-03 21:13:45 +02:00
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([
{
+11
View File
@@ -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",
+17 -1
View File
@@ -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),
},
};
}
+15
View File
@@ -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,
+8 -1
View File
@@ -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 {
+289
View File
@@ -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 };
}