// R-P8b (RA-C2) — real live board pub/sub. Uses the repo's existing in-process // EventEmitter (`~/utils/stream/emitter.server`, the same one that already backs // the workflow-execution SSE stream) so board mutations (stage move, bulk move, // create, update, undo, archive) push real change-events to the connected // pipeline-board SSE clients instead of only a snapshot + heartbeat. No new infra // dependency is introduced. // // Scope caveat: this emitter is per web process. With a single web instance (the // current deployment) every board client shares the process, so events reach all // of them. Horizontal scaling of `hivegrid-web` would need a cross-process // transport (e.g. Redis pub/sub) behind the same publish/subscribe API — tracked // as a follow-up, out of this ticket's scope. This is server-only (.server) and // is never imported by projectService.server (kept out of the pure-service test // import graph) — publishing happens in the route-level orchestration helpers. import { publishDashboardChange } from "~/services/dashboard/dashboardEvents.server"; import { emitter } from "~/utils/stream/emitter.server"; import type { ProjectBoardChange, ProjectBoardChangeType } from "./projectBoardEvents.shared"; /** * In-process channel name for one pipeline board. Includes the tenantId as * defence-in-depth even though pipeline ids are already globally unique — a * subscriber only ever binds to its own tenant+pipeline channel. */ export function projectBoardChannel(tenantId: string, pipelineId: string): string { return `projectBoard:${tenantId}:${pipelineId}`; } let listenerCapRaised = false; /** Subscribe an SSE stream to a pipeline board channel. Returns an unsubscribe fn. */ export function subscribeToProjectBoard(tenantId: string, pipelineId: string, onChange: (payload: string) => void): () => void { // Many concurrent board viewers can subscribe to the same channel; lift the // default 10-listener cap so Node does not log a MaxListenersExceededWarning. if (!listenerCapRaised) { emitter.setMaxListeners(0); listenerCapRaised = true; } const channel = projectBoardChannel(tenantId, pipelineId); emitter.on(channel, onChange); return () => { emitter.off(channel, onChange); }; } /** Publish a board change so connected clients of this pipeline board revalidate. */ export function publishProjectBoardChange(input: { tenantId: string; pipelineId: string; projectId?: string; type: ProjectBoardChangeType; }): void { const payload: ProjectBoardChange = { type: input.type, pipelineId: input.pipelineId, projectId: input.projectId, at: new Date().toISOString(), }; emitter.emit(projectBoardChannel(input.tenantId, input.pipelineId), JSON.stringify(payload)); // Project mutations also feed the tenant-wide dashboard stream (R-P11b): // sales/project KPI widgets recompute their snapshot on these changes. publishDashboardChange({ tenantId: input.tenantId, source: "projects" }); }