diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 3c5cac8c..ab7c95ae 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -13,6 +13,7 @@ import { SocialService } from "../services/social-service"; import { FileService } from "../services/file-service"; import { MigrationService } from "../services/migration-service"; import { OnboardingService } from "../services/onboarding-service"; +import { ComplianceService } from "../services/compliance-service"; export interface IpcDeps { connection: DatabaseConnection; @@ -30,6 +31,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const social = new SocialService(deps.connection.db); const files = new FileService(deps.connection.db, deps.artifactRoot); const migrations = new MigrationService(deps.connection.db); + const compliance = new ComplianceService(deps.connection.db); const onboarding = new OnboardingService({ db: deps.connection.db, saveProviderCredential: (input) => providers.saveCredential(input), @@ -144,6 +146,11 @@ export function registerIpcHandlers(deps: IpcDeps): void { }); ipcMain.handle(IPC_CHANNELS.createMigrationReport, (_event, input) => mutate(() => migrations.createReport(input))); + ipcMain.handle(IPC_CHANNELS.createComplianceExport, (_event, input) => mutate(() => compliance.createExport(input))); + ipcMain.handle(IPC_CHANNELS.createLegalHold, (_event, input) => mutate(() => compliance.createLegalHold(input))); + ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input))); + ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input))); + ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input))); ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize()); ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize()); diff --git a/AgentCompany-New/src/main/database/migrations/012-compliance-controls.ts b/AgentCompany-New/src/main/database/migrations/012-compliance-controls.ts new file mode 100644 index 00000000..e8ea7cb7 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/012-compliance-controls.ts @@ -0,0 +1,47 @@ +export const complianceControlsSchema = ` + alter table chat_spaces add column retention_policy text not null default 'standard_365d'; + + create table if not exists compliance_exports ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + subject_type text not null, + subject_id text not null, + export_type text not null, + state text not null, + payload_json text not null, + requested_by_employee_id text references employees(id) on delete set null, + created_at text not null, + completed_at text + ); + + create table if not exists legal_holds ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + entity_type text not null, + entity_id text not null, + reason text not null, + created_by_employee_id text references employees(id) on delete set null, + active integer not null, + created_at text not null, + released_at text + ); + + create table if not exists retention_freezes ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + scope_type text not null, + scope_id text not null, + reason text not null, + created_by_employee_id text references employees(id) on delete set null, + active integer not null, + created_at text not null, + released_at text + ); + + create index if not exists idx_compliance_exports_org_created on compliance_exports(org_id, created_at); + create index if not exists idx_legal_holds_org_entity on legal_holds(org_id, entity_type, entity_id, active); + create index if not exists idx_retention_freezes_org_scope on retention_freezes(org_id, scope_type, scope_id, active); + + insert into schema_migrations(version, name, applied_at) + values (12, 'compliance_controls', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 9436a8e5..91948028 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -10,6 +10,7 @@ import { providerHealthSchema } from "./migrations/008-provider-health"; import { socialGovernanceSchema } from "./migrations/009-social-governance"; import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security"; import { boardInterventionSchema } from "./migrations/011-board-interventions"; +import { complianceControlsSchema } from "./migrations/012-compliance-controls"; interface Migration { version: number; @@ -29,6 +30,7 @@ const MIGRATIONS: Migration[] = [ { version: 9, name: "social_governance", sql: socialGovernanceSchema }, { version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema }, { version: 11, name: "board_interventions", sql: boardInterventionSchema }, + { version: 12, name: "compliance_controls", sql: complianceControlsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/compliance-service.ts b/AgentCompany-New/src/main/services/compliance-service.ts new file mode 100644 index 00000000..23a62f7c --- /dev/null +++ b/AgentCompany-New/src/main/services/compliance-service.ts @@ -0,0 +1,245 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { + CreateComplianceExportInput, + CreateLegalHoldInput, + CreateRetentionFreezeInput, + SetLegalHoldStateInput, + SetRetentionFreezeStateInput, +} from "../../shared/commands"; +import { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE } from "../../shared/domain"; +import { createId } from "../../shared/ids"; +import { AuditService } from "./audit-service"; + +type Row = Record; + +export class ComplianceService { + private readonly audit: AuditService; + + constructor(private readonly db: DatabaseSync) { + this.audit = new AuditService(db); + } + + createExport(input: CreateComplianceExportInput): string { + this.assertOrg(input.orgId); + this.assertEmployee(input.orgId, input.requestedByEmployeeId, "Requesting employee was not found."); + this.assertSubject(input.orgId, input.subjectType, input.subjectId); + if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.assertDeleteAllowed(input); + const payload = this.subjectPayload(input.orgId, input.subjectType, input.subjectId); + if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.applyGdprDelete(input.orgId, input.subjectType, input.subjectId); + const id = createId("cmp"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into compliance_exports( + id, org_id, subject_type, subject_id, export_type, state, payload_json, + requested_by_employee_id, created_at, completed_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.orgId, + input.subjectType, + input.subjectId, + input.exportType, + COMPLIANCE_EXPORT_STATE.COMPLETED, + JSON.stringify(payload), + input.requestedByEmployeeId, + now, + now, + ); + this.audit.record({ + orgId: input.orgId, + entityType: "compliance_export", + entityId: id, + actorType: "employee", + actorId: input.requestedByEmployeeId, + action: input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE ? "gdpr_deleted" : "created", + payload: { subjectType: input.subjectType, subjectId: input.subjectId, exportType: input.exportType }, + }); + return id; + } + + createLegalHold(input: CreateLegalHoldInput): string { + const reason = input.reason.trim(); + if (!reason) throw new Error("Legal hold reason is required."); + this.assertOrg(input.orgId); + this.assertEmployee(input.orgId, input.createdByEmployeeId, "Legal hold employee was not found."); + this.assertHoldEntity(input.orgId, input.entityType, input.entityId); + const id = createId("hold"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into legal_holds(id, org_id, entity_type, entity_id, reason, created_by_employee_id, active, created_at, released_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, input.orgId, input.entityType, input.entityId, reason, input.createdByEmployeeId, 1, now, null); + this.audit.record({ + orgId: input.orgId, + entityType: "legal_hold", + entityId: id, + actorType: "employee", + actorId: input.createdByEmployeeId, + action: "created", + payload: { entityType: input.entityType, entityId: input.entityId }, + }); + return id; + } + + setLegalHoldState(input: SetLegalHoldStateInput): void { + this.assertEmployee(input.orgId, input.employeeId, "Legal hold employee was not found."); + const now = new Date().toISOString(); + const result = this.db.prepare(` + update legal_holds + set active = ?, released_at = ? + where id = ? and org_id = ? + `).run(input.active ? 1 : 0, input.active ? null : now, input.legalHoldId, input.orgId); + if (result.changes === 0) throw new Error("Legal hold was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "legal_hold", + entityId: input.legalHoldId, + actorType: "employee", + actorId: input.employeeId, + action: input.active ? "activated" : "released", + payload: { active: input.active }, + }); + } + + createRetentionFreeze(input: CreateRetentionFreezeInput): string { + const reason = input.reason.trim(); + if (!reason) throw new Error("Retention freeze reason is required."); + this.assertOrg(input.orgId); + this.assertEmployee(input.orgId, input.createdByEmployeeId, "Retention freeze employee was not found."); + this.assertFreezeScope(input.orgId, input.scopeType, input.scopeId); + const id = createId("frz"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into retention_freezes(id, org_id, scope_type, scope_id, reason, created_by_employee_id, active, created_at, released_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, input.orgId, input.scopeType, input.scopeId, reason, input.createdByEmployeeId, 1, now, null); + this.audit.record({ + orgId: input.orgId, + entityType: "retention_freeze", + entityId: id, + actorType: "employee", + actorId: input.createdByEmployeeId, + action: "created", + payload: { scopeType: input.scopeType, scopeId: input.scopeId }, + }); + return id; + } + + setRetentionFreezeState(input: SetRetentionFreezeStateInput): void { + this.assertEmployee(input.orgId, input.employeeId, "Retention freeze employee was not found."); + const now = new Date().toISOString(); + const result = this.db.prepare(` + update retention_freezes + set active = ?, released_at = ? + where id = ? and org_id = ? + `).run(input.active ? 1 : 0, input.active ? null : now, input.retentionFreezeId, input.orgId); + if (result.changes === 0) throw new Error("Retention freeze was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "retention_freeze", + entityId: input.retentionFreezeId, + actorType: "employee", + actorId: input.employeeId, + action: input.active ? "activated" : "released", + payload: { active: input.active }, + }); + } + + private subjectPayload(orgId: string, subjectType: string, subjectId: string): Record { + if (subjectType === "employee") { + return { + profile: this.one("employees", orgId, subjectId), + chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'employee' and sender_id = ? order by created_at").all(orgId, subjectId), + approvalsRequested: this.db.prepare("select * from approvals where org_id = ? and requested_by_type = 'employee' and requested_by_id = ? order by created_at").all(orgId, subjectId), + approvalsDecided: this.db.prepare("select * from approvals where org_id = ? and decided_by_employee_id = ? order by created_at").all(orgId, subjectId), + auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'employee' and actor_id = ? order by created_at").all(orgId, subjectId), + }; + } + return { + profile: this.one("agents", orgId, subjectId), + tasks: this.db.prepare("select * from tasks where org_id = ? and assignee_agent_id = ? order by created_at").all(orgId, subjectId), + taskRuns: this.db.prepare("select * from task_runs where org_id = ? and agent_id = ? order by created_at").all(orgId, subjectId), + chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'agent' and sender_id = ? order by created_at").all(orgId, subjectId), + auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'agent' and actor_id = ? order by created_at").all(orgId, subjectId), + }; + } + + private applyGdprDelete(orgId: string, subjectType: string, subjectId: string): void { + if (subjectType === "employee") { + this.db.prepare("update employees set display_name = ?, role = ? where id = ? and org_id = ?") + .run("Deleted employee", "Deleted", subjectId, orgId); + this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'employee' and sender_id = ?") + .run(orgId, subjectId); + this.db.prepare("update approvals set requested_by_id = null where org_id = ? and requested_by_type = 'employee' and requested_by_id = ?") + .run(orgId, subjectId); + return; + } + this.db.prepare("update agents set name = ?, role = ?, lifecycle = ?, model_provider_id = null, model_id = null where id = ? and org_id = ?") + .run("Deleted agent", "Deleted", "archived", subjectId, orgId); + this.db.prepare("update tasks set assignee_agent_id = null where org_id = ? and assignee_agent_id = ?").run(orgId, subjectId); + this.db.prepare("update task_runs set agent_id = null where org_id = ? and agent_id = ?").run(orgId, subjectId); + this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'agent' and sender_id = ?") + .run(orgId, subjectId); + } + + private assertDeleteAllowed(input: CreateComplianceExportInput): void { + const legalHold = this.db.prepare(` + select id from legal_holds + where org_id = ? and active = 1 + and ((entity_type = ? and entity_id = ?) or (entity_type = 'organization' and entity_id = ?)) + limit 1 + `).get(input.orgId, input.subjectType, input.subjectId, input.orgId); + const retentionFreeze = this.db.prepare(` + select id from retention_freezes + where org_id = ? and active = 1 and scope_type = 'organization' and scope_id = ? + limit 1 + `).get(input.orgId, input.orgId); + if (legalHold || retentionFreeze) throw new Error("Active compliance hold prevents GDPR delete."); + } + + private one(table: "employees" | "agents", orgId: string, id: string): Row { + const row = this.db.prepare(`select * from ${table} where id = ? and org_id = ?`).get(id, orgId) as Row | undefined; + if (!row) throw new Error("Compliance subject was not found."); + return row; + } + + private assertSubject(orgId: string, subjectType: string, subjectId: string): void { + if (subjectType === "employee") return void this.assertEmployee(orgId, subjectId, "Compliance subject was not found."); + if (subjectType === "agent") return void this.assertAgent(orgId, subjectId, "Compliance subject was not found."); + throw new Error(`Unsupported compliance subject type: ${subjectType}`); + } + + private assertHoldEntity(orgId: string, entityType: string, entityId: string): void { + if (entityType === "organization" && entityId === orgId) return; + if (entityType === "employee") return void this.assertEmployee(orgId, entityId, "Legal hold entity was not found."); + if (entityType === "agent") return void this.assertAgent(orgId, entityId, "Legal hold entity was not found."); + if (entityType === "chat_thread") return void this.assertOrgEntity("chat_threads", orgId, entityId, "Legal hold entity was not found."); + if (entityType === "file_object") return void this.assertOrgEntity("file_objects", orgId, entityId, "Legal hold entity was not found."); + throw new Error(`Unsupported legal hold entity type: ${entityType}`); + } + + private assertFreezeScope(orgId: string, scopeType: string, scopeId: string): void { + if (scopeType === "organization" && scopeId === orgId) return; + if (scopeType === "chat_space") return void this.assertOrgEntity("chat_spaces", orgId, scopeId, "Retention freeze scope was not found."); + if (scopeType === "file_object") return void this.assertOrgEntity("file_objects", orgId, scopeId, "Retention freeze scope was not found."); + throw new Error(`Unsupported retention freeze scope type: ${scopeType}`); + } + + private assertOrg(orgId: string): void { + const row = this.db.prepare("select id from organizations where id = ?").get(orgId); + if (!row) throw new Error("Organization was not found."); + } + + private assertEmployee(orgId: string, employeeId: string, message: string): void { + this.assertOrgEntity("employees", orgId, employeeId, message); + } + + private assertAgent(orgId: string, agentId: string, message: string): void { + this.assertOrgEntity("agents", orgId, agentId, message); + } + + private assertOrgEntity(table: "employees" | "agents" | "chat_threads" | "file_objects" | "chat_spaces", orgId: string, id: string, message: string): void { + const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId); + if (!row) throw new Error(message); + } +} diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index c8aabcf2..93f86a59 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -44,6 +44,9 @@ const LEGACY_TABLES = [ "browser_actions", "scheduled_posts", "policy_rules", + "compliance_exports", + "legal_holds", + "retention_freezes", "audit_events", ]; @@ -159,6 +162,12 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "scheduled_posts", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" }, { table: "scheduled_posts", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, { table: "policy_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "compliance_exports", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "compliance_exports", column: "requested_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_requester_employee_id" }, + { table: "legal_holds", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, + { table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "retention_freezes", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, { table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, ]; diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index e769ac86..b9f4b4dc 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -47,6 +47,10 @@ export class SnapshotService { .map((binding) => ({ ...binding, enabled: toBool(binding.enabled) })); const files = list(this.db, "file_objects", "created_at desc") .map((file) => ({ ...file, pinned: toBool(file.pinned) })); + const legalHolds = list(this.db, "legal_holds", "created_at desc") + .map((hold) => ({ ...hold, active: toBool(hold.active) })); + const retentionFreezes = list(this.db, "retention_freezes", "created_at desc") + .map((freeze) => ({ ...freeze, active: toBool(freeze.active) })); return { activeOrgId, @@ -83,6 +87,9 @@ export class SnapshotService { browserActions: list(this.db, "browser_actions", "created_at desc"), policyRules: list(this.db, "policy_rules", "created_at asc"), migrationReports: list(this.db, "migration_reports", "created_at desc"), + complianceExports: list(this.db, "compliance_exports", "created_at desc"), + legalHolds, + retentionFreezes, auditEvents: list(this.db, "audit_events", "created_at desc").slice(0, 200), }; } diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index a843154b..f44c528b 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -35,6 +35,11 @@ const api: DesktopApi = { importFile: (input) => invoke(IPC_CHANNELS.importFile, input), setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input), createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input), + createComplianceExport: (input) => invoke(IPC_CHANNELS.createComplianceExport, input), + createLegalHold: (input) => invoke(IPC_CHANNELS.createLegalHold, input), + setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input), + createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input), + setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input), subscribe: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload); ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener); diff --git a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx index 1168ac69..bd238a1d 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -7,6 +7,7 @@ import { BillingPanel } from "./settings/BillingPanel"; import { ModelsPanel } from "./settings/ModelsPanel"; import { PoliciesPanel } from "./settings/PoliciesPanel"; import { MigrationPanel } from "./settings/MigrationPanel"; +import { CompliancePanel } from "./settings/CompliancePanel"; import { NotificationsPanel, ProfilePanel, SecurityPanel } from "./settings/AccountPanels"; import type { PageProps } from "./types"; import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types"; @@ -19,6 +20,7 @@ type SettingsTab = | "providers" | "skills" | "policies" + | "compliance" | "migration" | "profile" | "notifications" @@ -39,6 +41,7 @@ const AGENTS_GROUP: NavSpec[] = [ { id: "providers", label: "Providers" }, { id: "skills", label: "Skills" }, { id: "policies", label: "Policies" }, + { id: "compliance", label: "Compliance" }, { id: "migration", label: "Migration" }, ]; const ACCOUNT_GROUP: NavSpec[] = [ @@ -64,9 +67,15 @@ export function SettingsPage({ snapshot, actions }: PageProps) { const skillBindings = useMemo(() => snapshot.skillBindings.filter((item) => item.orgId === orgId), [snapshot.skillBindings, orgId]); const policies = useMemo(() => snapshot.policyRules.filter((item) => item.orgId === orgId), [snapshot.policyRules, orgId]); const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]); + const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((item) => item.orgId === orgId), [snapshot.chatSpaces, orgId]); + const chatThreads = useMemo(() => snapshot.chatThreads.filter((item) => item.orgId === orgId), [snapshot.chatThreads, orgId]); + const files = useMemo(() => snapshot.files.filter((item) => item.orgId === orgId), [snapshot.files, orgId]); const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]); const auditEvents = useMemo(() => snapshot.auditEvents.filter((event) => event.orgId === orgId), [snapshot.auditEvents, orgId]); const migrationReports = useMemo(() => snapshot.migrationReports.filter((report) => report.orgId === orgId), [snapshot.migrationReports, orgId]); + const complianceExports = useMemo(() => snapshot.complianceExports.filter((item) => item.orgId === orgId), [snapshot.complianceExports, orgId]); + const legalHolds = useMemo(() => snapshot.legalHolds.filter((item) => item.orgId === orgId), [snapshot.legalHolds, orgId]); + const retentionFreezes = useMemo(() => snapshot.retentionFreezes.filter((item) => item.orgId === orgId), [snapshot.retentionFreezes, orgId]); return (
@@ -104,6 +113,21 @@ export function SettingsPage({ snapshot, actions }: PageProps) { /> )} {tab === "policies" && } + {tab === "compliance" && ( + + )} {tab === "migration" && } {tab === "profile" && } {tab === "notifications" && } diff --git a/AgentCompany-New/src/renderer/src/pages/settings/CompliancePanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/CompliancePanel.tsx new file mode 100644 index 00000000..8ec948f9 --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/settings/CompliancePanel.tsx @@ -0,0 +1,240 @@ +import { Children, type FormEvent, type ReactNode } from "react"; +import { Button, Icon, Pill } from "../../shared/ui"; +import type { + AgentRecord, + ChatSpaceRecord, + ChatThreadRecord, + ComplianceExportRecord, + EmployeeRecord, + FileObjectRecord, + LegalHoldRecord, + RetentionFreezeRecord, +} from "../../../../shared/domain"; +import type { SnapshotActions } from "../../app/useSnapshot"; +import { formatRelative } from "../types"; + +export function CompliancePanel({ + orgId, + employeeId, + employees, + agents, + chatSpaces, + chatThreads, + files, + exports, + legalHolds, + retentionFreezes, + actions, +}: { + orgId: string; + employeeId: string; + employees: EmployeeRecord[]; + agents: AgentRecord[]; + chatSpaces: ChatSpaceRecord[]; + chatThreads: ChatThreadRecord[]; + files: FileObjectRecord[]; + exports: ComplianceExportRecord[]; + legalHolds: LegalHoldRecord[]; + retentionFreezes: RetentionFreezeRecord[]; + actions: SnapshotActions; +}) { + const submitExport = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const [subjectType, subjectId] = splitPair(String(form.get("subject") ?? "")); + void actions.run(() => window.agentCompany.createComplianceExport({ + orgId, + subjectType: subjectType as "employee" | "agent", + subjectId, + exportType: String(form.get("exportType")) as "gdpr_export" | "gdpr_delete", + requestedByEmployeeId: employeeId, + })); + }; + + const submitHold = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const [entityType, entityId] = splitPair(String(form.get("entity") ?? "")); + const reason = String(form.get("reason") ?? "").trim(); + void actions.run(() => window.agentCompany.createLegalHold({ + orgId, + entityType: entityType as "organization" | "employee" | "agent" | "chat_thread" | "file_object", + entityId, + reason, + createdByEmployeeId: employeeId, + })).then((result) => { if (result) event.currentTarget.reset(); }); + }; + + const submitFreeze = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const [scopeType, scopeId] = splitPair(String(form.get("scope") ?? "")); + const reason = String(form.get("reason") ?? "").trim(); + void actions.run(() => window.agentCompany.createRetentionFreeze({ + orgId, + scopeType: scopeType as "organization" | "chat_space" | "file_object", + scopeId, + reason, + createdByEmployeeId: employeeId, + })).then((result) => { if (result) event.currentTarget.reset(); }); + }; + + return ( +
+

Compliance

+

GDPR exports, legal holds, and retention freezes.

+
+
+ +
+ + + + + + {employees.map((employee) => )} + {agents.map((agent) => )} + +
+ +
+
+
+ +
+ +
+ + + {employees.map((employee) => )} + {agents.map((agent) => )} + {chatThreads.map((thread) => )} + {files.map((file) => )} + + +
+ +
+ +
+ +
+ +
+ + + {chatSpaces.map((space) => )} + {files.map((file) => )} + + +
+ +
+ +
+ +
+ + + {exports.map((item) => ( + + ))} + +
+
+ +
+ + + {legalHolds.map((hold) => ( + void actions.run(() => window.agentCompany.setLegalHoldState({ orgId, legalHoldId: hold.id, active: !hold.active, employeeId }))} + /> + ))} + {retentionFreezes.map((freeze) => ( + void actions.run(() => window.agentCompany.setRetentionFreezeState({ orgId, retentionFreezeId: freeze.id, active: !freeze.active, employeeId }))} + /> + ))} + +
+
+ ); +} + +function SectionTitle({ icon, title }: { icon: "approve" | "docs" | "inbox" | "pause" | "shield"; title: string }) { + return ( +
+ +
{title}
+
+ ); +} + +function LabeledSelect({ label, name, children }: { label: string; name: string; children: ReactNode }) { + return ( + + ); +} + +function LabeledInput({ label, name }: { label: string; name: string }) { + return ( + + ); +} + +function RecordList({ emptyLabel, children }: { emptyLabel: string; children: ReactNode }) { + const items = Children.toArray(children).filter(Boolean); + return items.length > 0 ?
{items}
: {emptyLabel}; +} + +function RecordRow({ + title, + meta, + active, + actionLabel, + onAction, +}: { + title: string; + meta: string; + active?: boolean; + actionLabel?: string; + onAction?: () => void; +}) { + return ( +
+
+
{title}
+
{meta}
+
+ {active !== undefined && {active ? "active" : "released"}} + {onAction && actionLabel && } +
+ ); +} + +function splitPair(value: string): [string, string] { + const index = value.indexOf(":"); + if (index < 1) throw new Error("Compliance selector value is invalid."); + return [value.slice(0, index), value.slice(index + 1)]; +} diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 09f91b47..6a1237b2 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -1,5 +1,9 @@ import type { ApprovalState, + ComplianceExportType, + ComplianceSubjectType, + LegalHoldEntityType, + RetentionFreezeScopeType, RiskLevel, SocialPlatform, TaskPriority, @@ -172,6 +176,44 @@ export interface CreateMigrationReportInput { sourceDatabasePath: string; } +export interface CreateComplianceExportInput { + orgId: string; + subjectType: ComplianceSubjectType; + subjectId: string; + exportType: ComplianceExportType; + requestedByEmployeeId: string; +} + +export interface CreateLegalHoldInput { + orgId: string; + entityType: LegalHoldEntityType; + entityId: string; + reason: string; + createdByEmployeeId: string; +} + +export interface SetLegalHoldStateInput { + orgId: string; + legalHoldId: string; + active: boolean; + employeeId: string; +} + +export interface CreateRetentionFreezeInput { + orgId: string; + scopeType: RetentionFreezeScopeType; + scopeId: string; + reason: string; + createdByEmployeeId: string; +} + +export interface SetRetentionFreezeStateInput { + orgId: string; + retentionFreezeId: string; + active: boolean; + employeeId: string; +} + export interface CreateScheduledPostInput { orgId: string; socialAccountId: string; diff --git a/AgentCompany-New/src/shared/compliance-domain.ts b/AgentCompany-New/src/shared/compliance-domain.ts new file mode 100644 index 00000000..b3ba2a1b --- /dev/null +++ b/AgentCompany-New/src/shared/compliance-domain.ts @@ -0,0 +1,53 @@ +export const COMPLIANCE_EXPORT_TYPE = { + GDPR_EXPORT: "gdpr_export", + GDPR_DELETE: "gdpr_delete", +} as const; + +export const COMPLIANCE_EXPORT_STATE = { + COMPLETED: "completed", +} as const; + +type ValueOf = T[keyof T]; + +export type ComplianceExportType = ValueOf; +export type ComplianceExportState = ValueOf; +export type ComplianceSubjectType = "employee" | "agent"; +export type LegalHoldEntityType = "organization" | "employee" | "agent" | "chat_thread" | "file_object"; +export type RetentionFreezeScopeType = "organization" | "chat_space" | "file_object"; + +export interface ComplianceExportRecord { + id: string; + orgId: string; + subjectType: ComplianceSubjectType; + subjectId: string; + exportType: ComplianceExportType; + state: ComplianceExportState; + payloadJson: string; + requestedByEmployeeId: string | null; + createdAt: string; + completedAt: string | null; +} + +export interface LegalHoldRecord { + id: string; + orgId: string; + entityType: LegalHoldEntityType; + entityId: string; + reason: string; + createdByEmployeeId: string | null; + active: boolean; + createdAt: string; + releasedAt: string | null; +} + +export interface RetentionFreezeRecord { + id: string; + orgId: string; + scopeType: RetentionFreezeScopeType; + scopeId: string; + reason: string; + createdByEmployeeId: string | null; + active: boolean; + createdAt: string; + releasedAt: string | null; +} diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index a5ec6a34..13e39346 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -7,11 +7,14 @@ import type { CreateAgentInput, CreateApprovalInput, CreateChatSpaceInput, + CreateComplianceExportInput, + CreateLegalHoldInput, CreateMigrationReportInput, CheckProviderHealthInput, MarkChatSpaceReadInput, CreateOrganizationInput, CreateProviderResponseInput, + CreateRetentionFreezeInput, CreateScheduledPostInput, CreateSocialAccountInput, CreateTaskInput, @@ -26,6 +29,8 @@ import type { SendMessageInput, SetActiveOrganizationInput, SetFilePinnedInput, + SetLegalHoldStateInput, + SetRetentionFreezeStateInput, SetScheduledPostStateInput, UpdateTaskStatusInput, } from "./commands"; @@ -63,6 +68,11 @@ export interface DesktopApi { importFile(input: ImportFileInput): Promise>; setFilePinned(input: SetFilePinnedInput): Promise>; createMigrationReport(input: CreateMigrationReportInput): Promise>; + createComplianceExport(input: CreateComplianceExportInput): Promise>; + createLegalHold(input: CreateLegalHoldInput): Promise>; + setLegalHoldState(input: SetLegalHoldStateInput): Promise>; + createRetentionFreeze(input: CreateRetentionFreezeInput): Promise>; + setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; platform: "win32" | "darwin" | "linux"; windowMinimize(): Promise; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 313fb72b..1824b6f0 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -1,8 +1,7 @@ import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain"; - -export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE } from "./board-domain"; -export type { BoardInterventionAction, BoardInterventionMode, BoardInterventionRecord, ChatThreadBoardState } from "./board-domain"; - +import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain"; +export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; +export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain"; export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", @@ -192,6 +191,7 @@ export interface ChatSpaceRecord { name: string; topic: string; visibility: string; + retentionPolicy: string; createdAt: string; } @@ -494,5 +494,6 @@ export interface AppSnapshot { browserActions: BrowserActionRecord[]; policyRules: PolicyRuleRecord[]; migrationReports: MigrationReportRecord[]; + complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[]; auditEvents: AuditEventRecord[]; } diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 5d8f5316..dd6ae829 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -27,6 +27,11 @@ export const IPC_CHANNELS = { importFile: "agentcompany:import-file", setFilePinned: "agentcompany:set-file-pinned", createMigrationReport: "agentcompany:create-migration-report", + createComplianceExport: "agentcompany:create-compliance-export", + createLegalHold: "agentcompany:create-legal-hold", + setLegalHoldState: "agentcompany:set-legal-hold-state", + createRetentionFreeze: "agentcompany:create-retention-freeze", + setRetentionFreezeState: "agentcompany:set-retention-freeze-state", rendererEvent: "agentcompany:renderer-event", windowMinimize: "agentcompany:window-minimize", windowMaximize: "agentcompany:window-maximize", diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index 96b1ba37..a9df1328 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -92,6 +92,11 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Publish" }).click(); await expect(page.getByText("Published").first()).toBeVisible(); + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Compliance" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByText("gdpr export · employee").first()).toBeVisible(); + await verifyPagesDoNotOverflow(page); } finally { await app.close(); @@ -105,7 +110,7 @@ async function verifyPagesDoNotOverflow(page: Page): Promise { await page.getByRole("button", { name: nav }).click(); await expectPageToFit(page); } - for (const tab of ["Workspace", "Members", "Billing", "Models", "Providers", "Skills", "Policies", "Migration", "Profile", "Notifications", "Security"]) { + for (const tab of ["Workspace", "Members", "Billing", "Models", "Providers", "Skills", "Policies", "Compliance", "Migration", "Profile", "Notifications", "Security"]) { await page.getByRole("button", { name: "Settings" }).click(); await page.getByRole("button", { name: tab }).click(); await expectPageToFit(page); diff --git a/AgentCompany-New/tests/e2e/screenshots.spec.ts b/AgentCompany-New/tests/e2e/screenshots.spec.ts index 1f1eecff..8b23d3b1 100644 --- a/AgentCompany-New/tests/e2e/screenshots.spec.ts +++ b/AgentCompany-New/tests/e2e/screenshots.spec.ts @@ -48,7 +48,7 @@ test("captures every page for visual review", async () => { await go(page, "Social", outDir, "16-social"); await go(page, "Settings", outDir, "17-settings"); - for (const tab of ["Workspace", "Members", "Billing", "Models", "Skills", "Policies", "Migration", "Profile", "Notifications", "Security"]) { + for (const tab of ["Workspace", "Members", "Billing", "Models", "Skills", "Policies", "Compliance", "Migration", "Profile", "Notifications", "Security"]) { await page.getByRole("button", { name: "Settings" }).click(); await page.getByRole("button", { name: tab }).first().click(); await snap(page, outDir, `settings-${tab.toLowerCase()}`); diff --git a/AgentCompany-New/tests/unit/compliance-service.test.ts b/AgentCompany-New/tests/unit/compliance-service.test.ts new file mode 100644 index 00000000..4aa9d774 --- /dev/null +++ b/AgentCompany-New/tests/unit/compliance-service.test.ts @@ -0,0 +1,165 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { ChatService } from "../../src/main/services/chat-service"; +import { ComplianceService } from "../../src/main/services/compliance-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-compliance-${randomUUID()}.db`)); +} + +describe("compliance service", () => { + test("creates GDPR export payloads with audit coverage", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const compliance = new ComplianceService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + let spaceId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" }); + chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: "Exportable message", + attachmentFileIds: [], + }); + compliance.createExport({ + orgId, + subjectType: "employee", + subjectId: employeeId, + exportType: "gdpr_export", + requestedByEmployeeId: employeeId, + }); + }); + + const state = snapshot.load(); + const record = state.complianceExports[0]; + if (!record) throw new Error("Compliance export was not created."); + const payload = JSON.parse(record.payloadJson) as { profile: { display_name: string }; chatMessages: unknown[] }; + expect(record).toMatchObject({ subjectType: "employee", subjectId: employeeId, exportType: "gdpr_export", state: "completed" }); + expect(payload.profile.display_name).toBe("Dana Lee"); + expect(payload.chatMessages).toHaveLength(1); + expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "created")).toBe(true); + connection.close(); + }); + + test("blocks GDPR delete under holds and redacts after release", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const compliance = new ComplianceService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + let spaceId = ""; + let holdId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" }); + chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Delete me", attachmentFileIds: [] }); + holdId = compliance.createLegalHold({ + orgId, + entityType: "employee", + entityId: employeeId, + reason: "Discovery hold", + createdByEmployeeId: employeeId, + }); + }); + + expect(() => connection.transaction(() => compliance.createExport({ + orgId, + subjectType: "employee", + subjectId: employeeId, + exportType: "gdpr_delete", + requestedByEmployeeId: employeeId, + }))).toThrow("Active compliance hold prevents GDPR delete."); + + connection.transaction(() => { + compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId }); + compliance.createExport({ + orgId, + subjectType: "employee", + subjectId: employeeId, + exportType: "gdpr_delete", + requestedByEmployeeId: employeeId, + }); + }); + + const state = snapshot.load(); + expect(state.employees[0]?.displayName).toBe("Deleted employee"); + expect(state.chatMessages[0]?.senderId).toBeNull(); + expect(state.complianceExports[0]?.exportType).toBe("gdpr_delete"); + expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "gdpr_deleted")).toBe(true); + connection.close(); + }); + + test("blocks GDPR delete under organization retention freeze", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const compliance = new ComplianceService(connection.db); + let orgId = ""; + let employeeId = ""; + let agentId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + agentId = organization.createAgent({ + orgId, + name: "Ada", + role: "Release reviewer", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 10000, + managerAgentId: null, + }); + compliance.createRetentionFreeze({ + orgId, + scopeType: "organization", + scopeId: orgId, + reason: "Retention freeze", + createdByEmployeeId: employeeId, + }); + }); + + expect(() => connection.transaction(() => compliance.createExport({ + orgId, + subjectType: "agent", + subjectId: agentId, + exportType: "gdpr_delete", + requestedByEmployeeId: employeeId, + }))).toThrow("Active compliance hold prevents GDPR delete."); + connection.close(); + }); +}); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 560d7a1f..72173fc5 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index b246d02f..3247b8bc 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -128,6 +128,9 @@ describe("migration service", () => { create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text); create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text); create table policy_rules(id text primary key, org_id text); + create table compliance_exports(id text primary key, org_id text, requested_by_employee_id text); + create table legal_holds(id text primary key, org_id text, created_by_employee_id text); + create table retention_freezes(id text primary key, org_id text, created_by_employee_id text); create table audit_events(id text primary key, org_id text); insert into organizations(id) values ('org-1'); insert into employees(id, org_id) values ('emp-1', 'org-1'); @@ -166,6 +169,9 @@ describe("migration service", () => { insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1'); insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1'); insert into policy_rules(id, org_id) values ('policy-1', 'org-1'); + insert into compliance_exports(id, org_id, requested_by_employee_id) values ('cmp-1', 'org-1', 'emp-1'); + insert into legal_holds(id, org_id, created_by_employee_id) values ('hold-1', 'org-1', 'emp-1'); + insert into retention_freezes(id, org_id, created_by_employee_id) values ('freeze-1', 'org-1', 'emp-1'); insert into audit_events(id, org_id) values ('audit-1', 'org-1'); `); let orgId = ""; @@ -183,9 +189,11 @@ describe("migration service", () => { expect(body.tableCounts.board_interventions).toBe(1); expect(body.tableCounts.file_access_grants).toBe(1); expect(body.tableCounts.provider_requests).toBe(1); + expect(body.tableCounts.compliance_exports).toBe(1); expect(body.tableChecksums.audit_events).toHaveLength(64); expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]); expect(body.idMaps.board_interventions).toEqual(["bdi-1"]); + expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]); connection.close(); });