mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(sharing): the shared-access panel and the record-activity view
Settings gains one section for one concept: who else can open this health record, whose this account may open, and when they were opened. Split across two pages it would be two half-answers. An invitation confers nothing until the other person accepts it, and the card says so above the field rather than after the submit — that is the part somebody needs to know before they type a name. Refusals are named from the stable errorCode, because "no account with that name" and "that person already has access" are different situations with different next steps. Ended grants stay in the list. The row is the consent record, and "who had access, from when to when, and who ended it" is the question the panel exists to answer; a list that dropped them could not. Revoking asks once and then does it: reducing access must never be harder than granting it was. `GET /api/account/activity` answers the owner's other question. One row per person per day with a count, not one per request — a hundred identical lines is a way of answering nothing. The window is published with the rows rather than assumed: audit history is purged on a schedule the operator sets, so the view prints the number the server resolved from the same setting the purge job reads. An instance running ninety days says ninety. Six locales ship with it.
This commit is contained in:
1 parent
dd3d5646de
commit
5199de78af
20 files changed
+1620
No files matched your search
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* `GET /api/account/activity` — who opened this account's record, and when.
|
||||
*
|
||||
* The owner's side of the audit actor. Every row filed under this account that
|
||||
* somebody ELSE performed, newest first: the day-coalesced read rows the
|
||||
* delegated resolver writes, and any delegated action that lands an audit row
|
||||
* of its own. Rows the owner performed themselves are absent by construction —
|
||||
* `actorUserId` is null for those, and this feed is not a second copy of the
|
||||
* security-activity feed.
|
||||
*
|
||||
* **The window is published, not implied.** `retentionDays` rides the response
|
||||
* because audit rows are purged on a schedule the operator configures
|
||||
* (`AUDIT_LOG_RETENTION_DAYS`, 365 by default) and a view that showed "the
|
||||
* activity" without saying how far back it goes would claim a completeness it
|
||||
* does not have. The number is resolved server-side from the same function the
|
||||
* purge job uses, so the sentence in the UI cannot drift from the job that
|
||||
* makes it true — an instance running a 90-day window says 90.
|
||||
*
|
||||
* Not delegable: bare `requireAuth()` refuses under a switch. A delegate must
|
||||
* not be able to read the owner's record of who has been in it — that list
|
||||
* names other delegates, and a grant is not an introduction to the household.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* How many rows the feed returns. One row per (delegate, day) for reads, so a
|
||||
* hundred covers months of a busy household — and a longer list answers a
|
||||
* question nobody asked while making the recent activity harder to see.
|
||||
*/
|
||||
const MAX_ROWS = 100;
|
||||
|
||||
export interface RecordActivityEntry {
|
||||
id: string;
|
||||
/**
|
||||
* Who did it. Null when that account has since been deleted: the id on the
|
||||
* row is deliberately not a foreign key, so history is not rewritten when a
|
||||
* person leaves. A row whose actor cannot be resolved says "a deleted
|
||||
* account", which is true, rather than silently reading as the owner's own.
|
||||
*/
|
||||
actor: { id: string; username: string; displayName: string | null } | null;
|
||||
action: string;
|
||||
/**
|
||||
* For a coalesced read row, how many times that delegate opened the record
|
||||
* that day. Null for anything else — a single act has no count, and a `1`
|
||||
* there would invite the reader to add up numbers that mean different things.
|
||||
*/
|
||||
accesses: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The `accesses` counter, when the row is a coalesced read. */
|
||||
function readAccessCount(
|
||||
action: string,
|
||||
details: string | null,
|
||||
): number | null {
|
||||
if (action !== DELEGATED_ACCESS_ACTION || details === null) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(details);
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const value = (parsed as { accesses?: unknown }).accesses;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
// A row whose details cannot be parsed still happened. Dropping the count
|
||||
// loses a number; dropping the row would lose the access.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
actorUserId: { not: null },
|
||||
// A switch into one's own record is not a delegation and has nothing to
|
||||
// report. `actingActorFor` already refuses to stamp one, so this is the
|
||||
// second end of that rule rather than its only enforcement.
|
||||
NOT: { actorUserId: user.id },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: MAX_ROWS,
|
||||
select: {
|
||||
id: true,
|
||||
actorUserId: true,
|
||||
action: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const actorIds = [
|
||||
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
|
||||
];
|
||||
const actors = await prisma.user.findMany({
|
||||
where: { id: { in: actorIds } },
|
||||
select: GRANT_PARTY_SELECT,
|
||||
});
|
||||
const byId = new Map(actors.map((a) => [a.id, a]));
|
||||
|
||||
const entries: RecordActivityEntry[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
actor: row.actorUserId ? (byId.get(row.actorUserId) ?? null) : null,
|
||||
action: row.action,
|
||||
accesses: readAccessCount(row.action, row.details),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "sharing.activity.list" },
|
||||
meta: { entry_count: entries.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
entries,
|
||||
// The bound the view states out loud. Resolved here so the UI never has to
|
||||
// hardcode a number the operator can change.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user