mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat: describe managed sharing activity
Every action a manage-level route files now renders as its own sentence in the owner's activity view, in all six locales. A write delegate can only add, so the generic "made a change" was at least the right shape of fact; a manager changes and removes entries the owner made themselves, and the generic line withholds the only part that matters — whether a reading was corrected or deleted, whether a dose history was purged. The inventory reads the routes and calls the map rather than matching `case` in its source, so an action that falls through to the fallback fails, and it follows each resolved key into all six bundles. Every sentence interpolates the actor's name and nothing else: the audit table is not encrypted and a value in a verb line would be a health figure in a second store. The revoke dialog's retention disclosure no longer depends on which of two requests answers first. `retentionDays` rides the grant list, so a client holding a row to revoke holds the window it must state, and the branch that omitted the sentence is gone.
This commit is contained in:
1 parent
25b1b0eab6
commit
c92a80ee4c
15 files changed
+1131
-61
No files matched your search
@@ -0,0 +1,487 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { revokeBody } from "@/components/settings/access/grants-given-card";
|
||||
import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
|
||||
|
||||
/**
|
||||
* Every MANAGE-admitted action names itself, in all six locales.
|
||||
*
|
||||
* ## What this asks that the sibling guard does not
|
||||
*
|
||||
* `src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts` asks the
|
||||
* same question of the WRITE surface: a delegable write whose audit action has
|
||||
* no sentence renders as "made a change". MANAGE is where that answer stops
|
||||
* being merely vague and starts being wrong. A WRITE delegate can only add,
|
||||
* and "somebody added something" is at least the right shape of fact. A MANAGE
|
||||
* delegate can change and remove entries the owner made themselves, so the
|
||||
* generic line would tell an owner that something happened in their record
|
||||
* while withholding the only part they need — whether a reading was corrected
|
||||
* or deleted, whether a medication was stopped, whether the dose history was
|
||||
* purged. The level ships under "reconstructable or refused", and a feed that
|
||||
* cannot say which verb ran does not reconstruct anything.
|
||||
*
|
||||
* So the inventory here is the MANAGE half of the same sweep, and it goes two
|
||||
* steps further than its sibling:
|
||||
*
|
||||
* 1. it resolves the sentence by CALLING the map rather than by matching
|
||||
* `case "…"` in its source, because a `case` inside a comment matches and
|
||||
* an action whose arm falls through to the fallback does not;
|
||||
* 2. it follows the resolved key into all six bundles, because a key that
|
||||
* exists only in English renders as its own dotted path to everyone else.
|
||||
*
|
||||
* ## PHI
|
||||
*
|
||||
* Two legs, and both are about the same failure. The activity feed reads out
|
||||
* of the audit table, the audit table is not encrypted, and the temptation
|
||||
* whenever a sentence feels thin is to put the value in it — "{name} changed
|
||||
* your weight from 81 to 78". That is a health figure in a second store with
|
||||
* its own retention and no rotation story. So: every verb line interpolates
|
||||
* `name` and nothing else, in every locale, and the feed's own DTO carries no
|
||||
* `details` field for a future caller to reach for.
|
||||
*
|
||||
* ## Why the counts are of MATCHES
|
||||
*
|
||||
* The verdict is an empty list of unmapped actions, and an empty match set
|
||||
* says exactly the same thing. Every leg therefore pins the size of what it
|
||||
* matched — modules walked, MANAGE modules found, one of them named, actions
|
||||
* read, sentences resolved — so a matcher that quietly stopped matching fails
|
||||
* instead of passing. `describe("the matcher sees what it looks for")` proves
|
||||
* both halves against synthetic modules, including the one shape that silenced
|
||||
* the sibling guard: a resolver call prettier broke across lines.
|
||||
*/
|
||||
const API = join(process.cwd(), "src/app/api");
|
||||
const MESSAGES = join(process.cwd(), "messages");
|
||||
const ACTIVITY_ROUTE = join(
|
||||
process.cwd(),
|
||||
"src/app/api/account/activity/route.ts",
|
||||
);
|
||||
const GRANTS_GIVEN_CARD = join(
|
||||
process.cwd(),
|
||||
"src/components/settings/access/grants-given-card.tsx",
|
||||
);
|
||||
|
||||
/** The declaration a MANAGE-level route makes. */
|
||||
const RECORD_RESOLVER = "requireRecordAuth";
|
||||
/** The level this file is the inventory of. */
|
||||
const MANAGE = "manage";
|
||||
/** What an action with no sentence of its own renders as. */
|
||||
const FALLBACK_KEY = "recordSharing.activity.acted";
|
||||
/** The six bundles the product ships. */
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
|
||||
* `*` skips a leading dot and this tree has dot-directories. */
|
||||
function routeFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...routeFiles(full));
|
||||
else if (entry.name === "route.ts") out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ModuleFacts {
|
||||
/** Does the module pass `"manage"` to the record resolver anywhere. */
|
||||
managesRecord: boolean;
|
||||
/**
|
||||
* The action names of the module's AWAITED `auditLog("…")` calls.
|
||||
*
|
||||
* Awaited on purpose: a fire-and-forget breadcrumb on a validation failure
|
||||
* is not something to narrate to the owner.
|
||||
*/
|
||||
awaitedAuditActions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both facts off one parse.
|
||||
*
|
||||
* AST rather than substrings, for the reason the sibling guard learned by
|
||||
* going green while matching nothing: prettier moves a call's argument onto
|
||||
* its own line the moment the call grows a second one, and
|
||||
* `requireRecordAuth("manage"` stops appearing in the source text without
|
||||
* anything about the route changing. The import table resolves a local name
|
||||
* back to its exported one, so an alias and a namespace-qualified call both
|
||||
* count, and a comment naming either symbol counts for nothing.
|
||||
*/
|
||||
export function moduleFacts(source: string, fileName: string): ModuleFacts {
|
||||
const parsed = ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
const namespaces = new Set<string>();
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isImportDeclaration(node)) return;
|
||||
const bindings = node.importClause?.namedBindings;
|
||||
if (!bindings) return;
|
||||
if (ts.isNamespaceImport(bindings)) {
|
||||
namespaces.add(bindings.name.text);
|
||||
return;
|
||||
}
|
||||
for (const spec of bindings.elements) {
|
||||
aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text);
|
||||
}
|
||||
});
|
||||
|
||||
/** The original exported name behind this callee, or null. */
|
||||
const calleeName = (callee: ts.Expression): string | null => {
|
||||
if (ts.isIdentifier(callee)) return aliases.get(callee.text) ?? callee.text;
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
namespaces.has(callee.expression.text)
|
||||
) {
|
||||
return callee.name.text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The first argument when it is a plain string literal. */
|
||||
const firstStringArg = (node: ts.CallExpression): string | null => {
|
||||
const first = node.arguments[0];
|
||||
return first && ts.isStringLiteralLike(first) ? first.text : null;
|
||||
};
|
||||
|
||||
let managesRecord = false;
|
||||
const awaitedAuditActions: string[] = [];
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = calleeName(node.expression);
|
||||
if (name === RECORD_RESOLVER && firstStringArg(node) === MANAGE) {
|
||||
managesRecord = true;
|
||||
}
|
||||
}
|
||||
if (ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)) {
|
||||
const call = node.expression;
|
||||
if (calleeName(call.expression) === "auditLog") {
|
||||
const action = firstStringArg(call);
|
||||
if (action !== null) awaitedAuditActions.push(action);
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
parsed.forEachChild(visit);
|
||||
|
||||
return { managesRecord, awaitedAuditActions };
|
||||
}
|
||||
|
||||
/** The message key one action resolves to, or the fallback. */
|
||||
function verbKeyFor(action: string): { key: string; params: string[] } {
|
||||
let key = "";
|
||||
let params: string[] = [];
|
||||
recordActivityVerbLine(
|
||||
(resolved, given) => {
|
||||
key = resolved;
|
||||
params = Object.keys(given ?? {});
|
||||
return resolved;
|
||||
},
|
||||
action,
|
||||
"Alex",
|
||||
);
|
||||
return { key, params };
|
||||
}
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The leaf at a dotted path, or undefined. */
|
||||
function leaf(root: Record<string, unknown>, key: string): unknown {
|
||||
let node: unknown = root;
|
||||
for (const segment of key.split(".")) {
|
||||
if (typeof node !== "object" || node === null) return undefined;
|
||||
node = (node as Record<string, unknown>)[segment];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The `{placeholders}` a translated sentence interpolates. */
|
||||
function placeholders(sentence: string): string[] {
|
||||
return [...sentence.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
|
||||
}
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
const REFORMATTED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth(
|
||||
"manage",
|
||||
"labs",
|
||||
);
|
||||
await auditLog("thing.delete", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
|
||||
it("matches a resolver call broken across lines", () => {
|
||||
// The shape that silenced the sibling guard: the argument moves onto its
|
||||
// own line and a substring test goes quiet with nothing else changed.
|
||||
expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false);
|
||||
const facts = moduleFacts(REFORMATTED, "r.ts");
|
||||
expect(facts.managesRecord).toBe(true);
|
||||
expect(facts.awaitedAuditActions).toEqual(["thing.delete"]);
|
||||
});
|
||||
|
||||
it("matches an aliased and a namespace-qualified resolver call", () => {
|
||||
const ALIASED = `
|
||||
import { requireRecordAuth as resolveRecord } from "@/lib/api-handler";
|
||||
import { auditLog as record } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await resolveRecord("manage", "labs");
|
||||
await record("thing.aliased");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(ALIASED, "a.ts")).toEqual({
|
||||
managesRecord: true,
|
||||
awaitedAuditActions: ["thing.aliased"],
|
||||
});
|
||||
|
||||
const NAMESPACED = `
|
||||
import * as auth from "@/lib/api-handler";
|
||||
|
||||
export const DELETE = auth.apiHandler(async () => {
|
||||
const { user } = await auth.requireRecordAuth("manage", "labs");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(NAMESPACED, "n.ts").managesRecord).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enrol a write route that only talks about managing", () => {
|
||||
const WRITE_ONLY = `
|
||||
/** Delegable for writes. It must never take requireRecordAuth("manage"). */
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
|
||||
const LEVEL = "manage";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
return apiSuccess({ id: user.id, level: LEVEL });
|
||||
});
|
||||
`;
|
||||
// The raw text carries the exact substring a naive membership test uses.
|
||||
expect(WRITE_ONLY.includes('requireRecordAuth("manage")')).toBe(true);
|
||||
expect(moduleFacts(WRITE_ONLY, "wo.ts").managesRecord).toBe(false);
|
||||
});
|
||||
|
||||
it("reads only the awaited audit calls", () => {
|
||||
const MIXED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
void auditLog("thing.breadcrumb", {});
|
||||
await auditLog("thing.delete", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(MIXED, "m.ts").awaitedAuditActions).toEqual([
|
||||
"thing.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still reports the fallback for an action nobody mapped", () => {
|
||||
// The positive control for the recording translator below. Without it, a
|
||||
// `verbKeyFor` that always returned the same string would report every
|
||||
// action as specific and this file would prove nothing.
|
||||
expect(verbKeyFor("nothing.anybody.mapped").key).toBe(FALLBACK_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every MANAGE action has a sentence of its own", () => {
|
||||
const routes = routeFiles(API);
|
||||
const manageModules = routes
|
||||
.map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) }))
|
||||
.filter((m) => m.managesRecord);
|
||||
const actions = [
|
||||
...new Set(manageModules.flatMap((m) => m.awaitedAuditActions)),
|
||||
].sort();
|
||||
|
||||
it("matches the MANAGE modules, and not zero of them", () => {
|
||||
// A walk that finds nothing agrees with an inventory that covers nothing.
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
expect(manageModules.length).toBeGreaterThan(0);
|
||||
// Named anchors, so a matcher that collapsed to one stray file fails
|
||||
// rather than clearing the floor. Deleting a reading and purging a dose
|
||||
// history are the two acts the level exists for and the two an owner most
|
||||
// needs named.
|
||||
const found = manageModules.map((m) => relative(process.cwd(), m.file));
|
||||
expect(found).toContain("src/app/api/measurements/[id]/route.ts");
|
||||
expect(found).toContain(
|
||||
"src/app/api/medications/[id]/intake/purge/route.ts",
|
||||
);
|
||||
// And the actions themselves were read, not merely looked for.
|
||||
expect(actions.length).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("gives every one of them a specific verb rather than the fallback", () => {
|
||||
const generic = actions.filter(
|
||||
(action) => verbKeyFor(action).key === FALLBACK_KEY,
|
||||
);
|
||||
|
||||
expect(
|
||||
generic,
|
||||
"a MANAGE action with no verb line renders to the owner as the generic " +
|
||||
"'made a change'. Add a case to activity-verb.ts and a sentence to " +
|
||||
"all six bundles.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves every verb key in all six bundles", () => {
|
||||
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
|
||||
const missing: string[] = [];
|
||||
let resolved = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
const { key } = verbKeyFor(action);
|
||||
for (const locale of LOCALES) {
|
||||
const value = leaf(bundles.get(locale)!, key);
|
||||
if (typeof value === "string" && value.length > 0) resolved += 1;
|
||||
else missing.push(`${locale}: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing.sort()).toEqual([]);
|
||||
// Six locales for every action, and the actions were counted above.
|
||||
expect(resolved).toBe(actions.length * LOCALES.length);
|
||||
});
|
||||
|
||||
it("interpolates the actor's name and nothing else, in every locale", () => {
|
||||
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
|
||||
const leaky: string[] = [];
|
||||
let checked = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
const { key, params } = verbKeyFor(action);
|
||||
// The call site's own parameters first: a sentence cannot leak a value
|
||||
// the map never hands it.
|
||||
if (params.join(",") !== "name") leaky.push(`${key} passes ${params}`);
|
||||
for (const locale of LOCALES) {
|
||||
const value = leaf(bundles.get(locale)!, key);
|
||||
if (typeof value !== "string") continue;
|
||||
checked += 1;
|
||||
const found = placeholders(value);
|
||||
if (found.length !== 1 || found[0] !== "name") {
|
||||
leaky.push(`${locale}: ${key} interpolates ${found.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(leaky.sort()).toEqual([]);
|
||||
expect(checked).toBe(actions.length * LOCALES.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the retention disclosure does not depend on a race", () => {
|
||||
/**
|
||||
* The sentence that bounds attribution — "who entered what is answerable for
|
||||
* the next N days" — used to appear only if a SECOND query had already
|
||||
* answered. The revoke dialog reads the grant list; the number came off the
|
||||
* activity feed. Two independent requests, and whichever lost decided
|
||||
* whether the owner was told. An owner who clicked quickly, or whose
|
||||
* activity read failed, ended a manage grant having read one sentence fewer
|
||||
* than an owner who clicked slowly, with nothing on screen to indicate that
|
||||
* a sentence was missing.
|
||||
*
|
||||
* The number now rides the same payload as the row: if there is a grant to
|
||||
* revoke, `retentionDays` is already in hand. Deterministic means there is
|
||||
* no branch left, which is what both legs below check from opposite sides —
|
||||
* one that the sentence is always composed, one that the second query is
|
||||
* gone.
|
||||
*/
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}(${JSON.stringify(params)})` : key;
|
||||
|
||||
it("states the window on every grant that could have written something", () => {
|
||||
for (const access of ["WRITE", "MANAGE"] as const) {
|
||||
const body = revokeBody({ t, access, name: "Alex", retentionDays: 90 });
|
||||
expect(body).toContain("recordSharing.given.revokeEntriesStay");
|
||||
expect(body).toContain(
|
||||
'recordSharing.given.revokeAttribution({"days":90})',
|
||||
);
|
||||
}
|
||||
// READ keeps neither: a read grant never put anything in the record, so
|
||||
// saying what happens to what they entered would describe an act that
|
||||
// cannot have occurred.
|
||||
const read = revokeBody({
|
||||
t,
|
||||
access: "READ",
|
||||
name: "Alex",
|
||||
retentionDays: 90,
|
||||
});
|
||||
expect(read).not.toContain("recordSharing.given.revokeAttribution");
|
||||
});
|
||||
|
||||
it("takes the number from the read that produced the row", () => {
|
||||
const source = readFileSync(GRANTS_GIVEN_CARD, "utf8");
|
||||
// Positive control: the matcher can see a hook name in this file at all.
|
||||
expect(source.match(/useAccountGrants/g)?.length ?? 0).toBeGreaterThan(0);
|
||||
// And the second query, whose arrival used to decide the copy, is gone.
|
||||
expect(source).not.toContain("useRecordActivity");
|
||||
// The branch it fed is gone with it. A required number cannot be absent,
|
||||
// so there is nothing left for a caller to omit — which is the difference
|
||||
// between "the sentence is usually there" and "there is no path without
|
||||
// it". Both halves are named: the optional marker and the guard.
|
||||
expect(source).not.toContain("retentionDays?:");
|
||||
expect(source).not.toContain("retentionDays !== undefined");
|
||||
expect(source.match(/retentionDays: number/g)?.length ?? 0).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the activity feed carries no audit detail", () => {
|
||||
/**
|
||||
* The DTO the owner's feed publishes, read off the route.
|
||||
*
|
||||
* A field-level assertion rather than a sentence in a comment, because the
|
||||
* pressure to widen it is real and constant: every thin sentence in the feed
|
||||
* looks like it wants the value that would make it specific, and the audit
|
||||
* row usually has one. `details` is a free-form JSON column written by sixty
|
||||
* handlers; publishing it would put whatever any of them decided to file in
|
||||
* front of the record owner, and some of them file a label.
|
||||
*/
|
||||
it("publishes five fields, and `details` is not one of them", () => {
|
||||
const source = readFileSync(ACTIVITY_ROUTE, "utf8");
|
||||
const parsed = ts.createSourceFile(
|
||||
ACTIVITY_ROUTE,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
let members: string[] | null = null;
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isInterfaceDeclaration(node)) return;
|
||||
if (node.name.text !== "RecordActivityEntry") return;
|
||||
members = node.members
|
||||
.map((m) => (m.name && ts.isIdentifier(m.name) ? m.name.text : ""))
|
||||
.filter((n) => n.length > 0)
|
||||
.sort();
|
||||
});
|
||||
|
||||
// The interface was found at all. `toEqual` against null would report the
|
||||
// absence as a mismatch of the list, which reads like the wrong failure.
|
||||
expect(
|
||||
members,
|
||||
"RecordActivityEntry not found in the route",
|
||||
).not.toBeNull();
|
||||
expect(members).toEqual(["accesses", "action", "actor", "createdAt", "id"]);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
|
||||
@@ -102,6 +103,21 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({
|
||||
given: given.map((g) => toGrantView(g, g.grantee, now)),
|
||||
received: received.map((g) => toGrantView(g, g.grantor, now)),
|
||||
// v1.37.0 — the audit window, on the payload that carries the rows.
|
||||
//
|
||||
// The revoke dialog tells the owner how long "who entered what" stays
|
||||
// answerable after the access ends, and it can only say that with the
|
||||
// number the operator configured. It used to read that number off the
|
||||
// activity feed, a second and independent request, so whether the sentence
|
||||
// appeared depended on which of two reads finished first — and an owner
|
||||
// who clicked quickly ended a manage grant having been told less than one
|
||||
// who clicked slowly, with nothing on screen to say a sentence was
|
||||
// missing. Riding it here removes the race by construction: a client
|
||||
// holding a grant to revoke is holding the window it must disclose.
|
||||
//
|
||||
// Same resolver as `/api/account/activity` and as the purge job, so the
|
||||
// two surfaces cannot state different numbers.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,19 @@ import type {
|
||||
RecordActivity,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
/**
|
||||
* A grant payload, with the audit window every real one carries.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
|
||||
* dialog can state how long attribution survives without waiting on a second
|
||||
* request. A fixture that omitted it would be describing a payload the server
|
||||
* cannot send.
|
||||
*/
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
const activityRef: { value: RecordActivity | undefined } = { value: undefined };
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
@@ -75,7 +87,7 @@ const BASE: GrantRow = {
|
||||
|
||||
describe("who can open my record", () => {
|
||||
it("says so honestly when nobody can", () => {
|
||||
grantsRef.value = { given: [], received: [] };
|
||||
grantsRef.value = grantList();
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("Nobody else can open your record");
|
||||
expect(html).not.toContain('data-slot="grants-given-list"');
|
||||
@@ -86,7 +98,7 @@ describe("who can open my record", () => {
|
||||
// produced. A panel that compared `expiresAt` to the clock would be a
|
||||
// second opinion about somebody's access to a health record, and the two
|
||||
// would disagree first in the dangerous direction.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{ ...BASE, id: "a", state: "ACTIVE" },
|
||||
{ ...BASE, id: "p", state: "PENDING", acceptedAt: null },
|
||||
@@ -98,7 +110,7 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain('data-grant-state="ACTIVE"');
|
||||
expect(html).toContain('data-grant-state="PENDING"');
|
||||
@@ -109,7 +121,7 @@ describe("who can open my record", () => {
|
||||
// The row IS the consent record. "Who had access, from when to when, and
|
||||
// who ended it" is the question the panel exists to answer, and a list
|
||||
// that dropped the ended rows could not answer it.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
@@ -119,14 +131,14 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain('data-grant-state="REVOKED"');
|
||||
expect(html).toContain("You ended it");
|
||||
});
|
||||
|
||||
it("distinguishes a withdrawal from a handover", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
@@ -136,7 +148,7 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
// Two facts, one column. A trail that conflated them would misread an
|
||||
// ordinary handover as a falling-out.
|
||||
@@ -145,20 +157,20 @@ describe("who can open my record", () => {
|
||||
});
|
||||
|
||||
it("offers no way to end a grant that has already ended", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [{ ...BASE, state: "REVOKED", revokedAt: "2026-02-01T09:00:00Z" }],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const ended = render(<GrantsGivenCard />);
|
||||
expect(ended).not.toContain('data-slot="grant-revoke"');
|
||||
|
||||
grantsRef.value = { given: [BASE], received: [] };
|
||||
grantsRef.value = grantList({ given: [BASE] });
|
||||
const live = render(<GrantsGivenCard />);
|
||||
expect(live).toContain('data-slot="grant-revoke"');
|
||||
});
|
||||
|
||||
it("says a live grant has never been used rather than leaving a blank", () => {
|
||||
grantsRef.value = { given: [{ ...BASE, lastUsedAt: null }], received: [] };
|
||||
grantsRef.value = grantList({ given: [{ ...BASE, lastUsedAt: null }] });
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("never");
|
||||
});
|
||||
@@ -166,10 +178,10 @@ describe("who can open my record", () => {
|
||||
|
||||
describe("records I can open", () => {
|
||||
it("offers acceptance on an invitation and nothing else", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [{ ...BASE, state: "PENDING", acceptedAt: null }],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-slot="grant-accept"');
|
||||
// Nothing to open and nothing to hand back: an invitation confers nothing.
|
||||
@@ -178,7 +190,7 @@ describe("records I can open", () => {
|
||||
});
|
||||
|
||||
it("offers opening and handing back once it is active", () => {
|
||||
grantsRef.value = { given: [], received: [BASE] };
|
||||
grantsRef.value = grantList({ received: [BASE] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-slot="grant-open"');
|
||||
expect(html).toContain('data-account-id="acct-1"');
|
||||
|
||||
@@ -21,7 +21,18 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
/**
|
||||
* A grant payload, with the audit window every real one carries.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
|
||||
* dialog can state how long attribution survives without waiting on a second
|
||||
* request. A fixture that omitted it would be describing a payload the server
|
||||
* cannot send.
|
||||
*/
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -154,10 +165,10 @@ describe("offering a level", () => {
|
||||
|
||||
describe("accepting a level", () => {
|
||||
it("states the level and what accepting it means before the button", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [{ ...BASE, access: "WRITE" }],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html).toContain('data-grant-access="WRITE"');
|
||||
@@ -170,7 +181,7 @@ describe("accepting a level", () => {
|
||||
});
|
||||
|
||||
it("asks a manage invitation for its own, heavier consent", () => {
|
||||
grantsRef.value = { given: [], received: [{ ...BASE, access: "MANAGE" }] };
|
||||
grantsRef.value = grantList({ received: [{ ...BASE, access: "MANAGE" }] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html).toContain('data-grant-access="MANAGE"');
|
||||
@@ -188,7 +199,7 @@ describe("accepting a level", () => {
|
||||
});
|
||||
|
||||
it("does not ask a read invitation for a write consent", () => {
|
||||
grantsRef.value = { given: [], received: [{ ...BASE, access: "READ" }] };
|
||||
grantsRef.value = grantList({ received: [{ ...BASE, access: "READ" }] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-grant-access="READ"');
|
||||
expect(html).not.toContain('data-slot="grant-write-consent"');
|
||||
@@ -196,7 +207,7 @@ describe("accepting a level", () => {
|
||||
|
||||
it("stops saying it once the invitation has been answered", () => {
|
||||
// A sentence about a decision already taken is nagging, not consent.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [
|
||||
{
|
||||
@@ -206,7 +217,7 @@ describe("accepting a level", () => {
|
||||
acceptedAt: "2026-01-02T11:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).not.toContain('data-slot="grant-write-consent"');
|
||||
});
|
||||
@@ -254,11 +265,24 @@ describe("ending an access, and what it says about what was entered", () => {
|
||||
expect(body).toContain('revokeAttribution({"days":90})');
|
||||
});
|
||||
|
||||
it("omits the window rather than inventing one", () => {
|
||||
// The number is the operator's setting. A compiled-in 365 here would be
|
||||
// exactly the invented value the sentence exists to avoid.
|
||||
const body = revokeBody({ t, access: "WRITE", name: "Jo" });
|
||||
it("states the operator's window rather than a compiled-in one", () => {
|
||||
// The number is the operator's setting, so an instance running 90 days
|
||||
// says 90 and this file would fail on a hardcoded 365.
|
||||
//
|
||||
// v1.37.0 replaced the case that used to live here. It asserted that the
|
||||
// sentence was OMITTED when the number had not arrived, which was true and
|
||||
// was the defect: the number came off a second query, so whether an owner
|
||||
// was told how long attribution survives depended on which of two requests
|
||||
// landed first. The window rides the grant list now — a caller with a row
|
||||
// to revoke has it — so there is no absent case left to assert.
|
||||
const body = revokeBody({
|
||||
t,
|
||||
access: "WRITE",
|
||||
name: "Jo",
|
||||
retentionDays: 7,
|
||||
});
|
||||
expect(body).toContain("recordSharing.given.revokeEntriesStay");
|
||||
expect(body).not.toContain("revokeAttribution");
|
||||
expect(body).toContain('revokeAttribution({"days":7})');
|
||||
expect(body).not.toContain("365");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useAccountGrants,
|
||||
useRecordActivity,
|
||||
useRevokeGrant,
|
||||
type GrantRow,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
@@ -39,12 +38,6 @@ export function GrantsGivenCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useAccountGrants();
|
||||
const revoke = useRevokeGrant();
|
||||
// Read for one number: how long the activity view can still say who entered
|
||||
// what. The activity card on this same page holds the query, so this is the
|
||||
// cached answer rather than a second request — and when the server has not
|
||||
// answered yet, the sentence that would need the number is left out instead
|
||||
// of being written around a guessed 365.
|
||||
const activity = useRecordActivity();
|
||||
|
||||
const given = data?.given ?? [];
|
||||
|
||||
@@ -95,6 +88,10 @@ export function GrantsGivenCard() {
|
||||
// Only a live grant can be ended. An already-ended row keeps its history
|
||||
// and loses its button, rather than offering an act that would 409.
|
||||
if (grant.state !== "ACTIVE" && grant.state !== "PENDING") return null;
|
||||
// The row and the window arrive together. A grant that can be revoked came
|
||||
// out of a resolved payload, so there is no state in which this control
|
||||
// exists and the number behind its disclosure does not.
|
||||
if (!data) return null;
|
||||
const name = accountLabel(grant.account);
|
||||
return (
|
||||
<ConfirmButton
|
||||
@@ -110,7 +107,7 @@ export function GrantsGivenCard() {
|
||||
t,
|
||||
access: grant.access,
|
||||
name,
|
||||
retentionDays: activity.data?.retentionDays,
|
||||
retentionDays: data.retentionDays,
|
||||
})}
|
||||
confirmLabel={t("recordSharing.given.revokeConfirm")}
|
||||
pending={revoke.isPending}
|
||||
@@ -134,10 +131,17 @@ export function GrantsGivenCard() {
|
||||
* condition is "not READ" rather than "is WRITE", so the level that can also
|
||||
* change and remove entries carries both sentences too — for a manage grant
|
||||
* the question "what happens to what they did" is the sharper one, not the
|
||||
* softer. The last sentence is the honest bound on attribution — the activity
|
||||
* softer. The last sentence is the honest bound on attribution: the activity
|
||||
* view answers "who entered what" for as long as the audit window reaches, and
|
||||
* that window is the operator's setting. It is said with the number the server
|
||||
* resolved, or not said at all.
|
||||
* that window is the operator's setting.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` is required, and that is the whole fix. It used to
|
||||
* be optional and came from a second query, so the disclosure appeared only
|
||||
* when that query happened to have answered: two owners performing the same
|
||||
* act read different paragraphs, and the one who read less had no way to tell.
|
||||
* The number rides the grant list now, so a caller with a grant to revoke
|
||||
* cannot be without it and this function has no branch that omits the
|
||||
* sentence.
|
||||
*/
|
||||
export function revokeBody({
|
||||
t,
|
||||
@@ -148,17 +152,15 @@ export function revokeBody({
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
access: GrantRow["access"];
|
||||
name: string;
|
||||
/** Undefined until the server has said what the window is. */
|
||||
retentionDays?: number;
|
||||
/** The audit window, off the same payload the row came from. */
|
||||
retentionDays: number;
|
||||
}): string {
|
||||
const lines = [t("recordSharing.given.revokeBody", { name })];
|
||||
if (access !== "READ") {
|
||||
lines.push(t("recordSharing.given.revokeEntriesStay"));
|
||||
if (retentionDays !== undefined) {
|
||||
lines.push(
|
||||
t("recordSharing.given.revokeAttribution", { days: retentionDays }),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
t("recordSharing.given.revokeAttribution", { days: retentionDays }),
|
||||
);
|
||||
}
|
||||
return lines.join(" ");
|
||||
}
|
||||
@@ -111,6 +111,12 @@ const grantListResponse = z
|
||||
.object({
|
||||
given: z.array(grantView),
|
||||
received: z.array(grantView),
|
||||
retentionDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"How long the record's activity feed can still attribute an entry to the person who made it, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant. It rides this payload so a client ending an access can state the window in the same breath, without a second request whose timing would decide whether the owner was told.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AccountGrantList",
|
||||
|
||||
@@ -65,6 +65,16 @@ export interface GrantList {
|
||||
given: GrantRow[];
|
||||
/** Grants offered to this account on somebody else's. */
|
||||
received: GrantRow[];
|
||||
/**
|
||||
* How long the activity feed can still say who entered what, in days —
|
||||
* resolved by the server from the same setting the purge job reads.
|
||||
*
|
||||
* On this payload rather than fetched beside it, because the revoke dialog
|
||||
* has to disclose the window and a client holding a grant to revoke is
|
||||
* already holding the answer. Reading it off a second request made the
|
||||
* sentence appear or not depending on which read landed first.
|
||||
*/
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export interface RecordActivityEntry {
|
||||
|
||||
@@ -16,10 +16,34 @@ type Translator = (
|
||||
* mark a dose taken — and "made a change" for seven different things is a way
|
||||
* of answering nothing. An owner reading this at breakfast wants the verb.
|
||||
*
|
||||
* The map keeps a line for allergies, family history and a tracked value even
|
||||
* though a delegate can no longer add any of the three: rows written while
|
||||
* they were admitted sit in the same table and still have to render as
|
||||
* something, which is the same reason the fallback below exists.
|
||||
* The map keeps a line for a tracked value even though a delegate can no
|
||||
* longer add one: rows written while it was admitted sit in the same table and
|
||||
* still have to render as something, which is the same reason the fallback
|
||||
* below exists.
|
||||
*
|
||||
* ## v1.37.0 — the MANAGE half
|
||||
*
|
||||
* A WRITE delegate can only add, so the generic line was at least the right
|
||||
* shape of fact. A MANAGE delegate changes and removes entries the owner made
|
||||
* themselves, and there "somebody made a change in your record" withholds the
|
||||
* only part that matters: whether a reading was corrected or deleted, whether
|
||||
* a medication was stopped, whether a whole dose history was purged. The level
|
||||
* ships under "reconstructable or refused", and a feed that cannot say which
|
||||
* verb ran reconstructs nothing.
|
||||
*
|
||||
* So every action a MANAGE-declared route files has an arm below, and
|
||||
* `src/__tests__/manage-activity-inventory.test.ts` reads the routes to prove
|
||||
* it — by CALLING this function, not by matching `case` in its source, so an
|
||||
* action that quietly falls through to the fallback fails there.
|
||||
*
|
||||
* ## What a line may say
|
||||
*
|
||||
* The verb and the kind of thing, never the value. Every sentence here takes
|
||||
* `name` and nothing else, and the inventory guard holds that across all six
|
||||
* bundles. The activity feed reads out of the audit table, which is not
|
||||
* encrypted and has its own retention: "changed your weight from 81 to 78"
|
||||
* would put a health figure in a second store with no rotation story. What
|
||||
* changed is a question the record itself answers.
|
||||
*
|
||||
* ## Why unknown actions fall back rather than fail
|
||||
*
|
||||
@@ -82,6 +106,156 @@ export function recordActivityVerbLine(
|
||||
// client sent them together.
|
||||
case "measurement.create.batch":
|
||||
return t("recordSharing.activityVerb.measurementCreate", { name });
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v1.37.0 — MANAGE. Everything below can change or remove something */
|
||||
/* the owner put there themselves. */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
case "measurement.update":
|
||||
return t("recordSharing.activityVerb.measurementUpdate", { name });
|
||||
case "measurement.delete":
|
||||
return t("recordSharing.activityVerb.measurementDelete", { name });
|
||||
// Its own line rather than the singular one. "Removed a reading" for a
|
||||
// bulk delete is the sentence that would let a month of readings go
|
||||
// missing while the feed reported a tidy-up.
|
||||
case "measurement.delete.bulk":
|
||||
return t("recordSharing.activityVerb.measurementDeleteBulk", { name });
|
||||
case "measurement.restore":
|
||||
return t("recordSharing.activityVerb.measurementRestore", { name });
|
||||
case "measurementReminder.create":
|
||||
return t("recordSharing.activityVerb.measurementReminderCreate", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.update":
|
||||
return t("recordSharing.activityVerb.measurementReminderUpdate", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.delete":
|
||||
return t("recordSharing.activityVerb.measurementReminderDelete", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.complete":
|
||||
return t("recordSharing.activityVerb.measurementReminderComplete", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.satisfy":
|
||||
return t("recordSharing.activityVerb.measurementReminderSatisfy", {
|
||||
name,
|
||||
});
|
||||
|
||||
case "labResult.update":
|
||||
return t("recordSharing.activityVerb.labResultUpdate", { name });
|
||||
case "labResult.delete":
|
||||
return t("recordSharing.activityVerb.labResultDelete", { name });
|
||||
case "labResult.restore":
|
||||
return t("recordSharing.activityVerb.labResultRestore", { name });
|
||||
case "biomarker.update":
|
||||
return t("recordSharing.activityVerb.biomarkerUpdate", { name });
|
||||
case "biomarker.delete":
|
||||
return t("recordSharing.activityVerb.biomarkerDelete", { name });
|
||||
|
||||
case "allergy.update":
|
||||
return t("recordSharing.activityVerb.allergyUpdate", { name });
|
||||
case "allergy.delete":
|
||||
return t("recordSharing.activityVerb.allergyDelete", { name });
|
||||
case "family-history.update":
|
||||
return t("recordSharing.activityVerb.familyHistoryUpdate", { name });
|
||||
case "family-history.delete":
|
||||
return t("recordSharing.activityVerb.familyHistoryDelete", { name });
|
||||
|
||||
case "illness.episode.update":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeUpdate", { name });
|
||||
case "illness.episode.delete":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeDelete", { name });
|
||||
case "illness.episode.resolve":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeResolve", { name });
|
||||
case "illness.episode.restore":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeRestore", { name });
|
||||
case "illness.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.illnessDayLogUpsert", { name });
|
||||
|
||||
case "medication.update":
|
||||
return t("recordSharing.activityVerb.medicationUpdate", { name });
|
||||
case "medication.delete":
|
||||
return t("recordSharing.activityVerb.medicationDelete", { name });
|
||||
case "medication.glp1.update":
|
||||
return t("recordSharing.activityVerb.medicationGlp1Update", { name });
|
||||
case "medication.schedule_revision.created":
|
||||
return t("recordSharing.activityVerb.medicationScheduleCreate", { name });
|
||||
case "medication.schedule_revision.updated":
|
||||
return t("recordSharing.activityVerb.medicationScheduleUpdate", { name });
|
||||
case "medication.schedule_revision.deleted":
|
||||
return t("recordSharing.activityVerb.medicationScheduleDelete", { name });
|
||||
case "medication.inventory.create":
|
||||
return t("recordSharing.activityVerb.medicationInventoryCreate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.update":
|
||||
return t("recordSharing.activityVerb.medicationInventoryUpdate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.delete":
|
||||
return t("recordSharing.activityVerb.medicationInventoryDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.sideEffect.delete":
|
||||
return t("recordSharing.activityVerb.medicationSideEffectDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.intake.update":
|
||||
return t("recordSharing.activityVerb.medicationIntakeUpdate", { name });
|
||||
case "medication.intake.delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeDelete", { name });
|
||||
case "medication.intake.bulk_delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeBulkDelete", {
|
||||
name,
|
||||
});
|
||||
// The one that empties a medication's whole history in one request. It
|
||||
// says so, because "removed several doses" would read as a correction.
|
||||
case "medication.intake.purge":
|
||||
return t("recordSharing.activityVerb.medicationIntakePurge", { name });
|
||||
case "medication.intake.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeImport", { name });
|
||||
case "medication.intake.history.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeHistoryImport", {
|
||||
name,
|
||||
});
|
||||
|
||||
case "moodEntry.create":
|
||||
return t("recordSharing.activityVerb.moodEntryCreate", { name });
|
||||
case "moodEntry.update":
|
||||
return t("recordSharing.activityVerb.moodEntryUpdate", { name });
|
||||
case "moodEntry.delete":
|
||||
return t("recordSharing.activityVerb.moodEntryDelete", { name });
|
||||
case "mood.delete.bulk":
|
||||
return t("recordSharing.activityVerb.moodDeleteBulk", { name });
|
||||
case "mood.restore":
|
||||
return t("recordSharing.activityVerb.moodRestore", { name });
|
||||
case "mental-health.create":
|
||||
return t("recordSharing.activityVerb.mentalHealthCreate", { name });
|
||||
|
||||
case "cycle.cycle.delete":
|
||||
return t("recordSharing.activityVerb.cycleDelete", { name });
|
||||
case "cycle.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
|
||||
case "cycle.day-log.update":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
|
||||
case "cycle.day-log.delete":
|
||||
return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
|
||||
case "cycle.period.boundary":
|
||||
return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
|
||||
case "cycle.symptom.custom.create":
|
||||
return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
|
||||
|
||||
case "nutrient.water.write":
|
||||
return t("recordSharing.activityVerb.nutrientWaterWrite", { name });
|
||||
|
||||
// Not a change to the record, and the one act on this list that takes a
|
||||
// copy of all of it out of the instance. Named plainly for that reason.
|
||||
case "health-record.export":
|
||||
return t("recordSharing.activityVerb.healthRecordExport", { name });
|
||||
|
||||
default:
|
||||
return t("recordSharing.activity.acted", { name });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user