fix(symptoms): assign an occurrence to an illness by its link, not its time

The episode detail listed every symptom occurrence that fell inside the
episode's onset-to-recovery span as well as the ones filed against it, so a
long-running condition showed the symptoms of every illness overlapping it,
and picking an illness in the entry sheet changed nothing on the page. The
occurrence edit sheet carried no illness link at all, so once logged the
assignment could be neither seen nor changed.

- The episode card reads only the occurrences filed against the episode
  (`SymptomEvent.episodeId`), with a guard against any other.
- The edit sheet loads the link and edits it like the time and intensity;
  an unchanged link is not re-sent, so a link the list cannot show stays.
- One rule decides which episodes may take an occurrence
  (`isEpisodeLinkableAt`): an open one, or a resolved one whose span holds
  the occurrence's time. The server checks it against the time after the
  edit, and the entry and edit sheets offer exactly what it admits, so a
  backdated occurrence can be filed with an illness that has since ended.
- The illness page names the illness on each occurrence row.
- The day view opens a symptom's illness only while that episode exists.

The stored links were always written correctly, so no data repair is needed.

Refs #1237
This commit is contained in:
Marc-André Bombeck committed 2026-10-17 11:00:02 +02:00
1 parent 048ef0020e
commit c1df60693b
23 files changed
+904 -139

No files matched your search

+11 -4
View File
@@ -2,8 +2,9 @@
* 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.
* episode link. A new link must name an episode of the record that
* is open or ran over the occurrence's time; 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,
@@ -76,12 +77,18 @@ export const PATCH = apiHandler(
});
if (!existing) return notFound();
// A new link is checked against the time the occurrence will have after
// this edit, so moving it and filing it in one save is judged as a whole.
if (
entry.episodeId &&
entry.episodeId !== existing.episodeId &&
!(await isLinkableEpisode(user.id, entry.episodeId))
!(await isLinkableEpisode(
user.id,
entry.episodeId,
entry.occurredAt ?? existing.occurredAt,
))
) {
return apiError("Episode is not open", 422, {
return apiError("Episode did not run at that time", 422, {
errorCode: "symptoms.episode.notOpen",
});
}
+8 -4
View File
@@ -8,7 +8,7 @@
* 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.
* open or that ran over `occurredAt`. The note is encrypted at rest.
*
* Module-gated on the record (`illness.disabled`). `userId` comes from auth
* and is never a body field.
@@ -124,8 +124,12 @@ async function postEvent(request: NextRequest): Promise<Response> {
});
}
if (entry.episodeId && !(await isLinkableEpisode(user.id, entry.episodeId))) {
return apiError("Episode is not open", 422, {
const occurredAt = entry.occurredAt ?? new Date();
if (
entry.episodeId &&
!(await isLinkableEpisode(user.id, entry.episodeId, occurredAt))
) {
return apiError("Episode did not run at that time", 422, {
errorCode: "symptoms.episode.notOpen",
});
}
@@ -134,7 +138,7 @@ async function postEvent(request: NextRequest): Promise<Response> {
data: {
userId: user.id,
definitionId: definition.id,
occurredAt: entry.occurredAt ?? new Date(),
occurredAt,
intensity: entry.intensity,
noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null,
episodeId: entry.episodeId ?? null,