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
+48
@@ -705,4 +705,52 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"export",
|
||||
),
|
||||
handler(
|
||||
"app/api/symptoms/definitions/route.ts",
|
||||
"/api/symptoms/definitions",
|
||||
"POST",
|
||||
"illness",
|
||||
"manage",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/symptoms/definitions/[id]/route.ts",
|
||||
"/api/symptoms/definitions/[id]",
|
||||
"PATCH",
|
||||
"illness",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/symptoms/definitions/[id]/route.ts",
|
||||
"/api/symptoms/definitions/[id]",
|
||||
"DELETE",
|
||||
"illness",
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/symptoms/events/route.ts",
|
||||
"/api/symptoms/events",
|
||||
"POST",
|
||||
"illness",
|
||||
"write",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/symptoms/events/[id]/route.ts",
|
||||
"/api/symptoms/events/[id]",
|
||||
"PATCH",
|
||||
"illness",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/symptoms/events/[id]/route.ts",
|
||||
"/api/symptoms/events/[id]",
|
||||
"DELETE",
|
||||
"illness",
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
] as const satisfies readonly MutatingHandlerContract[];
|
||||
@@ -232,6 +232,9 @@ const COUNT_BACK: Record<
|
||||
p.familyHistoryEntry.count({ where: { userId } }),
|
||||
IllnessEpisode: (p, userId) => p.illnessEpisode.count({ where: { userId } }),
|
||||
IllnessDayLog: (p, userId) => p.illnessDayLog.count({ where: { userId } }),
|
||||
SymptomDefinition: (p, userId) =>
|
||||
p.symptomDefinition.count({ where: { userId } }),
|
||||
SymptomEvent: (p, userId) => p.symptomEvent.count({ where: { userId } }),
|
||||
IllnessSymptomLink: (p, userId) =>
|
||||
p.illnessSymptomLink.count({ where: { dayLog: { userId } } }),
|
||||
UserHealthProfile: (p, userId) =>
|
||||
@@ -750,6 +753,52 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.40 — two of the person's own symptoms and three occurrences: one filed
|
||||
// against the open cold, one with a note, one on its own in the gap.
|
||||
const aura = await prisma.symptomDefinition.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
labelEncrypted: encryptToBytes("Aura"),
|
||||
icon: "Zap",
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
const headache = await prisma.symptomDefinition.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
labelEncrypted: encryptToBytes("Headache"),
|
||||
icon: "Brain",
|
||||
sortOrder: 1,
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
await prisma.symptomEvent.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
definitionId: aura.id,
|
||||
occurredAt: AT("2026-06-21T09:00:00.000Z"),
|
||||
intensity: 4,
|
||||
episodeId: episode.id,
|
||||
},
|
||||
});
|
||||
await prisma.symptomEvent.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
definitionId: headache.id,
|
||||
occurredAt: AT("2026-06-21T11:00:00.000Z"),
|
||||
intensity: 8,
|
||||
noteEncrypted: encryptToBytes("left side, behind the eye"),
|
||||
},
|
||||
});
|
||||
await prisma.symptomEvent.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
definitionId: aura.id,
|
||||
occurredAt: AT("2026-07-10T18:30:00.000Z"),
|
||||
intensity: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.userHealthProfile.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
@@ -2108,6 +2157,69 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
"a condition's body site and side must survive the round trip",
|
||||
).toEqual({ bodySite: "Stomach", laterality: "BOTH" });
|
||||
|
||||
// v1.40 — the person's own symptoms: the names decrypt to what was typed,
|
||||
// each occurrence keeps its time, intensity and note, and the one filed
|
||||
// against the cold still points at the cold.
|
||||
const restoredSymptoms = await prisma.symptomDefinition.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
events: {
|
||||
orderBy: { occurredAt: "asc" },
|
||||
include: { episode: { select: { label: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
restoredSymptoms.map((definition) => ({
|
||||
label: decryptFromBytes(definition.labelEncrypted),
|
||||
icon: definition.icon,
|
||||
isActive: definition.isActive,
|
||||
events: definition.events.map((event) => ({
|
||||
at: event.occurredAt.toISOString(),
|
||||
intensity: event.intensity,
|
||||
note: event.noteEncrypted
|
||||
? decryptFromBytes(event.noteEncrypted)
|
||||
: null,
|
||||
episode: event.episode?.label ?? null,
|
||||
})),
|
||||
})),
|
||||
"symptom definitions and their occurrences must survive the round trip",
|
||||
).toEqual([
|
||||
{
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
isActive: true,
|
||||
events: [
|
||||
{
|
||||
at: "2026-06-21T09:00:00.000Z",
|
||||
intensity: 4,
|
||||
note: null,
|
||||
episode: "Cold",
|
||||
},
|
||||
{
|
||||
at: "2026-07-10T18:30:00.000Z",
|
||||
intensity: 2,
|
||||
note: null,
|
||||
episode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Headache",
|
||||
icon: "Brain",
|
||||
isActive: false,
|
||||
events: [
|
||||
{
|
||||
at: "2026-06-21T11:00:00.000Z",
|
||||
intensity: 8,
|
||||
note: "left side, behind the eye",
|
||||
episode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// The staged fact, and the decision on it.
|
||||
//
|
||||
// This is the assertion the count cannot make. `status`, `needsReview`,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
ADMITTED_MUTATING_HANDLERS,
|
||||
type MutatingHandlerContract,
|
||||
@@ -281,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("starts with a non-empty, exact handler inventory", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(83);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1331,6 +1332,178 @@ manageContract("POST /api/illness/episodes/[id]/restore", {
|
||||
)?.deletedAt === null,
|
||||
});
|
||||
|
||||
async function makeSymptomDefinition(userId: string) {
|
||||
return getPrismaClient().symptomDefinition.create({
|
||||
data: {
|
||||
userId,
|
||||
labelEncrypted: encryptToBytes("Aura"),
|
||||
icon: "Zap",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function makeSymptomEvent(userId: string) {
|
||||
const definition = await makeSymptomDefinition(userId);
|
||||
return getPrismaClient().symptomEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
definitionId: definition.id,
|
||||
occurredAt: new Date(ISO),
|
||||
intensity: 6,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
manageContract("POST /api/symptoms/definitions", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/symptoms/definitions" && entry.action === "POST",
|
||||
)!,
|
||||
prepare: async (ownerId) => ({ ownerId }),
|
||||
act: async () => {
|
||||
const { POST } = await import("@/app/api/symptoms/definitions/route");
|
||||
return call(POST as Handler, "POST", "/api/symptoms/definitions", {
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "symptoms.definition.create",
|
||||
applied: async ({ ownerId }) =>
|
||||
(await getPrismaClient().symptomDefinition.count({
|
||||
where: { userId: ownerId },
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("PATCH /api/symptoms/definitions/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/symptoms/definitions/[id]" &&
|
||||
entry.action === "PATCH",
|
||||
)!,
|
||||
prepare: (ownerId) => makeSymptomDefinition(ownerId),
|
||||
act: async (row) => {
|
||||
const { PATCH } = await import("@/app/api/symptoms/definitions/[id]/route");
|
||||
return call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/symptoms/definitions/${row.id}`,
|
||||
{ icon: "Brain" },
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "symptoms.definition.update",
|
||||
applied: async (row) =>
|
||||
(
|
||||
await getPrismaClient().symptomDefinition.findUnique({
|
||||
where: { id: row.id },
|
||||
})
|
||||
)?.icon === "Brain",
|
||||
});
|
||||
|
||||
manageContract("DELETE /api/symptoms/definitions/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/symptoms/definitions/[id]" &&
|
||||
entry.action === "DELETE",
|
||||
)!,
|
||||
prepare: (ownerId) => makeSymptomDefinition(ownerId),
|
||||
act: async (row) => {
|
||||
const { DELETE } =
|
||||
await import("@/app/api/symptoms/definitions/[id]/route");
|
||||
return call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/symptoms/definitions/${row.id}`,
|
||||
undefined,
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "symptoms.definition.delete",
|
||||
applied: async (row) =>
|
||||
(
|
||||
await getPrismaClient().symptomDefinition.findUnique({
|
||||
where: { id: row.id },
|
||||
})
|
||||
)?.isActive === false,
|
||||
});
|
||||
|
||||
writeEffectContract("POST /api/symptoms/events", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/symptoms/events" && entry.action === "POST",
|
||||
)!,
|
||||
prepare: async (ownerId) => ({
|
||||
ownerId,
|
||||
definition: await makeSymptomDefinition(ownerId),
|
||||
}),
|
||||
act: async ({ definition }) => {
|
||||
const { POST } = await import("@/app/api/symptoms/events/route");
|
||||
return call(POST as Handler, "POST", "/api/symptoms/events", {
|
||||
definitionId: definition.id,
|
||||
intensity: 7,
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "symptoms.event.create",
|
||||
applied: async ({ ownerId }) =>
|
||||
(await getPrismaClient().symptomEvent.count({
|
||||
where: { userId: ownerId },
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("PATCH /api/symptoms/events/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/symptoms/events/[id]" && entry.action === "PATCH",
|
||||
)!,
|
||||
prepare: (ownerId) => makeSymptomEvent(ownerId),
|
||||
act: async (row) => {
|
||||
const { PATCH } = await import("@/app/api/symptoms/events/[id]/route");
|
||||
return call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/symptoms/events/${row.id}`,
|
||||
{ intensity: 9 },
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "symptoms.event.update",
|
||||
applied: async (row) =>
|
||||
(
|
||||
await getPrismaClient().symptomEvent.findUnique({
|
||||
where: { id: row.id },
|
||||
})
|
||||
)?.intensity === 9,
|
||||
});
|
||||
|
||||
manageContract("DELETE /api/symptoms/events/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/symptoms/events/[id]" && entry.action === "DELETE",
|
||||
)!,
|
||||
prepare: (ownerId) => makeSymptomEvent(ownerId),
|
||||
act: async (row) => {
|
||||
const { DELETE } = await import("@/app/api/symptoms/events/[id]/route");
|
||||
return call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/symptoms/events/${row.id}`,
|
||||
undefined,
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "symptoms.event.delete",
|
||||
applied: async (row) =>
|
||||
(await getPrismaClient().symptomEvent.findUnique({
|
||||
where: { id: row.id },
|
||||
})) === null,
|
||||
});
|
||||
|
||||
writeEffectContract("POST /api/encounters", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/encounters" && entry.action === "POST",
|
||||
@@ -2580,13 +2753,13 @@ describe("the conditions the admissions were granted on", () => {
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("registers one strict actor-and-effect driver for every admission", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(77);
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(83);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("executes every registered driver through its owned effect and actor audit", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(77);
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(83);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user