Merge pull request #690 from MBombeck/work/sharing-client

Account sharing, the part people touch
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 17:13:30 +02:00
commit 210380f46d
60 files changed
+5648 -174

No files matched your search

+128
View File
@@ -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(),
});
});