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