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

@@ -0,0 +1,232 @@
/**
* One person-defined symptom (v1.40).
*
* PATCH /api/symptoms/definitions/{id} — rename, change the icon, reorder,
* hide or show again. Showing a hidden one re-clears the cap.
* DELETE /api/symptoms/definitions/{id} — hide by default (history kept);
* `?purge=true` deletes the definition and every event of it.
*
* Owner-scoped: another account's id is a 404. Module-gated on the record.
* The label is never logged.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import {
encryptSymptomText,
symptomDefinitionUpdateSchema,
toSymptomDefinitionDTO,
MAX_ACTIVE_SYMPTOM_DEFINITIONS,
} from "@/lib/symptoms/server";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
function notFound(): Response {
return apiError("Symptom not found", 404, {
errorCode: "symptoms.definition.notFound",
});
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user, actor } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`symptoms:definition:${actor.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = symptomDefinitionUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "symptoms.invalid",
});
}
const existing = await prisma.symptomDefinition.findFirst({
where: { id, userId: user.id },
select: { id: true, icon: true, isActive: true, sortOrder: true },
});
if (!existing) return notFound();
// A hidden definition does not hold a slot; showing it again takes one.
if (parsed.data.isActive === true && !existing.isActive) {
const activeCount = await prisma.symptomDefinition.count({
where: { userId: user.id, isActive: true },
});
if (activeCount >= MAX_ACTIVE_SYMPTOM_DEFINITIONS) {
return apiError(
`Symptom limit reached (${MAX_ACTIVE_SYMPTOM_DEFINITIONS})`,
422,
{
errorCode: "symptoms.definition.limitReached",
limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS,
},
);
}
}
const updated = await prisma.symptomDefinition.update({
where: { id: existing.id },
data: {
...(parsed.data.label !== undefined
? { labelEncrypted: encryptSymptomText(parsed.data.label) }
: {}),
...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}),
...(parsed.data.isActive !== undefined
? { isActive: parsed.data.isActive }
: {}),
...(parsed.data.sortOrder !== undefined
? { sortOrder: parsed.data.sortOrder }
: {}),
},
select: {
id: true,
labelEncrypted: true,
icon: true,
sortOrder: true,
isActive: true,
},
});
await auditLog("symptoms.definition.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
definitionId: id,
...overwriteDetails({
before: {
icon: existing.icon,
isActive: existing.isActive,
sortOrder: existing.sortOrder,
},
after: {
icon: updated.icon,
isActive: updated.isActive,
sortOrder: updated.sortOrder,
},
redacted: parsed.data.label !== undefined ? ["label"] : [],
}),
},
});
// Hiding or showing one changes the correlation channel set and the
// Coach's symptom block.
if (parsed.data.isActive !== undefined)
invalidateUserHealthContext(user.id);
annotate({
action: {
name: "symptoms.definition.update",
entity_type: "symptom_definition",
entity_id: id,
},
});
return apiSuccess(toSymptomDefinitionDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user, actor } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`symptoms:definition:${actor.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const { id } = await params;
const existing = await prisma.symptomDefinition.findFirst({
where: { id, userId: user.id },
select: {
id: true,
createdAt: true,
_count: { select: { events: true } },
},
});
if (!existing) return notFound();
const purge = request.nextUrl.searchParams.get("purge") === "true";
if (purge) {
// The FK cascade removes the events with it.
await prisma.symptomDefinition.delete({ where: { id: existing.id } });
} else {
await prisma.symptomDefinition.update({
where: { id: existing.id },
data: { isActive: false },
});
}
await auditLog("symptoms.definition.delete", {
userId: user.id,
ipAddress: getClientIp(request),
// C3 on the purge arm: the definition and its events are gone, so the
// audit row names what went. The label stays out (the person's words).
details: purge
? destroyedDetails({
model: "SymptomDefinition",
id,
label: null,
effectiveAt: existing.createdAt,
extra: { eventsDeleted: existing._count.events },
})
: { definitionId: id, purge },
});
invalidateUserHealthContext(user.id);
annotate({
action: {
name: "symptoms.definition.delete",
entity_type: "symptom_definition",
entity_id: id,
},
meta: { purge },
});
return apiSuccess({
deleted: true,
purged: purge,
eventsDeleted: purge ? existing._count.events : 0,
});
},
);
+139
View File
@@ -0,0 +1,139 @@
/**
* Person-defined symptoms (v1.40).
*
* GET /api/symptoms/definitions — the record's own symptom list with a
* 30-day summary per symptom. `?includeHidden=true` adds hidden ones.
* POST /api/symptoms/definitions — define one (`{ label, icon? }`). At most
* eight active definitions; the label is encrypted at rest.
*
* Rides the illness module: an account (or instance) with it off answers 403
* `illness.disabled` even to a valid Bearer token. The label is the person's
* own words for a health complaint, so it never reaches an audit row or a
* wide event.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import {
encryptSymptomText,
listSymptomDefinitions,
symptomDefinitionCreateSchema,
toSymptomDefinitionDTO,
MAX_ACTIVE_SYMPTOM_DEFINITIONS,
} from "@/lib/symptoms/server";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const includeHidden =
request.nextUrl.searchParams.get("includeHidden") === "true";
const definitions = await listSymptomDefinitions(user.id, { includeHidden });
annotate({
action: { name: "symptoms.definition.list" },
meta: { count: definitions.length, includeHidden },
});
return apiSuccess({ definitions, limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS });
});
/**
* Wrapped in `withIdempotency`: the label is ciphertext under a fresh IV, so
* no unique index can catch a replayed create, and a retry after a lost
* response would otherwise mint a second "aura" and burn a slot of eight.
*/
export const POST = apiHandler(withIdempotency<[NextRequest]>(postDefinition));
async function postDefinition(request: NextRequest): Promise<Response> {
// MANAGE, like the cycle module's own symptom list: the vocabulary of a
// record is the owner's, a delegate who may log an occurrence picks from it.
const { user, actor } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`symptoms:definition:${actor.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = symptomDefinitionCreateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "symptoms.invalid",
});
}
const activeCount = await prisma.symptomDefinition.count({
where: { userId: user.id, isActive: true },
});
if (activeCount >= MAX_ACTIVE_SYMPTOM_DEFINITIONS) {
return apiError(
`Symptom limit reached (${MAX_ACTIVE_SYMPTOM_DEFINITIONS})`,
422,
{
errorCode: "symptoms.definition.limitReached",
limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS,
},
);
}
const created = await prisma.symptomDefinition.create({
data: {
userId: user.id,
labelEncrypted: encryptSymptomText(parsed.data.label),
icon: parsed.data.icon ?? null,
sortOrder: activeCount,
},
select: {
id: true,
labelEncrypted: true,
icon: true,
sortOrder: true,
isActive: true,
},
});
await auditLog("symptoms.definition.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { definitionId: created.id, icon: created.icon },
});
annotate({
action: {
name: "symptoms.definition.create",
entity_type: "symptom_definition",
entity_id: created.id,
},
meta: { icon: created.icon },
});
return apiSuccess(toSymptomDefinitionDTO(created), 201);
}
+192
View File
@@ -0,0 +1,192 @@
/**
* One occurrence of a person-defined symptom (v1.40).
*
* PATCH /api/symptoms/events/{id} — change the time, intensity, note or
* episode link. A new link must name an open episode of the record;
* an unchanged link to an episode that has since resolved is kept.
* DELETE /api/symptoms/events/{id} — remove it.
*
* Owner-scoped (another account's id is a 404), module-gated on the record,
* MANAGE on a shared record. The note is never logged.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import {
encryptSymptomText,
isLinkableEpisode,
symptomEventUpdateSchema,
toSymptomEventDTO,
} from "@/lib/symptoms/server";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
function notFound(): Response {
return apiError("Symptom event not found", 404, {
errorCode: "symptoms.event.notFound",
});
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = symptomEventUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "symptoms.invalid",
});
}
const entry = parsed.data;
const existing = await prisma.symptomEvent.findFirst({
where: { id, userId: user.id },
select: {
id: true,
occurredAt: true,
intensity: true,
episodeId: true,
},
});
if (!existing) return notFound();
if (
entry.episodeId &&
entry.episodeId !== existing.episodeId &&
!(await isLinkableEpisode(user.id, entry.episodeId))
) {
return apiError("Episode is not open", 422, {
errorCode: "symptoms.episode.notOpen",
});
}
const updated = await prisma.symptomEvent.update({
where: { id: existing.id },
data: {
...(entry.occurredAt !== undefined
? { occurredAt: entry.occurredAt }
: {}),
...(entry.intensity !== undefined
? { intensity: entry.intensity }
: {}),
...(entry.note !== undefined
? {
noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null,
}
: {}),
...(entry.episodeId !== undefined
? { episodeId: entry.episodeId ?? null }
: {}),
},
});
await auditLog("symptoms.event.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
eventId: id,
...overwriteDetails({
before: {
occurredAt: existing.occurredAt,
intensity: existing.intensity,
episodeId: existing.episodeId,
},
after: {
occurredAt: updated.occurredAt,
intensity: updated.intensity,
episodeId: updated.episodeId,
},
redacted: entry.note !== undefined ? ["note"] : [],
}),
},
});
invalidateUserHealthContext(user.id);
annotate({
action: {
name: "symptoms.event.update",
entity_type: "symptom_event",
entity_id: id,
},
});
return apiSuccess(toSymptomEventDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const existing = await prisma.symptomEvent.findFirst({
where: { id, userId: user.id },
select: {
id: true,
definitionId: true,
occurredAt: true,
intensity: true,
},
});
if (!existing) return notFound();
await prisma.symptomEvent.delete({ where: { id: existing.id } });
await auditLog("symptoms.event.delete", {
userId: user.id,
ipAddress: getClientIp(request),
// C3: the row is gone after this; the audit row says what it was. The
// symptom's name is the person's own words and stays out of it, so the
// label is the definition id.
details: destroyedDetails({
model: "SymptomEvent",
id,
label: existing.definitionId,
effectiveAt: existing.occurredAt,
extra: { intensity: existing.intensity },
}),
});
invalidateUserHealthContext(user.id);
annotate({
action: {
name: "symptoms.event.delete",
entity_type: "symptom_event",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
+166
View File
@@ -0,0 +1,166 @@
/**
* Occurrences of person-defined symptoms (v1.40).
*
* GET /api/symptoms/events — newest first, optionally narrowed by
* `from` / `to` (ISO instants), `definitionId` and `episodeId`; at most
* 500 per page.
* POST /api/symptoms/events — log one (`{ definitionId, intensity,
* occurredAt?, note?, episodeId? }`). `occurredAt` defaults to now and
* may not lie in the future beyond the shared five-minute skew;
* `episodeId` must name one of the record's own episodes that is still
* open. The note is encrypted at rest.
*
* Module-gated on the record (`illness.disabled`). `userId` comes from auth
* and is never a body field.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import type { Prisma } from "@/generated/prisma/client";
import {
encryptSymptomText,
isLinkableEpisode,
symptomEventCreateSchema,
symptomEventListQuerySchema,
toSymptomEventDTO,
} from "@/lib/symptoms/server";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const sp = request.nextUrl.searchParams;
const parsed = symptomEventListQuerySchema.safeParse({
from: sp.get("from") ?? undefined,
to: sp.get("to") ?? undefined,
definitionId: sp.get("definitionId") ?? undefined,
episodeId: sp.get("episodeId") ?? undefined,
limit: sp.get("limit") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "symptoms.invalid",
});
}
const q = parsed.data;
const occurredAt: Prisma.DateTimeFilter = {};
if (q.from) occurredAt.gte = new Date(q.from);
if (q.to) occurredAt.lte = new Date(q.to);
const rows = await prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: q.limit,
});
annotate({
action: { name: "symptoms.event.list" },
meta: { count: rows.length, limit: q.limit },
});
return apiSuccess({
events: rows.map(toSymptomEventDTO),
limit: q.limit,
hasMore: rows.length === q.limit,
});
});
// A retry from a flaky connection re-sends the same `Idempotency-Key` and gets
// the first answer back instead of a second occurrence at the same minute.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEvent));
async function postEvent(request: NextRequest): Promise<Response> {
// WRITE, like opening an episode or adding a reading: logging what happened
// is an admitted delegated write; editing or removing it is MANAGE.
const { user } = await requireRecordAuth("write", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = symptomEventCreateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "symptoms.invalid",
});
}
const entry = parsed.data;
// A hidden definition stays loggable from an older client's cached list;
// only another account's id (or none) is refused.
const definition = await prisma.symptomDefinition.findFirst({
where: { id: entry.definitionId, userId: user.id },
select: { id: true },
});
if (!definition) {
return apiError("Symptom not found", 404, {
errorCode: "symptoms.definition.notFound",
});
}
if (entry.episodeId && !(await isLinkableEpisode(user.id, entry.episodeId))) {
return apiError("Episode is not open", 422, {
errorCode: "symptoms.episode.notOpen",
});
}
const created = await prisma.symptomEvent.create({
data: {
userId: user.id,
definitionId: definition.id,
occurredAt: entry.occurredAt ?? new Date(),
intensity: entry.intensity,
noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null,
episodeId: entry.episodeId ?? null,
},
});
await auditLog("symptoms.event.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
eventId: created.id,
definitionId: created.definitionId,
linked: created.episodeId !== null,
},
});
invalidateUserHealthContext(user.id);
annotate({
action: {
name: "symptoms.event.create",
entity_type: "symptom_event",
entity_id: created.id,
},
meta: { linked: created.episodeId !== null },
});
return apiSuccess(toSymptomEventDTO(created), 201);
}