mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
This commit is contained in:
60 files changed
+5648
-174
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