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:
co-authored by
Claude Sonnet 5
parent
e703f826de
commit
a0c1a9aa33
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user