Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/integrations/fileAudit.server.ts
T

85 lines
3.0 KiB
TypeScript

// P4 Files (AK-P4a) §12 — audit + activity wiring for the Files module.
//
// Audit (DoD 13): every write action records a tamper-evident AuditEvent (M001)
// via foundation.auditEvent with the shared db client.
//
// Activity (DoD 14): user-facing timeline entries are logged AGAINST the linked
// entity (the file itself is not an Activity subject in v1 — see decision 4 in
// the build plan). Only FileEntityType values that map to an ActivitySubjectType
// (contact/company/lead/deal/project/task) produce an activity; others are
// skipped (no shared @hivegrid/activity edit).
import type { JsonValue } from "@toolctrl/foundation";
import { createAuditEvent } from "@toolctrl/db/audit-events.server";
import { logActivity, type ActivitySubjectType, type ActivityType } from "@hivegrid/activity";
import type { FilesRouteContext } from "./fileAccess.server";
import type { FileEntityType } from "./fileService.server";
export type FileAuditAction = "file.uploaded" | "file.linked" | "file.unlinked" | "file.archived" | "file.reactivated" | "file.deleted" | "file.updated";
export async function auditFileEvent(input: {
ctx: Pick<FilesRouteContext, "tenantId" | "userId">;
action: FileAuditAction;
fileId: string;
metadata?: Record<string, JsonValue>;
}): Promise<void> {
try {
// createAuditEvent (M001) writes the tamper-evident, hash-chained AuditEvent
// and defaults its client to the shared db singleton.
await createAuditEvent({
tenantId: input.ctx.tenantId,
actorId: input.ctx.userId,
actorType: "user",
action: input.action,
targetType: "file",
targetId: input.fileId,
metadata: input.metadata ?? null,
});
} catch {
// Audit must never break the user action; the failure is logged upstream.
}
}
const ENTITY_TO_ACTIVITY_SUBJECT: Partial<Record<FileEntityType, ActivitySubjectType>> = {
contact: "contact",
company: "company",
lead: "lead",
deal: "deal",
project: "project",
task: "task",
};
/**
* Log a file-related activity against the linked entity's timeline. No-op when
* the entity type has no Activity subject mapping (e.g. user/form_submission).
*/
export async function logFileActivity(input: {
ctx: Pick<FilesRouteContext, "tenantId" | "userId">;
entityType: FileEntityType;
entityId: string;
activityType: Extract<ActivityType, "file_uploaded">;
summaryText: string;
fileId: string;
}): Promise<void> {
const subjectType = ENTITY_TO_ACTIVITY_SUBJECT[input.entityType];
if (!subjectType) {
return;
}
try {
// logActivity defaults its write client to the shared db singleton.
await logActivity({
tenantId: input.ctx.tenantId,
actorUserId: input.ctx.userId,
actorKind: "user",
activityType: input.activityType,
subjectType,
subjectId: input.entityId,
summaryText: input.summaryText,
// The file is not an Activity subject in v1; carry its id in metadata.
metadata: { fileId: input.fileId },
});
} catch {
// Activity logging is best-effort; never break the user action.
}
}