Files
AgentCompany/src/main/services/file-handoff-service.ts
T
ZacharyZhang-NY 07d0b4587d Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs,
release tooling, CI workflows, and root configs - leaves the tree; it stays
reachable in history. Everything under AgentCompany-New moves up unchanged:
same package name, so the production userData/database path is untouched,
and e2e/build scripts already resolved paths relative to the project. The
developer cred.txt stays gitignored at its new location. Full verify:ci
passes from the new root.
2026-07-07 00:04:13 -04:00

150 lines
5.3 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import type { SendMessageInput } from "../../shared/commands";
import type { FileScanState } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import type { MentionTarget, ResolvedMention } from "./chat-mentions";
export interface FileHandoffAttachment {
fileId: string;
checksum: string;
scanState: FileScanState;
scanLabelsJson: string;
objectRef: string;
}
interface RecordFileHandoffsInput {
orgId: string;
spaceId: string;
messageId: string;
threadId: string;
senderType: SendMessageInput["senderType"];
senderId: string | null;
attachments: FileHandoffAttachment[];
mentions: ResolvedMention[];
now: string;
}
export class FileHandoffService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
record(input: RecordFileHandoffsInput): void {
if (input.attachments.length === 0) return;
const recipients = this.attachmentGrantRecipients(input.orgId, input.spaceId, input.mentions);
const attachmentStatement = this.db.prepare(`
insert into chat_attachments(message_id, file_id, transfer_type, checksum, preview_key)
values (?, ?, ?, ?, ?)
`);
const grantStatement = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const handoffStatement = this.db.prepare(`
insert or ignore into file_handoff_records(
id, org_id, message_id, file_id, source_principal_type, source_principal_id,
target_principal_type, target_principal_id, transfer_type, checksum, scan_state,
scan_labels_json, policy_decision, object_ref, created_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const attachment of input.attachments) {
attachmentStatement.run(input.messageId, attachment.fileId, "handoff", attachment.checksum, null);
for (const recipient of recipients) {
const grant = grantStatement.run(
createId("fgr"),
input.orgId,
attachment.fileId,
recipient.type,
recipient.id,
input.senderType,
input.senderId,
input.messageId,
input.threadId,
"read",
input.now,
);
handoffStatement.run(
createId("fhr"),
input.orgId,
input.messageId,
attachment.fileId,
input.senderType,
input.senderId,
recipient.type,
recipient.id,
"handoff",
attachment.checksum,
attachment.scanState,
attachment.scanLabelsJson,
"approved_for_handoff",
attachment.objectRef,
input.now,
);
if (grant.changes > 0) this.recordGrantAudit(input, attachment.fileId, recipient);
}
}
}
private recordGrantAudit(input: RecordFileHandoffsInput, fileId: string, recipient: MentionTarget): void {
this.audit.record({
orgId: input.orgId,
entityType: "file_object",
entityId: fileId,
actorType: input.senderType,
actorId: input.senderId,
action: "access_granted",
payload: {
messageId: input.messageId,
threadId: input.threadId,
grantedToType: recipient.type,
grantedToId: recipient.id,
},
});
}
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.map((recipient) => recipient.type === "org_group" ? recipient : this.requireRecipient(orgId, recipient));
}
private requireRecipient(orgId: string, recipient: MentionTarget): MentionTarget {
if (recipient.type === "board") return recipient;
const table = recipient.type === "employee" ? "employees" : "agents";
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`)
.get(recipient.id, orgId) as { id: string } | undefined;
if (!row) throw new Error("Attachment grant recipient was not found.");
return recipient;
}
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.");
});
}
}