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