diff --git a/src/main/ipc-company-operations/document-handlers.ts b/src/main/ipc-company-operations/document-handlers.ts new file mode 100644 index 00000000..6005b2d1 --- /dev/null +++ b/src/main/ipc-company-operations/document-handlers.ts @@ -0,0 +1,73 @@ +import { IPC_CHANNELS, deleteEntitySchema, documentInputSchema } from "@shared/contracts"; +import { createDocumentWithLifecycle } from "../document-lifecycle"; +import { fail, ok } from "../ipc-domain-common"; +import type { CompanyOperationHandlerDependencies } from "./types"; + +export function registerDocumentHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + threadIntegration, + checkpointEngine, + lifecycleProcessor, + logger, +}: CompanyOperationHandlerDependencies) { + registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => { + const parsed = documentInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.authorAgentId, table: "agents", label: "document author" }, + { id: parsed.reviewerAgentId, table: "agents", label: "document reviewer" }, + { id: parsed.projectId, table: "projects", label: "document project" }, + { id: parsed.goalId, table: "goals", label: "document goal" }, + { id: parsed.parentDocId, table: "documents", label: "parent document" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + + const outcome = createDocumentWithLifecycle( + { + db, + threadIntegration, + checkpointEngine, + lifecycleProcessor, + publishDomainChanged, + logger, + }, + { + id: parsed.id ?? null, + companyId: parsed.companyId, + type: parsed.type, + title: parsed.title, + content: parsed.content ?? "", + authorAgentId: parsed.authorAgentId ?? null, + reviewerAgentId: parsed.reviewerAgentId ?? null, + projectId: parsed.projectId ?? null, + goalId: parsed.goalId ?? null, + parentDocId: parsed.parentDocId ?? null, + version: parsed.version ?? 1, + status: parsed.status ?? "draft", + tagsJson: parsed.tagsJson ?? "[]", + sourceRunId: null, + }, + ); + return ok(outcome.id); + }); + + registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.settings.belongsToCompany("documents", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Document not found in the current company."); + } + db.documentsMod.deleteDocument(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-company-operations/index.ts b/src/main/ipc-company-operations/index.ts new file mode 100644 index 00000000..d850c465 --- /dev/null +++ b/src/main/ipc-company-operations/index.ts @@ -0,0 +1,12 @@ +import { registerDocumentHandlers } from "./document-handlers"; +import { registerKnowledgeEntryHandlers } from "./knowledge-entry-handlers"; +import { registerMeetingHandlers } from "./meeting-handlers"; +import { registerSprintHandlers } from "./sprint-handlers"; +import type { CompanyOperationHandlerDependencies } from "./types"; + +export function registerCompanyOperationsHandlers(dependencies: CompanyOperationHandlerDependencies) { + registerMeetingHandlers(dependencies); + registerDocumentHandlers(dependencies); + registerKnowledgeEntryHandlers(dependencies); + registerSprintHandlers(dependencies); +} diff --git a/src/main/ipc-company-operations/knowledge-entry-handlers.ts b/src/main/ipc-company-operations/knowledge-entry-handlers.ts new file mode 100644 index 00000000..202d448a --- /dev/null +++ b/src/main/ipc-company-operations/knowledge-entry-handlers.ts @@ -0,0 +1,47 @@ +import { IPC_CHANNELS, deleteEntitySchema, knowledgeEntryInputSchema } from "@shared/contracts"; +import { fail, ok } from "../ipc-domain-common"; +import { COMPANY_SCOPED_REFERENCE_TABLES } from "./reference-tables"; +import type { CompanyOperationHandlerDependencies } from "./types"; + +export function registerKnowledgeEntryHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, +}: CompanyOperationHandlerDependencies) { + registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => { + const parsed = knowledgeEntryInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + + const knowledgeReferences = [ + { id: parsed.authorAgentId, table: "agents", label: "knowledge author" }, + ...(parsed.referencedEntityId && parsed.referencedEntityType && COMPANY_SCOPED_REFERENCE_TABLES.has(parsed.referencedEntityType) + ? [{ id: parsed.referencedEntityId, table: parsed.referencedEntityType, label: "referenced entity" }] + : []), + ]; + const invalidReference = findInvalidReference(parsed.companyId, [ + ...knowledgeReferences, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + + const id = db.knowledgeMod.saveKnowledgeEntry(parsed); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteKnowledgeEntry, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.settings.belongsToCompany("knowledge_base", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Knowledge entry not found in the current company."); + } + db.knowledgeMod.deleteKnowledgeEntry(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-company-operations/meeting-handlers.ts b/src/main/ipc-company-operations/meeting-handlers.ts new file mode 100644 index 00000000..3fa11fd6 --- /dev/null +++ b/src/main/ipc-company-operations/meeting-handlers.ts @@ -0,0 +1,53 @@ +import { IPC_CHANNELS, deleteEntitySchema, meetingInputSchema } from "@shared/contracts"; +import { fail, ok } from "../ipc-domain-common"; +import type { CompanyOperationHandlerDependencies } from "./types"; + +export function registerMeetingHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + logger, +}: CompanyOperationHandlerDependencies) { + registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => { + const parsed = meetingInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + + let participantAgentIds: string[] = []; + try { + const parsedParticipantIds = JSON.parse(parsed.participantAgentIds); + participantAgentIds = Array.isArray(parsedParticipantIds) + ? parsedParticipantIds.filter((value): value is string => typeof value === "string") + : []; + } catch (err) { + logger.warn(`[ipc/meetings] Meeting participants JSON parse failed:`, err instanceof Error ? err.message : String(err)); + return fail("INVALID_PARTICIPANTS", "Meeting participants must be valid JSON."); + } + + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.organizerAgentId, table: "agents", label: "meeting organizer" }, + ...participantAgentIds.map((agentId) => ({ id: agentId, table: "agents", label: "meeting participant" })), + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + + const id = db.knowledgeMod.saveMeeting(parsed); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteMeeting, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.settings.belongsToCompany("meetings", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Meeting not found in the current company."); + } + db.knowledgeMod.deleteMeeting(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-company-operations/reference-tables.ts b/src/main/ipc-company-operations/reference-tables.ts new file mode 100644 index 00000000..330ef2ed --- /dev/null +++ b/src/main/ipc-company-operations/reference-tables.ts @@ -0,0 +1,15 @@ +export const COMPANY_SCOPED_REFERENCE_TABLES = new Set([ + "agents", + "goals", + "projects", + "tasks", + "approvals", + "runs", + "secrets", + "meetings", + "documents", + "knowledge_base", + "sprints", + "automation_rules", + "workflow_pipelines", +]); diff --git a/src/main/ipc-company-operations/sprint-handlers.ts b/src/main/ipc-company-operations/sprint-handlers.ts new file mode 100644 index 00000000..100d7b33 --- /dev/null +++ b/src/main/ipc-company-operations/sprint-handlers.ts @@ -0,0 +1,50 @@ +import { IPC_CHANNELS, deleteEntitySchema, sprintInputSchema } from "@shared/contracts"; +import { fail, ok } from "../ipc-domain-common"; +import type { CompanyOperationHandlerDependencies } from "./types"; + +export function registerSprintHandlers({ + registerHandle, + db, + ensureCompanyExists, + publishDomainChanged, + dispatchAutomation, + logger, +}: CompanyOperationHandlerDependencies) { + registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => { + const parsed = sprintInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + + let previousSprintStatus: string | null = null; + if (parsed.id) { + try { + previousSprintStatus = db.snap.listSnapshot().sprints.find((s: { id: string }) => s.id === parsed.id)?.status ?? null; + } catch (err) { + logger.warn(`[ipc/sprints] listSnapshot failed while resolving sprint ${parsed.id} previous status:`, err instanceof Error ? err.message : String(err)); + previousSprintStatus = null; + } + } + + const id = db.knowledgeMod.saveSprint(parsed); + if (parsed.status === "active" && previousSprintStatus !== "active") { + dispatchAutomation("sprint_started", { companyId: parsed.companyId }); + } + if (parsed.status === "completed" && previousSprintStatus !== "completed") { + dispatchAutomation("sprint_ended", { companyId: parsed.companyId }); + } + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteSprint, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.settings.belongsToCompany("sprints", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Sprint not found in the current company."); + } + db.knowledgeMod.deleteSprint(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-company-operations/types.ts b/src/main/ipc-company-operations/types.ts new file mode 100644 index 00000000..b1172daf --- /dev/null +++ b/src/main/ipc-company-operations/types.ts @@ -0,0 +1,15 @@ +import type { RegisterDomainHandlersDependencies } from "../ipc-domain-common"; + +export type CompanyOperationHandlerDependencies = Pick< + RegisterDomainHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "findInvalidReference" + | "publishDomainChanged" + | "dispatchAutomation" + | "threadIntegration" + | "checkpointEngine" + | "lifecycleProcessor" + | "logger" +>; diff --git a/src/main/ipc-content-operations-handlers.ts b/src/main/ipc-content-operations-handlers.ts deleted file mode 100644 index f235742d..00000000 --- a/src/main/ipc-content-operations-handlers.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { - IPC_CHANNELS, - deleteEntitySchema, - documentInputSchema, - knowledgeEntryInputSchema, - meetingInputSchema, - sprintInputSchema, -} from "@shared/contracts"; -import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; -import { createDocumentWithLifecycle } from "./document-lifecycle"; - -const COMPANY_SCOPED_REFERENCE_TABLES = new Set([ - "agents", - "goals", - "projects", - "tasks", - "approvals", - "runs", - "secrets", - "meetings", - "documents", - "knowledge_base", - "sprints", - "automation_rules", - "workflow_pipelines", -]); - -export function registerContentOperationsHandlers({ - registerHandle, - db, - ensureCompanyExists, - findInvalidReference, - publishDomainChanged, - dispatchAutomation, - threadIntegration, - checkpointEngine, - lifecycleProcessor, - logger, -}: Pick< - RegisterDomainHandlersDependencies, - | "registerHandle" - | "db" - | "ensureCompanyExists" - | "findInvalidReference" - | "publishDomainChanged" - | "dispatchAutomation" - | "threadIntegration" - | "checkpointEngine" - | "lifecycleProcessor" - | "logger" ->) { - registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => { - const parsed = meetingInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - let participantAgentIds: string[] = []; - try { - const parsedParticipantIds = JSON.parse(parsed.participantAgentIds); - participantAgentIds = Array.isArray(parsedParticipantIds) - ? parsedParticipantIds.filter((value): value is string => typeof value === "string") - : []; - } catch (err) { - logger.warn(`[ipc/content] Meeting participants JSON parse failed:`, err instanceof Error ? err.message : String(err)); - return fail("INVALID_PARTICIPANTS", "Meeting participants must be valid JSON."); - } - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.organizerAgentId, table: "agents", label: "meeting organizer" }, - ...participantAgentIds.map((agentId) => ({ id: agentId, table: "agents", label: "meeting participant" })), - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.knowledgeMod.saveMeeting(parsed); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.deleteMeeting, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.settings.belongsToCompany("meetings", parsed.id, parsed.companyId)) { - return fail("ENTITY_NOT_FOUND", "Meeting not found in the current company."); - } - db.knowledgeMod.deleteMeeting(parsed.id, parsed.companyId); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => { - const parsed = documentInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.authorAgentId, table: "agents", label: "document author" }, - { id: parsed.reviewerAgentId, table: "agents", label: "document reviewer" }, - { id: parsed.projectId, table: "projects", label: "document project" }, - { id: parsed.goalId, table: "goals", label: "document goal" }, - { id: parsed.parentDocId, table: "documents", label: "parent document" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const outcome = createDocumentWithLifecycle( - { - db, - threadIntegration, - checkpointEngine, - lifecycleProcessor, - publishDomainChanged, - logger, - }, - { - id: parsed.id ?? null, - companyId: parsed.companyId, - type: parsed.type, - title: parsed.title, - content: parsed.content ?? "", - authorAgentId: parsed.authorAgentId ?? null, - reviewerAgentId: parsed.reviewerAgentId ?? null, - projectId: parsed.projectId ?? null, - goalId: parsed.goalId ?? null, - parentDocId: parsed.parentDocId ?? null, - version: parsed.version ?? 1, - status: parsed.status ?? "draft", - tagsJson: parsed.tagsJson ?? "[]", - sourceRunId: null, - }, - ); - return ok(outcome.id); - }); - - registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.settings.belongsToCompany("documents", parsed.id, parsed.companyId)) { - return fail("ENTITY_NOT_FOUND", "Document not found in the current company."); - } - db.documentsMod.deleteDocument(parsed.id, parsed.companyId); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => { - const parsed = knowledgeEntryInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const knowledgeReferences = [ - { id: parsed.authorAgentId, table: "agents", label: "knowledge author" }, - ...(parsed.referencedEntityId && parsed.referencedEntityType - && COMPANY_SCOPED_REFERENCE_TABLES.has(parsed.referencedEntityType) - ? [{ id: parsed.referencedEntityId, table: parsed.referencedEntityType, label: "referenced entity" }] - : []), - ]; - const invalidReference = findInvalidReference(parsed.companyId, [ - ...knowledgeReferences, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.knowledgeMod.saveKnowledgeEntry(parsed); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.deleteKnowledgeEntry, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.settings.belongsToCompany("knowledge_base", parsed.id, parsed.companyId)) { - return fail("ENTITY_NOT_FOUND", "Knowledge entry not found in the current company."); - } - db.knowledgeMod.deleteKnowledgeEntry(parsed.id, parsed.companyId); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => { - const parsed = sprintInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - let previousSprintStatus: string | null = null; - if (parsed.id) { - try { - previousSprintStatus = db.snap.listSnapshot().sprints.find((s: { id: string }) => s.id === parsed.id)?.status ?? null; - } catch (err) { - logger.warn(`[ipc/content] listSnapshot failed while resolving sprint ${parsed.id} previous status:`, err instanceof Error ? err.message : String(err)); - previousSprintStatus = null; - } - } - const id = db.knowledgeMod.saveSprint(parsed); - if (parsed.status === "active" && previousSprintStatus !== "active") { - dispatchAutomation("sprint_started", { companyId: parsed.companyId }); - } - if (parsed.status === "completed" && previousSprintStatus !== "completed") { - dispatchAutomation("sprint_ended", { companyId: parsed.companyId }); - } - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.deleteSprint, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.settings.belongsToCompany("sprints", parsed.id, parsed.companyId)) { - return fail("ENTITY_NOT_FOUND", "Sprint not found in the current company."); - } - db.knowledgeMod.deleteSprint(parsed.id, parsed.companyId); - publishDomainChanged(); - return ok(true); - }); -} diff --git a/src/main/ipc-domain-handlers.ts b/src/main/ipc-domain-handlers.ts index 8c8b3c8b..9cd8e7fb 100644 --- a/src/main/ipc-domain-handlers.ts +++ b/src/main/ipc-domain-handlers.ts @@ -2,7 +2,7 @@ import type { RegisterDomainHandlersDependencies } from "./ipc-domain-common"; import { registerAgentCoordinationHandlers } from "./ipc-agent-coordination-handlers"; import { registerAutomationOperationsHandlers } from "./ipc-automation-operations-handlers"; import { registerBrowserHandlers } from "./ipc-browser-handlers"; -import { registerContentOperationsHandlers } from "./ipc-content-operations-handlers"; +import { registerCompanyOperationsHandlers } from "./ipc-company-operations/index"; import { registerOnboardingHandlers } from "./ipc-onboarding-handlers"; import { registerRecoveryHandlers } from "./ipc-recovery-handlers"; import { registerSkillHandlers } from "./ipc-skill-handlers"; @@ -11,7 +11,7 @@ import { registerBudgetHandlers } from "./ipc-budget-handlers"; export function registerDomainHandlers(dependencies: RegisterDomainHandlersDependencies) { registerOnboardingHandlers(dependencies); registerBrowserHandlers(dependencies); - registerContentOperationsHandlers(dependencies); + registerCompanyOperationsHandlers(dependencies); registerAutomationOperationsHandlers(dependencies); registerAgentCoordinationHandlers(dependencies); registerRecoveryHandlers(dependencies); diff --git a/tests/unit/company-operation-handlers.test.ts b/tests/unit/company-operation-handlers.test.ts new file mode 100644 index 00000000..86de7427 --- /dev/null +++ b/tests/unit/company-operation-handlers.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { IPC_CHANNELS } from "../../src/shared/contracts"; +import { registerCompanyOperationsHandlers } from "../../src/main/ipc-company-operations"; +import type { CompanyOperationHandlerDependencies } from "../../src/main/ipc-company-operations/types"; + +describe("company operation IPC handlers", () => { + it("registers only company-operation channels through the company-operation registry", () => { + const registeredChannels = new Set(); + + registerCompanyOperationsHandlers({ + registerHandle: (channel) => { + registeredChannels.add(channel); + }, + db: {} as CompanyOperationHandlerDependencies["db"], + ensureCompanyExists: () => null, + findInvalidReference: () => null, + publishDomainChanged: vi.fn(), + dispatchAutomation: vi.fn(), + threadIntegration: {} as CompanyOperationHandlerDependencies["threadIntegration"], + checkpointEngine: {} as CompanyOperationHandlerDependencies["checkpointEngine"], + lifecycleProcessor: {} as CompanyOperationHandlerDependencies["lifecycleProcessor"], + logger: { warn: vi.fn(), error: vi.fn() }, + }); + + expect([...registeredChannels].sort()).toEqual([ + IPC_CHANNELS.deleteDocument, + IPC_CHANNELS.deleteKnowledgeEntry, + IPC_CHANNELS.deleteMeeting, + IPC_CHANNELS.deleteSprint, + IPC_CHANNELS.saveDocument, + IPC_CHANNELS.saveKnowledgeEntry, + IPC_CHANNELS.saveMeeting, + IPC_CHANNELS.saveSprint, + ].sort()); + }); +});