mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat: open the anamnesis record surface to managers
POST /api/allergies and POST /api/family-history have resolved
requireRecordAuth("manage", "profile") since the level shipped, while the only
form in the product that posts to either sat behind a Settings destination
classified unavailable. The write was admitted, audited and owner-scoped, and
nobody could reach it — the one-ended change this repository keeps
rediscovering, with the permission shipped and the surface left as a follow-up.
The destination now carries a classification that means what the routes mean:
reachable by everybody they already admit, which is a guardian inside the
profile they administer and an adult delegate holding manage. The four places
that decided visibility on the guardian-only literal were each updated
deliberately, and the shell that refused every settings path in an adult share
now admits exactly the destinations classified as record content. Record
configuration — modules, thresholds, notification routing — stays with the
owner at every level.
The page inside a record is its own component holding the two managers whose
routes answer for a delegate. Mounting the personal section would have put
three more forms on screen that refuse on submit. The family-history card says
whose statement the row is, which the route comment could not.
Both routes carried a long paragraph arguing the write was deliberately
unreachable. It is gone rather than left to send the next reader back to close
what this opens.
The integration coverage moves from the undeclared-mode refusal to a manage
contract: a manage delegate and a guardian each write and the row lands under
the owner with the delegate named as actor, while read and write delegates get
the insufficient-access refusal. Nothing exercised either route at manage
before.
This commit is contained in:
1 parent
0cdfb97a45
commit
ef242f33bb
17 files changed
+550
-119
No files matched your search
@@ -9758,6 +9758,9 @@
|
||||
"notFound": "Diesen Zugriff gibt es nicht mehr.",
|
||||
"revokeFailed": "Der Zugriff konnte nicht beendet werden.",
|
||||
"renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9758,6 +9758,9 @@
|
||||
"notFound": "That access is no longer there.",
|
||||
"revokeFailed": "The access could not be ended.",
|
||||
"renounceFailed": "The access could not be handed back."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9758,6 +9758,9 @@
|
||||
"notFound": "Ese acceso ya no existe.",
|
||||
"revokeFailed": "No se pudo terminar el acceso.",
|
||||
"renounceFailed": "No se pudo devolver el acceso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9758,6 +9758,9 @@
|
||||
"notFound": "Cet accès n'existe plus.",
|
||||
"revokeFailed": "L'accès n'a pas pu être terminé.",
|
||||
"renounceFailed": "L'accès n'a pas pu être rendu."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9758,6 +9758,9 @@
|
||||
"notFound": "Questo accesso non esiste più.",
|
||||
"revokeFailed": "Non è stato possibile terminare l'accesso.",
|
||||
"renounceFailed": "Non è stato possibile restituire l'accesso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9758,6 +9758,9 @@
|
||||
"notFound": "Tego dostępu już nie ma.",
|
||||
"revokeFailed": "Nie udało się zakończyć dostępu.",
|
||||
"renounceFailed": "Nie udało się zwrócić dostępu."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
|
||||
const pathnameRef = { value: "/settings/security" };
|
||||
const activeRecordRef = { value: true };
|
||||
/** The record on screen. `managed` unless a case says otherwise. */
|
||||
const recordRef: { value: { kind: "managed" | "shared"; level: string } } = {
|
||||
value: { kind: "managed", level: "manage" },
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathnameRef.value,
|
||||
@@ -23,8 +27,8 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
username: "managed-record",
|
||||
displayName: "Managed record",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
level: recordRef.value.level,
|
||||
recordKind: recordRef.value.kind,
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
},
|
||||
@@ -63,18 +67,36 @@ function renderSettings(pathname = "/settings/security") {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The destinations a record offers, by what kind of record it is.
|
||||
*
|
||||
* v1.37.0 — a managed profile offers guardian configuration AND the record
|
||||
* content a MANAGE holder may write, because a Guardian holds MANAGE. The
|
||||
* `manage-writable` kind is what carries the second half; before it existed
|
||||
* the anamnesis form had no page a Guardian could open, while the routes
|
||||
* behind it had admitted the write since the level shipped.
|
||||
*/
|
||||
const MANAGED_KINDS = new Set(["managed-guardian", "manage-writable"]);
|
||||
|
||||
describe("managed record navigation", () => {
|
||||
it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => {
|
||||
recordRef.value = { kind: "managed", level: "manage" };
|
||||
const html = renderSettings();
|
||||
const managedDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind === "managed-guardian",
|
||||
const managedDestinations = SETTINGS_SECTIONS.filter((section) =>
|
||||
MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
|
||||
);
|
||||
const unavailableDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind !== "managed-guardian",
|
||||
!MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
|
||||
);
|
||||
|
||||
// The partition is non-empty on both sides. A classification change that
|
||||
// emptied either would make one of the loops below prove nothing.
|
||||
expect(managedDestinations.length).toBeGreaterThan(1);
|
||||
expect(unavailableDestinations.length).toBeGreaterThan(1);
|
||||
// And the destination this release opened is really on the offered side.
|
||||
expect(managedDestinations.map((s) => s.slug)).toContain("anamnesis");
|
||||
|
||||
expect(html).toContain('data-active-record-id="managed-record"');
|
||||
expect(html).toContain('id="settings-section-security-title"');
|
||||
expect(html).not.toMatch(/href="[^"]*managed-record/);
|
||||
@@ -91,7 +113,46 @@ describe("managed record navigation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("offers an adult manager the record content and none of the configuration", () => {
|
||||
// The other half of the same classification, and the half that is easiest
|
||||
// to get wrong in the generous direction: a delegate manages somebody's
|
||||
// health record, not their account. Anamnese is offered; modules,
|
||||
// thresholds and notification routing are not, even though a Guardian
|
||||
// reaches all three.
|
||||
recordRef.value = { kind: "shared", level: "manage" };
|
||||
const html = renderSettings();
|
||||
|
||||
expect(html).toContain('href="/settings/anamnesis"');
|
||||
for (const guardianOnly of [
|
||||
"account",
|
||||
"modules",
|
||||
"notifications",
|
||||
"thresholds",
|
||||
"coach",
|
||||
]) {
|
||||
expect(html, guardianOnly).not.toContain(
|
||||
`href="/settings/${guardianOnly}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a WRITE delegate no settings destination at all", () => {
|
||||
// The refusal side. `manage-writable` is admitted by LEVEL, not by being
|
||||
// in a shared record: a WRITE grant reaches no Settings page, and a
|
||||
// destination it could see would be one the route below refuses.
|
||||
recordRef.value = { kind: "shared", level: "write" };
|
||||
const html = renderSettings();
|
||||
|
||||
expect(html).not.toContain('href="/settings/anamnesis"');
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(html, section.slug).not.toContain(
|
||||
`href="/settings/${section.slug}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the exit control present while administering a managed record", () => {
|
||||
recordRef.value = { kind: "managed", level: "manage" };
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SharedRecordBanner />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
isManageDelegateSettingsDestination,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
||||
@@ -74,6 +75,55 @@ describe("record settings contract", () => {
|
||||
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the anamnesis destination, which is the one piece of record
|
||||
* CONTENT under `/settings`.
|
||||
*
|
||||
* `POST /api/allergies` and `POST /api/family-history` have resolved
|
||||
* `requireRecordAuth("manage", "profile")` since MANAGE shipped, while the
|
||||
* only form that posts to either was classified unavailable — an admitted
|
||||
* write with no reachable caller. These three legs are the classification
|
||||
* half of closing that; the integration file drives the routes themselves.
|
||||
*/
|
||||
it("opens the anamnesis destination to everybody the routes already admit", () => {
|
||||
expect(classifySettingsDestination("anamnesis")).toEqual({
|
||||
kind: "manage-writable",
|
||||
guardianWritable: true,
|
||||
});
|
||||
// The Guardian half. Before this the destination existed and was
|
||||
// read-only for the one person the profile exists for.
|
||||
expect(isGuardianSettingsWriteAllowed("anamnesis")).toBe(true);
|
||||
// The adult-delegate half, and its boundary in the same breath: a MANAGE
|
||||
// grant opens the record's health background and nothing else under
|
||||
// Settings. Record CONFIGURATION — modules, thresholds, notification
|
||||
// routing — stays with the owner even though a Guardian may change it.
|
||||
expect(isManageDelegateSettingsDestination("anamnesis")).toBe(true);
|
||||
for (const guardianOnly of [
|
||||
"account",
|
||||
"modules",
|
||||
"notifications",
|
||||
"thresholds",
|
||||
"insights",
|
||||
"coach",
|
||||
"integrations",
|
||||
]) {
|
||||
expect(
|
||||
isManageDelegateSettingsDestination(guardianOnly),
|
||||
guardianOnly,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves exactly one destination on the manage-writable list", () => {
|
||||
// The list is short on purpose and a second entry is a consent decision,
|
||||
// not a classification tidy-up. Pinning the count means a destination
|
||||
// cannot join it as a side effect of somebody reclassifying a neighbour.
|
||||
const manageWritable = Object.entries(SETTINGS_DESTINATION_INVENTORY)
|
||||
.filter(([, value]) => value.kind === "manage-writable")
|
||||
.map(([slug]) => slug);
|
||||
expect(manageWritable).toEqual(["anamnesis"]);
|
||||
});
|
||||
|
||||
it("does not report a synthetic ledger row as a managed connection", () => {
|
||||
const connected = {
|
||||
withings: false,
|
||||
|
||||
@@ -67,39 +67,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
||||
|
||||
async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// v1.36.x — the GET above is delegable and this is not, which is the
|
||||
// opposite of where the classification landed and worth the paragraph.
|
||||
// v1.37.0 — MANAGE, and reachable.
|
||||
//
|
||||
// Nothing about the row changed: it is still a plain statement about the
|
||||
// record's own body, still the single most useful thing a caregiver could
|
||||
// contribute, and the argument for admitting it still holds. What it does
|
||||
// not have is a caller. The only place in the product that posts here is the
|
||||
// allergy manager in Settings → Anamnese, and `/settings/*` is not a
|
||||
// shared-record destination — the shell shows the "not part of what was
|
||||
// shared" panel there, so no delegate can reach the form at any level.
|
||||
// Recording an allergy for somebody whose record you manage is the case the
|
||||
// level exists for; on a managed profile it is the guardian's ordinary act.
|
||||
// Additive, audited, and the row belongs to the record owner.
|
||||
//
|
||||
// An admitted write with no reachable surface is the one-ended change this
|
||||
// repository keeps rediscovering (CLAUDE.md, "A two-ended change carries
|
||||
// both ends"): the permission ships, the consumer is the follow-up, and
|
||||
// nothing in the gate notices because every other check proves the other
|
||||
// end. The frozen list is built the other way round on purpose — its own
|
||||
// actor-surface note says the rest "arrive as their own diffs; naming them
|
||||
// before they exist would freeze a guess."
|
||||
// The surface is Settings → Anamnese inside the record
|
||||
// (`src/components/settings/record-anamnesis-section.tsx`, reached through
|
||||
// `record-settings-section-gate.tsx` for any destination classified
|
||||
// `manage-writable`). This arm carried a long paragraph through v1.36.x
|
||||
// arguing that the write was deliberately unreachable and waiting for that
|
||||
// surface; the surface exists now, so the paragraph is gone rather than left
|
||||
// to send the next reader back to close what it opens.
|
||||
//
|
||||
// So this arm waits for the surface that would exercise it, and the two
|
||||
// land together. Choosing that surface is design work, not a fix: allergies
|
||||
// and family history have exactly one home today, that home is a personal
|
||||
// account surface a switch rightly closes, and bolting a second copy onto a
|
||||
// shared page would split one concept across two places. Re-admitting is
|
||||
// one line here plus one entry in `sharing-surface-guard.test.ts` plus the
|
||||
// paragraph that argues it — which is exactly the reviewed diff that guard
|
||||
// exists to force.
|
||||
//
|
||||
// The half that was always the point is untouched: a caregiver can still
|
||||
// READ the allergy list inside the record.
|
||||
// v1.37.0 — MANAGE. Recording an allergy for somebody whose record you
|
||||
// manage is the case the level exists for; on a managed profile it is the
|
||||
// guardian's ordinary act. Additive, audited, and the row is the record's.
|
||||
// A READ or WRITE delegate reaches neither: the destination is not on their
|
||||
// Settings list, and this line refuses them with `sharing.access.denied`
|
||||
// regardless.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
|
||||
@@ -62,23 +62,18 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postFamilyHistory(request: NextRequest): Promise<Response> {
|
||||
// v1.36.x — not a delegated write, though the GET above is delegable. The
|
||||
// reasoning is written out at the sibling arm in `api/allergies/route.ts`
|
||||
// and is the same here: the only surface that posts to either route is the
|
||||
// manager in Settings → Anamnese, and `/settings/*` is closed inside a
|
||||
// shared record, so no delegate can reach the form at any level. An
|
||||
// admitted write with no reachable caller is a permission frozen ahead of
|
||||
// the surface that would exercise it.
|
||||
// v1.37.0 — MANAGE, and reachable. Third-party data by design, admitted on
|
||||
// the same reasoning the read arm was: it is the record's own health
|
||||
// background, and the surface is Settings → Anamnese inside the record
|
||||
// (`src/components/settings/record-anamnesis-section.tsx`).
|
||||
//
|
||||
// This arm carried an extra reason to wait. The row describes the record's
|
||||
// RELATIVES, so it is the one admitted write where third-party health
|
||||
// information is present by design — and frequently the delegate is that
|
||||
// relative, asserting a condition about themselves into somebody else's
|
||||
// record. The classification admitted it and named the discomfort. Landing
|
||||
// it together with the surface that offers it means the copy on that surface
|
||||
// can say whose statement the row is, which no route comment can.
|
||||
// v1.37.0 — MANAGE. Third-party data by design, admitted on the same
|
||||
// reasoning the read arm was: it is the record's own health background.
|
||||
// This arm carried an extra reason to wait, and that reason is now answered
|
||||
// where it can be. The row describes the record subject's RELATIVES, so it
|
||||
// is the one admitted write where third-party health information is present
|
||||
// by design — and frequently the delegate IS that relative, asserting a
|
||||
// condition about themselves into somebody else's record. No route comment
|
||||
// can say that to the person filling the form; the card above the form says
|
||||
// it (`recordSharing.anamnesis.relativeNote`).
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isManageDelegateSettingsDestination } from "@/lib/record-settings";
|
||||
import { isDestinationInSharedRecord } from "./nav-model";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
@@ -84,15 +85,35 @@ export function AuthShell({
|
||||
const {
|
||||
inSharedRecord,
|
||||
recordKind,
|
||||
level,
|
||||
sections,
|
||||
accessRefused,
|
||||
recordSessionPending,
|
||||
} = useRecordCapabilities();
|
||||
// A managed profile has a deliberately small Settings surface of its own.
|
||||
// Let that route family reach its record-aware gate; adult shared records
|
||||
// stay in the generic refusal branch even at MANAGE.
|
||||
const isManagedRecordSettingsPath =
|
||||
recordKind === "managed" && pathname.startsWith("/settings");
|
||||
// A shared record has a deliberately small Settings surface of its own, and
|
||||
// which routes belong to it depends on the record.
|
||||
//
|
||||
// A managed profile keeps the whole `/settings` family reaching its
|
||||
// record-aware gate: guardian configuration lives there and the gate refuses
|
||||
// per destination.
|
||||
//
|
||||
// v1.37.0 — an ordinary shared record at MANAGE reaches exactly the
|
||||
// destinations classified `manage-writable`, which today is Settings →
|
||||
// Anamnese and nothing else. That is not a widening of what a delegate may
|
||||
// do: `POST /api/allergies` and `POST /api/family-history` already resolve
|
||||
// `requireRecordAuth("manage", "profile")` and have done since the level
|
||||
// shipped. What was missing was any page from which to call them, and an
|
||||
// admitted write with no reachable caller is the half-wired state this
|
||||
// repository keeps rediscovering. Every other `/settings` path in an adult
|
||||
// share stays in the generic refusal branch, at every level.
|
||||
const settingsSlug = pathname.startsWith("/settings")
|
||||
? (pathname.split("/")[2] ?? "")
|
||||
: null;
|
||||
const isRecordSettingsPath =
|
||||
settingsSlug !== null &&
|
||||
(recordKind === "managed" ||
|
||||
(level === "manage" &&
|
||||
isManageDelegateSettingsDestination(settingsSlug)));
|
||||
// v1.36.0 — a page reached while acting on somebody else's record that
|
||||
// sharing does not cover. The nav already drops these entries, so arriving
|
||||
// here means a bookmark or a browser back button; the panel below explains
|
||||
@@ -101,7 +122,7 @@ export function AuthShell({
|
||||
const outsideSharedRecord =
|
||||
inSharedRecord &&
|
||||
!isDestinationInSharedRecord(pathname, sections) &&
|
||||
!isManagedRecordSettingsPath;
|
||||
!isRecordSettingsPath;
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldAlert, Users } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { AllergyManager } from "@/components/records/allergy-manager";
|
||||
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.37.0 — Settings → Anamnese, inside somebody else's record.
|
||||
*
|
||||
* ## Why a second component instead of the personal one
|
||||
*
|
||||
* `AnamnesisSection` renders five managers, and only two of them belong to the
|
||||
* record. The other three write through `/api/coach/about-me` and
|
||||
* `/api/anamnesis/facts`, both of which resolve a bare `requireAuth()` and
|
||||
* therefore refuse outright under a switch. Mounting the personal section here
|
||||
* would put three forms on screen that answer 403 on submit — the precise
|
||||
* failure `useRecordCapabilities` was written to end ("a control that appeared
|
||||
* before its route answered"). So this surface is the intersection of the
|
||||
* section and what the record routes admit, and it is a separate component so
|
||||
* that intersection is visible rather than expressed as four conditionals
|
||||
* inside the personal one.
|
||||
*
|
||||
* ## What is here
|
||||
*
|
||||
* Allergies and family history. Both post to routes that resolve
|
||||
* `requireRecordAuth("manage", "profile")`, so both land under the record
|
||||
* owner with the delegate named as actor, and a READ or WRITE delegate is
|
||||
* refused by the route as well as never reaching this page.
|
||||
*
|
||||
* Family history is the one admitted write where third-party health
|
||||
* information is present by design — the row describes the record subject's
|
||||
* relatives, and often enough the delegate IS that relative, asserting a
|
||||
* condition about themselves into somebody else's record. The route's own
|
||||
* comment named that discomfort and left it for the surface to say out loud,
|
||||
* because no route comment can. The sentence under the card is where it gets
|
||||
* said.
|
||||
*
|
||||
* The cache is not a concern here and the reason is worth naming: switching
|
||||
* records clears the whole query client, so the allergy and family-history
|
||||
* lists cannot carry one record's rows into another's view.
|
||||
*/
|
||||
export function RecordAnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="record-anamnesis-section">
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldAlert}
|
||||
title={t("records.allergies.cardTitle")}
|
||||
description={t("records.allergies.cardDescription")}
|
||||
/>
|
||||
<AllergyManager />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("records.family.cardTitle")}
|
||||
description={t("records.family.cardDescription")}
|
||||
/>
|
||||
<FamilyHistoryManager />
|
||||
<p
|
||||
data-slot="record-anamnesis-relative-note"
|
||||
className="text-foreground text-sm"
|
||||
>
|
||||
{t("recordSharing.anamnesis.relativeNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status";
|
||||
import { ManagedRecordSettingsSection } from "@/components/settings/managed-record-settings-section";
|
||||
import { RecordAnamnesisSection } from "@/components/settings/record-anamnesis-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
@@ -35,7 +36,7 @@ export function RecordSettingsSectionGate({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { isLoading } = useAuth();
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
const { inSharedRecord, recordKind, level } = useRecordCapabilities();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// A shared session must never begin by mounting the actor's Settings
|
||||
@@ -53,6 +54,21 @@ export function RecordSettingsSectionGate({
|
||||
if (!inSharedRecord) return children;
|
||||
|
||||
const destination = classifySettingsDestination(section);
|
||||
|
||||
// v1.37.0 — the one destination that is record CONTENT rather than record
|
||||
// configuration, and therefore the one every MANAGE holder reaches: a
|
||||
// Guardian inside the profile they administer, and an adult delegate whose
|
||||
// grant is MANAGE. Both post to routes that already resolve
|
||||
// `requireRecordAuth("manage", "profile")`; before this the forms had no
|
||||
// reachable page and the permission had no caller.
|
||||
//
|
||||
// Checked before the managed branches so a Guardian gets the same record
|
||||
// surface an adult manager does, rather than the guardian configuration
|
||||
// panel, which has no family for it.
|
||||
if (destination.kind === "manage-writable" && level === "manage") {
|
||||
return <RecordAnamnesisSection />;
|
||||
}
|
||||
|
||||
if (
|
||||
recordKind === "managed" &&
|
||||
destination.kind === "managed-guardian" &&
|
||||
|
||||
@@ -439,19 +439,32 @@ export function SettingsShell({
|
||||
modules?.[section.moduleGate] !== false,
|
||||
);
|
||||
|
||||
// The server has already resolved which record is active. In a managed
|
||||
// profile, only guardian settings are a navigation destination; the section
|
||||
// gate remains responsible for refusing any direct URL that is not here.
|
||||
// The server has already resolved which record is active, and which
|
||||
// destinations that record offers depends on what kind of record it is. The
|
||||
// section gate remains responsible for refusing any direct URL that is not
|
||||
// on the list; this filter only stops the nav from offering one.
|
||||
//
|
||||
// Keep `allVisibleSections` for the active heading so a direct unavailable
|
||||
// link identifies itself before the gate explains why it cannot open.
|
||||
//
|
||||
// * A managed profile shows its guardian configuration AND the record
|
||||
// content a MANAGE holder may write — the guardian holds MANAGE, so the
|
||||
// second set is theirs as well.
|
||||
// * An ordinary shared record at MANAGE shows only the record content. A
|
||||
// delegate manages somebody's health record, not their account: modules,
|
||||
// thresholds and notification routing stay with the owner.
|
||||
// * Every other shared record shows nothing here, because it reaches no
|
||||
// Settings destination at all.
|
||||
const visibleSections =
|
||||
activeRecord?.recordKind === "managed"
|
||||
? allVisibleSections.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind ===
|
||||
"managed-guardian",
|
||||
)
|
||||
: allVisibleSections;
|
||||
activeRecord === null
|
||||
? allVisibleSections
|
||||
: allVisibleSections.filter((section) => {
|
||||
const kind = classifySettingsDestination(section.slug).kind;
|
||||
if (activeRecord.recordKind === "managed") {
|
||||
return kind === "managed-guardian" || kind === "manage-writable";
|
||||
}
|
||||
return kind === "manage-writable" && activeRecord.level === "manage";
|
||||
});
|
||||
const activeSection = allVisibleSections.find(
|
||||
(section) => section.slug === activeSlug,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
export type SettingsDestinationKind =
|
||||
"personal" | "managed-guardian" | "adult-shared-unavailable" | "unavailable";
|
||||
| "personal"
|
||||
| "managed-guardian"
|
||||
| "manage-writable"
|
||||
| "adult-shared-unavailable"
|
||||
| "unavailable";
|
||||
|
||||
export interface SettingsDestinationClassification {
|
||||
kind: SettingsDestinationKind;
|
||||
@@ -23,6 +27,32 @@ const MANAGED_GUARDIAN_STATUS: SettingsDestinationClassification = {
|
||||
guardianWritable: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.37.0 — reachable by everybody the record routes behind it already admit.
|
||||
*
|
||||
* The other four kinds each answer for one population. `MANAGED_GUARDIAN` is
|
||||
* guardian-only; `ADULT_SHARED_UNAVAILABLE` is closed to adult shares and open
|
||||
* to nobody else; neither can express "the same set the route admits", which
|
||||
* for the anamnesis surface is a Guardian on a managed profile AND an adult
|
||||
* delegate holding MANAGE.
|
||||
*
|
||||
* That gap had a consequence rather than being a taxonomy nicety. `POST
|
||||
* /api/allergies` and `POST /api/family-history` both resolve
|
||||
* `requireRecordAuth("manage", "profile")` — the write is admitted, audited
|
||||
* and owner-scoped — while the only form in the product that posts to either
|
||||
* lives under Settings → Anamnese, which was classified unavailable. An
|
||||
* admitted write with no reachable caller is the one-ended change this
|
||||
* repository keeps rediscovering: the permission ships, the surface is "the
|
||||
* follow-up", and every other check proves the other end.
|
||||
*
|
||||
* `guardianWritable` is true because a Guardian writes here too; the kind is
|
||||
* what says the destination is not guardian-ONLY.
|
||||
*/
|
||||
const MANAGE_WRITABLE: SettingsDestinationClassification = {
|
||||
kind: "manage-writable",
|
||||
guardianWritable: true,
|
||||
};
|
||||
|
||||
const ADULT_SHARED_UNAVAILABLE: SettingsDestinationClassification = {
|
||||
kind: "adult-shared-unavailable",
|
||||
guardianWritable: false,
|
||||
@@ -55,7 +85,7 @@ export const SETTINGS_DESTINATION_INVENTORY = {
|
||||
labs: ADULT_SHARED_UNAVAILABLE,
|
||||
illness: ADULT_SHARED_UNAVAILABLE,
|
||||
environment: UNAVAILABLE,
|
||||
anamnesis: ADULT_SHARED_UNAVAILABLE,
|
||||
anamnesis: MANAGE_WRITABLE,
|
||||
score: ADULT_SHARED_UNAVAILABLE,
|
||||
vorsorge: ADULT_SHARED_UNAVAILABLE,
|
||||
thresholds: MANAGED_GUARDIAN,
|
||||
@@ -83,10 +113,36 @@ export function classifySettingsDestination(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* May a Guardian change this destination inside the profile they administer.
|
||||
*
|
||||
* Two kinds qualify, and the second is not a widening of the first: a
|
||||
* `manage-writable` destination is one the record routes already admit at
|
||||
* MANAGE, and a Guardian holds MANAGE by construction. Naming only
|
||||
* `managed-guardian` here would have left the anamnesis destination visible
|
||||
* and read-only for the one person the record exists for.
|
||||
*/
|
||||
export function isGuardianSettingsWriteAllowed(destination: string): boolean {
|
||||
const classification = classifySettingsDestination(destination);
|
||||
return (
|
||||
classification.kind === "managed-guardian" &&
|
||||
(classification.kind === "managed-guardian" ||
|
||||
classification.kind === "manage-writable") &&
|
||||
classification.guardianWritable
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this destination part of the record surface an adult MANAGE grant opens.
|
||||
*
|
||||
* Deliberately narrower than {@link isGuardianSettingsWriteAllowed}: a
|
||||
* Guardian administers a profile and reaches the record's configuration —
|
||||
* modules, thresholds, notification routing — while an adult delegate reaches
|
||||
* only the health record itself. `manage-writable` is the one kind that is
|
||||
* record content rather than record configuration, so it is the only kind an
|
||||
* ordinary shared record opens under `/settings`.
|
||||
*/
|
||||
export function isManageDelegateSettingsDestination(
|
||||
destination: string,
|
||||
): boolean {
|
||||
return classifySettingsDestination(destination).kind === "manage-writable";
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export { resolveManagedIntegrationState } from "./integrations";
|
||||
export {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
isManageDelegateSettingsDestination,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
type SettingsDestinationClassification,
|
||||
type SettingsDestinationKind,
|
||||
|
||||
@@ -117,7 +117,7 @@ async function signIn(userId: string) {
|
||||
async function switchInto(
|
||||
ownerId: string,
|
||||
delegateId: string,
|
||||
access: "READ" | "WRITE",
|
||||
access: "READ" | "WRITE" | "MANAGE",
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
@@ -421,82 +421,155 @@ writeContract("POST /api/biomarkers", {
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 4 + 5 — allergies and family history, the two that are NOT delegable */
|
||||
/* 4 + 5 — allergies and family history, admitted at MANAGE */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The opposite contract, and it earns its place beside the admitted ones.
|
||||
* The MANAGE contract, and why the WRITE one will not do.
|
||||
*
|
||||
* Both of these verbs were admitted and then withdrawn, and the argument for
|
||||
* admitting them was never wrong: an allergy is a plain statement about the
|
||||
* record's own body and the single most useful thing a caregiver could
|
||||
* contribute. What they lack is a caller. The only surface in the product that
|
||||
* posts to either lives in Settings, and a switch closes `/settings` — so no
|
||||
* delegate can reach the form at any grant level, and admitting the write
|
||||
* would freeze a permission ahead of anything that exercises it.
|
||||
* `writeContract` above asserts that a WRITE grant writes. A route that
|
||||
* declares `requireRecordAuth("manage", …)` must REFUSE a WRITE grant, so the
|
||||
* two contracts differ in their first leg and agree on every other: the row
|
||||
* lands under the owner and under nobody else, the audit row is filed under the
|
||||
* owner with the delegate named as actor, the refusals are the stable
|
||||
* `sharing.access.denied`, revocation takes effect on the very next request,
|
||||
* and an unswitched owner is unaffected.
|
||||
*
|
||||
* Both READ arms stay delegable; the read suite pins them. What is pinned here
|
||||
* is the pair that has to move together: the route refuses, and the owner's own
|
||||
* unswitched write still lands. Withdrawing a delegated write by breaking the
|
||||
* ordinary one would be the worse bug, and no other test in this file would
|
||||
* have noticed.
|
||||
* Two legs are new coverage rather than a rewrite, and they are the reason this
|
||||
* block exists. Nothing in the repository drove a MANAGE delegate against
|
||||
* either of these routes before, and nothing drove a Guardian against them at
|
||||
* all — the routes had admitted the write since the level shipped while the
|
||||
* only form that posts to them was classified unavailable, so the permission
|
||||
* had no reachable caller. Plan 13 opened the surface; these are the legs that
|
||||
* say the write it leads to actually lands.
|
||||
*
|
||||
* Re-admitting means flipping `requireAuth()` back to `requireRecordAuth`,
|
||||
* re-listing the route in `sharing-surface-guard.test.ts`, and replacing this
|
||||
* block with a `writeContract` — in the same diff as the caregiver-reachable
|
||||
* surface that made it worth doing.
|
||||
* What this block replaces is a `refusedWriteContract` whose legs asserted
|
||||
* `sharing.not_permitted` — the refusal a route that declares NO sharing mode
|
||||
* returns. These routes declare one, so a READ or WRITE delegate gets
|
||||
* `sharing.access.denied` instead, and those four assertions had been failing
|
||||
* against the shipped routes for a release.
|
||||
*/
|
||||
function refusedWriteContract<C = undefined>(
|
||||
name: string,
|
||||
c: {
|
||||
/** Seed whatever the write needs, owned by the RECORD. Mirrors `writeContract`. */
|
||||
prepare?: (recordOwnerId: string) => Promise<C>;
|
||||
call: (ctx: C) => Promise<Response>;
|
||||
ok: number;
|
||||
count: (userId: string) => Promise<number>;
|
||||
},
|
||||
) {
|
||||
function manageWriteContract<C>(name: string, c: WriteCase<C>) {
|
||||
const prepare = c.prepare ?? (async () => undefined as C);
|
||||
|
||||
describe(name, () => {
|
||||
it("writes into the owner's record under a live MANAGE grant", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ctx = await prepare(owner.id);
|
||||
|
||||
await switchInto(owner.id, delegate.id, "MANAGE");
|
||||
const response = await c.call(ctx);
|
||||
|
||||
expect(response.status).toBe(c.ok);
|
||||
// Under the owner, and under nobody else.
|
||||
expect(await c.count(owner.id)).toBe(1);
|
||||
expect(await c.count(delegate.id)).toBe(0);
|
||||
});
|
||||
|
||||
it("writes into a managed profile for its Guardian", async () => {
|
||||
// The population the surface exists for, driven through the shipped
|
||||
// creation service rather than a hand-planted grant: a Guardian is a
|
||||
// MANAGE holder on a credential-less record, and this is the only place
|
||||
// in the suite that puts one against these routes.
|
||||
const guardian = await makeUser("guardian");
|
||||
const { createManagedProfile } =
|
||||
await import("@/lib/managed-profiles/create");
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Managed profile",
|
||||
dateOfBirth: null,
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
const ctx = await prepare(profile.id);
|
||||
|
||||
const session = await signIn(guardian.id);
|
||||
await switchSessionTo(session.id, profile.id);
|
||||
|
||||
expect((await c.call(ctx)).status).toBe(c.ok);
|
||||
expect(await c.count(profile.id)).toBe(1);
|
||||
expect(await c.count(guardian.id)).toBe(0);
|
||||
});
|
||||
|
||||
it("files the audit row under the owner and names the delegate as actor", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ctx = await prepare(owner.id);
|
||||
|
||||
await switchInto(owner.id, delegate.id, "MANAGE");
|
||||
expect((await c.call(ctx)).status).toBe(c.ok);
|
||||
|
||||
const row = await getPrismaClient().auditLog.findFirst({
|
||||
where: { action: c.auditAction },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(row, `no ${c.auditAction} row was written`).not.toBeNull();
|
||||
expect(row?.userId).toBe(owner.id);
|
||||
expect(row?.actorUserId).toBe(delegate.id);
|
||||
});
|
||||
|
||||
for (const access of ["READ", "WRITE"] as const) {
|
||||
it(`refuses a delegate holding a ${access} grant, and writes nothing`, async () => {
|
||||
it(`refuses a ${access} delegate with sharing.access.denied, and writes nothing`, async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ctx = await prepare(owner.id);
|
||||
|
||||
await switchInto(owner.id, delegate.id, access);
|
||||
const response = await c.call(ctx);
|
||||
// The insufficient-level refusal, not the undeclared-mode one. The
|
||||
// route names a mode; what the delegate lacks is the level.
|
||||
await expectDenied(await c.call(ctx));
|
||||
|
||||
// The undeclared-mode refusal, not the no-grant one: the route names
|
||||
// no sharing mode at all, so the carrier is refused before any grant
|
||||
// is consulted. A WRITE grant makes no difference, which is the point.
|
||||
expect(response.status).toBe(403);
|
||||
expect((await payload(response)).meta?.errorCode).toBe(
|
||||
"sharing.not_permitted",
|
||||
);
|
||||
|
||||
// Neither account, not just not the owner's — a handler that fell
|
||||
// back to the caller would have written a row somewhere.
|
||||
expect(await c.count(owner.id)).toBe(0);
|
||||
expect(await c.count(delegate.id)).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("still lets the owner write it themselves", async () => {
|
||||
it("refuses a caller who names a record they were never granted", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const stranger = await makeUser("stranger");
|
||||
const ctx = await prepare(owner.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
await claimWithoutGrant(owner.id, stranger.id);
|
||||
await expectDenied(await c.call(ctx));
|
||||
|
||||
expect(await c.count(owner.id)).toBe(0);
|
||||
expect(await c.count(stranger.id)).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses the next request after the grant is revoked", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ctx = await prepare(owner.id);
|
||||
|
||||
const { grant } = await switchInto(owner.id, delegate.id, "MANAGE");
|
||||
expect((await c.call(ctx)).status).toBe(c.ok);
|
||||
|
||||
await revoke(grant.id, owner.id);
|
||||
await expectDenied(await c.call(ctx));
|
||||
expect(await c.count(owner.id)).toBe(1);
|
||||
});
|
||||
|
||||
it("is unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
const ctx = await prepare(plain.id);
|
||||
await signIn(plain.id);
|
||||
|
||||
const response = await c.call(ctx);
|
||||
|
||||
expect(response.status).toBe(c.ok);
|
||||
expect(await c.count(owner.id)).toBe(1);
|
||||
expect(await c.count(plain.id)).toBe(1);
|
||||
|
||||
const row = await getPrismaClient().auditLog.findFirst({
|
||||
where: { action: c.auditAction },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(row?.userId).toBe(plain.id);
|
||||
expect(row?.actorUserId).toBeNull();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refusedWriteContract("POST /api/allergies", {
|
||||
manageWriteContract("POST /api/allergies", {
|
||||
call: async () => {
|
||||
const { POST } = await import("@/app/api/allergies/route");
|
||||
return post(POST as Handler, "/api/allergies", {
|
||||
@@ -506,10 +579,11 @@ refusedWriteContract("POST /api/allergies", {
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "allergy.create",
|
||||
count: (userId) => getPrismaClient().allergy.count({ where: { userId } }),
|
||||
});
|
||||
|
||||
refusedWriteContract("POST /api/family-history", {
|
||||
manageWriteContract("POST /api/family-history", {
|
||||
call: async () => {
|
||||
const { POST } = await import("@/app/api/family-history/route");
|
||||
return post(POST as Handler, "/api/family-history", {
|
||||
@@ -518,6 +592,7 @@ refusedWriteContract("POST /api/family-history", {
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "family-history.create",
|
||||
count: (userId) =>
|
||||
getPrismaClient().familyHistoryEntry.count({ where: { userId } }),
|
||||
});
|
||||
@@ -578,6 +653,69 @@ describe("POST /api/illness/episodes — the module gate", () => {
|
||||
/* 7 — custom-metric entries, the third that is NOT delegable */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The contract for a route that declares NO sharing mode at all.
|
||||
*
|
||||
* Its legs assert `sharing.not_permitted` — the refusal the carrier collects
|
||||
* before any grant is consulted — which is a different fact from
|
||||
* `sharing.access.denied` and is what makes the pair worth keeping apart. A
|
||||
* route that names a mode and refuses a level answers the second; a route that
|
||||
* names nothing answers the first, and a WRITE grant makes no difference to it.
|
||||
*
|
||||
* v1.37.0 — allergies and family history left this helper for
|
||||
* `manageWriteContract` above, in the same diff as the surface that made their
|
||||
* admitted writes reachable. One caller remains.
|
||||
*/
|
||||
function refusedWriteContract<C = undefined>(
|
||||
name: string,
|
||||
c: {
|
||||
/** Seed whatever the write needs, owned by the RECORD. Mirrors `writeContract`. */
|
||||
prepare?: (recordOwnerId: string) => Promise<C>;
|
||||
call: (ctx: C) => Promise<Response>;
|
||||
ok: number;
|
||||
count: (userId: string) => Promise<number>;
|
||||
},
|
||||
) {
|
||||
const prepare = c.prepare ?? (async () => undefined as C);
|
||||
|
||||
describe(name, () => {
|
||||
for (const access of ["READ", "WRITE"] as const) {
|
||||
it(`refuses a delegate holding a ${access} grant, and writes nothing`, async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ctx = await prepare(owner.id);
|
||||
|
||||
await switchInto(owner.id, delegate.id, access);
|
||||
const response = await c.call(ctx);
|
||||
|
||||
// The undeclared-mode refusal, not the no-grant one: the route names
|
||||
// no sharing mode at all, so the carrier is refused before any grant
|
||||
// is consulted. A WRITE grant makes no difference, which is the point.
|
||||
expect(response.status).toBe(403);
|
||||
expect((await payload(response)).meta?.errorCode).toBe(
|
||||
"sharing.not_permitted",
|
||||
);
|
||||
|
||||
// Neither account, not just not the owner's — a handler that fell
|
||||
// back to the caller would have written a row somewhere.
|
||||
expect(await c.count(owner.id)).toBe(0);
|
||||
expect(await c.count(delegate.id)).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("still lets the owner write it themselves", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const ctx = await prepare(owner.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
const response = await c.call(ctx);
|
||||
|
||||
expect(response.status).toBe(c.ok);
|
||||
expect(await c.count(owner.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The third withdrawal, one release after the other two, and the delay is what
|
||||
* makes it worth a paragraph: the rule that removed allergies and family
|
||||
|
||||
Reference in new issue
Block a user