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

+3
View File
@@ -310,6 +310,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// (`laterality`) stays plaintext, as on `Encounter`.
{ model: "IllnessEpisode", field: "bodySiteEncrypted", kind: "bytes" },
{ model: "IllnessDayLog", field: "noteEncrypted", kind: "bytes" },
// v1.40 — the person's own symptom names and the note on one occurrence.
{ model: "SymptomDefinition", field: "labelEncrypted", kind: "bytes" },
{ model: "SymptomEvent", field: "noteEncrypted", kind: "bytes" },
// ───── v1.19.0 ECG waveform (Bytes column) ─────
{ model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" },
+3
View File
@@ -123,6 +123,8 @@ export const WIPE_MODELS = [
"Allergy",
"FamilyHistoryEntry",
"IllnessDayLog",
// Before its definition and before the episode it may point at (SetNull).
"SymptomEvent",
"EcgRecording",
"NutrientIntakeDay",
@@ -251,6 +253,7 @@ export const WIPE_MODELS = [
"MoodTagCategory",
"CustomMetric",
"IllnessEpisode",
"SymptomDefinition",
"Workout",
"InboundDocument",
// Encounters point here with SetNull, so the address book outlives the
@@ -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 };
}
+7
View File
@@ -346,6 +346,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"source-priority.body.invalid_json",
"source-priority.body.invalid_shape",
],
symptoms: [
"symptoms.definition.limitReached",
"symptoms.definition.notFound",
"symptoms.episode.notOpen",
"symptoms.event.notFound",
"symptoms.invalid",
],
tokens: [
"tokens.documents.ceiling_reached",
"tokens.measurements.ceiling_reached",
+4
View File
@@ -84,6 +84,7 @@ import { profilePaths } from "./profile";
import { recordSettingsPaths } from "./record-settings";
import { retiredPaths } from "./retired";
import { settingsPaths } from "./settings";
import { symptomPaths } from "./symptoms";
import { syncPaths } from "./sync";
import { workoutPaths } from "./workouts";
import { coachPrefsSchema } from "./shared";
@@ -177,6 +178,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// Papra, searching it and importing picked documents; cookie-only
// (appended, spread order is load-bearing).
...documentSourcePaths,
// v1.40 — person-defined symptoms and their occurrences, behind the illness
// module (appended, spread order is load-bearing).
...symptomPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
+371
View File
@@ -0,0 +1,371 @@
/**
* OpenAPI route table for person-defined symptoms (`/api/symptoms`, v1.40).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
* and queries reuse the runtime Zod schemas from `@/lib/symptoms/shared` so the
* wire contract stays single-source; response shapes mirror the DTOs the
* routes serialise (decrypted label and note, the 30-day summary).
*
* Every route rides the illness module: an account that turned it off (or an
* operator-disabled instance) answers 403 `illness.disabled` even to a valid
* Bearer token.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
symptomDefinitionCreateSchema,
symptomDefinitionUpdateSchema,
symptomEventCreateSchema,
symptomEventListQuerySchema,
symptomEventUpdateSchema,
} from "@/lib/symptoms/shared";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
const MODULE_OFF =
"`illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module.";
const createSymptomDefinitionRequest = symptomDefinitionCreateSchema.meta({
id: "CreateSymptomDefinitionRequest",
description:
"Define a symptom of your own. `label` (1-40 characters) is encrypted at rest; `icon` is one of the cycle custom-symptom icon names, null for the default glyph. At most eight active definitions per account (422 `symptoms.definition.limitReached`).",
});
const updateSymptomDefinitionRequest = symptomDefinitionUpdateSchema.meta({
id: "UpdateSymptomDefinitionRequest",
description:
"Partial edit; an omitted key leaves the column untouched. `isActive: false` hides the symptom (history kept, slot freed); `isActive: true` shows it again and re-checks the cap.",
});
const createSymptomEventRequest = symptomEventCreateSchema.meta({
id: "CreateSymptomEventRequest",
description:
"Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own illness episodes that is still open (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest.",
});
const updateSymptomEventRequest = symptomEventUpdateSchema.meta({
id: "UpdateSymptomEventRequest",
description:
"Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept.",
});
const listSymptomEventsQuery = symptomEventListQuerySchema.meta({
id: "ListSymptomEventsQuery",
description:
"Optional `from` / `to` (ISO instants, inclusive), `definitionId`, `episodeId`, and `limit` (1-500, default 200). Newest first.",
});
const symptomDefinition = z
.object({
id: z.string(),
label: z.string().nullable(),
icon: z.string().nullable(),
sortOrder: z.number().int(),
isActive: z.boolean(),
recent: z.object({
count30d: z.number().int(),
maxIntensity30d: z.number().int().nullable(),
lastOccurredAt: z.string().nullable(),
}),
})
.meta({
id: "SymptomDefinition",
description:
"A symptom the person defined. `label` is decrypted (null only on an unreadable value). `recent` is computed server-side: occurrences and the highest intensity over the last 30 days, and the last occurrence ever. Each active definition is also the correlation channel `SYMPTOM:<id>`.",
});
const symptomDefinitionList = z
.object({
definitions: z.array(symptomDefinition),
limit: z.number().int(),
})
.meta({
id: "SymptomDefinitionList",
description:
"The record's symptom definitions in display order, and the active-definition cap.",
});
const symptomEvent = z
.object({
id: z.string(),
definitionId: z.string(),
occurredAt: z.string(),
intensity: z.number().int(),
note: z.string().nullable(),
episodeId: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "SymptomEvent",
description:
"One occurrence of a defined symptom: when, how strong (0-10), the decrypted note, and the illness episode it was filed against (null when none).",
});
const symptomEventList = z
.object({
events: z.array(symptomEvent),
limit: z.number().int(),
hasMore: z.boolean(),
})
.meta({
id: "SymptomEventList",
description:
"A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the oldest `occurredAt` seen.",
});
const deletedResponse = z
.object({
deleted: z.literal(true),
purged: z.boolean(),
eventsDeleted: z.number().int(),
})
.meta({
id: "SymptomDefinitionDeleted",
description:
"`purged: false` means the definition was hidden and its history kept; `purged: true` means it and `eventsDeleted` occurrences were removed.",
});
const pathId = { path: z.object({ id: z.string() }) };
const definitionNotFound = {
"404": {
description:
"`symptoms.definition.notFound`: no definition with this id on the record.",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
const eventNotFound = {
"404": {
description:
"`symptoms.event.notFound`: no occurrence with this id on the record.",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
export const symptomPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/symptoms/definitions": {
get: {
tags: ["Symptoms"],
summary: "List your own symptoms (v1.40)",
description:
"The record's active symptom definitions with their 30-day summary; `includeHidden=true` adds the hidden ones. Rides the illness module.",
requestParams: {
query: z.object({
includeHidden: z.enum(["true", "false"]).optional(),
}),
},
responses: {
...recordRefusal(MODULE_OFF),
"200": {
description: "The definitions.",
content: {
"application/json": {
schema: dataEnvelope(
symptomDefinitionList,
"ListSymptomDefinitionsEnvelope",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Symptoms"],
summary: "Define a symptom (v1.40)",
description:
"Creates one definition. Idempotent under `Idempotency-Key`. Audits as `symptoms.definition.create` without the label. Rate-limited to 30 a minute.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
"application/json": { schema: createSymptomDefinitionRequest },
},
},
responses: {
...idempotentWrite(),
...recordRefusal(MODULE_OFF),
"201": {
description: "Definition created.",
content: {
"application/json": {
schema: dataEnvelope(
symptomDefinition,
"CreateSymptomDefinitionEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/symptoms/definitions/{id}": {
patch: {
tags: ["Symptoms"],
summary: "Edit, hide or show a symptom (v1.40)",
description:
"Partial edit. Audits as `symptoms.definition.update`, naming a label change without quoting it.",
requestParams: pathId,
requestBody: {
required: true,
content: {
"application/json": { schema: updateSymptomDefinitionRequest },
},
},
responses: {
...recordRefusal(MODULE_OFF),
"200": {
description: "Definition updated.",
content: {
"application/json": {
schema: dataEnvelope(
symptomDefinition,
"UpdateSymptomDefinitionEnvelope",
),
},
},
},
...definitionNotFound,
...stdResponses,
},
},
delete: {
tags: ["Symptoms"],
summary: "Hide or delete a symptom (v1.40)",
description:
"Hides the definition and keeps its history. `?purge=true` deletes it and every occurrence of it.",
requestParams: {
...pathId,
query: z.object({ purge: z.enum(["true", "false"]).optional() }),
},
responses: {
...recordRefusal(MODULE_OFF),
"200": {
description: "Hidden or deleted.",
content: {
"application/json": {
schema: dataEnvelope(
deletedResponse,
"DeleteSymptomDefinitionEnvelope",
),
},
},
},
...definitionNotFound,
...stdResponses,
},
},
},
"/api/symptoms/events": {
get: {
tags: ["Symptoms"],
summary: "List symptom occurrences (v1.40)",
description:
"Occurrences of the record's symptoms, newest first, narrowed by the optional query.",
requestParams: { query: listSymptomEventsQuery },
responses: {
...recordRefusal(MODULE_OFF),
"200": {
description: "A page of occurrences.",
content: {
"application/json": {
schema: dataEnvelope(
symptomEventList,
"ListSymptomEventsEnvelope",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Symptoms"],
summary: "Log a symptom occurrence (v1.40)",
description:
"Creates one occurrence. Idempotent under `Idempotency-Key`. A hidden definition can still be logged against; another record's definition 404s (`symptoms.definition.notFound`). Audits as `symptoms.event.create` without the note.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
"application/json": { schema: createSymptomEventRequest },
},
},
responses: {
...idempotentWrite(),
...recordRefusal(MODULE_OFF),
"201": {
description: "Occurrence logged.",
content: {
"application/json": {
schema: dataEnvelope(symptomEvent, "CreateSymptomEventEnvelope"),
},
},
},
...definitionNotFound,
...stdResponses,
},
},
},
"/api/symptoms/events/{id}": {
patch: {
tags: ["Symptoms"],
summary: "Edit a symptom occurrence (v1.40)",
description:
"Partial edit. Audits as `symptoms.event.update`, naming a note change without quoting it.",
requestParams: pathId,
requestBody: {
required: true,
content: {
"application/json": { schema: updateSymptomEventRequest },
},
},
responses: {
...recordRefusal(MODULE_OFF),
"200": {
description: "Occurrence updated.",
content: {
"application/json": {
schema: dataEnvelope(symptomEvent, "UpdateSymptomEventEnvelope"),
},
},
},
...eventNotFound,
...stdResponses,
},
},
delete: {
tags: ["Symptoms"],
summary: "Delete a symptom occurrence (v1.40)",
description: "Removes the occurrence. Audits as `symptoms.event.delete`.",
requestParams: pathId,
responses: {
...recordRefusal(MODULE_OFF),
"200": {
description: "Deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.literal(true) }),
"DeleteSymptomEventEnvelope",
),
},
},
},
...eventNotFound,
...stdResponses,
},
},
},
};
+12
View File
@@ -72,6 +72,8 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
case "illness.episode.create":
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
case "symptoms.event.create":
return t("recordSharing.activityVerb.symptomEventCreate", { name });
case "encounter.visit.create":
return t("recordSharing.activityVerb.encounterVisitCreate", { name });
case "vaccination.record.create":
@@ -193,6 +195,16 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.illnessEpisodeRestore", { name });
case "illness.day-log.upsert":
return t("recordSharing.activityVerb.illnessDayLogUpsert", { name });
case "symptoms.definition.create":
return t("recordSharing.activityVerb.symptomDefinitionCreate", { name });
case "symptoms.definition.update":
return t("recordSharing.activityVerb.symptomDefinitionUpdate", { name });
case "symptoms.definition.delete":
return t("recordSharing.activityVerb.symptomDefinitionDelete", { name });
case "symptoms.event.update":
return t("recordSharing.activityVerb.symptomEventUpdate", { name });
case "symptoms.event.delete":
return t("recordSharing.activityVerb.symptomEventDelete", { name });
case "encounter.visit.update":
return t("recordSharing.activityVerb.encounterVisitUpdate", { name });
+154
View File
@@ -0,0 +1,154 @@
/**
* Person-defined symptoms (v1.40): the server-only half. Label and note
* crypto, the row → wire mappers, the 30-day header summary and the one check
* that decides whether an event may be filed against an episode.
*
* Free text (the symptom's name, the note on one occurrence) is held
* AES-256-GCM encrypted in Bytes columns through the shared codec, the same
* codec the illness episode note uses, and decrypted fail-soft on read: a key
* gap on one row reads `null` rather than taking the page down.
*/
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type {
SymptomDefinition,
SymptomEvent,
} from "@/generated/prisma/client";
import type { SymptomDefinitionDTO, SymptomEventDTO } from "./shared";
export * from "./shared";
/** Encrypt a symptom label or event note for storage. */
export function encryptSymptomText(plaintext: string): Uint8Array<ArrayBuffer> {
return encryptToBytes(plaintext);
}
/** Decrypt a stored label or note; null on a missing or unreadable value. */
export function decryptSymptomText(
buf: Uint8Array | null,
field: "label" | "note",
): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptFromBytes(buf);
} catch (err) {
getEvent()?.addWarning(
`symptom ${field} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
/** Header summary of one definition over the trailing window. */
export interface SymptomRecentSummary {
count30d: number;
maxIntensity30d: number | null;
lastOccurredAt: string | null;
}
const EMPTY_RECENT: SymptomRecentSummary = {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
};
export function toSymptomDefinitionDTO(
row: Pick<
SymptomDefinition,
"id" | "labelEncrypted" | "icon" | "sortOrder" | "isActive"
>,
recent: SymptomRecentSummary = EMPTY_RECENT,
): SymptomDefinitionDTO {
return {
id: row.id,
label: decryptSymptomText(row.labelEncrypted, "label"),
icon: row.icon,
sortOrder: row.sortOrder,
isActive: row.isActive,
recent,
};
}
export function toSymptomEventDTO(row: SymptomEvent): SymptomEventDTO {
return {
id: row.id,
definitionId: row.definitionId,
occurredAt: row.occurredAt.toISOString(),
intensity: row.intensity,
note: decryptSymptomText(row.noteEncrypted, "note"),
episodeId: row.episodeId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Every definition of the account with its 30-day summary, ordered for the
* chip row. One grouped aggregate for the counts and maxima, one for the last
* occurrence over all time (a symptom last felt two months ago still says so).
*/
export async function listSymptomDefinitions(
userId: string,
options: { includeHidden: boolean; now?: Date },
): Promise<SymptomDefinitionDTO[]> {
const now = options.now ?? new Date();
const since = new Date(now.getTime() - 30 * DAY_MS);
const [rows, recent, last] = await Promise.all([
prisma.symptomDefinition.findMany({
where: { userId, ...(options.includeHidden ? {} : { isActive: true }) },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: {
id: true,
labelEncrypted: true,
icon: true,
sortOrder: true,
isActive: true,
},
}),
prisma.symptomEvent.groupBy({
by: ["definitionId"],
where: { userId, occurredAt: { gte: since, lte: now } },
_count: { _all: true },
_max: { intensity: true },
}),
prisma.symptomEvent.groupBy({
by: ["definitionId"],
where: { userId },
_max: { occurredAt: true },
}),
]);
const recentById = new Map(recent.map((r) => [r.definitionId, r]));
const lastById = new Map(last.map((r) => [r.definitionId, r._max]));
return rows.map((row) => {
const r = recentById.get(row.id);
const lastAt = lastById.get(row.id)?.occurredAt ?? null;
return toSymptomDefinitionDTO(row, {
count30d: r?._count._all ?? 0,
maxIntensity30d: r?._max.intensity ?? null,
lastOccurredAt: lastAt ? lastAt.toISOString() : null,
});
});
}
/**
* Whether `episodeId` names an episode an event of `userId` may be filed
* against: the caller's own, not deleted, and still open. A resolved episode
* is history; filing a new occurrence into it would rewrite what the person
* recorded about how it ended.
*/
export async function isLinkableEpisode(
userId: string,
episodeId: string,
): Promise<boolean> {
const episode = await prisma.illnessEpisode.findFirst({
where: { id: episodeId, userId, deletedAt: null, resolvedAt: null },
select: { id: true },
});
return episode !== null;
}
+154
View File
@@ -0,0 +1,154 @@
import { z } from "zod/v4";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
* Person-defined symptoms (v1.40): client-safe constants, wire types and the
* request schemas. Server-only helpers (label crypto, DTO mappers) live in
* `./server.ts`, so the quick-entry sheet can import this file without pulling
* `node:crypto` into the browser bundle.
*
* A definition is the person's own name for a symptom ("aura", "headache"); an
* event is one occurrence of it with a 0-10 intensity, the same numeric rating
* scale the pain score uses. Both ride the `illness` module.
*/
/**
* Active definitions per account. The same eight the custom-metric channels
* are capped at, and for the same reason: every active definition is its own
* correlation channel, so the cap bounds the discovery matrix as well as the
* chip row. A hidden definition does not count.
*/
export const MAX_ACTIVE_SYMPTOM_DEFINITIONS = 8;
/** The intensity scale: 0 none, 10 the worst imaginable (NRS). */
export const SYMPTOM_INTENSITY_MIN = 0;
export const SYMPTOM_INTENSITY_MAX = 10;
/** Upper bound on one events page. */
export const MAX_SYMPTOM_EVENTS_PAGE = 500;
/**
* Icon names a definition may carry: the cycle custom-symptom allowlist, which
* the iOS client already maps to SF Symbols.
*/
export const SYMPTOM_ICON_ALLOWLIST = CUSTOM_SYMPTOM_ICON_ALLOWLIST;
export type SymptomIconName = (typeof SYMPTOM_ICON_ALLOWLIST)[number];
const ICON_SET = new Set<string>(SYMPTOM_ICON_ALLOWLIST);
/** Correlation channel key prefix: `SYMPTOM:<definitionId>`. */
export const SYMPTOM_CHANNEL_PREFIX = "SYMPTOM:";
/** Wire shape of one definition. */
export interface SymptomDefinitionDTO {
id: string;
/** Decrypted; null only when the stored label cannot be read. */
label: string | null;
icon: string | null;
sortOrder: number;
isActive: boolean;
/** Server-computed over the last 30 days, for the section header line. */
recent: {
count30d: number;
maxIntensity30d: number | null;
lastOccurredAt: string | null;
};
}
/** Wire shape of one event. */
export interface SymptomEventDTO {
id: string;
definitionId: string;
occurredAt: string;
intensity: number;
note: string | null;
episodeId: string | null;
createdAt: string;
updatedAt: string;
}
const labelSchema = z
.string()
.trim()
.min(1, "Label must not be empty")
.max(40, "Label must be at most 40 characters");
const iconSchema = z
.string()
.refine((v) => ICON_SET.has(v), "Unknown icon")
.nullish();
export const symptomDefinitionCreateSchema = z.object({
label: labelSchema,
icon: iconSchema,
});
export const symptomDefinitionUpdateSchema = z
.object({
label: labelSchema.optional(),
icon: iconSchema,
isActive: z.boolean().optional(),
sortOrder: z.number().int().min(0).max(1000).optional(),
})
.refine(
(v) =>
v.label !== undefined ||
v.icon !== undefined ||
v.isActive !== undefined ||
v.sortOrder !== undefined,
"At least one field is required",
);
const intensitySchema = z
.number()
.int()
.min(SYMPTOM_INTENSITY_MIN)
.max(SYMPTOM_INTENSITY_MAX);
const occurredAtSchema = validateEntryInstant(
z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.pipe(z.date()),
);
export const symptomEventCreateSchema = z.object({
definitionId: z.string().min(1).max(40),
/** Defaults to now. Not in the future beyond the shared 5-minute skew. */
occurredAt: occurredAtSchema.optional(),
intensity: intensitySchema,
note: z.string().trim().max(500).nullish(),
/** Must be one of the caller's own episodes that is still open. */
episodeId: z.string().min(1).max(40).nullish(),
});
export const symptomEventUpdateSchema = z
.object({
occurredAt: occurredAtSchema.optional(),
intensity: intensitySchema.optional(),
note: z.string().trim().max(500).nullish(),
episodeId: z.string().min(1).max(40).nullish(),
})
.refine(
(v) =>
v.occurredAt !== undefined ||
v.intensity !== undefined ||
v.note !== undefined ||
v.episodeId !== undefined,
"At least one field is required",
);
export const symptomEventListQuerySchema = z.object({
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
definitionId: z.string().min(1).max(40).optional(),
episodeId: z.string().min(1).max(40).optional(),
limit: z.coerce
.number()
.int()
.min(1)
.max(MAX_SYMPTOM_EVENTS_PAGE)
.default(200),
});
+40
View File
@@ -1102,6 +1102,44 @@ const illnessEpisodeBackupSchema = z
})
.passthrough();
/**
* A person-defined symptom (v1.40) with every occurrence of it nested inside,
* the way a medication carries its side effects. `label` / `labelEncrypted`
* and `note` / `noteEncrypted` are the two ends of the free-text contract: a
* portable file carries the plaintext, a disaster-recovery file the ciphertext.
* An occurrence's `episodeId` is the exported episode's own id; the restore
* nulls one that names an episode the file does not carry.
*/
const symptomEventBackupSchema = z
.object({
id: z.string().min(1).optional(),
occurredAt: isoDateTime,
intensity: z.number().int().min(0).max(10),
note: z.string().nullable().optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
episodeId: z.string().min(1).nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
})
.passthrough();
const symptomDefinitionBackupSchema = z
.object({
id: z.string().min(1).optional(),
label: z.string().min(1).optional(),
labelEncrypted: base64BytesSchema.optional(),
icon: z.string().nullable().optional(),
sortOrder: z.number().int(),
isActive: z.boolean(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
events: z.array(symptomEventBackupSchema).default([]),
})
.passthrough()
.refine((d) => d.label !== undefined || d.labelEncrypted !== undefined, {
message: "A symptom definition needs a label or its ciphertext",
});
/**
* A practitioner, an encounter, and the edges between an encounter and the
* things it produced.
@@ -1853,6 +1891,8 @@ export const backupPayloadSchema = z
labResults: z.array(labResultBackupSchema).default([]),
biomarkers: z.array(biomarkerBackupSchema).default([]),
illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]),
// Defaulted: a file written before v1.40 carries no key.
symptomDefinitions: z.array(symptomDefinitionBackupSchema).default([]),
allergies: z.array(allergyBackupSchema).default([]),
familyHistory: z.array(familyHistoryBackupSchema).default([]),
workouts: z.array(workoutBackupSchema).default([]),