Add file handoff audit records

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:45:49 -04:00
1 parent babbf07cb2
commit 4dbd8997c0
10 files changed
+298 -100

No files matched your search

@@ -4,27 +4,26 @@ import {
CHAT_THREAD_BOARD_STATE,
FILE_SCAN_STATE,
RISK_LEVEL,
type FileScanState,
} from "../../shared/domain";
import { createId } from "../../shared/ids";
import { ApprovalService } from "./approval-service";
import { AuditService } from "./audit-service";
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
import { buildChatMessageStructure } from "./chat-message-structure";
import { recordChatMentions, resolveChatMentions, type MentionTarget, type ResolvedMention } from "./chat-mentions";
import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions";
import { FileHandoffService, type FileHandoffAttachment } from "./file-handoff-service";
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]);
interface AttachmentCandidate {
fileId: string;
checksum: string;
}
interface FileAttachmentRow {
id: string;
sha256: string;
scan_state: string;
scan_state: FileScanState;
scan_labels_json: string;
storage_path: string;
owner_type: string;
owner_id: string | null;
}
@@ -52,11 +51,13 @@ export class ChatService {
private readonly audit: AuditService;
private readonly approvals: ApprovalService;
private readonly boardInterventions: BoardInterventionService;
private readonly handoffs: FileHandoffService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.approvals = new ApprovalService(db);
this.boardInterventions = new BoardInterventionService(db);
this.handoffs = new FileHandoffService(db);
}
createSpace(input: CreateChatSpaceInput): string {
@@ -141,8 +142,17 @@ export class ChatService {
recordChatMentions(this.db, id, mentions);
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null;
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
this.recordAttachments(id, attachments);
this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now);
this.handoffs.record({
orgId: input.orgId,
spaceId: input.spaceId,
messageId: id,
threadId,
senderType: input.senderType,
senderId: input.senderId,
attachments,
mentions,
now,
});
if (input.senderId) {
const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active";
this.db.prepare(`
@@ -229,62 +239,6 @@ export class ChatService {
`).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now);
}
private recordAttachments(messageId: string, attachments: AttachmentCandidate[]): void {
for (const file of attachments) {
this.db.prepare(`
insert into chat_attachments(message_id, file_id, transfer_type, checksum, preview_key)
values (?, ?, ?, ?, ?)
`).run(messageId, file.fileId, "handoff", file.checksum, null);
}
}
private recordAttachmentGrants(
input: SendMessageInput,
messageId: string,
threadId: string,
attachments: AttachmentCandidate[],
mentions: ResolvedMention[],
now: string,
): void {
if (attachments.length === 0) return;
const recipients = this.attachmentGrantRecipients(input.orgId, input.spaceId, mentions);
const statement = this.db.prepare(`
insert or ignore into file_access_grants(
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
source_message_id, source_thread_id, access_level, created_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const attachment of attachments) {
for (const recipient of recipients) {
const result = statement.run(
createId("fgr"),
input.orgId,
attachment.fileId,
recipient.type,
recipient.id,
input.senderType,
input.senderId,
messageId,
threadId,
"read",
now,
);
if (result.changes > 0) {
this.audit.record({
orgId: input.orgId,
entityType: "file_object",
entityId: attachment.fileId,
actorType: input.senderType,
actorId: input.senderId,
action: "access_granted",
payload: { messageId, threadId, grantedToType: recipient.type, grantedToId: recipient.id },
});
}
}
}
}
private requireSpace(orgId: string, spaceId: string): SpaceRow {
const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?")
.get(spaceId, orgId) as SpaceRow | undefined;
@@ -331,18 +285,24 @@ export class ChatService {
return thread;
}
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] {
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): FileHandoffAttachment[] {
if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique.");
return fileIds.map((fileId) => {
const file = this.db.prepare(`
select id, sha256, scan_state, owner_type, owner_id
select id, sha256, scan_state, scan_labels_json, storage_path, owner_type, owner_id
from file_objects
where id = ? and org_id = ?
`).get(fileId, orgId) as FileAttachmentRow | undefined;
if (!file) throw new Error("Attachment file was not found.");
if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error("Attachment file is not approved for handoff.");
if (!this.senderCanReadFile(orgId, file, sender)) throw new Error("Attachment file access is required.");
return { fileId, checksum: file.sha256 };
return {
fileId,
checksum: file.sha256,
scanState: file.scan_state,
scanLabelsJson: file.scan_labels_json,
objectRef: file.storage_path,
};
});
}
@@ -364,35 +324,6 @@ export class ChatService {
return Boolean(grant);
}
private attachmentGrantRecipients(orgId: string, spaceId: string, mentions: ResolvedMention[]): MentionTarget[] {
const source = mentions.length > 0 ? mentions.map((mention) => mention.target) : this.spaceRecipients(spaceId);
const seen = new Set<string>();
const recipients: MentionTarget[] = [];
for (const target of source) {
const key = `${target.type}:${target.id}`;
if (seen.has(key)) continue;
seen.add(key);
recipients.push(target);
}
if (recipients.length === 0) throw new Error("Attachment grant recipient is required.");
return recipients;
}
private spaceRecipients(spaceId: string): MentionTarget[] {
const rows = this.db.prepare(`
select principal_type, principal_id
from chat_memberships
where space_id = ?
order by principal_type asc, principal_id asc
`).all(spaceId) as Array<{ principal_type: string; principal_id: string }>;
return rows.map((row) => {
if (row.principal_type === "employee" || row.principal_type === "agent") {
return { type: row.principal_type, id: row.principal_id };
}
throw new Error("Chat membership principal type is unsupported.");
});
}
private requireMembership(spaceId: string, principal: PrincipalRef): void {
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = ? and principal_id = ?")
.get(spaceId, principal.type, principal.id) as { space_id: string } | undefined;