mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
@@ -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