feat: implement support organization (006) — teams, agents, hierarchy, capability lookup

Phase 6 of the roadmap. Populates the identity/teams and
orchestration/hierarchy module directories (previously empty or
near-empty scaffold stubs) and replaces identity/agents' pre-existing
placeholder, which queried a generic User/UserRole model unrelated to
this system's real architecture (nothing since 002-saas-integration's
CustomerReference has used it).

Teams and agents: active/inactive CRUD, never hard deletion; team
deactivation never cascades to its agents. Skills: compound-unique
upsert on (agentId, skillTag), never duplicates. Availability: a single
current record per agent, deliberately last-write-wins rather than
optimistic-locked — operational telemetry, not a durable business
record. The dynamic hierarchy: a nestable, orderable HierarchyNode tree
with every field (scope, assignment strategy reference, entry/exit
conditions) stored as opaque admin-set data; cycle detection runs only
on reparenting edits (a new node can't form a cycle); every
create/edit/activate/deactivate is audited via AuditLog, reusing
002's existing writer pattern. A capability-eligibility read path
composes hierarchy scope with caller-supplied skills for the future
orchestration/assignment phase (Phase 7) to call, deliberately
excluding availability per doc 05's own capability-before-availability
ordering.

Found and fixed a real bug before it reached tests: the initial
availability upsert reset currentLoad to 0 on every update, not just
creation.

Adds 11 unit tests (cycle detection, capability matching) and 4
integration test files covering all four user stories. Full regression
(every pre-existing 002-005 integration test plus all new ones) run
against real Postgres/Redis/MinIO: 107 passed, 9 skipped (005's
AI-key-gated tests, unrelated), 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-02 18:12:26 +05:30
co-authored by Claude Sonnet 5
parent e703f826de
commit a0c1a9aa33
55 changed files with 1782 additions and 92 deletions
@@ -0,0 +1,97 @@
-- CreateTable
CREATE TABLE "teams" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agents" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_skills" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"skillTag" TEXT NOT NULL,
"level" INTEGER NOT NULL,
CONSTRAINT "agent_skills_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_availability" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"workingHours" JSONB NOT NULL,
"currentLoad" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agent_availability_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "hierarchy_nodes" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"parentId" TEXT,
"order" INTEGER NOT NULL,
"teamId" TEXT,
"skills" TEXT[],
"productScope" TEXT[],
"categoryScope" TEXT[],
"priorityScope" TEXT[],
"assignmentStrategy" TEXT NOT NULL,
"slaPolicyId" TEXT,
"escalationPolicyId" TEXT,
"entryConditions" JSONB,
"exitConditions" JSONB,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "hierarchy_nodes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "agents_teamId_active_idx" ON "agents"("teamId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "agent_skills_agentId_skillTag_key" ON "agent_skills"("agentId", "skillTag");
-- CreateIndex
CREATE UNIQUE INDEX "agent_availability_agentId_key" ON "agent_availability"("agentId");
-- CreateIndex
CREATE INDEX "hierarchy_nodes_parentId_order_idx" ON "hierarchy_nodes"("parentId", "order");
-- CreateIndex
CREATE INDEX "hierarchy_nodes_active_idx" ON "hierarchy_nodes"("active");
-- AddForeignKey
ALTER TABLE "agents" ADD CONSTRAINT "agents_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_skills" ADD CONSTRAINT "agent_skills_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_availability" ADD CONSTRAINT "agent_availability_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hierarchy_nodes" ADD CONSTRAINT "hierarchy_nodes_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "hierarchy_nodes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hierarchy_nodes" ADD CONSTRAINT "hierarchy_nodes_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+84
View File
@@ -382,3 +382,87 @@ model AIConfidencePolicy {
@@unique([productId, categoryId])
@@map("ai_confidence_policies")
}
model Team {
id String @id @default(cuid())
name String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agents Agent[]
hierarchyNodes HierarchyNode[]
@@map("teams")
}
model Agent {
id String @id @default(cuid())
teamId String
team Team @relation(fields: [teamId], references: [id])
name String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
skills AgentSkill[]
availability AgentAvailability?
@@index([teamId, active])
@@map("agents")
}
model AgentSkill {
id String @id @default(cuid())
agentId String
agent Agent @relation(fields: [agentId], references: [id])
skillTag String
level Int // proficiency, used by a future SKILL_BASED assignment strategy — not
// interpreted by this feature
@@unique([agentId, skillTag])
@@map("agent_skills")
}
model AgentAvailability {
id String @id @default(cuid())
agentId String @unique
agent Agent @relation(fields: [agentId], references: [id])
status String // available | busy | away | offline — validated at the schema layer
workingHours Json // per business calendar — opaque to this feature
currentLoad Int @default(0)
updatedAt DateTime @updatedAt
// Last-write-wins on purpose — see specs/006-support-organization/research.md "Availability
// concurrency"; no expectedVersion field here, unlike Ticket.status/KnowledgeEntry.version.
@@map("agent_availability")
}
model HierarchyNode {
id String @id @default(cuid())
name String
parentId String?
parent HierarchyNode? @relation("HierarchyTree", fields: [parentId], references: [id])
children HierarchyNode[] @relation("HierarchyTree")
order Int
teamId String?
team Team? @relation(fields: [teamId], references: [id])
skills String[]
productScope String[] // external product ids; empty = matches every product
categoryScope String[] // free text; empty = matches every category
priorityScope String[] // free text; empty = matches every priority
assignmentStrategy String // free-text reference — no real strategy table exists yet (Phase 7)
slaPolicyId String? // free-text reference — no SlaPolicy table exists yet (Phase 8)
escalationPolicyId String? // free-text reference — no EscalationPolicy table exists yet
entryConditions Json? // opaque rule expression — stored, not evaluated, by this feature
exitConditions Json? // opaque rule expression — stored, not evaluated, by this feature
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId, order])
@@index([active])
@@map("hierarchy_nodes")
}