mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
Reference in new issue
Block a user