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
+33
View File
@@ -126,3 +126,36 @@ permission/risk-gated tool system, optionally walking a matching runbook step by
- Semantic/vector retrieval, product-signal webhook verification, model routing/fallback, cost
dashboards, and localization are intentionally out of scope here — see
`specs/005-ai-support/spec.md` Assumptions.
# Support Organization
Admin CRUD for `Team`/`Agent`/`AgentSkill`/`AgentAvailability`, plus a fully dynamic
`HierarchyNode` tree — this system's explicit routing differentiator (doc 01: "changing one must
never require a code change"). See
`specs/006-support-organization/contracts/support-org-contract.md` for the full route list and
`specs/006-support-organization/quickstart.md` for runnable scenarios.
- **Teams and agents**: `POST/PATCH/GET /admin/teams`, `POST/PATCH/GET /admin/agents`
active/inactive only, never hard deletion. Deactivating a team never cascades into deactivating
its agents.
- **Skills and availability**: `PUT /admin/agents/:agentId/skills/:skillTag` upserts a
proficiency level (never duplicates the same tag). `PUT /admin/agents/:agentId/availability`
upserts the agent's single current record — status/working hours/current load — using
**last-write-wins**, not this system's usual `expectedVersion` optimistic-concurrency pattern:
availability is frequently-changing operational telemetry, not a durable business record (see
`specs/006-support-organization/research.md`).
- **The dynamic hierarchy**: `POST/PUT /admin/hierarchy-nodes` build an arbitrary, nestable tree
scoped to product/category/priority, each node carrying an `assignmentStrategy` and
`slaPolicyId`/`escalationPolicyId` **reference** (no such policy tables exist yet — Phase 7/8)
and opaque `entryConditions`/`exitConditions`. A reparenting edit that would make a node its own
ancestor is rejected with `400 CYCLE_DETECTED`; every create/edit/activate/deactivate writes an
`AuditLog` row.
- **Capability-eligibility lookup** (`GET /support-org/capability-eligibility?skills=&productId=&
categoryId=&priorityId=`) is a **read-only** contract for the future orchestration/assignment
phase (Phase 7) to call — it answers "who is capability-eligible" (active agent, active team,
holds every required skill, composed with any matching hierarchy node's own skills), and
deliberately **never** filters by availability, working hours, or current load (doc 05 §3:
"capability is evaluated before availability"). It does not make an assignment decision.
- This feature supersedes the pre-existing `identity/agents` scaffold stub, which queried a
generic `User`/`UserRole` model unrelated to this system's real architecture (nothing else uses
it — `CustomerReference`, built in 002, is the real customer-identity mechanism).
`identity/customers` and `identity/auth` are untouched — out of scope here.
@@ -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")
}
@@ -48,3 +48,22 @@
`/knowledge/retrieve` being built for 005-ai-support to call rather than that later feature
reaching into 004's internals.
- All items pass; no revision iterations were needed.
## Implementation notes (added during /speckit-implement)
- **Found and fixed a real bug before it reached tests**: the initial `AgentAvailability.upsert`
wrote `currentLoad: data.currentLoad ?? 0` unconditionally on every call, including updates —
meaning an update that only changed `status` would silently reset `currentLoad` back to `0`
every time, even if it had since been incremented by something else. Fixed by only defaulting
to `0` on the `create` branch of the upsert; the `update` branch omits the field entirely
unless the caller explicitly supplied a new value.
- `identity/teams` was *also* already a `User`-unrelated but still fully placeholder scaffold
(`findAllTeams()` hardcoded to return `[]`, no real query, its one route unregistered) — the
spec's Assumptions only called out `identity/agents`/`identity/customers`'s `User`-based stub;
`identity/teams`' emptier placeholder was found and superseded the same way during
implementation.
- All four user stories' integration tests passed on the first real run against Postgres — no
further bugs surfaced beyond the availability-upsert one above. Full regression (every
pre-existing integration test file across 002-005 plus all four new ones) was run together
against real Docker-provisioned Postgres/Redis/MinIO: 107 passed, 9 skipped (005's AI-key-gated
tests, unrelated to this feature), 0 failed.
+33 -33
View File
@@ -25,13 +25,13 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Setup
- [ ] T001 [P] Populate `src/modules/identity/teams/` with the standard module shape
- [x] T001 [P] Populate `src/modules/identity/teams/` with the standard module shape
(`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
`constants/`, `index.ts`) — this directory currently has no files
- [ ] T002 [P] Replace `src/modules/identity/agents/`'s existing `User`-based stub content
- [x] T002 [P] Replace `src/modules/identity/agents/`'s existing `User`-based stub content
(`repository/agents.repository.ts`, `service/agents.service.ts`,
`schema/agents.schema.ts`) — keep the module's file layout, replace what each file does
- [ ] T003 [P] Populate `src/modules/orchestration/hierarchy/` with the standard module shape —
- [x] T003 [P] Populate `src/modules/orchestration/hierarchy/` with the standard module shape —
this directory currently only has an empty `index.ts`
---
@@ -42,12 +42,12 @@ All file paths are relative to `supporthub-api/` (repo root).
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [ ] T004 Add `Team`, `Agent`, `AgentSkill`, `AgentAvailability`, `HierarchyNode` models to
- [x] T004 Add `Team`, `Agent`, `AgentSkill`, `AgentAvailability`, `HierarchyNode` models to
`prisma/schema.prisma` per data-model.md (including `@@unique([agentId, skillTag])` on
`AgentSkill`, `agentId @unique` on `AgentAvailability`, the `HierarchyTree` self-relation
on `HierarchyNode`, and the `(teamId, active)`/`(parentId, order)`/`(active)` indexes)
(depends on T001-T003)
- [ ] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T004 (depends on T004)
**Checkpoint**: Schema migrated. User stories can now be built.
@@ -63,28 +63,28 @@ never cascades.
### Tests for User Story 1
- [ ] T006 [US1] Integration test covering Quickstart Scenario 1 (create team/agent, agent
- [x] T006 [US1] Integration test covering Quickstart Scenario 1 (create team/agent, agent
appears in roster, deactivate excludes from active listing without deleting, reactivate
restores it, team deactivation doesn't cascade) against a real Postgres in
`tests/integration/support-org-teams-agents.test.ts`
### Implementation for User Story 1
- [ ] T007 [P] [US1] Add `TeamsRepository`/`TeamsService` (create; update; findById with agent
- [x] T007 [P] [US1] Add `TeamsRepository`/`TeamsService` (create; update; findById with agent
roster; findAll) in `src/modules/identity/teams/repository/teams.repository.ts` +
`service/teams.service.ts` (depends on T005)
- [ ] T008 [US1] Add `AgentsRepository`/`AgentsService` (create; update; findById; findAll with
- [x] T008 [US1] Add `AgentsRepository`/`AgentsService` (create; update; findById; findAll with
`active`/`teamId` filters) — replacing the existing `User`-based stub — in
`src/modules/identity/agents/repository/agents.repository.ts` +
`service/agents.service.ts` (depends on T005, T007 for the `teamId` FK)
- [ ] T009 [US1] Add Zod schemas + routes: `POST /admin/teams`, `PATCH /admin/teams/:teamId`,
- [x] T009 [US1] Add Zod schemas + routes: `POST /admin/teams`, `PATCH /admin/teams/:teamId`,
`GET /admin/teams/:teamId`, `GET /admin/teams` (gated by `fastify.authenticate`) in
`identity/teams/schema/` + `routes/`, registered from `src/api/routes.ts` (depends on T007)
- [ ] T010 [US1] Add Zod schemas + routes: `POST /admin/teams/:teamId/agents`,
- [x] T010 [US1] Add Zod schemas + routes: `POST /admin/teams/:teamId/agents`,
`PATCH /admin/agents/:agentId`, `GET /admin/agents/:agentId`, `GET /admin/agents` (gated by
`fastify.authenticate`) in `identity/agents/schema/` + `routes/`, registered from
`src/api/routes.ts` (depends on T008, T009)
- [ ] T011 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass
- [x] T011 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass
**Checkpoint**: Teams and agents can be created, updated, and deactivated/reactivated without
ever losing history. Usable as the foundation every later story needs.
@@ -99,26 +99,26 @@ ever losing history. Usable as the foundation every later story needs.
### Tests for User Story 2
- [ ] T012 [US2] Integration test covering Quickstart Scenario 2 (skill upsert doesn't
- [x] T012 [US2] Integration test covering Quickstart Scenario 2 (skill upsert doesn't
duplicate, availability upsert stays single-record, invalid status rejected) against a
real Postgres in `tests/integration/support-org-skills-availability.test.ts`
### Implementation for User Story 2
- [ ] T013 [P] [US2] Add `AgentSkillsRepository`/`AgentSkillsService` (`upsert(agentId,
- [x] T013 [P] [US2] Add `AgentSkillsRepository`/`AgentSkillsService` (`upsert(agentId,
skillTag, level)`; `findAllByAgent`) in
`src/modules/identity/agents/repository/agent-skills.repository.ts` +
`service/agent-skills.service.ts` (depends on T005)
- [ ] T014 [P] [US2] Add `AgentAvailabilityRepository`/`AgentAvailabilityService`
- [x] T014 [P] [US2] Add `AgentAvailabilityRepository`/`AgentAvailabilityService`
(`upsert(agentId, status, workingHours, currentLoad?)`; `findByAgent`) — `status` validated
against the fixed `available`/`busy`/`away`/`offline` set at the Zod schema layer (FR-008)
— in `src/modules/identity/agents/repository/agent-availability.repository.ts` +
`service/agent-availability.service.ts` (depends on T005)
- [ ] T015 [US2] Add Zod schemas + routes: `PUT /admin/agents/:agentId/skills/:skillTag`,
- [x] T015 [US2] Add Zod schemas + routes: `PUT /admin/agents/:agentId/skills/:skillTag`,
`GET /admin/agents/:agentId/skills`, `PUT /admin/agents/:agentId/availability`,
`GET /admin/agents/:agentId/availability` (gated by `fastify.authenticate`) in
`identity/agents/schema/` + `routes/` (depends on T013, T014)
- [ ] T016 [US2] Run Quickstart Scenario 2 locally and confirm all 5 steps pass
- [x] T016 [US2] Run Quickstart Scenario 2 locally and confirm all 5 steps pass
**Checkpoint**: Agents carry real, current skill and availability data — everything a future
capability/assignment lookup needs to read.
@@ -134,37 +134,37 @@ opaque data for a future orchestration engine.
### Tests for User Story 3
- [ ] T017 [P] [US3] Unit tests for the pure cycle-detection function — a direct self-reference
- [x] T017 [P] [US3] Unit tests for the pure cycle-detection function — a direct self-reference
rejected, a transitive cycle (A's new parent is A's own descendant) rejected, a legitimate
reparent to an unrelated node accepted — in `tests/unit/support-org/cycle-check.test.ts`
- [ ] T018 [US3] Integration test covering Quickstart Scenario 3 (parent/child creation, order
- [x] T018 [US3] Integration test covering Quickstart Scenario 3 (parent/child creation, order
preserved, nonexistent `parentId` rejected, self-reparent rejected, deactivation doesn't
cascade) against a real Postgres in `tests/integration/support-org-hierarchy.test.ts`
### Implementation for User Story 3
- [ ] T019 [P] [US3] Add the pure cycle-detection function `wouldCreateCycle(nodeId,
- [x] T019 [P] [US3] Add the pure cycle-detection function `wouldCreateCycle(nodeId,
candidateParentId, ancestorChainOf: (id) => string[]) => boolean` in
`src/modules/orchestration/hierarchy/service/cycle-check.ts` (no dependencies — pure
function, takes the ancestor-lookup as an injected function so it's testable without a
database)
- [ ] T020 [US3] Add `HierarchyAuditLogRepository.writeHierarchyAuditEvent(...)` (research.md,
- [x] T020 [US3] Add `HierarchyAuditLogRepository.writeHierarchyAuditEvent(...)` (research.md,
same shape as `catalog/products`' `writeIntegrationAuditEvent`) in
`src/modules/orchestration/hierarchy/repository/hierarchy-audit-log.repository.ts`
(depends on T005)
- [ ] T021 [US3] Add `HierarchyRepository` (create — validates `parentId` exists, `404` if not;
- [x] T021 [US3] Add `HierarchyRepository` (create — validates `parentId` exists, `404` if not;
`updateById` — validates via T019 before applying a `parentId` change; `findById`;
`findChildren(parentId, ordered)`; `findAll(activeOnly?)`; `setActive`) +
`HierarchyService` (wraps the repository, calls T020 on every create/edit/activate/
deactivate — FR-017) in
`src/modules/orchestration/hierarchy/repository/hierarchy.repository.ts` +
`service/hierarchy.service.ts` (depends on T019, T020)
- [ ] T022 [US3] Add Zod schemas + routes: `POST /admin/hierarchy-nodes`,
- [x] T022 [US3] Add Zod schemas + routes: `POST /admin/hierarchy-nodes`,
`PUT /admin/hierarchy-nodes/:nodeId`, `PATCH .../activate`, `PATCH .../deactivate`,
`GET /admin/hierarchy-nodes/:nodeId`, `GET .../children`, `GET /admin/hierarchy-nodes`
(gated by `fastify.authenticate`) in `orchestration/hierarchy/schema/` + `routes/`,
registered from `src/api/routes.ts` (depends on T021)
- [ ] T023 [US3] Run Quickstart Scenario 3 locally and confirm all 6 steps pass
- [x] T023 [US3] Run Quickstart Scenario 3 locally and confirm all 6 steps pass
**Checkpoint**: The dynamic support hierarchy — this feature's core differentiator — is fully
configurable, cycle-safe, and audited.
@@ -180,32 +180,32 @@ excluding availability.
### Tests for User Story 4
- [ ] T024 [P] [US4] Unit tests for the pure capability-matching predicate (agent skill set is a
- [x] T024 [P] [US4] Unit tests for the pure capability-matching predicate (agent skill set is a
superset of the required set → eligible; missing one required skill → not eligible; empty
hierarchy scope matches any product/category/priority) in
`tests/unit/support-org/capability-match.test.ts`
- [ ] T025 [US4] Integration test covering Quickstart Scenario 4 (skill-only match; availability
- [x] T025 [US4] Integration test covering Quickstart Scenario 4 (skill-only match; availability
never filters; inactive agent excluded; empty result on no match, never an error;
hierarchy-scope skill composition) against a real Postgres in
`tests/integration/support-org-capability-lookup.test.ts`
### Implementation for User Story 4
- [ ] T026 [P] [US4] Add the pure capability-matching predicate `isCapabilityEligible(agentSkills:
- [x] T026 [P] [US4] Add the pure capability-matching predicate `isCapabilityEligible(agentSkills:
string[], requiredSkills: string[]) => boolean` in
`src/modules/orchestration/hierarchy/service/capability-match.ts` (no dependencies)
- [ ] T027 [US4] Add `CapabilityLookupService.findEligibleAgents(skills, productId?, categoryId?,
- [x] T027 [US4] Add `CapabilityLookupService.findEligibleAgents(skills, productId?, categoryId?,
priorityId?)`: finds active `HierarchyNode`s whose scope arrays are empty-or-matching for
each given context value, unions their `skills` into the requested set, queries active
agents (active team) via T026's predicate applied to each candidate's `AgentSkill` tags —
in `src/modules/orchestration/hierarchy/service/capability-lookup.service.ts` (depends on
T026, and on `identity/agents`' repository being queryable — reuses its exported
repository rather than duplicating the query)
- [ ] T028 [US4] Add Zod schema + `GET /support-org/capability-eligibility` route (no
- [x] T028 [US4] Add Zod schema + `GET /support-org/capability-eligibility` route (no
`fastify.authenticate` gate — research.md "Admin endpoint authentication") in
`orchestration/hierarchy/schema/` + `routes/`, registered from `src/api/routes.ts`
(depends on T027)
- [ ] T029 [US4] Run Quickstart Scenario 4 locally and confirm all 5 steps pass
- [x] T029 [US4] Run Quickstart Scenario 4 locally and confirm all 5 steps pass
**Checkpoint**: All four user stories work independently and together — a full support
organization (teams, agents, skills, availability, hierarchy) exists and is queryable by
@@ -215,13 +215,13 @@ capability, ready for Phase 7's orchestration engine to build on.
## Phase 7: Polish & Cross-Cutting Concerns
- [ ] T030 [P] Add a "Support Organization" section to `README.md` describing the admin CRUD,
- [x] T030 [P] Add a "Support Organization" section to `README.md` describing the admin CRUD,
the cycle-safety/audit guarantees on hierarchy nodes, and the capability-eligibility
contract (explicitly noting what it does *not* do — availability filtering, assignment)
- [ ] T031 [P] Update `specs/006-support-organization/checklists/requirements.md` Notes with any
- [x] T031 [P] Update `specs/006-support-organization/checklists/requirements.md` Notes with any
implementation-time findings
- [ ] T032 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [ ] T033 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
- [x] T032 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T033 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
elsewhere, then the full integration suite against real Docker-provisioned infra
---
+6
View File
@@ -11,6 +11,9 @@ import { messagesRoutes } from '@/modules/ticketing/messages';
import { attachmentsRoutes } from '@/modules/ticketing/attachments';
import { knowledgeRoutes } from '@/modules/ai-support/knowledge';
import { sessionsRoutes } from '@/modules/ai-support/sessions';
import { teamsRoutes } from '@/modules/identity/teams';
import { agentsRoutes } from '@/modules/identity/agents';
import { hierarchyRoutes } from '@/modules/orchestration/hierarchy';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
@@ -23,5 +26,8 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(attachmentsRoutes);
await app.register(knowledgeRoutes);
await app.register(sessionsRoutes);
await app.register(teamsRoutes);
await app.register(agentsRoutes);
await app.register(hierarchyRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
@@ -1,16 +1,77 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { agentsService, AgentsService } from '../service';
import {
agentsService,
AgentsService,
agentSkillsService,
AgentSkillsService,
agentAvailabilityService,
AgentAvailabilityService,
} from '../service';
import {
createAgentSchema,
updateAgentSchema,
listAgentsQuerySchema,
upsertAgentSkillSchema,
upsertAgentAvailabilitySchema,
} from '../schema';
export class AgentsController {
constructor(private readonly service: AgentsService = agentsService) {}
constructor(
private readonly service: AgentsService = agentsService,
private readonly skills: AgentSkillsService = agentSkillsService,
private readonly availability: AgentAvailabilityService = agentAvailabilityService,
) {}
async getAgents(_request: FastifyRequest, reply: FastifyReply) {
const agents = await this.service.listAgents();
return reply.status(200).send({
success: true,
data: agents,
meta: null,
});
async create(request: FastifyRequest, reply: FastifyReply) {
const { teamId } = request.params as { teamId: string };
const body = createAgentSchema.parse(request.body);
const agent = await this.service.create({ ...body, teamId });
return reply.status(201).send({ success: true, data: agent, meta: null });
}
async update(request: FastifyRequest, reply: FastifyReply) {
const { agentId } = request.params as { agentId: string };
const body = updateAgentSchema.parse(request.body);
const agent = await this.service.update(agentId, body);
return reply.status(200).send({ success: true, data: agent, meta: null });
}
async getById(request: FastifyRequest, reply: FastifyReply) {
const { agentId } = request.params as { agentId: string };
const agent = await this.service.getById(agentId);
return reply.status(200).send({ success: true, data: agent, meta: null });
}
async list(request: FastifyRequest, reply: FastifyReply) {
const query = listAgentsQuerySchema.parse(request.query);
const agents = await this.service.listAll(query);
return reply.status(200).send({ success: true, data: agents, meta: null });
}
async upsertSkill(request: FastifyRequest, reply: FastifyReply) {
const { agentId, skillTag } = request.params as { agentId: string; skillTag: string };
const { level } = upsertAgentSkillSchema.parse(request.body);
const skill = await this.skills.upsert(agentId, skillTag, level);
return reply.status(200).send({ success: true, data: skill, meta: null });
}
async listSkills(request: FastifyRequest, reply: FastifyReply) {
const { agentId } = request.params as { agentId: string };
const skills = await this.skills.listForAgent(agentId);
return reply.status(200).send({ success: true, data: skills, meta: null });
}
async upsertAvailability(request: FastifyRequest, reply: FastifyReply) {
const { agentId } = request.params as { agentId: string };
const body = upsertAgentAvailabilitySchema.parse(request.body);
const record = await this.availability.upsert(agentId, body);
return reply.status(200).send({ success: true, data: record, meta: null });
}
async getAvailability(request: FastifyRequest, reply: FastifyReply) {
const { agentId } = request.params as { agentId: string };
const record = await this.availability.getForAgent(agentId);
return reply.status(200).send({ success: true, data: record, meta: null });
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
export { agentsRoutes } from './routes';
export { AgentsService, agentsService } from './service';
export type { AgentProfile } from './types';
export { agentsRepository, AgentsRepository } from './repository';
export type { CreateAgentData, UpdateAgentData, FindAgentsFilter } from './repository';
@@ -0,0 +1,39 @@
import { AgentAvailability, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface UpsertAvailabilityData {
status: string;
workingHours: Record<string, unknown>;
currentLoad?: number | undefined;
}
export class AgentAvailabilityRepository {
constructor(private readonly prisma = prismaClient) {}
/** FR-007: upsert keyed on `agentId` (unique) — exactly one current record per agent, ever.
* Last-write-wins on purpose (research.md "Availability concurrency") — no expectedVersion.
* `currentLoad` defaults to 0 only on first creation; an update that omits it leaves the
* existing value alone rather than silently resetting it to 0 every time status changes. */
async upsert(agentId: string, data: UpsertAvailabilityData): Promise<AgentAvailability> {
return this.prisma.agentAvailability.upsert({
where: { agentId },
create: {
agentId,
status: data.status,
workingHours: data.workingHours as Prisma.InputJsonValue,
currentLoad: data.currentLoad ?? 0,
},
update: {
status: data.status,
workingHours: data.workingHours as Prisma.InputJsonValue,
...(data.currentLoad !== undefined ? { currentLoad: data.currentLoad } : {}),
},
});
}
async findByAgent(agentId: string): Promise<AgentAvailability | null> {
return this.prisma.agentAvailability.findUnique({ where: { agentId } });
}
}
export const agentAvailabilityRepository = new AgentAvailabilityRepository();
@@ -0,0 +1,22 @@
import { AgentSkill } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class AgentSkillsRepository {
constructor(private readonly prisma = prismaClient) {}
/** FR-006: upsert keyed on the (agentId, skillTag) compound unique — updates `level` in place
* for an existing tag rather than creating a duplicate row (research.md). */
async upsert(agentId: string, skillTag: string, level: number): Promise<AgentSkill> {
return this.prisma.agentSkill.upsert({
where: { agentId_skillTag: { agentId, skillTag } },
create: { agentId, skillTag, level },
update: { level },
});
}
async findAllByAgent(agentId: string): Promise<AgentSkill[]> {
return this.prisma.agentSkill.findMany({ where: { agentId } });
}
}
export const agentSkillsRepository = new AgentSkillsRepository();
@@ -1,12 +1,66 @@
import { Agent, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
import { UserRole } from '@prisma/client';
export interface CreateAgentData {
teamId: string;
name: string;
}
export interface UpdateAgentData {
name?: string | undefined;
teamId?: string | undefined;
active?: boolean | undefined;
}
export interface FindAgentsFilter {
active?: boolean | undefined;
teamId?: string | undefined;
}
export class AgentsRepository {
constructor(private readonly prisma = prismaClient) {}
async findAllAgents(): Promise<unknown[]> {
return this.prisma.user.findMany({
where: { role: UserRole.AGENT },
async create(data: CreateAgentData): Promise<Agent> {
return this.prisma.agent.create({ data });
}
/** FR-003: deactivation is a plain field update — the record and its skills/availability are
* never deleted. */
async update(agentId: string, data: UpdateAgentData): Promise<Agent | null> {
try {
return await this.prisma.agent.update({
where: { id: agentId },
data: data as Prisma.AgentUpdateInput,
});
} catch {
return null;
}
}
async findById(agentId: string) {
return this.prisma.agent.findUnique({
where: { id: agentId },
include: { skills: true, availability: true, team: true },
});
}
async findAll(filter: FindAgentsFilter): Promise<Agent[]> {
return this.prisma.agent.findMany({
where: {
...(filter.active !== undefined ? { active: filter.active } : {}),
...(filter.teamId !== undefined ? { teamId: filter.teamId } : {}),
},
orderBy: { name: 'asc' },
});
}
/** Used by the capability-eligibility lookup (orchestration/hierarchy) — active agents on
* active teams, with their skill tags, so the caller never has to make a second cross-module
* query for team status. */
async findActiveWithSkillsAndActiveTeam() {
return this.prisma.agent.findMany({
where: { active: true, team: { active: true } },
include: { skills: true },
});
}
}
@@ -1 +1,3 @@
export * from './agents.repository';
export * from './agent-skills.repository';
export * from './agent-availability.repository';
@@ -1,6 +1,39 @@
import { FastifyInstance } from 'fastify';
import { agentsController } from '../controller';
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
* (research.md "Admin endpoint authentication"). */
export async function agentsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/agents', (req, reply) => agentsController.getAgents(req, reply));
fastify.post('/admin/teams/:teamId/agents', { preHandler: fastify.authenticate }, (req, reply) =>
agentsController.create(req, reply),
);
fastify.patch('/admin/agents/:agentId', { preHandler: fastify.authenticate }, (req, reply) =>
agentsController.update(req, reply),
);
fastify.get('/admin/agents/:agentId', { preHandler: fastify.authenticate }, (req, reply) =>
agentsController.getById(req, reply),
);
fastify.get('/admin/agents', { preHandler: fastify.authenticate }, (req, reply) =>
agentsController.list(req, reply),
);
fastify.put(
'/admin/agents/:agentId/skills/:skillTag',
{ preHandler: fastify.authenticate },
(req, reply) => agentsController.upsertSkill(req, reply),
);
fastify.get('/admin/agents/:agentId/skills', { preHandler: fastify.authenticate }, (req, reply) =>
agentsController.listSkills(req, reply),
);
fastify.put(
'/admin/agents/:agentId/availability',
{ preHandler: fastify.authenticate },
(req, reply) => agentsController.upsertAvailability(req, reply),
);
fastify.get(
'/admin/agents/:agentId/availability',
{ preHandler: fastify.authenticate },
(req, reply) => agentsController.getAvailability(req, reply),
);
}
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const AVAILABILITY_STATUSES = ['available', 'busy', 'away', 'offline'] as const;
export const upsertAgentAvailabilitySchema = z
.object({
status: z.enum(AVAILABILITY_STATUSES),
workingHours: z.record(z.string(), z.unknown()),
currentLoad: z.number().int().min(0).optional(),
})
.strict();
export type UpsertAgentAvailabilityBody = z.infer<typeof upsertAgentAvailabilitySchema>;
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const upsertAgentSkillSchema = z
.object({
level: z.number().int().min(0),
})
.strict();
export type UpsertAgentSkillBody = z.infer<typeof upsertAgentSkillSchema>;
@@ -1,6 +1,24 @@
import { z } from 'zod';
export const agentQuerySchema = z.object({
page: z.coerce.number().optional(),
limit: z.coerce.number().optional(),
export const createAgentSchema = z
.object({
name: z.string().min(1),
})
.strict();
export const updateAgentSchema = z
.object({
name: z.string().min(1).optional(),
teamId: z.string().min(1).optional(),
active: z.boolean().optional(),
})
.strict();
export const listAgentsQuerySchema = z.object({
active: z.coerce.boolean().optional(),
teamId: z.string().optional(),
});
export type CreateAgentBody = z.infer<typeof createAgentSchema>;
export type UpdateAgentBody = z.infer<typeof updateAgentSchema>;
export type ListAgentsQuery = z.infer<typeof listAgentsQuerySchema>;
@@ -1 +1,3 @@
export * from './agents.schema';
export * from './agent-skills.schema';
export * from './agent-availability.schema';
@@ -0,0 +1,23 @@
import { AgentAvailability } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import {
agentAvailabilityRepository,
AgentAvailabilityRepository,
UpsertAvailabilityData,
} from '../repository/agent-availability.repository';
export class AgentAvailabilityService {
constructor(private readonly repo: AgentAvailabilityRepository = agentAvailabilityRepository) {}
async upsert(agentId: string, data: UpsertAvailabilityData): Promise<AgentAvailability> {
return this.repo.upsert(agentId, data);
}
async getForAgent(agentId: string): Promise<AgentAvailability> {
const availability = await this.repo.findByAgent(agentId);
if (!availability) throw new NotFoundError('No availability set for this agent.');
return availability;
}
}
export const agentAvailabilityService = new AgentAvailabilityService();
@@ -0,0 +1,19 @@
import { AgentSkill } from '@prisma/client';
import {
agentSkillsRepository,
AgentSkillsRepository,
} from '../repository/agent-skills.repository';
export class AgentSkillsService {
constructor(private readonly repo: AgentSkillsRepository = agentSkillsRepository) {}
async upsert(agentId: string, skillTag: string, level: number): Promise<AgentSkill> {
return this.repo.upsert(agentId, skillTag, level);
}
async listForAgent(agentId: string): Promise<AgentSkill[]> {
return this.repo.findAllByAgent(agentId);
}
}
export const agentSkillsService = new AgentSkillsService();
@@ -1,10 +1,44 @@
import { agentsRepository, AgentsRepository } from '../repository';
import { Agent } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { teamsRepository } from '@/modules/identity/teams';
import {
agentsRepository,
AgentsRepository,
CreateAgentData,
UpdateAgentData,
FindAgentsFilter,
} from '../repository';
export class AgentsService {
constructor(private readonly repo: AgentsRepository = agentsRepository) {}
async listAgents(): Promise<unknown[]> {
return this.repo.findAllAgents();
/** FR-002: an agent is always assigned to an existing team — validated here rather than left
* to surface as a raw Prisma foreign-key error, matching every other feature's "resolve first,
* then act" convention. */
async create(data: CreateAgentData): Promise<Agent> {
const team = await teamsRepository.findById(data.teamId);
if (!team) throw new NotFoundError('Team not found.');
return this.repo.create(data);
}
async update(agentId: string, data: UpdateAgentData): Promise<Agent> {
if (data.teamId !== undefined) {
const team = await teamsRepository.findById(data.teamId);
if (!team) throw new NotFoundError('Team not found.');
}
const updated = await this.repo.update(agentId, data);
if (!updated) throw new NotFoundError('Agent not found.');
return updated;
}
async getById(agentId: string) {
const agent = await this.repo.findById(agentId);
if (!agent) throw new NotFoundError('Agent not found.');
return agent;
}
async listAll(filter: FindAgentsFilter): Promise<Agent[]> {
return this.repo.findAll(filter);
}
}
@@ -1 +1,3 @@
export * from './agents.service';
export * from './agent-skills.service';
export * from './agent-availability.service';
@@ -1,5 +1 @@
export interface AgentProfile {
id: string;
email: string;
name: string;
}
export {};
@@ -1,16 +1,32 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { teamsService, TeamsService } from '../service';
import { createTeamSchema, updateTeamSchema } from '../schema';
export class TeamsController {
constructor(private readonly service: TeamsService = teamsService) {}
async getTeams(_request: FastifyRequest, reply: FastifyReply) {
const teams = await this.service.listTeams();
return reply.status(200).send({
success: true,
data: teams,
meta: null,
});
async create(request: FastifyRequest, reply: FastifyReply) {
const body = createTeamSchema.parse(request.body);
const team = await this.service.create(body);
return reply.status(201).send({ success: true, data: team, meta: null });
}
async update(request: FastifyRequest, reply: FastifyReply) {
const { teamId } = request.params as { teamId: string };
const body = updateTeamSchema.parse(request.body);
const team = await this.service.update(teamId, body);
return reply.status(200).send({ success: true, data: team, meta: null });
}
async getById(request: FastifyRequest, reply: FastifyReply) {
const { teamId } = request.params as { teamId: string };
const team = await this.service.getByIdWithAgents(teamId);
return reply.status(200).send({ success: true, data: team, meta: null });
}
async list(_request: FastifyRequest, reply: FastifyReply) {
const teams = await this.service.listAll();
return reply.status(200).send({ success: true, data: teams, meta: null });
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
export { teamsRoutes } from './routes';
export { TeamsService, teamsService } from './service';
export type { TeamProfile } from './types';
export { teamsRepository, TeamsRepository } from './repository';
export type { CreateTeamData, UpdateTeamData } from './repository';
@@ -1,10 +1,45 @@
import { Team, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateTeamData {
name: string;
}
export interface UpdateTeamData {
name?: string | undefined;
active?: boolean | undefined;
}
export class TeamsRepository {
constructor(private readonly prisma = prismaClient) {}
async findAllTeams(): Promise<unknown[]> {
return [];
async create(data: CreateTeamData): Promise<Team> {
return this.prisma.team.create({ data });
}
/** FR-004: deactivation never cascades — this is a plain field update, never touching
* `Agent.active`. */
async update(teamId: string, data: UpdateTeamData): Promise<Team | null> {
try {
return await this.prisma.team.update({
where: { id: teamId },
data: data as Prisma.TeamUpdateInput,
});
} catch {
return null;
}
}
async findById(teamId: string): Promise<Team | null> {
return this.prisma.team.findUnique({ where: { id: teamId } });
}
async findByIdWithAgents(teamId: string) {
return this.prisma.team.findUnique({ where: { id: teamId }, include: { agents: true } });
}
async findAll(): Promise<Team[]> {
return this.prisma.team.findMany({ orderBy: { name: 'asc' } });
}
}
@@ -1,6 +1,19 @@
import { FastifyInstance } from 'fastify';
import { teamsController } from '../controller';
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
* (research.md "Admin endpoint authentication"). */
export async function teamsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/teams', (req, reply) => teamsController.getTeams(req, reply));
fastify.post('/admin/teams', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.create(req, reply),
);
fastify.patch('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.update(req, reply),
);
fastify.get('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.getById(req, reply),
);
fastify.get('/admin/teams', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.list(req, reply),
);
}
@@ -1,6 +1,17 @@
import { z } from 'zod';
export const teamQuerySchema = z.object({
page: z.coerce.number().optional(),
limit: z.coerce.number().optional(),
});
export const createTeamSchema = z
.object({
name: z.string().min(1),
})
.strict();
export const updateTeamSchema = z
.object({
name: z.string().min(1).optional(),
active: z.boolean().optional(),
})
.strict();
export type CreateTeamBody = z.infer<typeof createTeamSchema>;
export type UpdateTeamBody = z.infer<typeof updateTeamSchema>;
@@ -1,10 +1,28 @@
import { teamsRepository, TeamsRepository } from '../repository';
import { Team } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { teamsRepository, TeamsRepository, CreateTeamData, UpdateTeamData } from '../repository';
export class TeamsService {
constructor(private readonly repo: TeamsRepository = teamsRepository) {}
async listTeams(): Promise<unknown[]> {
return this.repo.findAllTeams();
async create(data: CreateTeamData): Promise<Team> {
return this.repo.create(data);
}
async update(teamId: string, data: UpdateTeamData): Promise<Team> {
const updated = await this.repo.update(teamId, data);
if (!updated) throw new NotFoundError('Team not found.');
return updated;
}
async getByIdWithAgents(teamId: string) {
const team = await this.repo.findByIdWithAgents(teamId);
if (!team) throw new NotFoundError('Team not found.');
return team;
}
async listAll(): Promise<Team[]> {
return this.repo.findAll();
}
}
@@ -1,4 +1 @@
export interface TeamProfile {
id: string;
name: string;
}
export {};
@@ -0,0 +1,3 @@
export const HIERARCHY_CONSTANTS = {
MODULE_NAME: 'ORCHESTRATION_HIERARCHY',
} as const;
@@ -0,0 +1,24 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import {
capabilityLookupService,
CapabilityLookupService,
} from '../service/capability-lookup.service';
import { capabilityEligibilityQuerySchema } from '../schema';
export class CapabilityLookupController {
constructor(private readonly service: CapabilityLookupService = capabilityLookupService) {}
async findEligible(request: FastifyRequest, reply: FastifyReply) {
const { skills, productId, categoryId, priorityId } = capabilityEligibilityQuerySchema.parse(
request.query,
);
const agents = await this.service.findEligibleAgents(skills, {
productId,
categoryId,
priorityId,
});
return reply.status(200).send({ success: true, data: agents, meta: null });
}
}
export const capabilityLookupController = new CapabilityLookupController();
@@ -0,0 +1,57 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { hierarchyService, HierarchyService } from '../service/hierarchy.service';
import { createHierarchyNodeSchema, updateHierarchyNodeSchema } from '../schema';
import { HierarchyNodeData } from '../repository';
function actorFrom(request: FastifyRequest): string {
return request.reqContext?.actorId ?? 'unknown';
}
export class HierarchyController {
constructor(private readonly service: HierarchyService = hierarchyService) {}
async create(request: FastifyRequest, reply: FastifyReply) {
const body = createHierarchyNodeSchema.parse(request.body);
const node = await this.service.create(actorFrom(request), body as HierarchyNodeData);
return reply.status(201).send({ success: true, data: node, meta: null });
}
async update(request: FastifyRequest, reply: FastifyReply) {
const { nodeId } = request.params as { nodeId: string };
const body = updateHierarchyNodeSchema.parse(request.body);
const node = await this.service.update(actorFrom(request), nodeId, body as HierarchyNodeData);
return reply.status(200).send({ success: true, data: node, meta: null });
}
async activate(request: FastifyRequest, reply: FastifyReply) {
const { nodeId } = request.params as { nodeId: string };
const node = await this.service.setActive(actorFrom(request), nodeId, true);
return reply.status(200).send({ success: true, data: node, meta: null });
}
async deactivate(request: FastifyRequest, reply: FastifyReply) {
const { nodeId } = request.params as { nodeId: string };
const node = await this.service.setActive(actorFrom(request), nodeId, false);
return reply.status(200).send({ success: true, data: node, meta: null });
}
async getById(request: FastifyRequest, reply: FastifyReply) {
const { nodeId } = request.params as { nodeId: string };
const node = await this.service.getById(nodeId);
return reply.status(200).send({ success: true, data: node, meta: null });
}
async getChildren(request: FastifyRequest, reply: FastifyReply) {
const { nodeId } = request.params as { nodeId: string };
const children = await this.service.getChildren(nodeId);
return reply.status(200).send({ success: true, data: children, meta: null });
}
async list(request: FastifyRequest, reply: FastifyReply) {
const { active } = request.query as { active?: string };
const nodes = await this.service.listAll(active === 'true');
return reply.status(200).send({ success: true, data: nodes, meta: null });
}
}
export const hierarchyController = new HierarchyController();
@@ -0,0 +1,5 @@
export { HierarchyController, hierarchyController } from './hierarchy.controller';
export {
CapabilityLookupController,
capabilityLookupController,
} from './capability-lookup.controller';
+6 -11
View File
@@ -1,11 +1,6 @@
export const HIERARCHY_CONSTANTS = {
MODULE_NAME: 'ORCHESTRATION_HIERARCHY',
} as const;
export class HierarchyService {
async getHierarchyTree() {
return [];
}
}
export const hierarchyService = new HierarchyService();
export { hierarchyRoutes } from './routes';
export { HierarchyService, hierarchyService } from './service';
export { CapabilityLookupService, capabilityLookupService } from './service';
export type { CapabilityLookupContext } from './service';
export { hierarchyRepository, HierarchyRepository } from './repository';
export type { HierarchyNodeData } from './repository';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,28 @@
import type { Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
/**
* FR-017/doc 07: one AuditLog row per hierarchy node create/edit/activate/deactivate. Same shape
* as catalog/products' existing writeIntegrationAuditEvent (research.md "Hierarchy changes are
* audited via a dedicated per-module writer") — this codebase's only other AuditLog writer.
*/
export async function writeHierarchyAuditEvent(params: {
actor: string;
actorType: string;
action: string;
entityId: string;
reason?: string;
metadata?: Prisma.InputJsonValue;
}): Promise<void> {
await prismaClient.auditLog.create({
data: {
actor: params.actor,
actorType: params.actorType,
action: params.action,
entityType: 'HierarchyNode',
entityId: params.entityId,
...(params.reason !== undefined ? { reason: params.reason } : {}),
...(params.metadata !== undefined ? { metadata: params.metadata } : {}),
},
});
}
@@ -0,0 +1,78 @@
import { HierarchyNode, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface HierarchyNodeData {
name: string;
parentId?: string | null | undefined;
order: number;
teamId?: string | null | undefined;
skills?: string[] | undefined;
productScope?: string[] | undefined;
categoryScope?: string[] | undefined;
priorityScope?: string[] | undefined;
assignmentStrategy: string;
slaPolicyId?: string | null | undefined;
escalationPolicyId?: string | null | undefined;
entryConditions?: Prisma.InputJsonValue | undefined;
exitConditions?: Prisma.InputJsonValue | undefined;
}
export class HierarchyRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: HierarchyNodeData): Promise<HierarchyNode> {
return this.prisma.hierarchyNode.create({ data: data as Prisma.HierarchyNodeCreateInput });
}
async update(nodeId: string, data: HierarchyNodeData): Promise<HierarchyNode | null> {
try {
return await this.prisma.hierarchyNode.update({
where: { id: nodeId },
data: data as Prisma.HierarchyNodeUpdateInput,
});
} catch {
return null;
}
}
async setActive(nodeId: string, active: boolean): Promise<HierarchyNode | null> {
try {
return await this.prisma.hierarchyNode.update({ where: { id: nodeId }, data: { active } });
} catch {
return null;
}
}
async findById(nodeId: string): Promise<HierarchyNode | null> {
return this.prisma.hierarchyNode.findUnique({ where: { id: nodeId } });
}
async findChildren(parentId: string): Promise<HierarchyNode[]> {
return this.prisma.hierarchyNode.findMany({ where: { parentId }, orderBy: { order: 'asc' } });
}
async findAll(activeOnly: boolean): Promise<HierarchyNode[]> {
return this.prisma.hierarchyNode.findMany({
where: activeOnly ? { active: true } : {},
orderBy: { order: 'asc' },
});
}
async findActiveNodes(): Promise<HierarchyNode[]> {
return this.prisma.hierarchyNode.findMany({ where: { active: true } });
}
/** cycle-check.ts's `ancestorChainOf` dependency: every ancestor id from `nodeId`'s immediate
* parent up to the root, exclusive of `nodeId` itself. Bounded by the tree's actual depth. */
async getAncestorChain(nodeId: string): Promise<string[]> {
const chain: string[] = [];
let current = await this.findById(nodeId);
while (current?.parentId) {
chain.push(current.parentId);
current = await this.findById(current.parentId);
}
return chain;
}
}
export const hierarchyRepository = new HierarchyRepository();
@@ -0,0 +1,2 @@
export * from './hierarchy.repository';
export * from './hierarchy-audit-log.repository';
@@ -0,0 +1,46 @@
import { FastifyInstance } from 'fastify';
import { hierarchyController, capabilityLookupController } from '../controller';
/**
* contracts/support-org-contract.md: admin hierarchy-node routes gated by fastify.authenticate
* (known limitation inherited from 002/003/004/005). The capability-eligibility lookup is not
* gated — a read path a future orchestration caller will use (research.md "Admin endpoint
* authentication").
*/
export async function hierarchyRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/admin/hierarchy-nodes', { preHandler: fastify.authenticate }, (req, reply) =>
hierarchyController.create(req, reply),
);
fastify.put(
'/admin/hierarchy-nodes/:nodeId',
{ preHandler: fastify.authenticate },
(req, reply) => hierarchyController.update(req, reply),
);
fastify.patch(
'/admin/hierarchy-nodes/:nodeId/activate',
{ preHandler: fastify.authenticate },
(req, reply) => hierarchyController.activate(req, reply),
);
fastify.patch(
'/admin/hierarchy-nodes/:nodeId/deactivate',
{ preHandler: fastify.authenticate },
(req, reply) => hierarchyController.deactivate(req, reply),
);
fastify.get(
'/admin/hierarchy-nodes/:nodeId',
{ preHandler: fastify.authenticate },
(req, reply) => hierarchyController.getById(req, reply),
);
fastify.get(
'/admin/hierarchy-nodes/:nodeId/children',
{ preHandler: fastify.authenticate },
(req, reply) => hierarchyController.getChildren(req, reply),
);
fastify.get('/admin/hierarchy-nodes', { preHandler: fastify.authenticate }, (req, reply) =>
hierarchyController.list(req, reply),
);
fastify.get('/support-org/capability-eligibility', (req, reply) =>
capabilityLookupController.findEligible(req, reply),
);
}
@@ -0,0 +1 @@
export { hierarchyRoutes } from './hierarchy.routes';
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const capabilityEligibilityQuerySchema = z.object({
skills: z
.string()
.min(1)
.transform((val) =>
val
.split(',')
.map((s) => s.trim())
.filter(Boolean),
),
productId: z.string().optional(),
categoryId: z.string().optional(),
priorityId: z.string().optional(),
});
export type CapabilityEligibilityQuery = z.infer<typeof capabilityEligibilityQuerySchema>;
@@ -0,0 +1,23 @@
import { z } from 'zod';
const hierarchyNodeBodySchema = z.object({
name: z.string().min(1),
parentId: z.string().nullable().optional(),
order: z.number().int(),
teamId: z.string().nullable().optional(),
skills: z.array(z.string()).optional(),
productScope: z.array(z.string()).optional(),
categoryScope: z.array(z.string()).optional(),
priorityScope: z.array(z.string()).optional(),
assignmentStrategy: z.string().min(1),
slaPolicyId: z.string().nullable().optional(),
escalationPolicyId: z.string().nullable().optional(),
entryConditions: z.record(z.string(), z.unknown()).nullable().optional(),
exitConditions: z.record(z.string(), z.unknown()).nullable().optional(),
});
export const createHierarchyNodeSchema = hierarchyNodeBodySchema.strict();
export const updateHierarchyNodeSchema = hierarchyNodeBodySchema.strict();
export type CreateHierarchyNodeBody = z.infer<typeof createHierarchyNodeSchema>;
export type UpdateHierarchyNodeBody = z.infer<typeof updateHierarchyNodeSchema>;
@@ -0,0 +1,2 @@
export * from './hierarchy.schema';
export * from './capability-lookup.schema';
@@ -0,0 +1,46 @@
import { agentsRepository } from '@/modules/identity/agents';
import { hierarchyRepository, HierarchyRepository } from '../repository';
import { isCapabilityEligible, scopeMatches } from './capability-match';
export interface CapabilityLookupContext {
productId?: string | undefined;
categoryId?: string | undefined;
priorityId?: string | undefined;
}
export class CapabilityLookupService {
constructor(private readonly hierarchy: HierarchyRepository = hierarchyRepository) {}
/**
* FR-014/FR-015/FR-016 (research.md "Capability-eligibility lookup"): resolves which active
* hierarchy nodes apply to the given context, unions their `skills` into the caller-supplied
* requirement, and returns active agents (on active teams) who hold every skill in that
* combined set — never filtered by availability (FR-015), never an error on no match
* (FR-016).
*/
async findEligibleAgents(requiredSkills: string[], context: CapabilityLookupContext) {
const activeNodes = await this.hierarchy.findActiveNodes();
const matchingNodes = activeNodes.filter(
(node) =>
scopeMatches(node.productScope, context.productId) &&
scopeMatches(node.categoryScope, context.categoryId) &&
scopeMatches(node.priorityScope, context.priorityId),
);
const combinedSkills = new Set(requiredSkills);
for (const node of matchingNodes) {
for (const skill of node.skills) combinedSkills.add(skill);
}
const skillsToMatch = [...combinedSkills];
const candidates = await agentsRepository.findActiveWithSkillsAndActiveTeam();
return candidates.filter((agent) =>
isCapabilityEligible(
agent.skills.map((s) => s.skillTag),
skillsToMatch,
),
);
}
}
export const capabilityLookupService = new CapabilityLookupService();
@@ -0,0 +1,22 @@
/**
* FR-014: an agent is capability-eligible when their skill set is a superset of every required
* skill — presence only, proficiency `level` is not a gate here (research.md: `level` feeds a
* future SKILL_BASED assignment strategy's weighting, not eligibility). Pure — no dependency on
* how the caller obtained either array.
*/
export function isCapabilityEligible(agentSkills: string[], requiredSkills: string[]): boolean {
if (requiredSkills.length === 0) return true;
const held = new Set(agentSkills);
return requiredSkills.every((skill) => held.has(skill));
}
/**
* FR-014's scope-matching half: an empty scope array matches every value (spec.md Edge Cases —
* "an unconfigured scope is match on skill alone, not match nothing"); a non-empty one requires
* containment.
*/
export function scopeMatches(scope: string[], value: string | undefined): boolean {
if (scope.length === 0) return true;
if (value === undefined) return false;
return scope.includes(value);
}
@@ -0,0 +1,23 @@
/**
* FR-011: rejects a hierarchy edit that would make a node its own ancestor, directly or
* transitively. Pure — the ancestor-chain lookup is injected so this is testable without a
* database (research.md "Hierarchy node — parent existence checked on create, cycle checked on
* reparent").
*
* @param nodeId the node being edited
* @param candidateParentId the new parentId being proposed (null = becoming a root node, never a
* cycle)
* @param ancestorChainOf given a node id, returns every ancestor id from its immediate parent up
* to the root (exclusive of the node itself)
*/
export function wouldCreateCycle(
nodeId: string,
candidateParentId: string | null,
ancestorChainOf: (id: string) => string[],
): boolean {
if (candidateParentId === null) return false;
if (candidateParentId === nodeId) return true;
const ancestors = ancestorChainOf(candidateParentId);
return ancestors.includes(nodeId);
}
@@ -0,0 +1,105 @@
import { HierarchyNode } from '@prisma/client';
import { NotFoundError, AppError } from '@/common/errors';
import {
hierarchyRepository,
HierarchyRepository,
HierarchyNodeData,
writeHierarchyAuditEvent,
} from '../repository';
import { wouldCreateCycle } from './cycle-check';
export const HIERARCHY_AUDIT_ACTIONS = {
CREATED: 'hierarchy_node.created',
UPDATED: 'hierarchy_node.updated',
ACTIVATED: 'hierarchy_node.activated',
DEACTIVATED: 'hierarchy_node.deactivated',
} as const;
export class HierarchyService {
constructor(private readonly repo: HierarchyRepository = hierarchyRepository) {}
/** FR-009: a given parentId must resolve to an existing node — never silently becomes a root
* node instead. A brand-new node can't form a cycle (nothing points to it yet — research.md),
* so no cycle check runs here. */
async create(actor: string, data: HierarchyNodeData): Promise<HierarchyNode> {
if (data.parentId) {
const parent = await this.repo.findById(data.parentId);
if (!parent) throw new NotFoundError('Parent hierarchy node not found.');
}
const node = await this.repo.create(data);
await writeHierarchyAuditEvent({
actor,
actorType: 'admin',
action: HIERARCHY_AUDIT_ACTIONS.CREATED,
entityId: node.id,
metadata: { name: node.name, parentId: node.parentId },
});
return node;
}
/** FR-011: reparenting is the only operation that can introduce a cycle — checked here via
* cycle-check.ts's pure function, fed by the repository's ancestor-chain walk. */
async update(actor: string, nodeId: string, data: HierarchyNodeData): Promise<HierarchyNode> {
const existing = await this.repo.findById(nodeId);
if (!existing) throw new NotFoundError('Hierarchy node not found.');
if (data.parentId) {
const parent = await this.repo.findById(data.parentId);
if (!parent) throw new NotFoundError('Parent hierarchy node not found.');
}
const newParentId = data.parentId ?? null;
if (newParentId !== existing.parentId) {
// wouldCreateCycle's ancestorChainOf is synchronous (so the pure function stays trivially
// unit-testable without a database) — the async DB walk happens once, up front, and the
// pure function just checks the already-fetched chain.
const ancestorChain = newParentId ? await this.repo.getAncestorChain(newParentId) : [];
if (wouldCreateCycle(nodeId, newParentId, () => ancestorChain)) {
throw new AppError(
'This change would make the node its own ancestor.',
'CYCLE_DETECTED',
400,
);
}
}
const updated = await this.repo.update(nodeId, data);
if (!updated) throw new NotFoundError('Hierarchy node not found.');
await writeHierarchyAuditEvent({
actor,
actorType: 'admin',
action: HIERARCHY_AUDIT_ACTIONS.UPDATED,
entityId: updated.id,
metadata: { name: updated.name, parentId: updated.parentId },
});
return updated;
}
async setActive(actor: string, nodeId: string, active: boolean): Promise<HierarchyNode> {
const updated = await this.repo.setActive(nodeId, active);
if (!updated) throw new NotFoundError('Hierarchy node not found.');
await writeHierarchyAuditEvent({
actor,
actorType: 'admin',
action: active ? HIERARCHY_AUDIT_ACTIONS.ACTIVATED : HIERARCHY_AUDIT_ACTIONS.DEACTIVATED,
entityId: updated.id,
});
return updated;
}
async getById(nodeId: string): Promise<HierarchyNode> {
const node = await this.repo.findById(nodeId);
if (!node) throw new NotFoundError('Hierarchy node not found.');
return node;
}
async getChildren(parentId: string): Promise<HierarchyNode[]> {
return this.repo.findChildren(parentId);
}
async listAll(activeOnly: boolean): Promise<HierarchyNode[]> {
return this.repo.findAll(activeOnly);
}
}
export const hierarchyService = new HierarchyService();
@@ -0,0 +1,5 @@
export { HierarchyService, hierarchyService, HIERARCHY_AUDIT_ACTIONS } from './hierarchy.service';
export { wouldCreateCycle } from './cycle-check';
export { isCapabilityEligible, scopeMatches } from './capability-match';
export { CapabilityLookupService, capabilityLookupService } from './capability-lookup.service';
export type { CapabilityLookupContext } from './capability-lookup.service';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,138 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
/** Covers specs/006-support-organization/quickstart.md Scenario 4 against a real Postgres. */
describe('Support organization — capability eligibility lookup (User Story 4)', () => {
let app: FastifyInstance;
const teamName = `Test Capability Team ${Date.now()}`;
const skillX = `skill_x_${Date.now()}`;
const skillY = `skill_y_${Date.now()}`;
const skillZ = `skill_z_${Date.now()}`;
let teamId: string;
let agentAId: string;
let agentBId: string;
const nodeIds: string[] = [];
beforeAll(async () => {
app = await buildApp();
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: teamName },
});
teamId = team.json().data.id;
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Agent A' },
});
agentAId = agentA.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillX}`,
payload: { level: 3 },
});
const agentB = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Agent B' },
});
agentBId = agentB.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillY}`,
payload: { level: 3 },
});
});
afterAll(async () => {
await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: nodeIds } } });
await prismaClient.auditLog.deleteMany({
where: { entityType: 'HierarchyNode', entityId: { in: nodeIds } },
});
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentAId, agentBId] } } });
await prismaClient.agentAvailability.deleteMany({
where: { agentId: { in: [agentAId, agentBId] } },
});
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await app.close();
});
it('Scenario 4: skill-only match, availability never filters, inactive excluded, empty on no match, hierarchy-scope composition', async () => {
const onlyX = await app.inject({
method: 'GET',
url: `/support-org/capability-eligibility?skills=${skillX}`,
});
const onlyXIds = onlyX.json().data.map((a: { id: string }) => a.id);
expect(onlyXIds).toContain(agentAId);
expect(onlyXIds).not.toContain(agentBId);
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/availability`,
payload: { status: 'offline', workingHours: {} },
});
const stillOffline = await app.inject({
method: 'GET',
url: `/support-org/capability-eligibility?skills=${skillX}`,
});
expect(stillOffline.json().data.map((a: { id: string }) => a.id)).toContain(agentAId);
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentAId}`,
payload: { active: false },
});
const afterDeactivation = await app.inject({
method: 'GET',
url: `/support-org/capability-eligibility?skills=${skillX}`,
});
expect(afterDeactivation.json().data.map((a: { id: string }) => a.id)).not.toContain(agentAId);
const noMatch = await app.inject({
method: 'GET',
url: `/support-org/capability-eligibility?skills=skill_nobody_has_${Date.now()}`,
});
expect(noMatch.statusCode).toBe(200);
expect(noMatch.json().data).toEqual([]);
// Reactivate agent A and give it skillZ too, then create a scoped hierarchy node requiring
// skillZ for a given product — only an agent holding BOTH x and z should be eligible.
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentAId}`,
payload: { active: true },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillZ}`,
payload: { level: 2 },
});
const productId = `TEST_CAP_PRODUCT_${Date.now()}`;
const node = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'Capability Scope Node',
order: 0,
productScope: [productId],
skills: [skillZ],
assignmentStrategy: 'ROUND_ROBIN',
},
});
nodeIds.push(node.json().data.id);
const scoped = await app.inject({
method: 'GET',
url: `/support-org/capability-eligibility?skills=${skillX}&productId=${productId}`,
});
const scopedIds = scoped.json().data.map((a: { id: string }) => a.id);
expect(scopedIds).toContain(agentAId); // holds x and z
expect(scopedIds).not.toContain(agentBId); // holds y only, missing x and z
});
});
@@ -0,0 +1,161 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
/** Covers specs/006-support-organization/quickstart.md Scenario 3 against a real Postgres. */
describe('Support organization — dynamic hierarchy (User Story 3)', () => {
let app: FastifyInstance;
const rootName = `Test Root ${Date.now()}`;
let rootId: string;
let childId: string;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await prismaClient.hierarchyNode.deleteMany({
where: { name: { startsWith: 'Test' }, id: { in: [rootId, childId].filter(Boolean) } },
});
await prismaClient.auditLog.deleteMany({
where: { entityType: 'HierarchyNode', entityId: { in: [rootId, childId].filter(Boolean) } },
});
await app.close();
});
it('Scenario 3: parent/child creation, order, nonexistent parent rejected, cycle rejected, deactivation does not cascade', async () => {
const createRoot = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: { name: rootName, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
expect(createRoot.statusCode).toBe(201);
rootId = createRoot.json().data.id;
const createChild = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: `${rootName} Child`,
parentId: rootId,
order: 0,
assignmentStrategy: 'ROUND_ROBIN',
},
});
expect(createChild.statusCode).toBe(201);
childId = createChild.json().data.id;
const children = await app.inject({
method: 'GET',
url: `/admin/hierarchy-nodes/${rootId}/children`,
});
expect(children.json().data.map((n: { id: string }) => n.id)).toContain(childId);
const badParent = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'Orphan',
parentId: 'nonexistent-node-id',
order: 0,
assignmentStrategy: 'ROUND_ROBIN',
},
});
expect(badParent.statusCode).toBe(404);
const selfCycle = await app.inject({
method: 'PUT',
url: `/admin/hierarchy-nodes/${childId}`,
payload: {
name: `${rootName} Child`,
parentId: childId,
order: 0,
assignmentStrategy: 'ROUND_ROBIN',
},
});
expect(selfCycle.statusCode).toBe(400);
expect(selfCycle.json().error.code).toBe('CYCLE_DETECTED');
const transitiveCycle = await app.inject({
method: 'PUT',
url: `/admin/hierarchy-nodes/${rootId}`,
payload: { name: rootName, parentId: childId, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
expect(transitiveCycle.statusCode).toBe(400);
expect(transitiveCycle.json().error.code).toBe('CYCLE_DETECTED');
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${rootId}/deactivate` });
const activeTree = await app.inject({
method: 'GET',
url: '/admin/hierarchy-nodes?active=true',
});
const activeIds = activeTree.json().data.map((n: { id: string }) => n.id);
expect(activeIds).not.toContain(rootId);
const childAfterParentDeactivation = await app.inject({
method: 'GET',
url: `/admin/hierarchy-nodes/${childId}`,
});
expect(childAfterParentDeactivation.json().data.active).toBe(true);
});
it('preserves sibling order under the same parent', async () => {
const parent = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: { name: `${rootName} Order Parent`, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
const parentId = parent.json().data.id;
const second = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: { name: 'Second', parentId, order: 2, assignmentStrategy: 'ROUND_ROBIN' },
});
const first = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: { name: 'First', parentId, order: 1, assignmentStrategy: 'ROUND_ROBIN' },
});
const children = await app.inject({
method: 'GET',
url: `/admin/hierarchy-nodes/${parentId}/children`,
});
const orderedIds = children.json().data.map((n: { id: string }) => n.id);
expect(orderedIds).toEqual([first.json().data.id, second.json().data.id]);
await prismaClient.hierarchyNode.deleteMany({
where: { id: { in: [parentId, first.json().data.id, second.json().data.id] } },
});
await prismaClient.auditLog.deleteMany({
where: {
entityType: 'HierarchyNode',
entityId: { in: [parentId, first.json().data.id, second.json().data.id] },
},
});
});
it('every create/edit/activate/deactivate writes exactly one audit log row', async () => {
const created = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: { name: `${rootName} Audited`, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
const nodeId = created.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/deactivate` });
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/activate` });
const auditRows = await prismaClient.auditLog.findMany({
where: { entityType: 'HierarchyNode', entityId: nodeId },
});
expect(auditRows).toHaveLength(3); // created, deactivated, activated
await prismaClient.hierarchyNode.deleteMany({ where: { id: nodeId } });
await prismaClient.auditLog.deleteMany({
where: { entityType: 'HierarchyNode', entityId: nodeId },
});
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
/** Covers specs/006-support-organization/quickstart.md Scenario 2 against a real Postgres. */
describe('Support organization — agent skills and availability (User Story 2)', () => {
let app: FastifyInstance;
const teamName = `Test Skills Team ${Date.now()}`;
let teamId: string;
let agentId: string;
beforeAll(async () => {
app = await buildApp();
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: teamName },
});
teamId = team.json().data.id;
const agent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Skilled Agent' },
});
agentId = agent.json().data.id;
});
afterAll(async () => {
await prismaClient.agentSkill.deleteMany({ where: { agentId } });
await prismaClient.agentAvailability.deleteMany({ where: { agentId } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await app.close();
});
it('Scenario 2: skill upsert never duplicates, availability upsert stays single-record', async () => {
const addSkill = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/pdf_conversion`,
payload: { level: 3 },
});
expect(addSkill.statusCode).toBe(200);
const updateSkill = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/pdf_conversion`,
payload: { level: 5 },
});
expect(updateSkill.statusCode).toBe(200);
const skills = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}/skills` });
const pdfSkills = skills
.json()
.data.filter((s: { skillTag: string }) => s.skillTag === 'pdf_conversion');
expect(pdfSkills).toHaveLength(1);
expect(pdfSkills[0].level).toBe(5);
const setAvailability = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/availability`,
payload: { status: 'busy', workingHours: { mon: '9-17' } },
});
expect(setAvailability.statusCode).toBe(200);
expect(setAvailability.json().data.currentLoad).toBe(0);
const updateAvailability = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/availability`,
payload: { status: 'available', workingHours: { mon: '9-17' } },
});
expect(updateAvailability.statusCode).toBe(200);
const current = await app.inject({
method: 'GET',
url: `/admin/agents/${agentId}/availability`,
});
expect(current.json().data.status).toBe('available');
const allRecords = await prismaClient.agentAvailability.findMany({ where: { agentId } });
expect(allRecords).toHaveLength(1);
});
it('rejects an invalid availability status', async () => {
const response = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/availability`,
payload: { status: 'not_a_real_status', workingHours: {} },
});
expect(response.statusCode).toBe(400);
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
/** Covers specs/006-support-organization/quickstart.md Scenario 1 against a real Postgres. */
describe('Support organization — teams and agents (User Story 1)', () => {
let app: FastifyInstance;
const teamName = `Test Team ${Date.now()}`;
let teamId: string;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await prismaClient.agentSkill.deleteMany({ where: { agent: { team: { name: teamName } } } });
await prismaClient.agentAvailability.deleteMany({
where: { agent: { team: { name: teamName } } },
});
await prismaClient.agent.deleteMany({ where: { team: { name: teamName } } });
await prismaClient.team.deleteMany({ where: { name: teamName } });
await app.close();
});
it('Scenario 1: create, roster, deactivate/reactivate, team deactivation does not cascade', async () => {
const createTeam = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: teamName },
});
expect(createTeam.statusCode).toBe(201);
expect(createTeam.json().data.active).toBe(true);
teamId = createTeam.json().data.id;
const createAgent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Agent A' },
});
expect(createAgent.statusCode).toBe(201);
const agentId = createAgent.json().data.id;
const roster = await app.inject({ method: 'GET', url: `/admin/teams/${teamId}` });
expect(roster.json().data.agents.map((a: { id: string }) => a.id)).toContain(agentId);
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentId}`,
payload: { active: false },
});
const activeListing = await app.inject({
method: 'GET',
url: `/admin/agents?active=true&teamId=${teamId}`,
});
expect(activeListing.json().data.map((a: { id: string }) => a.id)).not.toContain(agentId);
const directFetch = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}` });
expect(directFetch.statusCode).toBe(200);
expect(directFetch.json().data.active).toBe(false);
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentId}`,
payload: { active: true },
});
const reactivatedListing = await app.inject({
method: 'GET',
url: `/admin/agents?active=true&teamId=${teamId}`,
});
expect(reactivatedListing.json().data.map((a: { id: string }) => a.id)).toContain(agentId);
await app.inject({
method: 'PATCH',
url: `/admin/teams/${teamId}`,
payload: { active: false },
});
const agentAfterTeamDeactivation = await app.inject({
method: 'GET',
url: `/admin/agents/${agentId}`,
});
expect(agentAfterTeamDeactivation.json().data.active).toBe(true);
});
it('404s creating an agent on a nonexistent team', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/teams/nonexistent-team-id/agents',
payload: { name: 'Ghost Agent' },
});
expect(response.statusCode).toBe(404);
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import {
isCapabilityEligible,
scopeMatches,
} from '@/modules/orchestration/hierarchy/service/capability-match';
describe('isCapabilityEligible', () => {
it('is eligible when the agent holds every required skill', () => {
expect(isCapabilityEligible(['a', 'b', 'c'], ['a', 'b'])).toBe(true);
});
it('is not eligible when missing even one required skill', () => {
expect(isCapabilityEligible(['a', 'c'], ['a', 'b'])).toBe(false);
});
it('is eligible when no skills are required at all', () => {
expect(isCapabilityEligible([], [])).toBe(true);
});
it('is not eligible when the agent has no skills but at least one is required', () => {
expect(isCapabilityEligible([], ['a'])).toBe(false);
});
});
describe('scopeMatches', () => {
it('an empty scope matches any value, including undefined', () => {
expect(scopeMatches([], 'product-1')).toBe(true);
expect(scopeMatches([], undefined)).toBe(true);
});
it('a non-empty scope requires containment', () => {
expect(scopeMatches(['product-1'], 'product-1')).toBe(true);
expect(scopeMatches(['product-1'], 'product-2')).toBe(false);
});
it('a non-empty scope never matches an undefined value', () => {
expect(scopeMatches(['product-1'], undefined)).toBe(false);
});
});
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { wouldCreateCycle } from '@/modules/orchestration/hierarchy/service/cycle-check';
describe('wouldCreateCycle', () => {
it('allows becoming a root node (null parent) — never a cycle', () => {
expect(wouldCreateCycle('node-a', null, () => [])).toBe(false);
});
it('rejects a direct self-reference', () => {
expect(wouldCreateCycle('node-a', 'node-a', () => [])).toBe(true);
});
it('rejects a transitive cycle — the new parent is a descendant of the node being edited', () => {
// node-a -> node-b -> node-c ; editing node-a to have parent node-c is a cycle since
// node-c's ancestor chain already includes node-a.
const ancestorChainOf = (id: string) => (id === 'node-c' ? ['node-b', 'node-a'] : []);
expect(wouldCreateCycle('node-a', 'node-c', ancestorChainOf)).toBe(true);
});
it('allows a legitimate reparent to an unrelated node', () => {
const ancestorChainOf = (id: string) => (id === 'node-z' ? ['node-y'] : []);
expect(wouldCreateCycle('node-a', 'node-z', ancestorChainOf)).toBe(false);
});
});