# P6: Unified Visibility Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Show, per deal, the Hermes tasks it produced and their state, in the control panel, and unify notifications. **Architecture:** A read-only Postgres query joins `deal_map` to kanban task state and `audit_log`. A Next.js API route exposes it; a panel section renders it. Telegram stays the internal alert channel; CRM tasks stay the owner channel; this plan documents the split and removes duplication. **Tech Stack:** Next.js (App Router), TypeScript, Postgres (`pg`), the existing `Factronics-control-panel` patterns (`app/api/approvals/route.ts`, `components/hermes/approvals-section.tsx`, `lib/session.ts`). **Depends on:** P1 (`deal_map`). Read-only. **Spec:** `../specs/2026-09-24-sales-pipeline-hermes-design.md` (SP6, section 5.6). --- ## Prerequisites - Access to the `Coordenadas-co/Factronics-control-panel` repo. - `DATABASE_URL` read-only already used by the panel. - Confirm the panel's test runner (vitest or jest) and use it consistently. --- ## File Structure | Path | Responsibility | |---|---| | `lib/pipeline.ts` | Read-only queries for deal to task state | | `app/api/pipeline/route.ts` | JSON endpoint | | `components/hermes/pipeline-section.tsx` | Panel UI section | | `app/page.tsx` | Mount the section | | `docs/fleet-integration.md` | Document the notification split | --- ## Task 1: Panel data layer **Files:** - Create: `lib/pipeline.ts` - Test: `lib/pipeline.test.ts` - [ ] **Step 1: Write the failing test** Create `lib/pipeline.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { getDealTasks, shapePipelineRows } from "./pipeline"; describe("shapePipelineRows", () => { it("groups tasks by deal", () => { const rows = [ { deal_id: "D1", dealstage: "closedwon", kanban_task_id: "t1", domain: "onboarding", task_type: "onboarding_intake", status: "done" }, { deal_id: "D1", dealstage: "qualifiedtobuy", kanban_task_id: "t2", domain: "operations", task_type: "deal_product_details_track", status: "running" }, ]; const shaped = shapePipelineRows(rows); expect(shaped).toHaveLength(1); expect(shaped[0].deal_id).toBe("D1"); expect(shaped[0].tasks).toHaveLength(2); }); }); describe("getDealTasks", () => { it("queries deal_map with a bounded lookback", async () => { const query = vi.fn().mockResolvedValue({ rows: [] }); await getDealTasks("D1", query); expect(query).toHaveBeenCalledOnce(); expect(query.mock.calls[0][0]).toContain("deal_map"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run lib/pipeline.test.ts` Expected: FAIL (module missing). - [ ] **Step 3: Write the data layer** Create `lib/pipeline.ts`: ```typescript export type PipelineRow = { deal_id: string; dealstage: string; kanban_task_id: string | null; domain: string | null; task_type: string | null; status: string | null; }; export type ShapedDeal = { deal_id: string; tasks: PipelineRow[]; }; export function shapePipelineRows(rows: PipelineRow[]): ShapedDeal[] { const byDeal = new Map(); for (const r of rows) { if (!byDeal.has(r.deal_id)) byDeal.set(r.deal_id, []); byDeal.get(r.deal_id)!.push(r); } return [...byDeal.entries()].map(([deal_id, tasks]) => ({ deal_id, tasks })); } export async function getDealTasks(dealId: string, query: (sql: string, params?: unknown[]) => Promise<{ rows: PipelineRow[] }>) { return query( `SELECT deal_id, dealstage, kanban_task_id, domain, task_type, status FROM deal_map WHERE deal_id = $1 ORDER BY created_at DESC`, [dealId], ); } export async function getRecentPipeline(query: (sql: string) => Promise<{ rows: PipelineRow[] }>) { return query( `SELECT deal_id, dealstage, kanban_task_id, domain, task_type, status FROM deal_map WHERE created_at > now() - interval '30 days' ORDER BY created_at DESC`, ); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run lib/pipeline.test.ts` Expected: 2 passed. - [ ] **Step 5: Commit** ```bash git add lib/pipeline.ts lib/pipeline.test.ts git commit -m "feat(panel): pipeline data layer" ``` --- ## Task 2: API route **Files:** - Create: `app/api/pipeline/route.ts` - Test: `app/api/pipeline/route.test.ts` - [ ] **Step 1: Write the failing test** Create `app/api/pipeline/route.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; vi.mock("@/lib/db", () => ({ pool: { query: vi.fn().mockResolvedValue({ rows: [] }) } })); import { GET } from "./route"; describe("GET /api/pipeline", () => { it("returns shaped deals", async () => { const res = await GET(new Request("http://localhost/api/pipeline")); expect(res.status).toBe(200); const body = await res.json(); expect(Array.isArray(body.deals)).toBe(true); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run app/api/pipeline/route.test.ts` Expected: FAIL. - [ ] **Step 3: Write the route** Create `app/api/pipeline/route.ts`: ```typescript import { NextResponse } from "next/server"; import { pool } from "@/lib/db"; import { getRecentPipeline, shapePipelineRows } from "@/lib/pipeline"; export const dynamic = "force-dynamic"; export async function GET() { const { rows } = await getRecentPipeline((sql) => pool.query(sql)); return NextResponse.json({ deals: shapePipelineRows(rows) }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run app/api/pipeline/route.test.ts` Expected: 1 passed. - [ ] **Step 5: Commit** ```bash git add app/api/pipeline/route.ts app/api/pipeline/route.test.ts git commit -m "feat(panel): pipeline API route" ``` --- ## Task 3: Panel section and mount **Files:** - Create: `components/hermes/pipeline-section.tsx` - Modify: `app/page.tsx` - [ ] **Step 1:** Create `components/hermes/pipeline-section.tsx` mirroring the existing `approvals-section.tsx`: fetch `/api/pipeline`, render a table of deals with their tasks (stage, domain, task_type, status), with loading and empty states. - [ ] **Step 2:** Mount the section on the dashboard page next to the approvals and inbox sections. - [ ] **Step 3:** Run `npm run lint` and `npm run typecheck` (use the exact scripts in `package.json`) and fix issues. - [ ] **Step 4: Commit** ```bash git add components/hermes/pipeline-section.tsx app/page.tsx git commit -m "feat(panel): pipeline visibility section" ``` --- ## Task 4: Notification unification **Files:** - Modify: `docs/fleet-integration.md` - [ ] **Step 1:** Document the split: Telegram is the internal channel (cross-agent alerts, see `hermes send`); CRM tasks are the owner channel; client email is draft-only. Remove any duplicate alert where both fired for the same event. - [ ] **Step 2:** Add a note that `writeback_queue` approvals (P3) surface in the panel's approvals section using the same read-only `DATABASE_URL`. - [ ] **Step 3: Commit** ```bash git add docs/fleet-integration.md git commit -m "docs(panel): notification and approval split" ``` --- ## Task 5: Verification - [ ] **Step 1:** With `deal_map` populated from the P1 smoke test, load `/api/pipeline` and confirm the deals and tasks appear. - [ ] **Step 2:** Confirm the section renders on the dashboard and shows task state per deal. - [ ] **Step 3:** Confirm no write occurs from the panel (read-only `DATABASE_URL`). --- ## Definition of done - The panel shows deal to task state per deal, read-only. - `writeback_queue` approvals surface in the existing approvals section. - Notification channels are documented with no duplication.