mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #714 from MBombeck/fix/audit-v1361
Stop a delegate deferring somebody else's medication reminders
This commit is contained in:
15 files changed
+260
-32
No files matched your search
+11
-1
@@ -71,9 +71,19 @@
|
||||
- The activity feed showed a blank line when a delegate marked a dose, because
|
||||
the sentence was keyed on a name the route does not use.
|
||||
|
||||
- Marking a dose and deferring a reminder went through the same request, so
|
||||
somebody with write access could postpone one of your medication reminders
|
||||
rather than only record a dose. There was no upper bound on how far, you were
|
||||
not told, and the activity feed described it as marking a dose. Deferring a
|
||||
reminder is yours alone now, and so is changing a dose you already recorded.
|
||||
|
||||
- Inside somebody else's record the BMI chart divided their weights by the
|
||||
height of whoever was looking. It draws nothing there now rather than a
|
||||
number belonging to two different people.
|
||||
|
||||
### Internal
|
||||
|
||||
- Two high-severity advisories on the outbound HTTP path, published after the
|
||||
- Three high-severity advisories on the outbound HTTP path, published after the
|
||||
last release, are closed. One of them is in the client that backs the
|
||||
application's single documented egress boundary, so the dispatcher's own suite
|
||||
was re-run against the new version rather than assumed.
|
||||
|
||||
+4
-2
@@ -9391,6 +9391,7 @@
|
||||
"banner": {
|
||||
"viewing": "Du bist in der Akte von {name}",
|
||||
"readOnly": "Du kannst sie lesen, nicht ändern.",
|
||||
"canAdd": "Du kannst etwas hinzufügen, aber nichts ändern oder löschen.",
|
||||
"leave": "Zurück zu meiner Akte"
|
||||
},
|
||||
"switcher": {
|
||||
@@ -9437,7 +9438,7 @@
|
||||
"accessReadLabel": "Darf lesen",
|
||||
"accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.",
|
||||
"accessWriteLabel": "Darf lesen und hinzufügen",
|
||||
"accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, Allergien und andere Beobachtungen, ein neues Medikament und eine als genommen markierte Dosis. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat. Das kannst nur du.",
|
||||
"accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, einen Wert auf einer selbst angelegten Messgröße, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.",
|
||||
"expiresLabel": "Zugriff endet am (optional)",
|
||||
"expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.",
|
||||
"submit": "Einladung senden",
|
||||
@@ -9513,7 +9514,8 @@
|
||||
"customMetricEntryCreate": "{name} hat einen eigenen Messwert eingetragen",
|
||||
"medicationSideEffectCreate": "{name} hat eine Nebenwirkung notiert",
|
||||
"medicationCreate": "{name} hat ein Medikament angelegt",
|
||||
"medicationIntake": "{name} hat eine Dosis eingetragen"
|
||||
"medicationIntake": "{name} hat eine Dosis eingetragen",
|
||||
"medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen"
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -9391,6 +9391,7 @@
|
||||
"banner": {
|
||||
"viewing": "You are in {name}'s record",
|
||||
"readOnly": "You can read it, not change it.",
|
||||
"canAdd": "You can add to it, but not change or delete what is there.",
|
||||
"leave": "Back to my record"
|
||||
},
|
||||
"switcher": {
|
||||
@@ -9437,7 +9438,7 @@
|
||||
"accessReadLabel": "Can view",
|
||||
"accessReadCapability": "They can read everything this record shares. They cannot add or change anything.",
|
||||
"accessWriteLabel": "Can view and add",
|
||||
"accessWriteCapability": "They can also add to the record: readings, lab results, allergies and other observations, a new medication, and marking a dose as taken. They cannot edit or delete anything, not even what they added. Only you can.",
|
||||
"accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a value on a metric you track yourself, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.",
|
||||
"expiresLabel": "Access ends on (optional)",
|
||||
"expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.",
|
||||
"submit": "Send invitation",
|
||||
@@ -9513,7 +9514,8 @@
|
||||
"customMetricEntryCreate": "{name} logged a tracked value",
|
||||
"medicationSideEffectCreate": "{name} noted a side effect",
|
||||
"medicationCreate": "{name} added a medication",
|
||||
"medicationIntake": "{name} marked a dose"
|
||||
"medicationIntake": "{name} marked a dose",
|
||||
"medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -9391,6 +9391,7 @@
|
||||
"banner": {
|
||||
"viewing": "Estás en el historial de {name}",
|
||||
"readOnly": "Puedes leerlo, no modificarlo.",
|
||||
"canAdd": "Puedes añadir cosas, pero no cambiar ni borrar lo que hay.",
|
||||
"leave": "Volver a mi historial"
|
||||
},
|
||||
"switcher": {
|
||||
@@ -9437,7 +9438,7 @@
|
||||
"accessReadLabel": "Puede ver",
|
||||
"accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.",
|
||||
"accessWriteLabel": "Puede ver y añadir",
|
||||
"accessWriteCapability": "También puede añadir cosas al historial: mediciones, resultados de laboratorio, alergias y otras observaciones, un medicamento nuevo y una dosis marcada como tomada. No puede editar ni eliminar nada, tampoco lo que ha añadido. Solo tú puedes.",
|
||||
"accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un valor en una métrica propia, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.",
|
||||
"expiresLabel": "El acceso termina el (opcional)",
|
||||
"expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.",
|
||||
"submit": "Enviar invitación",
|
||||
@@ -9513,7 +9514,8 @@
|
||||
"customMetricEntryCreate": "{name} registró un valor propio",
|
||||
"medicationSideEffectCreate": "{name} anotó un efecto secundario",
|
||||
"medicationCreate": "{name} añadió un medicamento",
|
||||
"medicationIntake": "{name} registró una dosis"
|
||||
"medicationIntake": "{name} registró una dosis",
|
||||
"medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado"
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -9391,6 +9391,7 @@
|
||||
"banner": {
|
||||
"viewing": "Vous êtes dans le dossier de {name}",
|
||||
"readOnly": "Vous pouvez le lire, pas le modifier.",
|
||||
"canAdd": "Vous pouvez y ajouter, mais pas modifier ni supprimer ce qui s'y trouve.",
|
||||
"leave": "Revenir à mon dossier"
|
||||
},
|
||||
"switcher": {
|
||||
@@ -9437,7 +9438,7 @@
|
||||
"accessReadLabel": "Peut consulter",
|
||||
"accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.",
|
||||
"accessWriteLabel": "Peut consulter et ajouter",
|
||||
"accessWriteCapability": "Elle peut aussi ajouter des choses au dossier : des mesures, des résultats d'analyses, des allergies et d'autres observations, un nouveau médicament, et une prise marquée comme faite. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté. Vous êtes la seule personne à pouvoir le faire.",
|
||||
"accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, une valeur sur une mesure que vous suivez vous-même, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.",
|
||||
"expiresLabel": "L'accès prend fin le (facultatif)",
|
||||
"expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.",
|
||||
"submit": "Envoyer l'invitation",
|
||||
@@ -9513,7 +9514,8 @@
|
||||
"customMetricEntryCreate": "{name} a enregistré une valeur personnalisée",
|
||||
"medicationSideEffectCreate": "{name} a noté un effet indésirable",
|
||||
"medicationCreate": "{name} a ajouté un médicament",
|
||||
"medicationIntake": "{name} a enregistré une prise"
|
||||
"medicationIntake": "{name} a enregistré une prise",
|
||||
"medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé"
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -9391,6 +9391,7 @@
|
||||
"banner": {
|
||||
"viewing": "Sei nella cartella di {name}",
|
||||
"readOnly": "Puoi leggerla, non modificarla.",
|
||||
"canAdd": "Puoi aggiungere qualcosa, ma non modificare né eliminare ciò che c'è.",
|
||||
"leave": "Torna alla mia cartella"
|
||||
},
|
||||
"switcher": {
|
||||
@@ -9437,7 +9438,7 @@
|
||||
"accessReadLabel": "Può vedere",
|
||||
"accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.",
|
||||
"accessWriteLabel": "Può vedere e aggiungere",
|
||||
"accessWriteCapability": "Può anche aggiungere qualcosa alla cartella: misurazioni, risultati di laboratorio, allergie e altre osservazioni, un nuovo farmaco e una dose segnata come presa. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto. Puoi farlo solo tu.",
|
||||
"accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un valore su una metrica che segui tu, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.",
|
||||
"expiresLabel": "L'accesso termina il (facoltativo)",
|
||||
"expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.",
|
||||
"submit": "Invia invito",
|
||||
@@ -9513,7 +9514,8 @@
|
||||
"customMetricEntryCreate": "{name} ha registrato un valore personalizzato",
|
||||
"medicationSideEffectCreate": "{name} ha annotato un effetto collaterale",
|
||||
"medicationCreate": "{name} ha aggiunto un farmaco",
|
||||
"medicationIntake": "{name} ha registrato una dose"
|
||||
"medicationIntake": "{name} ha registrato una dose",
|
||||
"medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato"
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -9391,6 +9391,7 @@
|
||||
"banner": {
|
||||
"viewing": "Jesteś w karcie użytkownika {name}",
|
||||
"readOnly": "Możesz ją czytać, nie zmieniać.",
|
||||
"canAdd": "Możesz coś dodać, ale nie zmieniać ani usuwać tego, co już jest.",
|
||||
"leave": "Wróć do mojej karty"
|
||||
},
|
||||
"switcher": {
|
||||
@@ -9437,7 +9438,7 @@
|
||||
"accessReadLabel": "Może przeglądać",
|
||||
"accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.",
|
||||
"accessWriteLabel": "Może przeglądać i dodawać",
|
||||
"accessWriteCapability": "Może też dodawać do karty: pomiary, wyniki badań, alergie i inne obserwacje, nowy lek oraz oznaczenie dawki jako przyjętej. Nie może niczego edytować ani usuwać, także własnych wpisów. Możesz to zrobić tylko ty.",
|
||||
"accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, wartość we własnej mierze, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.",
|
||||
"expiresLabel": "Dostęp kończy się (opcjonalnie)",
|
||||
"expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.",
|
||||
"submit": "Wyślij zaproszenie",
|
||||
@@ -9513,7 +9514,8 @@
|
||||
"customMetricEntryCreate": "{name} zapisał własny pomiar",
|
||||
"medicationSideEffectCreate": "{name} zanotował działanie niepożądane",
|
||||
"medicationCreate": "{name} dodał lek",
|
||||
"medicationIntake": "{name} zapisał przyjęcie dawki"
|
||||
"medicationIntake": "{name} zapisał przyjęcie dawki",
|
||||
"medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,61 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Intake event not found", 404);
|
||||
}
|
||||
|
||||
// v1.36.1 — this route is admitted for delegates because marking a dose is
|
||||
// the thing a person standing next to the patient needs to do. It is an
|
||||
// UPDATE, though, and two of the transitions it can express are not that.
|
||||
//
|
||||
// Snoozing writes `snoozedUntil` on the OWNER's medication row, and the
|
||||
// reminder cron skips a medication for as long as that stamp is in the
|
||||
// future. The field is unbounded, the owner is deliberately not notified of
|
||||
// a delegated snooze (there is nothing useful to say about a thirty-minute
|
||||
// deferral), and the activity feed renders the whole family as "marked a
|
||||
// dose". So a delegate could switch the owner's medication reminders off for
|
||||
// years, and every surface that exists to make delegation visible would
|
||||
// describe it as marking a dose. In an adherence product that is the worst
|
||||
// thing on this route.
|
||||
//
|
||||
// Flipping an already-resolved event is the other one: taken to skipped and
|
||||
// back rewrites the owner's compliance history and refunds inventory. The
|
||||
// shipped promise, on the consent screen and in the release notes, is that
|
||||
// editing what is already there stays with the owner. Marking a dose that is
|
||||
// still open is a contribution; changing a decision the owner already
|
||||
// recorded is an edit.
|
||||
//
|
||||
// Both refuse for a delegate and stay open to the owner, who is the only
|
||||
// person the snooze and the correction were ever for.
|
||||
if (actor.id !== user.id) {
|
||||
const alreadyResolved = existing.takenAt !== null || existing.skipped;
|
||||
const refusal =
|
||||
status === "snoozed"
|
||||
? "snooze"
|
||||
: alreadyResolved
|
||||
? "resolved_event"
|
||||
: null;
|
||||
if (refusal) {
|
||||
annotate({
|
||||
action: { name: "medications.intake.update.refused" },
|
||||
meta: { reason: refusal },
|
||||
});
|
||||
// Filed under the OWNER. `auditLog` stamps the actor itself from the
|
||||
// request's acting context, which is what puts this on the trail the
|
||||
// owner's activity panel reads — a refused attempt to change their
|
||||
// record is worth as much to them as a successful one.
|
||||
await auditLog("medications.intake.update.refused", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { intakeId, status, reason: refusal },
|
||||
});
|
||||
return apiError(
|
||||
refusal === "snooze"
|
||||
? "Snoozing a reminder is not part of shared access"
|
||||
: "Changing a dose that is already recorded is not part of shared access",
|
||||
403,
|
||||
{ errorCode: "sharing.not_permitted" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site for a
|
||||
// "taken" toggle. A site outside the medication's effective allowed
|
||||
// set is a hard 422; non-injection / tracking-off / non-taken drops it.
|
||||
@@ -557,9 +612,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// v1.36.x — "somebody else marked your dose". The helper refuses on self and
|
||||
// on a snooze, resolves both names itself, and never throws; awaited because
|
||||
// the latency it adds lands on the caregiver's request and never on the
|
||||
// owner's own hot path.
|
||||
// on a snooze, resolves both names itself, and never throws.
|
||||
//
|
||||
// Fire and forget, like every neighbour above. The dispatcher awaits each
|
||||
// channel in the cascade, so an unreachable one would have added its whole
|
||||
// timeout to the caregiver's request — the person standing next to the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -684,8 +685,23 @@ export function HealthChart({
|
||||
// every chart card.
|
||||
const viewportWidth = useViewportWidth();
|
||||
|
||||
// v1.36.1 — `useAuth()` answers about the person at the keyboard, and under a
|
||||
// switch that is the delegate. Dividing the OWNER's weights by the
|
||||
// DELEGATE's height squared produces a plausible-looking BMI that is simply
|
||||
// somebody else's, shaded by fixed category bands, on a page that names the
|
||||
// owner. The same substitution was fixed for the reference bands in this
|
||||
// release; this is the same mistake one component over, and a wrong health
|
||||
// figure is worse than an absent one.
|
||||
//
|
||||
// Inside a shared record the chart therefore draws no BMI at all rather than
|
||||
// a wrong one. Publishing the owner's height to every client that can read
|
||||
// their weights is a bigger decision than this fix, and it belongs with the
|
||||
// per-module scope work rather than here.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const bmiDivisor =
|
||||
valueMode === "bmi" && user?.heightCm ? (user.heightCm / 100) ** 2 : null;
|
||||
valueMode === "bmi" && !inSharedRecord && user?.heightCm
|
||||
? (user.heightCm / 100) ** 2
|
||||
: null;
|
||||
|
||||
// v1.4.28 FB-D2 (R1.2 H0) — derive a bounded date window from the
|
||||
// active range selector so the chart fetches only what it will
|
||||
|
||||
@@ -75,13 +75,19 @@ export function SharedRecordBanner() {
|
||||
<span className="text-foreground font-medium">
|
||||
{t("recordSharing.banner.viewing", { name })}
|
||||
</span>{" "}
|
||||
{/* Resolved server-side. `canWrite` is false for every grant in v1, and
|
||||
the day it is not, this line stops claiming otherwise on its own. */}
|
||||
{!active.canWrite && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("recordSharing.banner.readOnly")}
|
||||
</span>
|
||||
)}
|
||||
{/* Resolved server-side, and it says something at BOTH levels now.
|
||||
v1.36.1 is the release that made `canWrite` true, and until this
|
||||
line changed the write case rendered nothing at all: a delegate who
|
||||
could add to somebody's health record was told only whose record it
|
||||
was. That matters more than the read case, not less, and the rest of
|
||||
the product leans on it — a control the record refuses is absent
|
||||
rather than disabled, on the stated grounds that the banner names
|
||||
the mode globally. It has to actually do that. */}
|
||||
<span className="text-muted-foreground">
|
||||
{active.canWrite
|
||||
? t("recordSharing.banner.canAdd")
|
||||
: t("recordSharing.banner.readOnly")}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -89,7 +89,16 @@ describe("offering a level", () => {
|
||||
const html = render(<GrantInviteCard />);
|
||||
// The thing a person reading "write access" gets wrong.
|
||||
expect(html).toContain("cannot edit or delete anything");
|
||||
expect(html).toContain("marking a dose as taken");
|
||||
expect(html).toContain("marking a dose taken or skipped");
|
||||
// v1.36.1 — the copy named allergies, which left the admitted set before
|
||||
// this shipped, and it did so on the one screen where somebody decides who
|
||||
// may write into their health record. Assert the withdrawal too, not only
|
||||
// the presence of the rest: a consent notice that over-promises is worse
|
||||
// than one that is merely incomplete.
|
||||
expect(html).not.toContain("allergies");
|
||||
// Deferring a reminder is the capability the same request could reach and
|
||||
// delegation does not cover; the notice has to say so.
|
||||
expect(html).toContain("cannot defer one of your reminders");
|
||||
// And the narrow option still says it is narrow.
|
||||
expect(html).toContain("They cannot add or change anything.");
|
||||
});
|
||||
|
||||
@@ -22,9 +22,13 @@ import {
|
||||
* ## The two answers, and why there are two
|
||||
*
|
||||
* A grant at WRITE admits a short, closed list of verbs: entering readings,
|
||||
* lab results, allergies, family history, an illness entry, a biomarker, a
|
||||
* tracked value, a side effect, a medication, and marking a dose taken. It
|
||||
* admits nothing else. Editing, deleting, restoring, purging, importing and
|
||||
* lab results, an illness entry, a biomarker, a tracked value, a side effect, a
|
||||
* medication, and marking a dose taken or skipped. It admits nothing else.
|
||||
* Allergies and family history were on this list and left it before v1.36.1
|
||||
* shipped — the argument for them held, but the only surface that posts to
|
||||
* either lives under `/settings`, which a switch closes, so the permission had
|
||||
* no reachable caller. `delegable-surface-guard.test.ts` carries that argument
|
||||
* in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and
|
||||
* bulk-acting stay with the owner, including on an entry the delegate added
|
||||
* themselves a minute ago, and so does every create the list does not name
|
||||
* (a mood entry, a screener, a cycle day). Two booleans carry that:
|
||||
|
||||
@@ -74,10 +74,14 @@ export interface NotifyDelegatedIntakeInput {
|
||||
* Tell the owner that somebody else marked one of their doses.
|
||||
*
|
||||
* Never throws: a notification that fails is not a reason to fail the write
|
||||
* that produced it. Await it — the call sites reach this only on a delegated
|
||||
* write, so the added latency lands on the caregiver's request and never on
|
||||
* the owner's own hot path, and awaiting is what makes "one row in
|
||||
* `push_attempts`" a thing a test can assert rather than a thing it can race.
|
||||
* that produced it. The call sites do NOT await it, and this line used to say
|
||||
* the opposite. The dispatcher awaits each channel of the cascade in turn, so
|
||||
* one unreachable channel added its whole timeout to the request of the person
|
||||
* standing next to the patient waiting for a dose to register — which is the
|
||||
* one request in this product that should never be slow. The owner learning a
|
||||
* moment later is the better trade. The integration test polls the
|
||||
* `push_attempts` ledger rather than relying on the await, so nothing about
|
||||
* the proof depends on the caller's choice here.
|
||||
*/
|
||||
export async function notifyDelegatedIntake(
|
||||
input: NotifyDelegatedIntakeInput,
|
||||
|
||||
@@ -64,6 +64,16 @@ export function recordActivityVerbLine(
|
||||
case "medication.intake":
|
||||
case "medications.intake.update":
|
||||
return t("recordSharing.activityVerb.medicationIntake", { name });
|
||||
// The refused half of the same route, and it earns a line of its own for
|
||||
// the reason the whole panel exists. Deferring a reminder and rewriting a
|
||||
// dose the owner already recorded are the two things that request can
|
||||
// express and delegation does not cover. Folding them into "marked a dose"
|
||||
// is what made the gap dangerous in the first place: the owner would have
|
||||
// seen the sentence for a contribution against an attempt to switch their
|
||||
// reminders off. An attempt that was refused is still something the person
|
||||
// whose record it is should be able to see.
|
||||
case "medications.intake.update.refused":
|
||||
return t("recordSharing.activityVerb.medicationIntakeRefused", { name });
|
||||
// A batch post is still readings arriving; the owner does not care that the
|
||||
// client sent them together.
|
||||
case "measurement.create.batch":
|
||||
|
||||
@@ -796,6 +796,109 @@ writeContract<{ medicationId: string; intakeId: string }>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The two transitions the same route can express that are NOT marking a dose.
|
||||
*
|
||||
* `POST /api/medications/intake` is admitted because a caregiver standing next
|
||||
* to the patient has to be able to say the tablet went down. It is an update,
|
||||
* though, and the body can carry two other things.
|
||||
*
|
||||
* Snoozing writes `snoozedUntil` on the OWNER's medication row, and the
|
||||
* reminder cron skips a medication for as long as that stamp is in the future.
|
||||
* The field takes any future instant, the owner is deliberately not notified of
|
||||
* a delegated snooze, and the activity feed renders the whole family as
|
||||
* "marked a dose" — so before this was closed, a delegate could switch the
|
||||
* owner's medication reminders off for years and every surface built to make
|
||||
* delegation visible would have called it marking a dose.
|
||||
*
|
||||
* Flipping an already-resolved event is the second: taken to skipped rewrites
|
||||
* the owner's compliance history and refunds inventory, and the consent screen
|
||||
* promises that changing what is already there stays with the owner.
|
||||
*
|
||||
* Both assert the DATA, not only the status: a 403 that had already written
|
||||
* `snoozedUntil` would be worse than no refusal at all, because it would read
|
||||
* as protection.
|
||||
*/
|
||||
describe("POST /api/medications/intake — what a delegate may NOT do with it", () => {
|
||||
it("refuses to snooze the owner's reminders, and writes no stamp", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const { medicationId, intakeId } = await seedPendingDose(owner.id);
|
||||
|
||||
await switchInto(owner.id, delegate.id, "WRITE");
|
||||
const { POST } = await import("@/app/api/medications/intake/route");
|
||||
const response = await post(POST as Handler, "/api/medications/intake", {
|
||||
intakeId,
|
||||
status: "snoozed",
|
||||
snoozedUntil: new Date(Date.now() + 5 * 365 * 86_400_000).toISOString(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect((await payload(response)).meta?.errorCode).toBe(
|
||||
"sharing.not_permitted",
|
||||
);
|
||||
|
||||
// The reminder cron reads this column and skips the medication while it is
|
||||
// in the future. It has to still be null.
|
||||
const med = await getPrismaClient().medication.findUniqueOrThrow({
|
||||
where: { id: medicationId },
|
||||
select: { snoozedUntil: true },
|
||||
});
|
||||
expect(med.snoozedUntil).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to flip a dose the owner already recorded, and leaves it alone", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const { intakeId } = await seedPendingDose(owner.id);
|
||||
|
||||
// The owner took it themselves.
|
||||
const takenAt = new Date(Date.now() - 3_600_000);
|
||||
await getPrismaClient().medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
data: { takenAt },
|
||||
});
|
||||
|
||||
await switchInto(owner.id, delegate.id, "WRITE");
|
||||
const { POST } = await import("@/app/api/medications/intake/route");
|
||||
const response = await post(POST as Handler, "/api/medications/intake", {
|
||||
intakeId,
|
||||
status: "skipped",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
const event =
|
||||
await getPrismaClient().medicationIntakeEvent.findUniqueOrThrow({
|
||||
where: { id: intakeId },
|
||||
select: { takenAt: true, skipped: true },
|
||||
});
|
||||
expect(event.skipped).toBe(false);
|
||||
expect(event.takenAt?.toISOString()).toBe(takenAt.toISOString());
|
||||
});
|
||||
|
||||
it("still lets the owner snooze their own reminder", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const { medicationId, intakeId } = await seedPendingDose(owner.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
const until = new Date(Date.now() + 1_800_000);
|
||||
const { POST } = await import("@/app/api/medications/intake/route");
|
||||
const response = await post(POST as Handler, "/api/medications/intake", {
|
||||
intakeId,
|
||||
status: "snoozed",
|
||||
snoozedUntil: until.toISOString(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const med = await getPrismaClient().medication.findUniqueOrThrow({
|
||||
where: { id: medicationId },
|
||||
select: { snoozedUntil: true },
|
||||
});
|
||||
expect(med.snoozedUntil?.toISOString()).toBe(until.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
writeContract<{ medicationId: string; intakeId: string }>(
|
||||
"POST /api/medications/[id]/intake",
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user