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

+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),
});