Compare commits
16
Commits
Generated
+138
@@ -19,11 +19,13 @@
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bullmq": "^5.7.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.26.2",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"luxon": "^3.7.2",
|
||||
"pino": "^8.20.0",
|
||||
"pino-pretty": "^11.0.0",
|
||||
@@ -31,6 +33,8 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/luxon": "^3.7.5",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||
@@ -1968,6 +1972,13 @@
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1975,6 +1986,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
||||
@@ -1982,6 +2004,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -2547,6 +2576,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -2618,6 +2656,12 @@
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "5.81.3",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz",
|
||||
@@ -3070,6 +3114,15 @@
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@@ -4319,6 +4372,49 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -4544,6 +4640,42 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -4551,6 +4683,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
|
||||
@@ -56,11 +56,13 @@
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bullmq": "^5.7.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.26.2",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"luxon": "^3.7.2",
|
||||
"pino": "^8.20.0",
|
||||
"pino-pretty": "^11.0.0",
|
||||
@@ -68,6 +70,8 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/luxon": "^3.7.5",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "agents" ADD COLUMN "userId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- The default above exists only to satisfy the NOT NULL constraint against this (empty)
|
||||
-- table at migration time — application code always provides a real bcryptjs hash on every
|
||||
-- User row it creates (specs/010-identity-auth/data-model.md), so the default itself is
|
||||
-- dropped immediately below to keep schema.prisma and the live database in agreement (no
|
||||
-- default declared in the Prisma schema).
|
||||
ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP DEFAULT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "agents_userId_key" ON "agents"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "agents" ADD CONSTRAINT "agents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
|
||||
+17
-6
@@ -14,12 +14,17 @@ enum UserRole {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String
|
||||
role UserRole @default(CUSTOMER)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String
|
||||
role UserRole @default(CUSTOMER)
|
||||
passwordHash String // bcryptjs hash — never the plaintext password; see
|
||||
// specs/010-identity-auth/data-model.md
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
agent Agent?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -417,6 +422,11 @@ model Agent {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Nullable link to the login identity this routing/skills profile belongs to — schema
|
||||
// capability only, no workflow sets it yet; see specs/010-identity-auth/research.md.
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
skills AgentSkill[]
|
||||
availability AgentAvailability?
|
||||
assignments Assignment[]
|
||||
@@ -497,6 +507,7 @@ model Assignment {
|
||||
unassignedAt DateTime?
|
||||
|
||||
@@index([ticketId, isCurrent])
|
||||
@@index([agentId, isCurrent])
|
||||
@@map("assignments")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaClient, UserRole } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(' -> Seeding demo environment data...');
|
||||
|
||||
// Legacy demo row, pre-existing since before 010-identity-auth: a CUSTOMER-role User is
|
||||
// never a real login identity (customer identity is exclusively SaaS-delegated, see
|
||||
// specs/010-identity-auth/spec.md Assumptions) — passwordHash is populated only to satisfy
|
||||
// the column's NOT NULL constraint; this account can never authenticate via /auth/login.
|
||||
await prisma.user.upsert({
|
||||
where: { email: 'john.doe@example.com' },
|
||||
update: {},
|
||||
@@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
||||
email: 'john.doe@example.com',
|
||||
name: 'John Doe (Demo Customer)',
|
||||
role: UserRole.CUSTOMER,
|
||||
passwordHash: await bcrypt.hash(randomUUID(), 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { PrismaClient, UserRole } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Local/development bootstrap credentials only (specs/010-identity-auth/spec.md Edge Cases) —
|
||||
// never used for a real deployment, which provisions its own first admin out of band.
|
||||
const DEV_ADMIN_PASSWORD = 'ChangeMe123!';
|
||||
const DEV_AGENT_PASSWORD = 'ChangeMe123!';
|
||||
|
||||
export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||
email: 'admin@supporthub.internal',
|
||||
name: 'System Admin',
|
||||
role: UserRole.ADMIN,
|
||||
passwordHash: await bcrypt.hash(DEV_ADMIN_PASSWORD, 10),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +28,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||
email: 'agent@supporthub.internal',
|
||||
name: 'Default Support Agent',
|
||||
role: UserRole.AGENT,
|
||||
passwordHash: await bcrypt.hash(DEV_AGENT_PASSWORD, 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Specification Quality Checklist: Identity and Authentication
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- This feature was not on the original 11-phase roadmap — it surfaced as a genuine blocking gap
|
||||
while planning supporthub-web's `001-agent-admin-ui`: `fastify.authenticate` has been a
|
||||
complete no-op stub since 002, and `identity/auth`'s login endpoint has never taken a
|
||||
password. Numbered 010 in supporthub-api's own sequence since it's a real, immediately-needed
|
||||
backend prerequisite, not deferred hardening.
|
||||
- `User`/`UserRole` (with two seeded-but-passwordless demo accounts,
|
||||
`admin@supporthub.internal`/`agent@supporthub.internal`) and the `AuthUser`/`JwtPayload`
|
||||
types in `src/common/types` were all found already scaffolded, unwired, and clearly intended
|
||||
for exactly this feature since the original pre-speckit scaffold — this is a "finish the
|
||||
originally-intended wiring" feature, not a new design invented from nothing.
|
||||
- Scope is deliberately narrow: real login + real route gating + role checks + a self-identity
|
||||
endpoint + admin-created accounts + logout. Password reset, MFA, rate-limiting, and
|
||||
registration are explicitly out of scope (Assumptions), matching Phase 11's own "security
|
||||
hardening pass" as the more appropriate later home for those.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Implementation-time finding**: making `fastify.authenticate` genuinely reject invalid/missing
|
||||
tokens (FR-004) had a far larger blast radius than this feature's own tasks.md anticipated.
|
||||
Dozens of routes across features 002-009 were already declared with `fastify.authenticate` as
|
||||
a preHandler — safe to write against a no-op stub, but every one of those pre-existing
|
||||
integration tests had been calling them with no `Authorization` header. Making the check real
|
||||
broke ~18 integration test files suite-wide, requiring a `tests/helpers/auth.ts` (`loginAs`/
|
||||
`authHeader`) and a file-by-file pass adding real bearer tokens, well beyond the mechanical
|
||||
`requireRole('ADMIN')` rollout research.md had scoped for. A related, recurring bug: several
|
||||
files already declared a local `const token = issueIntegrationToken(...)` for the unrelated
|
||||
002 customer-trust-boundary flow, and naming the new admin/agent token variable `token` in the
|
||||
same scope produced a `ReferenceError: Cannot access 'token' before initialization` — a genuine
|
||||
temporal-dead-zone collision, not a tooling bug — fixed by using a non-colliding name
|
||||
(`authToken`/`adminToken`/`agentToken`) per file.
|
||||
- A second, subtler implementation-time finding: once admin-setup calls in test `beforeAll`
|
||||
blocks started actually succeeding (previously they silently 401'd against the no-op stub),
|
||||
wildcard/global SLA policies created by one integration test file could genuinely match tickets
|
||||
created by another file running against the same shared throwaway Postgres, leaving orphaned
|
||||
`sla_run` rows that RESTRICT-violated the FK on cleanup. Fixed by widening the affected files'
|
||||
`afterAll` cleanup to delete `sla_run` rows by `ticketId` *and* by `policyId`, not just one or
|
||||
the other.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Contract: Identity and Authentication
|
||||
|
||||
## Login
|
||||
|
||||
- `POST /auth/login` — body `{ email, password }`. `401` on any failure (wrong password, no
|
||||
such account, or a deactivated account) with an identical response body/status in every case
|
||||
(FR-002/SC-003) — never a distinguishable "no such user" vs "wrong password." `200` with
|
||||
`{ token, user: { id, email, name, role } }` on success.
|
||||
|
||||
## Self-identity
|
||||
|
||||
- `GET /auth/me` — gated by `fastify.authenticate`. `401` if the token is missing/invalid/
|
||||
expired/revoked. `401` if the account behind a structurally-valid token no longer exists or
|
||||
is deactivated (re-validated against current state, not the token's own claims alone). `200`
|
||||
with `{ id, email, name, role }` on success.
|
||||
|
||||
## Account creation (admin-only)
|
||||
|
||||
- `POST /admin/users` — gated by `fastify.authenticate` + `requireRole('ADMIN')`. Body
|
||||
`{ email, name, role, password }` (`role` one of `ADMIN`/`AGENT`). `403` for a valid non-admin
|
||||
session. `409` if `email` is already in use. `201` with the created `{ id, email, name, role
|
||||
}` (never the password or its hash) on success.
|
||||
|
||||
## Logout
|
||||
|
||||
- `POST /auth/logout` — gated by `fastify.authenticate`. Revokes the calling token's own `jti`
|
||||
(Redis denylist, TTL = remaining lifetime) so it's rejected on any further use even before its
|
||||
natural expiry. `200` on success.
|
||||
|
||||
## Guarantees (callable contract)
|
||||
|
||||
1. **Every route already gated by `fastify.authenticate` across 002-009 continues to accept a
|
||||
valid session and now genuinely rejects a missing/invalid/expired/revoked one** — the gate
|
||||
itself changes from a no-op to a real check; which routes carry the gate is unchanged
|
||||
(FR-006, SC-001).
|
||||
2. **A route additionally gated by `requireRole('ADMIN')` rejects a structurally valid session
|
||||
whose role isn't `ADMIN`, with a response distinguishable from "no valid session at all"**
|
||||
(403 vs 401) (FR-005, SC-002).
|
||||
3. **A login failure never reveals whether the submitted email corresponds to an existing
|
||||
account** — verified by comparing the exact response for a wrong password against a wholly
|
||||
nonexistent email (FR-002, SC-003).
|
||||
4. **No password is ever stored, logged, or returned anywhere in plaintext** — only
|
||||
`passwordHash` is persisted, and no response body (login, self-identity, account creation)
|
||||
ever includes it (FR-003, SC-004).
|
||||
5. **An admin-created account can log in immediately with the password it was created with, no
|
||||
manual step in between** (FR-008, SC-005).
|
||||
6. **A token revoked via logout is rejected on any further use, even before its natural expiry**
|
||||
(FR-009).
|
||||
@@ -0,0 +1,60 @@
|
||||
# Data Model: Identity and Authentication
|
||||
|
||||
## User (modified — two additive columns)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(uuid())` | unchanged |
|
||||
| `email` | `String @unique` | unchanged |
|
||||
| `name` | `String` | unchanged |
|
||||
| `role` | `UserRole @default(CUSTOMER)` | unchanged enum (`ADMIN \| AGENT \| CUSTOMER`) — this feature never assigns `CUSTOMER` (research.md/spec.md Assumptions); every row this feature creates or updates is `ADMIN` or `AGENT` |
|
||||
| **`passwordHash`** | **`String`** | **new** — bcryptjs hash, never the plaintext password; `NOT NULL` since every account this feature manages must be able to log in (FR-010 requires both seeded demo accounts to get a real one) |
|
||||
| **`active`** | **`Boolean @default(true)`** | **new** — mirrors `Agent.active`'s existing convention exactly; a deactivated account's session is rejected on re-validation (Edge Cases/User Story 3), without a hard delete |
|
||||
| `createdAt` / `updatedAt` | `DateTime` | unchanged |
|
||||
|
||||
## Agent (modified — one additive, nullable column)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| **`userId`** | **`String? @unique`** | **new** — nullable FK to `User.id`, the schema capability to identify which login identity a routing/skills profile belongs to (research.md). No endpoint in this feature sets it; a follow-up in `identity/agents` (006) is expected to. |
|
||||
|
||||
No new Prisma model for "Session" — a session is a signed JWT the server never persists
|
||||
(research.md's short-lived-JWT-plus-revocation-denylist decision); the denylist itself lives in
|
||||
Redis (`auth:revoked:<jti>`, TTL = remaining token lifetime), not Postgres.
|
||||
|
||||
## JwtPayload (existing type, `src/common/types/auth.types.ts` — one additive field)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `sub` | `string` | the `User.id` |
|
||||
| `email` | `string` | unchanged |
|
||||
| `role` | `string` | unchanged — `User.role` at issuance time |
|
||||
| `actorType` | `ActorType` | unchanged — always `ActorType.USER` for these sessions (research.md) |
|
||||
| **`jti`** | **`string`** | **new** — random UUID per issued token, the revocation-denylist key |
|
||||
| `iat` / `exp` | `number` | unchanged, standard JWT claims |
|
||||
|
||||
## AuthUser (existing type, unchanged)
|
||||
|
||||
`{ id, email, role, actorType }` — what `fastify.authenticate` sets on `request.user` after
|
||||
verifying the token; the same fields returned by login (FR-001) and the self-identity endpoint
|
||||
(FR-007), minus `jti`/`iat`/`exp` (those are token bookkeeping, not identity).
|
||||
|
||||
## Validation rules
|
||||
|
||||
- Login: `email` a valid email string, `password` non-empty. The response for "no such user"
|
||||
and "wrong password" MUST be byte-for-byte identical (FR-002/SC-003) — achieved by always
|
||||
running the bcrypt comparison against either the found user's hash or a fixed dummy hash when
|
||||
no user is found, so the response timing and shape never differ by branch.
|
||||
- Account creation (User Story 4): `email` valid and not already in use, `name` non-empty,
|
||||
`role` one of `ADMIN`/`AGENT` (never `CUSTOMER`, research.md), `password` non-empty (hashed
|
||||
before storage, never persisted or logged in plaintext).
|
||||
|
||||
## State / lifecycle
|
||||
|
||||
- `User.active` (new column, above) is the deactivation flag. The self-identity endpoint (User
|
||||
Story 3) re-fetches the `User` row by `sub` on every call and rejects if it no longer exists
|
||||
or `active: false` — this is the feature's only server-side re-validation path; `fastify
|
||||
.authenticate` itself does not re-fetch on every request (that would defeat the point of a
|
||||
stateless JWT check), so a deactivated account's *other* already-issued-token requests remain
|
||||
valid until that token's natural expiry or an explicit logout, exactly as spec.md's Edge Cases
|
||||
already scopes it ("a short token lifetime bounds the rest").
|
||||
@@ -0,0 +1,136 @@
|
||||
# Implementation Plan: Identity and Authentication
|
||||
|
||||
**Branch**: `010-identity-auth` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/010-identity-auth/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Finishes the original, never-wired scaffold: `identity/auth`'s email-only login stub becomes a
|
||||
real bcryptjs-verified, JWT-issuing login; `fastify.authenticate` (currently a complete no-op)
|
||||
becomes a real signature/expiry/revocation check populating `request.user` and the already-
|
||||
shared `request.reqContext.actorId`/`actorType` fields every module since 007 already reads; a
|
||||
new `requireRole(...roles)` preHandler factory adds role-based gating on top. `User` gains
|
||||
`passwordHash` and `active` columns. Logout revokes a token's `jti` via the same Redis-denylist
|
||||
shape 002's replay protection already established.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+.
|
||||
|
||||
**Primary Dependencies**: `jsonwebtoken` (new — JWT sign/verify), `bcryptjs` (new — password
|
||||
hashing, pure JS to avoid native-build friction on Windows dev environments). Reuses existing
|
||||
`ioredis` for the revocation denylist.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma (`User.passwordHash`, `User.active`). Redis for the
|
||||
revocation denylist (`auth:revoked:<jti>`, TTL = remaining token lifetime) — same shape as
|
||||
002's `hasSeenJti`/`markJtiSeen`.
|
||||
|
||||
**Testing**: Vitest — unit tests for password verification's identical-response-on-failure
|
||||
behavior and the `requireRole` preHandler's role-matching logic; integration tests against real
|
||||
Postgres/Redis for the full login → gated-route → logout flow, and specifically re-verifying at
|
||||
least one already-shipped admin route per module (002-009) now genuinely rejects an invalid
|
||||
session.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `src/plugins/auth.plugin.ts`,
|
||||
populates `src/modules/identity/auth/`, adds `POST /admin/users` (a new small surface, placed
|
||||
alongside `identity/agents`'s own admin routes since account management is an identity concern,
|
||||
not `identity/auth`'s own — `identity/auth` owns login/logout/self-identity, not account CRUD).
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: Token verification (signature + expiry + Redis denylist check) must stay
|
||||
a single Redis round trip, not a Postgres query, on every gated request — only the self-identity
|
||||
endpoint (User Story 3) re-fetches from Postgres, by design (research.md).
|
||||
|
||||
**Constraints**: MUST NOT reveal account existence via login's failure response (FR-002); MUST
|
||||
NOT ever store or return a plaintext password (FR-003); MUST NOT change which existing routes
|
||||
are gated, only make the gate real (FR-006); MUST re-validate against current account state on
|
||||
the self-identity endpoint specifically, not on every request (data-model.md).
|
||||
|
||||
**Scale/Scope**: One modified plugin (`auth.plugin.ts`), one populated module
|
||||
(`identity/auth`), one new small admin-account-creation surface, two new dependencies, two new
|
||||
`User` columns, one seed-script update. Explicitly excludes: password reset, MFA, login-specific
|
||||
rate-limiting, and retroactively adding `requireRole('ADMIN')` to every existing admin route
|
||||
beyond a representative sample (research.md — tracked as this feature's own Polish-phase
|
||||
mechanical task, not a redesign of any other feature's access model).
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle / Section | Check | Result |
|
||||
|---|---|---|
|
||||
| I. SaaS Is the Sole Identity & Access Authority | This feature authenticates SupportHub's own staff (`User`/`Agent`), explicitly never `CUSTOMER`-role accounts (research.md/spec.md Assumptions) — customer identity remains exclusively SaaS-delegated via 002's own trust boundary, untouched by this feature. Matches the constitution's own carve-out: "SupportHub is the sole authority only for its own domain: ... support org structure." | PASS |
|
||||
| II. Configuration Over Hardcoding | Token lifetime and any future role list are read from a config value (research.md's 4-hour default), never a magic number duplicated at each call site. | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | `identity/auth` keeps its standard shape; `requireRole` is exported from `identity/auth`'s own public `index.ts` for other modules' routes to compose with, the same way `fastify.authenticate` itself is already a cross-cutting plugin-level primitive, not a module import. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI involvement in this feature. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
|
||||
| VI. Durable Audit & History | This feature is what finally makes 007-009's own audit fields (`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) accurate for real agent/admin actions instead of always falling back to `'unknown'` (research.md) — directly strengthens, not just satisfies, this principle. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Token verification and revocation are stateless/Redis-TTL-based, not an in-memory timer; two concurrent login attempts for the same account are independently evaluated with no shared mutable state (spec.md Edge Cases). | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — this feature doesn't touch tickets or problems. | PASS — N/A |
|
||||
| Technology & Platform Constraints | Two new, narrowly-scoped dependencies (`jsonwebtoken`, `bcryptjs`), both justified in research.md; reuses existing Redis infrastructure, no new infrastructure category introduced. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
All gates above remain PASS after Phase 1 design. Principle VI is worth restating post-design:
|
||||
this feature has no user-facing "audit" screen of its own, but its real effect is retroactively
|
||||
correcting the audit trail of every feature since 007 that could only ever record `'unknown'`
|
||||
as the acting agent/admin — a materially more accurate audit history the moment this ships.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/010-identity-auth/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # MODIFIED — User.passwordHash, User.active
|
||||
│ └── seed/roles.seed.ts # MODIFIED — seeded accounts get real password hashes
|
||||
├── src/
|
||||
│ ├── plugins/
|
||||
│ │ └── auth.plugin.ts # REPLACED stub — real JWT verify + revocation
|
||||
│ │ check, populates request.user + reqContext
|
||||
│ ├── infrastructure/
|
||||
│ │ └── cache/ # MODIFIED — revocation denylist helpers
|
||||
│ │ alongside the existing jti-replay helpers
|
||||
│ └── modules/
|
||||
│ └── identity/
|
||||
│ ├── auth/ # REPLACED stub — full real login/logout/
|
||||
│ │ ├── controller/ routes/ schema/ self-identity, requireRole exported from
|
||||
│ │ │ repository/ service/ types/ its own public index.ts
|
||||
│ │ │ mapper/ constants/ index.ts
|
||||
│ │ └── (no engine/ — no real decision logic beyond password/token checks)
|
||||
│ └── agents/ # MODIFIED — new POST /admin/users route
|
||||
│ └── (existing module, account-creation surface added alongside its own
|
||||
│ existing agent-roster admin routes)
|
||||
└── tests/
|
||||
├── unit/identity/ # password-failure-response-parity,
|
||||
│ requireRole matching logic
|
||||
└── integration/ # full login/gating/logout flow, spot-checks
|
||||
across 002-009's own existing admin routes
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project. `POST /admin/users` (account creation) lives under
|
||||
`identity/agents` rather than `identity/auth`, since `identity/auth` owns authentication
|
||||
mechanics (login/logout/self-identity) while account/roster management is already that
|
||||
module's own established concern — mirrors 006's own precedent of `identity/agents` owning
|
||||
agent-roster CRUD.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,52 @@
|
||||
# Quickstart: Validating Identity and Authentication
|
||||
|
||||
Prerequisites: migrations applied; `npm run prisma:seed` run so the two demo accounts exist
|
||||
with their new real passwords (documented in the seed script itself, local/dev use only).
|
||||
|
||||
## Scenario 1 — login (User Story 1)
|
||||
|
||||
1. `POST /auth/login` with the seeded admin's correct email/password. **Expected**: `200`, a
|
||||
token, and `{id, email, name, role: 'ADMIN'}`.
|
||||
2. Repeat with the correct email but a wrong password. **Expected**: `401`.
|
||||
3. Repeat with an email that doesn't exist at all. **Expected**: the exact same `401` body/
|
||||
status as step 2 — diff the two responses to confirm they're indistinguishable.
|
||||
|
||||
## Scenario 2 — route gating and role enforcement (User Story 2)
|
||||
|
||||
1. Call an existing admin route (e.g. `POST /admin/teams`) with no `Authorization` header.
|
||||
**Expected**: `401`.
|
||||
2. Repeat with a malformed token (`Bearer not-a-real-token`). **Expected**: `401`.
|
||||
3. Log in as the seeded agent (role `AGENT`); call an admin-only route gated by
|
||||
`requireRole('ADMIN')`. **Expected**: `403`.
|
||||
4. Log in as the seeded admin; repeat step 3's call. **Expected**: `200`/`201` (whatever that
|
||||
route normally returns on success).
|
||||
|
||||
## Scenario 3 — self-identity (User Story 3)
|
||||
|
||||
1. Log in; call `GET /auth/me` with the resulting token. **Expected**: `200`, identity matches
|
||||
the login response exactly.
|
||||
2. Directly deactivate that account (`active: false`) via a direct DB update (simulating an
|
||||
admin action no UI exists for yet); repeat the same `GET /auth/me` call with the same,
|
||||
still-unexpired token. **Expected**: `401` — re-validated against current account state, not
|
||||
the token's own claims.
|
||||
|
||||
## Scenario 4 — admin creates an account (User Story 4)
|
||||
|
||||
1. Log in as admin; `POST /admin/users` with a new email/name/role `AGENT`/password.
|
||||
**Expected**: `201`, response never includes the password or its hash.
|
||||
2. Log in as a non-admin (the seeded agent); repeat step 1. **Expected**: `403`.
|
||||
3. Immediately log in as the newly-created account with the password from step 1. **Expected**:
|
||||
`200` — no manual step needed in between.
|
||||
4. Repeat step 1 with an email already in use. **Expected**: `409`.
|
||||
|
||||
## Scenario 5 — logout (User Story 5)
|
||||
|
||||
1. Log in; call `POST /auth/logout` with the resulting token. **Expected**: `200`.
|
||||
2. Immediately reuse that same token on any gated route. **Expected**: `401` — rejected even
|
||||
though it hasn't naturally expired.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All five scenarios pass, and Scenario 2 is additionally verified against at least one
|
||||
already-shipped admin route from each of 002-009 (not just a route this feature itself adds),
|
||||
proving the real gate actually protects what the no-op stub never did.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Phase 0 Research: Identity and Authentication
|
||||
|
||||
## Decision: Finish the existing scaffold's own intended design, not a new one
|
||||
|
||||
- **Decision**: `User`/`UserRole`, the two seeded-but-passwordless demo accounts, and
|
||||
`AuthUser`/`JwtPayload` in `src/common/types` are the real target — this feature adds a
|
||||
`passwordHash` column, replaces `identity/auth`'s email-only stub with real password
|
||||
verification and JWT issuance, and makes `fastify.authenticate` actually verify that JWT.
|
||||
- **Rationale**: Every shape needed (the JWT payload's exact fields, the user/role model, even
|
||||
the bootstrap accounts) was already scaffolded before this session's spec-driven rebuild
|
||||
began — this is the same "give an existing, unwired scaffold its first real implementation"
|
||||
pattern every other phase in this codebase has followed, not a new design decision.
|
||||
- **Alternatives considered**: A separate, purpose-built `Session`/`Credential` model instead of
|
||||
extending `User` — rejected; `User` already has exactly the fields a staff account needs
|
||||
(email, name, role), and doc 06 never defined a competing entity for this.
|
||||
|
||||
## Decision: reuse the existing, already-required `JWT_SECRET` env var — don't invent a new one
|
||||
|
||||
- **Decision**: Token signing/verification uses `env.JWT_SECRET` — a `z.string().min(16)`,
|
||||
no-default, required environment variable already defined in `src/config/env.ts` and already
|
||||
set in `.env.test`/`.env.example`/`vitest.config.ts` since before this session's spec-driven
|
||||
rebuild began. This feature adds no new secret env var, only `AUTH_TOKEN_LIFETIME_HOURS`
|
||||
(a non-secret, defaultable number).
|
||||
- **Rationale**: Same "finish the scaffold's own intended design" pattern as `User`/
|
||||
`JwtPayload` themselves — `JWT_SECRET` was clearly provisioned for exactly this feature and
|
||||
has simply never been read by any code until now.
|
||||
- **Alternatives considered**: A feature-specific `AUTH_JWT_SECRET` — considered and rejected
|
||||
once `JWT_SECRET` was found; would create two secrets doing the identical job.
|
||||
|
||||
## Decision: `jsonwebtoken` for signing/verifying, `bcryptjs` for password hashing
|
||||
|
||||
- **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept
|
||||
consistent with `auth.plugin.ts`'s existing manual-decorator style rather than introducing the
|
||||
`@fastify/jwt` plugin ecosystem) and `bcryptjs` (pure JavaScript, no native compilation step —
|
||||
`bcrypt`/`argon2`'s native bindings are a real source of friction on this team's Windows dev
|
||||
environment, confirmed earlier this session when Docker/Prisma tooling already needed
|
||||
workarounds for the same class of platform friction).
|
||||
- **Rationale**: Both are the standard, widely-used choice for their job; `bcryptjs`
|
||||
specifically avoids re-litigating the native-module build problems this session has already
|
||||
hit more than once on Windows.
|
||||
- **Alternatives considered**: `@fastify/jwt` — rejected only for consistency with this
|
||||
codebase's existing hand-rolled decorator style, not a correctness concern. `argon2` —
|
||||
rejected for the same native-build-friction reason as `bcrypt`; `bcryptjs` is a well-
|
||||
established, secure-enough choice for this scale (JWT `Vitest`-verified via existing
|
||||
precedent, not a cryptographic novelty).
|
||||
|
||||
## Decision: Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism
|
||||
|
||||
- **Decision**: `JwtPayload` gains a `jti` (JWT ID, a random UUID per issued token). Logout adds
|
||||
that `jti` to a Redis key (`auth:revoked:<jti>`) with a TTL equal to the token's own remaining
|
||||
lifetime. `fastify.authenticate` checks this key (in addition to verifying the signature and
|
||||
expiry) before accepting a token.
|
||||
- **Rationale**: This is the exact same shape as 002's `hasSeenJti`/`markJtiSeen` replay-
|
||||
protection mechanism (`src/infrastructure/cache`) — reused directly rather than inventing a
|
||||
second Redis-backed token-tracking pattern. A TTL equal to remaining lifetime means the
|
||||
denylist entry is automatically cleaned up and never grows unbounded.
|
||||
- **Alternatives considered**: A full server-side session table (every issued token recorded in
|
||||
Postgres, checked on every request) — rejected as unnecessary weight; spec.md's own
|
||||
Assumptions explicitly chose "short-lived JWT + revocation-on-logout-only" over full session
|
||||
tracking, and Redis is already the right tool for this exact shape of check (fast, TTL-native).
|
||||
|
||||
## Decision: A 4-hour token lifetime
|
||||
|
||||
- **Decision**: Issued JWTs expire 4 hours after issuance (`exp` claim).
|
||||
- **Rationale**: Long enough that a working agent isn't repeatedly forced to re-authenticate
|
||||
mid-shift, short enough that a leaked/forgotten token's exposure window is bounded in hours,
|
||||
not days — a reasonable default for an internal staff tool with no remember-me/refresh-token
|
||||
flow in this feature's scope (Assumptions: no MFA/hardening pass yet either).
|
||||
- **Alternatives considered**: A refresh-token pair (short-lived access token + long-lived
|
||||
refresh token) — rejected as more mechanism than this feature's scope calls for; nothing in
|
||||
spec.md's user stories requires silent re-authentication, and it can be added later without
|
||||
breaking the token shape this feature establishes.
|
||||
|
||||
## Decision: `fastify.authenticate` also populates the existing, already-shared `reqContext.actorId`/`actorType`
|
||||
|
||||
- **Decision**: On a valid token, `fastify.authenticate` sets `request.user` (the full
|
||||
`AuthUser`) AND `request.reqContext.actorId = user.id`, `request.reqContext.actorType =
|
||||
ActorType.USER` — the same two `RequestContext` fields `authenticateProductIntegration`
|
||||
already populates for customer-originated requests (002).
|
||||
- **Rationale**: Every module from 007 onward already reads `request.reqContext?.actorId ??
|
||||
'unknown'` in its controllers (`actorFrom(request)` helpers in assignments, tickets,
|
||||
escalation, resolutions) expecting exactly this to eventually be populated by a real staff
|
||||
auth mechanism — this was a forward-compatible convention already in place, not something
|
||||
this feature needs to change call sites for. Every one of those audit trails (
|
||||
`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) becomes accurate for real
|
||||
agent/admin actions the moment this feature ships, with no changes to 007-009's own code.
|
||||
- **Alternatives considered**: A separate `request.user`-only convention, leaving `reqContext
|
||||
.actorId` customer-only — rejected; would require touching every existing `actorFrom` call
|
||||
site across four already-shipped features for no benefit, when the field was clearly designed
|
||||
to be auth-mechanism-agnostic from the start.
|
||||
|
||||
## Decision: Role-gating via a `requireRole(...roles)` preHandler factory, not a fixed decorator
|
||||
|
||||
- **Decision**: A new exported function, `requireRole(...allowedRoles: string[])`, returns a
|
||||
Fastify preHandler that checks `request.user?.role` against the given list, throwing
|
||||
`AuthorizationError` (403) if it doesn't match — used as
|
||||
`{ preHandler: [fastify.authenticate, requireRole('ADMIN')] }`. Not a fixed
|
||||
`fastify.requireAdmin` decorator, even though `ADMIN` is the only role checked today.
|
||||
- **Rationale**: A factory function generalizes to any future role/permission check (e.g. a
|
||||
hypothetical `SENIOR_AGENT`) without a new decorator per role; `fastify.authenticate` and
|
||||
`requireRole` compose as two separate preHandlers, matching this codebase's existing
|
||||
`[fastify.authenticateProductIntegration, fastify.checkIntegrationRateLimit]` two-step
|
||||
preHandler-array convention exactly.
|
||||
- **Alternatives considered**: A single combined `fastify.authenticateAdmin` decorator —
|
||||
rejected; would duplicate `fastify.authenticate`'s own token-verification logic for every new
|
||||
role instead of composing with it.
|
||||
|
||||
## Decision: `Agent.userId` is added as a nullable link, but linking is not this feature's own workflow
|
||||
|
||||
- **Decision**: `Agent` gains `userId String? @unique`, a nullable FK to `User.id` — the schema
|
||||
capability to say "this login identity's routing/skills profile is that `Agent` row" — but
|
||||
this feature does not add an endpoint or admin screen to set it. No seed data links the
|
||||
demo agent account to an `Agent` row either (none is seeded for it today).
|
||||
- **Rationale**: While investigating account creation (User Story 4), a real gap surfaced: a
|
||||
`User` (the thing that logs in) and an `Agent` (the thing 006/007 route tickets to) have never
|
||||
been connected — an `AGENT`-role `User` today has no way to be identified as a *specific*
|
||||
`Agent` for "tickets assigned to me"-style queries supporthub-web's own agent dashboard will
|
||||
need. Adding the column now is cheap and unblocks that later without a schema change at that
|
||||
point; building the actual linking workflow (which almost certainly belongs in 006's
|
||||
`identity/agents` admin screens, alongside team/skill assignment, not this identity/auth
|
||||
feature) is a real, separate piece of scope this feature doesn't need to solve today.
|
||||
- **Alternatives considered**: Building the full link-an-account-to-an-agent workflow as part of
|
||||
this feature — rejected as scope creep; this feature's own job is proving a `User` *can*
|
||||
authenticate and be authorized, not completing every downstream consumer of that identity.
|
||||
Making `Agent.userId` required — rejected; an `Agent` created via 006's existing screens has
|
||||
no `User` account requirement today and shouldn't suddenly need one just because this feature
|
||||
exists.
|
||||
|
||||
## Decision: Which existing routes get gated is unchanged — only the gate itself becomes real
|
||||
|
||||
- **Decision**: This feature does not add `fastify.authenticate` to any route that doesn't
|
||||
already have it, and does not add `requireRole('ADMIN')` to every existing admin route as
|
||||
part of this feature's own implementation — SC-002 is satisfied by demonstrating the
|
||||
mechanism works on a representative sample (one action per module), with the mechanical work
|
||||
of adding `requireRole('ADMIN')` to every remaining `/admin/*` route across 002-009 tracked as
|
||||
this feature's own Polish-phase task, not a scope expansion into re-designing any other
|
||||
feature's authorization model.
|
||||
- **Rationale**: FR-006 is explicit: "this feature does not change which routes are gated, only
|
||||
makes the gate real." Deciding which of the many already-shipped admin routes should be
|
||||
admin-only vs. any-authenticated-agent is a real per-route judgment call (e.g., should an
|
||||
agent be able to create a hierarchy node? almost certainly not; should an agent read one?
|
||||
probably yes) — this feature makes that judgment call possible to enforce, and applies it
|
||||
everywhere in its own Polish phase, but doesn't silently redesign any other feature's own
|
||||
intended access model beyond what's obviously admin-only (write/config endpoints) vs.
|
||||
read/agent-usable.
|
||||
- **Alternatives considered**: Leaving every existing route exactly as `fastify.authenticate`-
|
||||
only (no `requireRole`) and treating role-based gating as entirely out of scope — rejected;
|
||||
spec.md's own User Story 2/FR-005 explicitly requires admin-only enforcement to exist
|
||||
somewhere concrete, not just as an available-but-unused mechanism.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Feature Specification: Identity and Authentication
|
||||
|
||||
**Feature Branch**: `010-identity-auth`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "supporthub-web's admin/agent role-gating (its own Phase 1,
|
||||
001-agent-admin-ui) has no real backend to build on: `fastify.authenticate` is a complete
|
||||
no-op stub, and the existing `identity/auth` scaffold's login endpoint accepts an email alone
|
||||
with no password and returns the raw user record, never a session. Build minimal, real
|
||||
agent/admin authentication in supporthub-api first, as a prerequisite for the frontend feature."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An agent or admin logs in and receives a session (Priority: P1)
|
||||
|
||||
A user with a SupportHub-issued account (never a SaaS-delegated identity — this is SupportHub's
|
||||
own staff, per Constitution Principle I's "support org structure" being SupportHub's own
|
||||
authority) logs in with their email and password and receives a session token that authorizes
|
||||
their subsequent requests.
|
||||
|
||||
**Why this priority**: Every other story in this feature, and the entire admin/agent-facing
|
||||
half of supporthub-web, has nothing to build on without this.
|
||||
|
||||
**Independent Test**: Log in with a seeded account's correct credentials; confirm a session
|
||||
token is returned and a subsequent authenticated request using it succeeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user account with a set password, **When** they submit the correct email and
|
||||
password, **Then** they receive a session token and their own `id`/`email`/`name`/`role`.
|
||||
2. **Given** a user account, **When** they submit an incorrect password, **Then** the request
|
||||
is rejected with no session token issued — the rejection message MUST NOT reveal whether the
|
||||
email itself was valid (never "wrong password" vs "no such user" as distinguishable
|
||||
responses).
|
||||
3. **Given** no account exists for a submitted email, **When** login is attempted, **Then** it
|
||||
is rejected with the same indistinguishable-from-wrong-password response as Scenario 2.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1)
|
||||
|
||||
Every existing `/admin/*` route (and any other route already gated by the `fastify.authenticate`
|
||||
stub across features 002-009) actually rejects a request with no valid session, and every route
|
||||
that should be admin-only actually rejects a valid session whose role isn't `ADMIN`.
|
||||
|
||||
**Why this priority**: This is the entire point of the feature — without it, User Story 1
|
||||
issues a token that nothing on the backend actually checks, which is no better than the current
|
||||
no-op stub.
|
||||
|
||||
**Independent Test**: Call an existing admin route (e.g. creating a team) with no
|
||||
`Authorization` header, with an expired/malformed token, with a valid agent-role token, and
|
||||
with a valid admin-role token; confirm exactly the last one succeeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a request with no `Authorization` header, **When** it hits a route gated by
|
||||
`fastify.authenticate`, **Then** it's rejected as unauthorized.
|
||||
2. **Given** a request with a malformed, expired, or tampered token, **When** it hits a gated
|
||||
route, **Then** it's rejected as unauthorized — never silently treated as anonymous/no-op the
|
||||
way the current stub does.
|
||||
3. **Given** a valid session for a user whose role is `AGENT`, **When** it hits a route that
|
||||
requires the `ADMIN` role specifically, **Then** it's rejected as forbidden, distinct from
|
||||
the unauthorized case above.
|
||||
4. **Given** a valid session for a user whose role is `ADMIN`, **When** it hits any route gated
|
||||
by either `fastify.authenticate` or an admin-only requirement, **Then** it succeeds.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An authenticated user can identify themselves (Priority: P2)
|
||||
|
||||
A logged-in user can ask "who am I" and get back their own identity and role, without needing
|
||||
to decode their own session token client-side.
|
||||
|
||||
**Why this priority**: Depends on User Story 1. supporthub-web's role-gating (rendering the
|
||||
admin portal only for admins) needs a reliable way to know the current session's role after
|
||||
the token is already held — decoding a JWT's claims client-side is a reasonable fallback, but a
|
||||
real endpoint is what lets that identity be revalidated against current server-side state (e.g.
|
||||
a deactivated account) rather than trusting a possibly-stale token's own claims forever.
|
||||
|
||||
**Independent Test**: Log in, then call the "who am I" endpoint with the resulting session;
|
||||
confirm it returns the same identity and role as the login response, and that it's rejected
|
||||
under the same conditions as User Story 2.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid session, **When** the identity endpoint is called, **Then** it returns the
|
||||
current `id`/`email`/`name`/`role` for that session.
|
||||
2. **Given** a session for an account that has since been deactivated, **When** the identity
|
||||
endpoint (or any gated route) is called, **Then** it's rejected — a session's validity is
|
||||
re-checked against current account state, not just the token's own unexpired signature.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
|
||||
|
||||
An admin creates a new user account (agent or admin role) with an initial password, since there
|
||||
is no public self-signup for SupportHub's own staff accounts.
|
||||
|
||||
**Why this priority**: Depends on User Story 2 (admin-only gating). Without this, the only way
|
||||
to add a second real account is a direct database write — fine for the one seeded bootstrap
|
||||
admin, not for onboarding a real team.
|
||||
|
||||
**Independent Test**: As an admin, create a new agent account with a password; confirm the new
|
||||
account can immediately log in (User Story 1) with those credentials.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authenticated admin, **When** they create a new account with an email,
|
||||
name, role, and initial password, **Then** it's created and can log in immediately.
|
||||
2. **Given** a non-admin session, **When** they attempt to create an account, **Then** it's
|
||||
rejected as forbidden (User Story 2's own guarantee, exercised here specifically).
|
||||
3. **Given** an email already in use by an existing account, **When** account creation is
|
||||
attempted, **Then** it's rejected — never a second account silently sharing one email.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - A user logs out (Priority: P3)
|
||||
|
||||
A logged-in user can end their own session explicitly, rather than only ever waiting for it to
|
||||
expire.
|
||||
|
||||
**Why this priority**: Lowest priority — a short-lived token that simply expires already
|
||||
bounds the exposure of a lost/leftover session; an explicit logout is a UX nicety layered on
|
||||
top, not a security-critical gap the way User Stories 1-2 are.
|
||||
|
||||
**Independent Test**: Log in, log out, then attempt to use the same token again; confirm it's
|
||||
now rejected.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid session, **When** the user logs out, **Then** that specific token is
|
||||
rejected on any subsequent use, even though it hasn't yet expired.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens to a session already issued to a user whose password is changed or whose account
|
||||
is deactivated? Out of scope for this feature to build a full revocation-on-every-write
|
||||
mechanism (Assumptions) — User Story 3's re-check-on-identity-call is the only server-side
|
||||
re-validation this feature guarantees; a short token lifetime (Assumptions) bounds the rest.
|
||||
- What happens if two login attempts for the same account happen concurrently with different
|
||||
passwords (e.g. a credential-stuffing attempt racing a real login)? Each is evaluated
|
||||
independently against the stored password hash — no shared mutable state between them, so no
|
||||
new concurrency concern is introduced.
|
||||
- What happens to the two demo accounts the seed script already creates
|
||||
(`admin@supporthub.internal`, `agent@supporthub.internal`) which currently have no password?
|
||||
This feature MUST give them real, seeded passwords (documented for local/dev use only) so the
|
||||
existing seed script keeps producing an immediately-usable bootstrap admin — never account
|
||||
IDs that exist but can never actually log in.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST let a user log in with email and password, returning a session
|
||||
token and their own identity (`id`/`email`/`name`/`role`) on success.
|
||||
- **FR-002**: A login attempt with an incorrect password or an unrecognized email MUST be
|
||||
rejected with an indistinguishable response — the system MUST NOT reveal whether a submitted
|
||||
email corresponds to an existing account.
|
||||
- **FR-003**: Passwords MUST be stored only as a salted hash, never in plaintext or in any
|
||||
reversible form.
|
||||
- **FR-004**: `fastify.authenticate` MUST reject a request with a missing, malformed, expired,
|
||||
or otherwise invalid session token — it MUST NOT pass a request through as anonymous/no-op
|
||||
the way the current stub does.
|
||||
- **FR-005**: The system MUST provide a way to require a specific role (at minimum, `ADMIN`)
|
||||
on a route, distinct from and layered on top of `fastify.authenticate`'s own valid-session
|
||||
check, returning a distinguishable forbidden (not unauthorized) response when the role
|
||||
requirement fails.
|
||||
- **FR-006**: Every existing route currently gated by `fastify.authenticate` (across
|
||||
002-009's own admin/read surfaces) MUST continue to work for a valid session and MUST now
|
||||
actually reject an invalid one — this feature does not change which routes are gated, only
|
||||
makes the gate real.
|
||||
- **FR-007**: The system MUST provide an endpoint that returns the current session's own
|
||||
identity and role, re-validated against current account state (not solely the token's own
|
||||
claims).
|
||||
- **FR-008**: The system MUST let an authenticated admin create a new account (email, name,
|
||||
role, initial password), rejecting a duplicate email.
|
||||
- **FR-009**: The system MUST let a user invalidate their own current session token before its
|
||||
natural expiry.
|
||||
- **FR-010**: The two existing seeded demo accounts MUST be given real, working passwords as
|
||||
part of this feature, documented as local/development credentials.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **User**: A SupportHub staff identity — email, name, role (`ADMIN`/`AGENT`), and (new in this
|
||||
feature) a securely hashed password. Distinct from `Agent` (the routing/skills/team-membership
|
||||
profile an `AGENT`-role `User` has) and from a SaaS-delegated customer identity, which this
|
||||
feature does not touch.
|
||||
- **Session**: The short-lived, server-issued proof that a `User` authenticated successfully,
|
||||
carrying their `id`, `email`, and `role`; revocable before its natural expiry (User Story 5).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of requests to a `fastify.authenticate`-gated route with no valid session are
|
||||
rejected, verified across every module's existing admin routes (002-009), not just this
|
||||
feature's own new endpoints.
|
||||
- **SC-002**: 100% of admin-only actions are rejected for a valid non-admin session, verified for
|
||||
at least one action from each module that has one.
|
||||
- **SC-003**: 0% of login rejections reveal whether the submitted email corresponds to an
|
||||
existing account, verified by comparing the exact response for both cases.
|
||||
- **SC-004**: 100% of passwords are stored only as a hash — verified by inspecting the stored
|
||||
representation directly, never as a value that could be reversed to the original password.
|
||||
- **SC-005**: An admin can create a working new account and have it log in successfully within
|
||||
the same test run, with no manual database step.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **No password-reset/forgot-password flow is built in this feature** — an admin can create a
|
||||
new account (User Story 4), but resetting an existing one's forgotten password is out of
|
||||
scope; the smallest viable fix today is an admin recreating the account or a direct
|
||||
operational fix, not a self-service flow.
|
||||
- **Session tokens are short-lived JWTs with a fixed expiry, not a server-side session store per
|
||||
token** — logout (User Story 5) is implemented via a revocation check (a denylist of
|
||||
logged-out-early tokens), not full server-side session tracking; this keeps token validation
|
||||
fast (no DB round trip on every request) while still making explicit logout meaningfully
|
||||
different from "wait for expiry." The exact expiry duration and revocation mechanism are
|
||||
research.md decisions, not fixed here.
|
||||
- **No account self-registration** — every account is created either by the seed script (the
|
||||
two bootstrap demo accounts) or by an existing admin (User Story 4); there is no public
|
||||
sign-up endpoint, consistent with these being SupportHub's own staff accounts, never a
|
||||
SaaS-delegated customer identity.
|
||||
- **This feature does not add a password-strength policy, MFA, or rate-limiting specifically
|
||||
for login attempts beyond what 002's existing generic rate-limit infrastructure might already
|
||||
cover incidentally** — those are real hardening concerns explicitly named in
|
||||
`docs/10-implementation-roadmap.md`'s own Phase 11 ("security hardening pass"), not this
|
||||
feature's job to anticipate.
|
||||
- **The `CUSTOMER` value already defined on `UserRole` is never assigned by this feature** — no
|
||||
code path in this feature creates a `User` with `role: CUSTOMER`; per Constitution Principle
|
||||
I, customer identity remains exclusively SaaS-delegated (002's inbound trust boundary), never
|
||||
a local `User` row. The enum value's continued existence is a pre-existing scaffold detail
|
||||
this feature doesn't need to remove to stay correct.
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
description: "Task list for 010-identity-auth"
|
||||
---
|
||||
|
||||
# Tasks: Identity and Authentication
|
||||
|
||||
**Input**: Design documents from `specs/010-identity-auth/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/identity-auth-contract.md](./contracts/identity-auth-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Tests**: Included as first-class tasks. Pure logic worth a unit test: the identical-failure-
|
||||
response behavior (FR-002/SC-003) and the `requireRole` matching logic. Everything else is
|
||||
best proven end-to-end against a real Postgres/Redis, including a specific pass re-verifying
|
||||
existing 002-009 admin routes now actually reject an invalid session.
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 login, US2 = P1 route/role gating,
|
||||
US3 = P2 self-identity, US4 = P2 admin-created accounts, US5 = P3 logout).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] T001 [P] Add `jsonwebtoken` and `bcryptjs` (plus `@types/jsonwebtoken`,
|
||||
`@types/bcryptjs`) to `package.json`
|
||||
- [x] T002 [P] Add `AUTH_JWT_SECRET` (required, no default — never a committed secret) and
|
||||
`AUTH_TOKEN_LIFETIME_HOURS` (`z.coerce.number().default(4)`) to `src/config/env.ts`,
|
||||
exposed via a new `src/config/auth.ts` (`authConfig.jwtSecret`,
|
||||
`authConfig.tokenLifetimeHours`), matching `orchestrationConfig`'s exact shape
|
||||
- [x] T003 [P] Populate `src/modules/identity/auth/` with the full standard shape around its
|
||||
existing files, replacing the email-only `AuthService.validateCredentials`/
|
||||
`AuthRepository.findByEmail`-only stub content
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Schema for the entities every user story needs.
|
||||
|
||||
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
|
||||
|
||||
- [x] T004 Add `User.passwordHash` (`String`, required) and `User.active` (`Boolean
|
||||
@default(true)`) to `prisma/schema.prisma`, plus `Agent.userId` (`String? @unique`, FK to
|
||||
`User.id` — research.md's additive, not-yet-consumed link) (depends on T001-T003)
|
||||
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`)
|
||||
for T004 (depends on T004)
|
||||
- [x] T006 Update `prisma/seed/roles.seed.ts` to set a real bcryptjs-hashed password on both
|
||||
seeded accounts (`admin@supporthub.internal`, `agent@supporthub.internal`), documenting
|
||||
the plaintext dev password in a comment directly above the hash call (local/dev use only,
|
||||
per spec.md Edge Cases) (depends on T005)
|
||||
|
||||
**Checkpoint**: Schema migrated, demo accounts have real passwords. User stories can now be
|
||||
built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - An agent or admin logs in and receives a session (Priority: P1) 🎯 MVP (part 1)
|
||||
|
||||
**Goal**: Real password verification and JWT issuance, with an identical failure response
|
||||
regardless of which reason login failed.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T007 [P] [US1] Unit test: given a found user with a matching/non-matching password, and
|
||||
given no user found at all, the login-failure path produces byte-identical response
|
||||
shape/status in the non-matching and no-user cases — in
|
||||
`tests/unit/identity/login-failure-parity.test.ts`
|
||||
- [x] T008 [US1] Integration test covering Quickstart Scenario 1 (correct login succeeds with a
|
||||
token + identity; wrong password and nonexistent email produce the same `401`) against a
|
||||
real Postgres in `tests/integration/identity-auth-flow.test.ts` (depends on T006)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T009 [US1] Add `hashPassword`/`verifyPassword` (bcryptjs) and `signToken`/`verifyToken`
|
||||
(jsonwebtoken, embedding `sub`/`email`/`role`/`actorType`/`jti`/`iat`/`exp` per
|
||||
data-model.md) in `identity/auth/mapper/` (depends on T002)
|
||||
- [x] T010 [US1] Add `AuthRepository.findActiveByEmail` (replacing `findByEmail`) in
|
||||
`identity/auth/repository/` (depends on T005)
|
||||
- [x] T011 [US1] Add `AuthService.login(email, password)`: looks up the user, compares against
|
||||
either the found hash or a fixed dummy hash when not found (FR-002's timing/shape
|
||||
parity), returns `{ token, user }` or throws a single, identical `AuthenticationError` for
|
||||
every failure branch — in `identity/auth/service/` (depends on T009, T010)
|
||||
- [x] T012 [US1] Replace `POST /auth/login`'s schema (`email` + `password`, replacing the
|
||||
email-only schema) and controller in `identity/auth/schema/` + `controller/`, registered
|
||||
from `src/api/routes.ts` (depends on T011)
|
||||
- [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: Login works and never leaks account existence through its failure response.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1) 🎯 MVP (part 2)
|
||||
|
||||
**Goal**: `fastify.authenticate` actually verifies; `requireRole` enforces role on top of it;
|
||||
every existing gated route is re-verified.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T014 [P] [US2] Unit test for `requireRole`'s matching logic (allowed role passes, wrong
|
||||
role throws `AuthorizationError`, no `request.user` at all throws) in
|
||||
`tests/unit/identity/require-role.test.ts`
|
||||
- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (no header, malformed token,
|
||||
wrong-role token, correct-role token) against a real Postgres/Redis in
|
||||
`tests/integration/identity-auth-flow.test.ts` (depends on T008)
|
||||
- [x] T016 [US2] Integration test spot-checking at least one existing admin route per module
|
||||
(002's product-integration admin route, 004's knowledge admin route, 006's team-creation
|
||||
route, 007's manual-assignment route, 008's SLA-policy route, 009's investigation route)
|
||||
now rejects a missing/invalid session — in `tests/integration/identity-auth-flow.test.ts`
|
||||
(depends on T015)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T017 [US2] Add revocation-denylist helpers (`isTokenRevoked`, `revokeToken`) in
|
||||
`src/infrastructure/cache/`, alongside the existing `hasSeenJti`/`markJtiSeen` (same
|
||||
Redis-key-with-TTL shape, research.md) (depends on T002)
|
||||
- [x] T018 [US2] Replace `auth.plugin.ts`'s `authenticate` stub: verify the JWT signature and
|
||||
expiry, check T017's revocation denylist, and on success set `request.user` (the full
|
||||
`AuthUser`) and `request.reqContext.actorId`/`actorType` — throw `AuthenticationError` on
|
||||
any failure, never pass through as anonymous (depends on T009, T017)
|
||||
- [x] T019 [US2] Add `requireRole(...allowedRoles: string[])` preHandler factory (checks
|
||||
`request.user?.role`, throws `AuthorizationError` if it doesn't match) in
|
||||
`identity/auth/service/` (or a dedicated `identity/auth/guards/` file), exported from
|
||||
`identity/auth`'s public `index.ts` (depends on T018)
|
||||
- [x] T020 [US2] Add `requireRole('ADMIN')` to every existing write/config admin route across
|
||||
002-009 that doesn't already distinguish agent-vs-admin access (product-integration
|
||||
admin, knowledge admin, teams/hierarchy admin, SLA/escalation-policy admin) — read-only
|
||||
routes and ticket-working routes an agent legitimately uses stay `fastify.authenticate`-
|
||||
only (research.md's own scoping: this is a mechanical pass applying an existing judgment,
|
||||
not a new design) (depends on T019)
|
||||
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass
|
||||
|
||||
**Checkpoint**: Every P1 user story is complete — a session is real, and it's actually checked
|
||||
everywhere it's supposed to be. This is the feature's MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - An authenticated user can identify themselves (Priority: P2)
|
||||
|
||||
**Goal**: A self-identity endpoint that re-validates against current account state.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (identity matches login;
|
||||
deactivating the account rejects a still-unexpired token's use of this endpoint
|
||||
specifically) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T023 [US3] Add `AuthService.getCurrentUser(userId)`: re-fetches the `User` row, throws
|
||||
`AuthenticationError` if it no longer exists or `active: false` — in `identity/auth/
|
||||
service/` (depends on T010)
|
||||
- [x] T024 [US3] Add `GET /auth/me` route (gated by `fastify.authenticate`) in `identity/auth/
|
||||
controller/` + `routes/` (depends on T023)
|
||||
- [x] T025 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass
|
||||
|
||||
**Checkpoint**: A session can be introspected and is re-validated against live account state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
|
||||
|
||||
**Goal**: Admin-only account creation, immediately usable to log in.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 4.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [x] T026 [US4] Integration test covering Quickstart Scenario 4 (admin creates an account and
|
||||
it logs in immediately; non-admin rejected; duplicate email rejected) — in
|
||||
`tests/integration/identity-auth-flow.test.ts` (depends on T021)
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T027 [US4] Add `UsersService.create(email, name, role, password)` (resolve-or-409 on
|
||||
duplicate email, hashes the password via T009) in `identity/agents/service/` (research.md
|
||||
— account creation lives alongside `identity/agents`'s own roster CRUD, not
|
||||
`identity/auth`) (depends on T009)
|
||||
- [x] T028 [US4] Add `POST /admin/users` route (gated by `fastify.authenticate` +
|
||||
`requireRole('ADMIN')`) in `identity/agents/controller/` + `routes/`, registered from
|
||||
`src/api/routes.ts` — response never includes the password or hash (depends on T019, T027)
|
||||
- [x] T029 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass
|
||||
|
||||
**Checkpoint**: New staff accounts can be provisioned without a manual database write.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - A user logs out (Priority: P3)
|
||||
|
||||
**Goal**: Explicit, immediate session revocation.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 5.
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [x] T030 [US5] Integration test covering Quickstart Scenario 5 (logout succeeds; the same
|
||||
token is rejected immediately afterward) — in `tests/integration/identity-auth-flow.test.ts`
|
||||
(depends on T021)
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [x] T031 [US5] Add `AuthService.logout(jti, remainingTtlSeconds)`: calls T017's `revokeToken`
|
||||
— in `identity/auth/service/` (depends on T017)
|
||||
- [x] T032 [US5] Add `POST /auth/logout` route (gated by `fastify.authenticate`) in
|
||||
`identity/auth/controller/` + `routes/` (depends on T031)
|
||||
- [x] T033 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass
|
||||
|
||||
**Checkpoint**: All five user stories work independently and together — real login, real
|
||||
gating, self-identity, admin-provisioned accounts, and logout form one coherent auth system.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T034 [P] Update `specs/010-identity-auth/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T035 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T036 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing
|
||||
broke elsewhere, then the full integration suite (including 002-009's own suites, since
|
||||
T020 adds `requireRole` to their existing routes) against real Docker-provisioned
|
||||
Postgres/Redis
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies
|
||||
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2-US5
|
||||
- **User Story 2 (Phase 4)**: Depends on US1 (a real token to verify)
|
||||
- **User Story 3 (Phase 5)**: Depends on US2 (the gate US3's own route sits behind)
|
||||
- **User Story 4 (Phase 6)**: Depends on US2 (`requireRole('ADMIN')`)
|
||||
- **User Story 5 (Phase 7)**: Depends on US2 (the gate logout's own route sits behind) and
|
||||
US1's token shape (`jti`)
|
||||
- **Polish (Phase 8)**: Depends on all five user stories
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001-T003 (independent scaffolding)
|
||||
- T007 (unit test) alongside T009-T011 (the implementation it tests)
|
||||
- T014 (unit test) alongside T019 (the implementation it tests)
|
||||
- T034 in Polish
|
||||
|
||||
### Sequencing Note
|
||||
|
||||
T020 (adding `requireRole('ADMIN')` across 002-009's existing routes) is the one task in this
|
||||
feature that touches code outside `identity/*` — run each touched module's own existing test
|
||||
suite immediately after, not only in T036's final regression pass, so a role-gating regression
|
||||
in, say, 007's own suite is caught close to its cause rather than at the very end.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1-2 Only)
|
||||
|
||||
1. Setup + Foundational (T001-T006)
|
||||
2. User Story 1 (T007-T013) → login works, no account-existence leak
|
||||
3. User Story 2 (T014-T021) → the gate is real everywhere it already existed
|
||||
4. **STOP and VALIDATE**: Quickstart Scenarios 1-2 pass, including the cross-module spot-check
|
||||
(T016). This is the feature's MVP — every other user story is a smaller addition on top of a
|
||||
now-real auth system.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → schema migrated, demo accounts have real passwords
|
||||
2. Add User Story 1 → login is real
|
||||
3. Add User Story 2 → the gate is real everywhere (P1-complete, MVP)
|
||||
4. Add User Story 3 → self-identity, re-validated against live account state
|
||||
5. Add User Story 4 → admins can provision new accounts
|
||||
6. Add User Story 5 → explicit logout
|
||||
7. Polish → full regression across every feature this touches
|
||||
@@ -0,0 +1,58 @@
|
||||
# Specification Quality Checklist: Agent Ticket Queue
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- This feature was not on the original 11-phase roadmap, and wasn't anticipated by 010's own
|
||||
scope either — it surfaced while beginning supporthub-web's 001-agent-admin-ui planning: its
|
||||
User Story 1 (agent dashboard) needs to list "tickets currently assigned to me," and no route,
|
||||
repository method, or even a documented gap anywhere in the ticketing or orchestration modules
|
||||
answers that question. Numbered 011 in supporthub-api's own sequence for the same reason 010
|
||||
was — a genuine, immediately-needed backend prerequisite discovered while building the
|
||||
consuming feature, not deferred hardening.
|
||||
- User Story 1 (linking `Agent.userId`) is itself a "finish the scaffold's own intended design"
|
||||
case, same pattern as 010: the field was added in 010-identity-auth specifically for this
|
||||
purpose ("schema capability only, no workflow sets it yet") and simply never got its own
|
||||
endpoint until now.
|
||||
- Deliberately narrow: this is not a general ticket search/list endpoint (Assumptions) — only
|
||||
the one query supporthub-web's agent dashboard actually needs, to avoid speculative scope
|
||||
beyond what 001-agent-admin-ui's own spec calls for.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Implementation-time finding**: research.md's plan to add a dedicated
|
||||
`AgentsService.requireAgentForUser` guard (rather than inlining the lookup in the ticketing
|
||||
controller) turned out to matter for testability, not just style — it let T007's unit test
|
||||
exercise the "no linked agent" rejection with a fake repository, with no real database
|
||||
involved, exactly the kind of isolated unit coverage tasks.md asked for. Worth defaulting to
|
||||
this shape (a small service method over inline controller logic) whenever a cross-module
|
||||
guard needs its own unit test.
|
||||
- No other deviations from plan.md — the two-routes-sharing-one-service-method design, the
|
||||
proactive existence/role/duplicate-link checks, and the new composite index all worked exactly
|
||||
as researched, and the full regression suite (unit + integration) stayed clean throughout.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Contract: Agent Ticket Queue
|
||||
|
||||
## `PATCH /admin/agents/:agentId` (existing route, extended)
|
||||
|
||||
**Auth**: `fastify.authenticate` (unchanged — this route was already agent-usable, not
|
||||
admin-only, since agents may already update their own roster fields per existing precedent).
|
||||
|
||||
**Request body** (existing shape plus one new optional field):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "string, optional",
|
||||
"teamId": "string, optional",
|
||||
"active": "boolean, optional",
|
||||
"userId": "string | null, optional"
|
||||
}
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
- `200` — updated `Agent`, including `userId`.
|
||||
- `404` — `agentId` doesn't exist, or (new) the target `userId` doesn't exist as a `User`.
|
||||
- `400` — (new) the target `User`'s role is not `AGENT`.
|
||||
- `409` — (new) the target `userId` is already linked to a different `Agent`.
|
||||
|
||||
## `GET /agents/me/tickets`
|
||||
|
||||
**Auth**: `fastify.authenticate` only — no `requireRole`, since any authenticated `AGENT` (or
|
||||
`ADMIN`, who may also hold an agent profile) may call this for their own session.
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "string",
|
||||
"code": "string",
|
||||
"status": "string",
|
||||
"priority": "string",
|
||||
"severity": "string",
|
||||
"product": { "id": "string", "externalProductId": "string", "name": "string" },
|
||||
"customer": { "externalUserId": "string", "externalTenantId": "string" },
|
||||
"assignedAt": "ISO 8601 datetime",
|
||||
"sla": {
|
||||
"status": "string",
|
||||
"firstResponseDueAt": "ISO 8601 datetime | null",
|
||||
"resolutionDueAt": "ISO 8601 datetime | null",
|
||||
"breachedAt": "ISO 8601 datetime | null"
|
||||
}
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
`sla` is `null` when no `SLARun` exists yet for that ticket.
|
||||
|
||||
**Response `404`**: the session's `User` has no linked `Agent` row
|
||||
(`{ "success": false, "error": { "code": "NOT_FOUND", "message": "No agent profile is linked to this account." } }`).
|
||||
|
||||
## `GET /admin/agents/:agentId/tickets`
|
||||
|
||||
**Auth**: `fastify.authenticate` + `requireRole('ADMIN')`.
|
||||
|
||||
**Response**: identical shape to `GET /agents/me/tickets`'s `200`, for the `agentId` named in
|
||||
the URL. `404` if `agentId` doesn't exist as an `Agent` row (a plain "agent not found," distinct
|
||||
from the self-route's "no agent linked to this account").
|
||||
@@ -0,0 +1,48 @@
|
||||
# Data Model: Agent Ticket Queue
|
||||
|
||||
## Modified: `Agent`
|
||||
|
||||
No new column — `userId`/`user` already exist (010-identity-auth). This feature is the first to
|
||||
actually write `userId` through an endpoint, and adds the supporting index below.
|
||||
|
||||
```prisma
|
||||
model Assignment {
|
||||
// ...existing fields unchanged...
|
||||
|
||||
@@index([ticketId, isCurrent])
|
||||
@@index([agentId, isCurrent]) // NEW — supports "current assignments for agent X"
|
||||
@@map("assignments")
|
||||
}
|
||||
```
|
||||
|
||||
## New (response-shape only, no new table): `AssignedTicketSummary`
|
||||
|
||||
A read projection, not a persisted entity — assembled per-request from `Ticket` joined to its
|
||||
current `Assignment`, `Product`, `CustomerReference`, and (if present) `SLARun`.
|
||||
|
||||
| Field | Source | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `Ticket.id` | |
|
||||
| `code` | `Ticket.code` | e.g. `ACME-2026-0042` |
|
||||
| `status` | `Ticket.status` | One of the 12 lifecycle states (003's own state machine) |
|
||||
| `priority` | `Ticket.priority` | Opaque string, as already modeled |
|
||||
| `severity` | `Ticket.severity` | Opaque string, as already modeled |
|
||||
| `product` | `Ticket.product` | `{ id, externalProductId, name }` |
|
||||
| `customer` | `Ticket.customer` | `{ externalUserId, externalTenantId }` — no PII beyond what 002's own `CustomerReference` already stores |
|
||||
| `assignedAt` | `Assignment.assignedAt` | The current assignment's start time |
|
||||
| `sla` | `SLARun` (nullable) | `{ status, firstResponseDueAt, resolutionDueAt, breachedAt }` or `null` if no `SLARun` exists yet for this ticket |
|
||||
|
||||
## Validation / Business Rules
|
||||
|
||||
- **Linking** (`PATCH /admin/agents/:agentId`'s new `userId` field):
|
||||
- The target `User` must exist and have role `AGENT` (FR-001).
|
||||
- No other `Agent` row may already have that `userId` (FR-001) — checked proactively before
|
||||
the write (research.md), not left to the database's own `@unique` constraint to reject.
|
||||
- `userId: null` explicitly unlinks (distinct from omitting the field, which leaves it
|
||||
unchanged — the existing `updateAgentSchema` pattern for optional fields).
|
||||
- **Listing** (`GET /agents/me/tickets`, `GET /admin/agents/:agentId/tickets`):
|
||||
- Only `Assignment.isCurrent: true` rows are considered (FR-003).
|
||||
- The agent-self route resolves `agentId` exclusively from `request.user.id` → `Agent.userId`
|
||||
lookup — never from any request input (FR-004).
|
||||
- A session with no linked `Agent` row throws a specific `NotFoundError`
|
||||
("No agent profile is linked to this account."), never an empty array (FR-006).
|
||||
@@ -0,0 +1,110 @@
|
||||
# Implementation Plan: Agent Ticket Queue
|
||||
|
||||
**Branch**: `011-agent-ticket-queue` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/011-agent-ticket-queue/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Finishes wiring `Agent.userId` (added in 010-identity-auth as schema-only) by extending the
|
||||
existing `PATCH /admin/agents/:agentId` with an optional `userId`, then adds the ticket-query
|
||||
this unblocks: `GET /agents/me/tickets` (agent's own session) and
|
||||
`GET /admin/agents/:agentId/tickets` (admin, explicit agent) — both returning the same
|
||||
summarized, dashboard-ready projection of every ticket currently assigned to that agent.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — reuses Prisma, the existing `identity/agents` and
|
||||
`ticketing/tickets` modules, and 010's `requireRole`.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma. Adds one index (`Assignment @@index([agentId, isCurrent])`)
|
||||
— the query this feature introduces (all current assignments for one agent) has no supporting
|
||||
index today; the existing `[ticketId, isCurrent]` index doesn't serve an agent-first lookup.
|
||||
|
||||
**Testing**: Vitest — unit test for the "no linked Agent" rejection path; integration tests
|
||||
against real Postgres/Redis for linking, the agent's-own-session query, the admin explicit-
|
||||
agent query, and cross-agent isolation (one agent never sees another's tickets).
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `identity/agents` (linking
|
||||
endpoint, `userId` already returned by existing reads) and `ticketing/tickets` (new summary
|
||||
query + routes) — no new module, since "list my tickets" is a ticketing concern reading
|
||||
orchestration's `Assignment` state, matching 003's existing module boundary (ticketing already
|
||||
depends on orchestration's public surface for status-transition side effects).
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: The ticket-summary query is one indexed query for current assignments
|
||||
plus a single batched fetch of their tickets (with product/customer/SLA-run relations) — no
|
||||
N+1 per-ticket round trip, matching FR-003/SC-001's "single request" requirement.
|
||||
|
||||
**Constraints**: MUST NOT let an agent's own-session call accept a client-supplied `agentId`
|
||||
(FR-004 — always resolved from the session's own linked `Agent` row). MUST reject a session
|
||||
with no linked `Agent` row distinguishably from an empty list (FR-006).
|
||||
|
||||
**Scale/Scope**: One new admin endpoint (link), two new read endpoints (agent-self, admin-
|
||||
explicit) sharing one service method, one new Prisma index. Explicitly excludes: a general
|
||||
ticket search/filter endpoint, pagination, and self-service linking (spec.md Assumptions).
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle / Section | Check | Result |
|
||||
|---|---|---|
|
||||
| I. SaaS Is the Sole Identity & Access Authority | Purely internal to SupportHub's own domain (agent roster, ticket assignment) — no SaaS/customer identity involved. | PASS — N/A |
|
||||
| II. Configuration Over Hardcoding | No new configurable values introduced. | PASS — N/A |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | The new query lives in `ticketing/tickets` (the module that owns `Ticket`), reading `Assignment` via orchestration's own public `index.ts` export — no reach-through to orchestration's internals. The link endpoint lives in `identity/agents`, alongside its existing agent CRUD. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||||
| VI. Durable Audit & History | No new mutable state beyond the `Agent.userId` link itself, which `Agent`'s own `updatedAt` already timestamps. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Read-only queries plus one simple linking write guarded by the existing `@unique` constraint on `Agent.userId` (a concurrent double-link race is rejected by the database itself, not application logic). | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — no problem-management involvement. | PASS — N/A |
|
||||
| Technology & Platform Constraints | No new dependencies or infrastructure. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/011-agent-ticket-queue/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── prisma/
|
||||
│ └── schema.prisma # MODIFIED — Assignment @@index([agentId, isCurrent])
|
||||
└── src/
|
||||
└── modules/
|
||||
├── identity/
|
||||
│ └── agents/ # MODIFIED — link-user endpoint alongside existing agent CRUD
|
||||
│ ├── controller/ routes/ schema/
|
||||
│ └── service/
|
||||
└── ticketing/
|
||||
└── tickets/ # MODIFIED — new agent-assigned-tickets summary query
|
||||
├── controller/ routes/ schema/
|
||||
└── service/ mapper/
|
||||
└── tests/
|
||||
├── unit/identity/ # "no linked Agent" rejection unit test
|
||||
└── integration/ # linking flow + both list endpoints + cross-agent isolation
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module. The link endpoint extends
|
||||
`identity/agents` (already owns agent CRUD); the ticket-summary query extends
|
||||
`ticketing/tickets` (already owns `Ticket`) rather than a new module, since this is one small
|
||||
read query, not a new bounded concern.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,35 @@
|
||||
# Quickstart: Validating Agent Ticket Queue
|
||||
|
||||
Prerequisites: 010-identity-auth's login working; an existing `Team`/`Agent`/`User` (role
|
||||
`AGENT`) to link.
|
||||
|
||||
## Scenario 1 — linking (User Story 1)
|
||||
|
||||
1. `PATCH /admin/agents/:agentId` with `{ "userId": "<agent's User.id>" }` as an admin.
|
||||
**Expected**: `200`, response's `userId` matches.
|
||||
2. Repeat with a `userId` belonging to a `User` whose role is `ADMIN`. **Expected**: `400`.
|
||||
3. Repeat step 1's `userId` against a *different* `agentId`. **Expected**: `409`.
|
||||
|
||||
## Scenario 2 — an agent lists their own tickets (User Story 2)
|
||||
|
||||
1. With two tickets currently assigned to the linked agent (via the existing orchestration
|
||||
assignment flow) and one assigned to a different agent, log in as that agent and call
|
||||
`GET /agents/me/tickets`. **Expected**: `200`, exactly the two tickets, each with `product`/
|
||||
`customer`/`priority`/`severity`/`status`/`assignedAt`/`sla` populated.
|
||||
2. Reassign one of those two tickets away (to a different agent or node). **Expected**: calling
|
||||
`GET /agents/me/tickets` again returns only the one remaining ticket.
|
||||
3. Log in as a `User` (role `AGENT`) with no linked `Agent` row and call the same endpoint.
|
||||
**Expected**: `404` with the specific "no agent profile linked" message, not `[]`.
|
||||
|
||||
## Scenario 3 — an admin lists a specific agent's tickets
|
||||
|
||||
1. Log in as admin; call `GET /admin/agents/:agentId/tickets` for the agent from Scenario 2.
|
||||
**Expected**: `200`, same ticket set and shape as that agent's own `GET /agents/me/tickets`
|
||||
call.
|
||||
2. Log in as a non-admin agent; call the same admin route for another agent's `agentId`.
|
||||
**Expected**: `403`.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All three scenarios pass, and Scenario 2 step 2 specifically confirms the list reflects live
|
||||
assignment state rather than a snapshot from when the agent first logged in.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Research: Agent Ticket Queue
|
||||
|
||||
## Decision: extend the existing `PATCH /admin/agents/:agentId`, don't add a new link endpoint
|
||||
|
||||
- **Decision**: Add an optional `userId: z.string().min(1).nullable().optional()` to
|
||||
`updateAgentSchema` and handle it in `AgentsService.update` (proactively check the target
|
||||
`User`'s role and any existing link before writing, same pre-check style as
|
||||
`UsersService.create`'s duplicate-email check — see 010-identity-auth), rather than a
|
||||
dedicated `PATCH /admin/agents/:agentId/link-user` route.
|
||||
- **Rationale**: `PATCH /admin/agents/:agentId` already exists as the one place an agent's
|
||||
mutable fields are updated (`name`, `teamId`, `active`) — `userId` is exactly that kind of
|
||||
field, not a distinct workflow. A second endpoint would duplicate routing/auth wiring for no
|
||||
behavioral gain.
|
||||
- **Alternatives considered**: A dedicated `/link-user` endpoint — rejected as an unnecessary
|
||||
extra surface once the existing update endpoint's shape was checked and found to already fit.
|
||||
|
||||
## Decision: proactive existence/role checks, not a caught unique-constraint error
|
||||
|
||||
- **Decision**: Before writing `userId`, look up the target `User` (404 if it doesn't exist,
|
||||
a clear rejection if its role isn't `AGENT`) and look up any existing `Agent` already linked
|
||||
to that `userId` (a clear `ConflictError` if one exists and isn't this same agent) — the same
|
||||
pattern `UsersService.create` (010-identity-auth) already established for its own duplicate-
|
||||
email check, rather than letting Postgres's `@unique` constraint on `Agent.userId` throw and
|
||||
translating that error after the fact.
|
||||
- **Rationale**: Consistency with the one precedent this codebase already has for "reject a
|
||||
would-be duplicate before writing," and a clearer error message than parsing a raw
|
||||
`PrismaClientKnownRequestError` code.
|
||||
- **Alternatives considered**: Catch `P2002` (unique constraint violation) and translate it —
|
||||
workable, but the proactive-check style already used by `UsersService.create` was preferred
|
||||
for consistency within the same codebase.
|
||||
|
||||
## Decision: the ticket-summary query lives in `ticketing/tickets`, not `orchestration/assignments`
|
||||
|
||||
- **Decision**: `TicketsService` (or a new `TicketsRepository` method) owns the new
|
||||
"tickets currently assigned to agent X" query, reading `Assignment` rows via
|
||||
`orchestration/assignments`'s own already-public repository/service surface (its `index.ts`),
|
||||
not by reaching into `orchestration`'s internals.
|
||||
- **Rationale**: The result is fundamentally a list of `Ticket`s (with a projection of
|
||||
product/customer/SLA data) — `ticketing/tickets` already owns `Ticket` and its existing
|
||||
`findById`/`findByCode` methods; `orchestration/assignments` owns the assignment *decision*
|
||||
and *history*, not ticket listing. This mirrors 009's own precedent of `problem-management`
|
||||
reading `ticketing`'s public surface rather than duplicating ticket state there.
|
||||
- **Alternatives considered**: A new cross-cutting `reporting`/`dashboard` module — rejected as
|
||||
premature; this is one query, not a new bounded concern (spec.md Assumptions explicitly rule
|
||||
out a general-purpose list/search endpoint).
|
||||
|
||||
## Decision: one new Prisma index, `Assignment @@index([agentId, isCurrent])`
|
||||
|
||||
- **Decision**: Add this composite index. The existing `@@index([ticketId, isCurrent])` supports
|
||||
"is this ticket currently assigned, and to whom" (007's own original query shape); this
|
||||
feature's query is the mirror image — "which tickets is this agent currently assigned to" —
|
||||
and has no supporting index today.
|
||||
- **Rationale**: Without it, "all current assignments for agent X" is a sequential scan over the
|
||||
whole `assignments` table. Cheap, purely additive schema change; no data migration needed
|
||||
beyond the index build itself.
|
||||
- **Alternatives considered**: Rely on the existing `[ticketId, isCurrent]` index (Postgres can't
|
||||
use a composite index efficiently for a query that doesn't lead with its first column) —
|
||||
rejected; a plain sequential scan is the actual alternative, not this index.
|
||||
|
||||
## Decision: two routes sharing one service method, not one route with an optional param
|
||||
|
||||
- **Decision**: `GET /agents/me/tickets` (`fastify.authenticate` only — resolves the agent from
|
||||
`request.user.id` via the new `Agent.userId` link) and `GET /admin/agents/:agentId/tickets`
|
||||
(`fastify.authenticate` + `requireRole('ADMIN')` — resolves the agent directly from the URL
|
||||
param) both call the same `TicketsService.listAssignedTo(agentId)`.
|
||||
- **Rationale**: FR-004 requires an agent's own call can never accept a client-supplied
|
||||
`agentId` — collapsing both into one route with an optional query param would make that
|
||||
invariant a runtime `if` instead of a routing-level guarantee. Two routes make "whose tickets"
|
||||
structurally unambiguous per caller type, matching 010's own precedent of `GET /auth/me` vs.
|
||||
an admin-only equivalent being distinct routes rather than one parameterized one.
|
||||
- **Alternatives considered**: `GET /tickets?assignedAgentId=<id or 'me'>` — rejected; makes
|
||||
FR-004's guarantee a body of validation logic rather than routing structure.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Feature Specification: Agent Ticket Queue
|
||||
|
||||
**Feature Branch**: `011-agent-ticket-queue`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Give agents and the frontend a way to list tickets currently
|
||||
assigned to a given agent, with enough summary detail (customer, product, priority, status, SLA
|
||||
state) to power an agent dashboard, since no such query exists anywhere in the ticketing or
|
||||
orchestration modules today."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An admin links a staff account to its agent roster entry (Priority: P1)
|
||||
|
||||
An admin connects an existing `User` account (role `AGENT`, from 010-identity-auth) to its
|
||||
corresponding `Agent` roster row (from 006-support-organization), so the platform knows which
|
||||
login belongs to which routing/skills profile.
|
||||
|
||||
**Why this priority**: Every other story here depends on resolving "this logged-in session" to
|
||||
"this agent's roster row." `Agent.userId` was added in 010-identity-auth specifically for this
|
||||
purpose but has never been set by any workflow — this is that missing workflow.
|
||||
|
||||
**Independent Test**: Create a `User` (role `AGENT`) and a separate `Agent` roster row; link
|
||||
them via the admin endpoint; confirm the link is retrievable and that linking a `User` already
|
||||
linked to a different `Agent` is rejected.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an unlinked `Agent` and a `User` with role `AGENT` not yet linked to any agent,
|
||||
**When** an admin links them, **Then** the `Agent` row's `userId` is set and retrievable.
|
||||
2. **Given** a `User` already linked to `Agent` A, **When** an admin attempts to link that same
|
||||
`User` to `Agent` B, **Then** the request is rejected (the existing unique constraint on
|
||||
`Agent.userId` is surfaced as a clear conflict, not a raw database error).
|
||||
3. **Given** a `User` whose role is `ADMIN` rather than `AGENT`, **When** an admin attempts to
|
||||
link it to an `Agent` row, **Then** the request is rejected — an `Agent` roster row
|
||||
represents a working agent, not an admin-only account.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An agent retrieves their own currently-assigned tickets (Priority: P1)
|
||||
|
||||
An authenticated agent (or an admin looking at a specific agent, for support purposes) can
|
||||
retrieve a list of every ticket currently assigned to that agent, each with enough summary data
|
||||
— customer reference, product, priority, severity, status, and SLA state if a run exists — to
|
||||
power an agent dashboard without a further per-ticket fetch.
|
||||
|
||||
**Why this priority**: This is the entire reason this feature exists — supporthub-web's own
|
||||
agent-dashboard user story (its 001-agent-admin-ui, User Story 1) has no data source without it,
|
||||
and no other endpoint in the ticketing or orchestration modules answers this question today.
|
||||
|
||||
**Independent Test**: With two tickets currently assigned to an agent (via the existing
|
||||
orchestration assignment engine) and a third assigned to a different agent, call the new
|
||||
endpoint as the first agent; confirm exactly the first two are returned, each with the summary
|
||||
fields populated, and the third is absent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with two tickets currently assigned to them, **When** they call this
|
||||
endpoint, **Then** both are returned, each including customer reference, product, priority,
|
||||
severity, status, and SLA state (or an explicit absence of one, if no `SLARun` exists yet).
|
||||
2. **Given** an agent with zero currently-assigned tickets, **When** they call this endpoint,
|
||||
**Then** an empty list is returned — not an error.
|
||||
3. **Given** a ticket reassigned away from an agent (its `Assignment.isCurrent` flips to another
|
||||
agent's row), **When** the original agent calls this endpoint again, **Then** that ticket no
|
||||
longer appears.
|
||||
4. **Given** a `User` session with no linked `Agent` row at all (User Story 1 never completed
|
||||
for this account), **When** that session calls this endpoint, **Then** the response is a
|
||||
clear, specific rejection — never a silent empty list that could be mistaken for "no tickets
|
||||
assigned," and never a raw null-reference error.
|
||||
5. **Given** an admin session, **When** they call this endpoint for a specific `agentId`,
|
||||
**Then** the same summary list is returned for that agent — an admin's own use of the
|
||||
endpoint is explicit about which agent it's asking about, unlike an agent's own call, which
|
||||
is always implicitly about themselves.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if an agent has a ticket assigned whose `Problem`/`Product`/`CustomerReference`
|
||||
was deleted (should not happen under normal FK constraints, but the endpoint's own contract
|
||||
should be explicit): every relation this endpoint reads is a required, non-nullable foreign
|
||||
key already enforced by the schema, so this case cannot occur without a prior data-integrity
|
||||
violation elsewhere: not specifically handled here.
|
||||
- What happens if two `Agent` rows somehow both have `isCurrent: true` assignments for the same
|
||||
ticket (should be impossible under 007's own assignment invariant)? This endpoint trusts that
|
||||
invariant rather than re-deriving it — it is 007's own concern, not this feature's.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST let an admin set an `Agent` row's linked `User` (`userId`), MUST
|
||||
reject linking a `User` already linked to a different `Agent`, and MUST reject linking a
|
||||
`User` whose role is not `AGENT`.
|
||||
- **FR-002**: The system MUST let an admin read which `User`, if any, an `Agent` row is linked
|
||||
to (already covered by the existing `GET /admin/agents/:agentId`, which returns the full
|
||||
`Agent` row — this FR only requires `userId` not be excluded from that response).
|
||||
- **FR-003**: The system MUST provide an endpoint that returns every ticket currently assigned
|
||||
(`Assignment.isCurrent: true`) to a given agent, each with customer reference, product,
|
||||
priority, severity, status, and SLA state summarized without a further per-ticket request.
|
||||
- **FR-004**: When called by an agent's own session, the endpoint MUST resolve "which agent" from
|
||||
that session's linked `Agent` row (User Story 1), never from a client-supplied agent ID — an
|
||||
agent can only ever list their own tickets this way.
|
||||
- **FR-005**: When called by an admin session with an explicit `agentId`, the endpoint MUST
|
||||
return that agent's tickets — an admin-only capability for support/oversight purposes.
|
||||
- **FR-006**: The system MUST reject a call from a session with no linked `Agent` row with a
|
||||
specific, distinguishable error — never an empty list.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Agent-User Link**: The (now finally wired) association between a `User` account and the
|
||||
`Agent` roster row it authenticates as, via `Agent.userId`.
|
||||
- **Assigned Ticket Summary**: A read-only projection of a `Ticket` plus its current
|
||||
`Assignment` and (if present) `SLARun`, shaped for list display rather than full detail.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An agent's currently-assigned tickets are retrievable in a single request, with
|
||||
zero additional per-ticket requests needed to populate a dashboard-style summary list.
|
||||
- **SC-002**: 100% of sessions with no linked `Agent` row receive a specific rejection from the
|
||||
new endpoint, never an empty list indistinguishable from "genuinely zero tickets assigned."
|
||||
- **SC-003**: 0% of one agent's currently-assigned tickets are visible to another agent calling
|
||||
the endpoint as themselves.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **This feature does not add a general-purpose ticket search/filter/list endpoint** — only the
|
||||
narrow "tickets currently assigned to a specific agent" query supporthub-web's agent dashboard
|
||||
needs. A broader admin-facing ticket search is explicitly out of scope, deferred until a
|
||||
concrete need names its own filters.
|
||||
- **Linking (User Story 1) is a one-time admin action per agent, not a self-service flow** — an
|
||||
agent does not link their own account; matches 006/010's own existing pattern of admin-managed
|
||||
roster and account provisioning.
|
||||
- **No pagination is included** — an individual agent's currently-assigned ticket count is
|
||||
small enough (bounded by realistic per-agent workload) that a single unpaginated list is
|
||||
sufficient for this feature's scope; revisit if a future feature's data suggests otherwise.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
description: "Task list for 011-agent-ticket-queue"
|
||||
---
|
||||
|
||||
# Tasks: Agent Ticket Queue
|
||||
|
||||
**Input**: Design documents from `specs/011-agent-ticket-queue/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/agent-ticket-queue-contract.md](./contracts/agent-ticket-queue-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 linking, US2 = P1 ticket listing).
|
||||
US2 depends on a helper US1 also needs, so despite being nominally independent, build US1 first.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [x] T001 Add `Assignment @@index([agentId, isCurrent])` to `prisma/schema.prisma`; generate
|
||||
the migration (`prisma migrate diff` → hand-write `migration.sql` → `prisma migrate
|
||||
deploy`, this session's established non-interactive workaround) and run
|
||||
`npm run prisma:generate`
|
||||
|
||||
**Checkpoint**: Index in place. Both user stories can now be built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - An admin links a staff account to its agent roster entry (Priority: P1)
|
||||
|
||||
**Goal**: `Agent.userId` becomes settable through the existing update endpoint, with the
|
||||
rejection rules FR-001 requires.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T002 [P] [US1] Integration test covering Quickstart Scenario 1 (link succeeds; non-AGENT
|
||||
role rejected 400; already-linked-elsewhere rejected 409) in
|
||||
`tests/integration/agent-ticket-queue.test.ts` (depends on T001)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T003 [US1] Add `AgentsRepository.findByUserId(userId)` in
|
||||
`src/modules/identity/agents/repository/agents.repository.ts` — shared by this story's
|
||||
own duplicate-link check and by User Story 2's agent-self route (T010)
|
||||
- [x] T004 [US1] Add `userId: z.string().min(1).nullable().optional()` to `updateAgentSchema` in
|
||||
`src/modules/identity/agents/schema/agents.schema.ts`
|
||||
- [x] T005 [US1] In `AgentsService.update` (`src/modules/identity/agents/service/agents.service.ts`),
|
||||
when `data.userId !== undefined`: if non-null, look up the target `User` (via a small
|
||||
`UsersRepository.findById`) — 404 if missing, reject with a `ValidationError` if its role
|
||||
isn't `AGENT`; look up any `Agent` already linked to that `userId` (T003's
|
||||
`findByUserId`) — `ConflictError` if it's a different agent than `agentId` (depends on
|
||||
T003, T004)
|
||||
- [x] T006 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: An agent's login can now be resolved to its roster row.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - An agent retrieves their own currently-assigned tickets (Priority: P1)
|
||||
|
||||
**Goal**: Both list endpoints return the same summarized projection, correctly scoped per
|
||||
caller.
|
||||
|
||||
**Independent Test**: Quickstart Scenarios 2-3.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T007 [P] [US2] Unit test: given a `User` id with no linked `Agent`, the service throws the
|
||||
specific `NotFoundError` — in `tests/unit/identity/agent-ticket-queue-guard.test.ts`
|
||||
- [x] T008 [US2] Integration test covering Quickstart Scenarios 2-3 (agent sees exactly their
|
||||
own current assignments; list updates after a reassignment; no-linked-agent session gets
|
||||
404 not `[]`; admin route returns the same shape for an explicit `agentId`; non-admin
|
||||
calling the admin route for another agent gets 403) in
|
||||
`tests/integration/agent-ticket-queue.test.ts` (depends on T006)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T009 [US2] Add `TicketsRepository.findAssignedToAgent(agentId)` in
|
||||
`src/modules/ticketing/tickets/repository/tickets.repository.ts` — one query joining
|
||||
current `Assignment` (via orchestration's public repository/service surface) to `Ticket`
|
||||
with `product`/`customer`/`sLARun` relations (depends on T001)
|
||||
- [x] T010 [US2] Add `TicketsService.listAssignedTo(agentId)` mapping each row to the
|
||||
`AssignedTicketSummary` shape (data-model.md) in
|
||||
`src/modules/ticketing/tickets/service/tickets.service.ts` (depends on T009)
|
||||
- [x] T011 [US2] Add `GET /agents/me/tickets` (`fastify.authenticate` only; resolves `agentId`
|
||||
via `agentsService`'s `findByUserId` (T003) against `request.user.id`, throwing the
|
||||
FR-006 `NotFoundError` if none) and `GET /admin/agents/:agentId/tickets`
|
||||
(`fastify.authenticate` + `requireRole('ADMIN')`) in `src/modules/ticketing/tickets/
|
||||
controller/` + `routes/`, registered from `src/api/routes.ts` (depends on T003, T010)
|
||||
- [x] T012 [US2] Run Quickstart Scenarios 2-3 locally and confirm all steps pass
|
||||
|
||||
**Checkpoint**: supporthub-web's agent dashboard now has a real data source.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T013 [P] Update `specs/011-agent-ticket-queue/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T014 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T015 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — BLOCKS both user stories
|
||||
- **User Story 1 (Phase 2)**: Depends on Foundational
|
||||
- **User Story 2 (Phase 3)**: Depends on Foundational and on T003 (built in Phase 2) — build
|
||||
Phase 2 before Phase 3 despite the two stories being otherwise independent
|
||||
- **Polish (Phase 4)**: Depends on both user stories
|
||||
@@ -0,0 +1,60 @@
|
||||
# Specification Quality Checklist: Admin List Views
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Discovered the same way 011-agent-ticket-queue was: while building supporthub-web's
|
||||
001-agent-admin-ui (User Stories 6 and 7 this time), a research pass over supporthub-api's
|
||||
actual endpoints found no cross-ticket SLA-run or escalation-event listing at all, and no
|
||||
products-with-integration-status endpoint — three separate but same-shaped gaps (an existing
|
||||
domain's data, never exposed as a list/join query), bundled into one feature rather than three
|
||||
separate ones since none is large enough to justify its own spec.
|
||||
- Deliberately narrow: read-only, no new persisted entity, no general search/filter API beyond
|
||||
the one filter (`status`) and one cap (`limit`) each list actually needs, per Assumptions.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Implementation-time finding**: research.md's plan.md draft had described the existing
|
||||
single-ticket `GET /tickets/:ticketId/sla-run` as "agent-facing (fastify.authenticate)" — it's
|
||||
actually fully ungated (no preHandler at all). Didn't change this feature's own design
|
||||
(`GET /admin/sla-runs`/`GET /admin/escalation-events` still use `fastify.authenticate`, a
|
||||
deliberately more conservative choice than the existing route, matching spec.md's own
|
||||
"agent-usable" wording), but worth correcting for anyone reading research.md later.
|
||||
- No `SLA_RUN_STATUSES` constant existed anywhere before this feature — `SLARun.status` had
|
||||
only ever been written as free strings across the pause/resume/breach-detection code paths.
|
||||
Centralized it in `orchestration/sla/mapper/sla-run-status.ts` since this feature is the first
|
||||
caller that needs to validate against it, not just write it.
|
||||
- **Follow-up (post-implementation)**: while building supporthub-web's own knowledge-governance
|
||||
screen against this feature's own spirit, found a fourth same-shaped gap this spec's own scope
|
||||
didn't originally name: `GET /knowledge/retrieve` (004-product-knowledge) only ever returns
|
||||
`status: 'published'` entries — a governance screen that needs to see and publish a *draft*
|
||||
entry had no endpoint to list it at all. Added `GET /admin/products/:externalProductId/
|
||||
knowledge` directly to the knowledge module (not this feature's own routes, since it lives
|
||||
where `KnowledgeEntry` itself does) in a small follow-up commit, same spirit as this spec's
|
||||
three original endpoints.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Contract: Admin List Views
|
||||
|
||||
## `GET /admin/sla-runs`
|
||||
|
||||
**Auth**: `fastify.authenticate` only (agent-usable, per spec.md Assumptions).
|
||||
|
||||
**Query**: `status?: 'running' | 'paused' | 'warning' | 'breached' | 'completed'`
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ticketId": "string",
|
||||
"ticketCode": "string",
|
||||
"status": "string",
|
||||
"firstResponseDueAt": "ISO 8601 datetime | null",
|
||||
"resolutionDueAt": "ISO 8601 datetime | null",
|
||||
"breachedAt": "ISO 8601 datetime | null",
|
||||
"firstResponseBreachedAt": "ISO 8601 datetime | null"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response `400`**: an invalid `status` value.
|
||||
|
||||
## `GET /admin/escalation-events`
|
||||
|
||||
**Auth**: `fastify.authenticate` only.
|
||||
|
||||
**Query**: `limit?: number` (1-200, default 50)
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ticketId": "string",
|
||||
"ticketCode": "string",
|
||||
"reason": "string",
|
||||
"ruleId": "string | null",
|
||||
"triggeredBy": "string",
|
||||
"toNodeId": "string | null",
|
||||
"createdAt": "ISO 8601 datetime"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
Ordered most-recent-first (`createdAt desc`).
|
||||
|
||||
## `GET /admin/products`
|
||||
|
||||
**Auth**: `fastify.authenticate` + `requireRole('ADMIN')`.
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "string",
|
||||
"externalProductId": "string",
|
||||
"name": "string",
|
||||
"status": "string",
|
||||
"supportEnabled": true,
|
||||
"integrationStatus": "active | suspended | null"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
`integrationStatus` is `null` when the product has no `ProductIntegration` at all — never
|
||||
defaulted to `"active"` or any other value that could be mistaken for a real integration state.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Data Model: Admin List Views
|
||||
|
||||
No schema changes. Three response-shape projections over existing models.
|
||||
|
||||
## `SlaRunListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `ticketId` | `SLARun.ticketId` |
|
||||
| `ticketCode` | `SLARun.ticket.code` (via `include`) |
|
||||
| `status` | `SLARun.status` |
|
||||
| `firstResponseDueAt` | `SLARun.firstResponseDueAt` |
|
||||
| `resolutionDueAt` | `SLARun.resolutionDueAt` |
|
||||
| `breachedAt` | `SLARun.breachedAt` |
|
||||
| `firstResponseBreachedAt` | `SLARun.firstResponseBreachedAt` |
|
||||
|
||||
## `EscalationEventListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `ticketId` | `EscalationEvent.ticketId` |
|
||||
| `ticketCode` | `EscalationEvent.ticket.code` (via `include`) |
|
||||
| `reason` | `EscalationEvent.reason` |
|
||||
| `ruleId` | `EscalationEvent.ruleId` (null for manual/no-match) |
|
||||
| `triggeredBy` | `EscalationEvent.triggeredBy` |
|
||||
| `toNodeId` | `EscalationEvent.toNodeId` |
|
||||
| `createdAt` | `EscalationEvent.createdAt` |
|
||||
|
||||
## `ProductCatalogListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `id` | `Product.id` |
|
||||
| `externalProductId` | `Product.externalProductId` |
|
||||
| `name` | `Product.name` |
|
||||
| `status` | `Product.status` |
|
||||
| `supportEnabled` | `Product.supportEnabled` |
|
||||
| `integrationStatus` | Derived: `product.integration?.status ?? null` — never the full `ProductIntegration` row (research.md) |
|
||||
|
||||
## Validation / Business Rules
|
||||
|
||||
- `GET /admin/sla-runs?status=` — `status` validated against `SLA_RUN_STATUSES` (`running`,
|
||||
`paused`, `warning`, `breached`, `completed`); omitted means unfiltered.
|
||||
- `GET /admin/escalation-events?limit=` — `limit` coerced, `1..200`, default `50`; ordered by
|
||||
`createdAt desc`.
|
||||
- `GET /admin/products` — no filter; ordered by `name asc` (matches existing catalog list
|
||||
conventions elsewhere in this codebase, e.g. `TeamsRepository.findAll`).
|
||||
@@ -0,0 +1,104 @@
|
||||
# Implementation Plan: Admin List Views
|
||||
|
||||
**Branch**: `012-admin-list-views` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/012-admin-list-views/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Adds three read-only endpoints, each a straightforward `findMany` on an already-existing model
|
||||
plus a small ticket-id/code projection: `GET /admin/sla-runs` (optional `?status=`),
|
||||
`GET /admin/escalation-events` (optional `?limit=`), and `GET /admin/products` (products joined
|
||||
to their integration's status). No new persisted entity, no write capability.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — Prisma only.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma. No schema change — every field already exists; these are
|
||||
projections over `SLARun`, `EscalationEvent`, and `Product`/`ProductIntegration`.
|
||||
|
||||
**Testing**: Vitest — integration tests against real Postgres/Redis for each endpoint's filter/
|
||||
ordering/projection behavior, plus one admin-role-gating check for `GET /admin/products`.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `orchestration/sla` (new route +
|
||||
repository method), `orchestration/escalation` (new route + repository method), and
|
||||
`catalog/products` (new admin route + repository method) — no new module, each list lives in
|
||||
the module that already owns its underlying model.
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: Each list is one indexed/simple query — `SLARun` has no per-status
|
||||
index today (status is a small string column, not indexed), acceptable at this stage per
|
||||
spec.md's own "no general search API" scoping; revisit if a future feature's data volume
|
||||
demands one.
|
||||
|
||||
**Constraints**: FR-004 — read-only, no new write path. The product-catalog list must not leak
|
||||
`ProductIntegration.credentialRef` (encrypted secret) or any other sensitive integration field
|
||||
— only `status` is projected.
|
||||
|
||||
**Scale/Scope**: Three new GET routes across three existing modules, three new repository
|
||||
methods, no new module, no schema migration. Explicitly excludes: pagination (spec.md
|
||||
Assumptions — `limit` only on the escalation-event list), and any filter beyond `status`/`limit`.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle / Section | Check | Result |
|
||||
|---|---|---|
|
||||
| I. SaaS Is the Sole Identity & Access Authority | Purely internal SupportHub domain (SLA/escalation/product-catalog monitoring) — no SaaS/customer identity involved. | PASS — N/A |
|
||||
| II. Configuration Over Hardcoding | No new configurable values. | PASS — N/A |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | Each list lives in the module that already owns its model (`orchestration/sla`, `orchestration/escalation`, `catalog/products`) — no cross-module reach-through; the ticket id/code projection reads `ticketsRepository`'s own public surface via `ticketing/tickets`'s existing `index.ts`. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||||
| VI. Durable Audit & History | Not applicable — no new mutable state. | PASS — N/A |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Read-only queries; no concurrency concern. | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A |
|
||||
| Technology & Platform Constraints | No new dependencies or infrastructure. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/012-admin-list-views/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
└── src/
|
||||
└── modules/
|
||||
├── orchestration/
|
||||
│ ├── sla/ # MODIFIED — GET /admin/sla-runs
|
||||
│ │ ├── controller/ routes/
|
||||
│ │ └── repository/ (new findAll(status?) method)
|
||||
│ └── escalation/ # MODIFIED — GET /admin/escalation-events
|
||||
│ ├── controller/ routes/
|
||||
│ └── repository/ (new findRecent(limit?) method)
|
||||
└── catalog/
|
||||
└── products/ # MODIFIED — GET /admin/products
|
||||
├── controller/ routes/
|
||||
└── repository/ (new findAllWithIntegrationStatus() method)
|
||||
└── tests/
|
||||
└── integration/ # one new test file per endpoint's own scenarios
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module — each endpoint extends the module that
|
||||
already owns its underlying data, matching 011-agent-ticket-queue's own precedent.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,27 @@
|
||||
# Quickstart: Validating Admin List Views
|
||||
|
||||
## Scenario 1 — SLA runs across tickets
|
||||
|
||||
1. With SLA runs in `running`, `paused`, and `breached` states across three tickets, call
|
||||
`GET /admin/sla-runs` as any authenticated agent. **Expected**: `200`, all three, each with
|
||||
`ticketId`/`ticketCode` populated.
|
||||
2. Repeat with `?status=breached`. **Expected**: only the breached run.
|
||||
3. Repeat with `?status=not-a-real-status`. **Expected**: `400`.
|
||||
|
||||
## Scenario 2 — recent escalation events across tickets
|
||||
|
||||
1. With one automatic and one manual escalation event recorded on two different tickets, call
|
||||
`GET /admin/escalation-events`. **Expected**: `200`, both, most-recent-first, the automatic
|
||||
one showing its `ruleId` and the manual one showing `ruleId: null` and its `triggeredBy`.
|
||||
|
||||
## Scenario 3 — product catalog with integration status
|
||||
|
||||
1. With one product that has an active integration and one with no integration at all, call
|
||||
`GET /admin/products` as an admin. **Expected**: `200`, the first shows
|
||||
`integrationStatus: "active"`, the second shows `integrationStatus: null`.
|
||||
2. Repeat as a non-admin agent. **Expected**: `403`.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All three scenarios pass against a real Postgres/Redis, and none of the three endpoints leaks
|
||||
`ProductIntegration.credentialRef` or any other integration-internal field.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Research: Admin List Views
|
||||
|
||||
## Decision: project ticket id/code via a second query, not a raw join
|
||||
|
||||
- **Decision**: Each repository method fetches its own rows (`SLARun[]`/`EscalationEvent[]`)
|
||||
with Prisma's own `include: { ticket: { select: { id: true, code: true } } }` — a single
|
||||
Prisma query using the existing `ticket` relation already on both models, not a hand-written
|
||||
SQL join or a second round-trip.
|
||||
- **Rationale**: Both `SLARun` and `EscalationEvent` already have a `ticket` relation
|
||||
(`@relation(fields: [ticketId], references: [id])`) — Prisma's `include` turns this into one
|
||||
query, not N+1, and needs no new repository dependency on `ticketsRepository`.
|
||||
- **Alternatives considered**: A second batched `ticketsRepository.findByIds(...)` call — works,
|
||||
but `include` is simpler and already idiomatic in this codebase's own repositories (e.g.
|
||||
011-agent-ticket-queue's `findAssignedToAgent`).
|
||||
|
||||
## Decision: `status` filter on `GET /admin/sla-runs` is validated against `SLA_RUN_STATUSES`
|
||||
|
||||
- **Decision**: `status` is an optional query param validated with
|
||||
`z.enum(['running', 'paused', 'warning', 'breached', 'completed']).optional()` — the same
|
||||
status vocabulary `SLARun.status` already uses (008-sla-escalation).
|
||||
- **Rationale**: A typo'd status silently returning zero rows (if left as a free string) would
|
||||
be a confusing, silent failure mode for a monitoring view; validating it up front makes an
|
||||
invalid filter a clear `400`, matching this codebase's existing "resolve/validate first, then
|
||||
act" convention (e.g. 011's proactive existence checks).
|
||||
- **Alternatives considered**: A free-text `z.string().optional()` — rejected for the silent-
|
||||
wrong-filter risk above.
|
||||
|
||||
## Decision: `GET /admin/escalation-events` defaults to `limit=50`, capped at `200`
|
||||
|
||||
- **Decision**: `limit` is `z.coerce.number().int().positive().max(200).default(50)`.
|
||||
- **Rationale**: Unlike `SLARun` (bounded by currently-open tickets) or `Product` (bounded by
|
||||
catalog size), `EscalationEvent` rows only ever accumulate — an unbounded list would grow
|
||||
without limit. A sane default plus a hard ceiling avoids both an accidentally-enormous
|
||||
response and a caller needing to know to always pass one.
|
||||
- **Alternatives considered**: True cursor-based pagination — rejected as more than this
|
||||
feature's own scope calls for (spec.md Assumptions); a capped `limit` is enough for a
|
||||
"recent escalations" monitoring view.
|
||||
|
||||
## Decision: product-catalog integration status is a derived string, not the raw `ProductIntegration` row
|
||||
|
||||
- **Decision**: `GET /admin/products` returns `integrationStatus: 'active' | 'suspended' | null`
|
||||
(`null` when `product.integration` is absent) — never the full `ProductIntegration` object.
|
||||
- **Rationale**: `ProductIntegration.credentialRef` is an encrypted secret at rest
|
||||
(002-saas-integration); even encrypted, there's no reason for a list-view response to include
|
||||
it, or any other integration-internal field (`rateLimitPerMinute`, `allowedScope`, etc.) this
|
||||
screen doesn't render (FR-003's own "constraints" — plan.md).
|
||||
- **Alternatives considered**: Nesting the full `include: { integration: true }` result under
|
||||
the product — rejected; a derived, minimal field is both simpler for the frontend and doesn't
|
||||
require re-auditing every future `ProductIntegration` field addition for accidental exposure
|
||||
through a public-adjacent list view (this route is admin-only, but the same discipline this
|
||||
codebase already applies to `AssignedTicketSummary`'s own minimal projection applies here too).
|
||||
|
||||
## Decision: `GET /admin/products` is a new admin route, not an extension of the existing public `GET /products`
|
||||
|
||||
- **Decision**: A separate route rather than adding an optional `includeIntegrationStatus` query
|
||||
param to the existing public, ungated `GET /products`.
|
||||
- **Rationale**: `GET /products` is intentionally public (spec.md Assumptions of
|
||||
002-saas-integration's own catalog read); layering an admin-only field onto a public route
|
||||
via a query flag would make that route's own auth requirement conditional on which fields
|
||||
were requested — a confusing, easy-to-get-wrong pattern. A separate `requireRole('ADMIN')`
|
||||
route keeps the gate unconditional and obvious.
|
||||
- **Alternatives considered**: The query-flag approach above — rejected for the reason stated.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Feature Specification: Admin List Views
|
||||
|
||||
**Feature Branch**: `012-admin-list-views`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Add missing read-only list endpoints supporthub-web's admin
|
||||
monitoring and catalog screens need: SLA runs across tickets, recent escalation events across
|
||||
tickets, and products with their integration status, none of which exist as a single query
|
||||
today."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An agent or admin sees SLA status across every ticket at a glance (Priority: P1)
|
||||
|
||||
Rather than checking one ticket's SLA state at a time, an agent or admin retrieves a list of
|
||||
every ticket's current SLA run, filterable by status (running/paused/warning/breached), each
|
||||
entry carrying enough to identify and link to its ticket.
|
||||
|
||||
**Why this priority**: This is the entire reason this feature exists — supporthub-web's own
|
||||
001-agent-admin-ui, User Story 6, has no data source for its SLA monitor view without it, and
|
||||
no endpoint in the SLA module answers "every ticket's SLA state," only one ticket's own.
|
||||
|
||||
**Independent Test**: With SLA runs in different states across several tickets, call this
|
||||
endpoint unfiltered and confirm every run appears; call it filtered by `status=breached` and
|
||||
confirm only breached runs appear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tickets with SLA runs in running, paused, and breached states, **When** the
|
||||
endpoint is called with no filter, **Then** every run is returned, each including its
|
||||
ticket's id and code, status, and due/breached timestamps.
|
||||
2. **Given** the same tickets, **When** the endpoint is called with `status=breached`, **Then**
|
||||
only the breached runs are returned.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An agent or admin sees recent escalation events across every ticket (Priority: P1)
|
||||
|
||||
An agent or admin retrieves a list of recent escalation events across all tickets — each
|
||||
showing the triggering reason, the rule that fired it (if automatic) or the actor who triggered
|
||||
it (if manual), and the resulting target hierarchy node.
|
||||
|
||||
**Why this priority**: The same 001-agent-admin-ui User Story 6 has no data source for its
|
||||
escalation matrix view without it — today the only way to see an escalation event at all is
|
||||
`EscalationEventRepository.findAllForTicket`, which requires already knowing which ticket to
|
||||
ask about.
|
||||
|
||||
**Independent Test**: With escalation events (both automatic and manual) recorded across
|
||||
several tickets, call this endpoint and confirm every event appears, most recent first, each
|
||||
identifying its ticket, reason, rule-or-actor, and target node.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** three tickets each with one escalation event, **When** the endpoint is called,
|
||||
**Then** all three appear, ordered most-recent-first, each including its ticket id/code,
|
||||
reason, `ruleId` (or null for manual), `triggeredBy`, and `toNodeId`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An admin views the product catalog with integration status (Priority: P2)
|
||||
|
||||
An admin retrieves the product catalog with each product's integration status
|
||||
(active/suspended) visible directly in the list, rather than needing a second lookup per
|
||||
product.
|
||||
|
||||
**Why this priority**: Lower than User Stories 1-2 (matches 001-agent-admin-ui's own User Story
|
||||
7 being P3) — the product catalog changes far less often than SLA/escalation state, but its own
|
||||
consuming frontend story still has no single query to build a list screen against: the existing
|
||||
public `GET /products` doesn't include `ProductIntegration`, and integration status is only
|
||||
otherwise reachable per-integration-id, not per-product.
|
||||
|
||||
**Independent Test**: With two products, one with an active integration and one with a
|
||||
suspended integration, call this endpoint and confirm each product's own integration status is
|
||||
present without a further request.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a product with an active integration and one with a suspended integration, **When**
|
||||
an admin calls this endpoint, **Then** both appear with their correct integration status;
|
||||
a product with no integration at all shows a clearly-absent (not misleadingly "active")
|
||||
status.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens to a ticket whose SLA run was already marked `completed` (ticket resolved)? It
|
||||
still appears in the unfiltered SLA-run list (this is a monitoring view of everything that
|
||||
exists, not just "currently at risk") but is excluded by a `status=breached`/`running`/etc.
|
||||
filter unless it matches.
|
||||
- What happens for a ticket with no SLA run at all (no matching policy, or the run hasn't been
|
||||
created yet)? It simply doesn't appear in this list — this endpoint lists existing `SLARun`
|
||||
rows, it does not synthesize one for every ticket.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide an endpoint listing every `SLARun`, each including its
|
||||
owning ticket's id and code, optionally filtered by `status`.
|
||||
- **FR-002**: The system MUST provide an endpoint listing recent `EscalationEvent` rows across
|
||||
all tickets, most-recent-first, each including its owning ticket's id and code.
|
||||
- **FR-003**: The system MUST provide an endpoint listing the product catalog with each
|
||||
product's integration status included, distinguishing "has an active integration," "has a
|
||||
suspended integration," and "has no integration at all."
|
||||
- **FR-004**: All three endpoints are read-only (no new write capability) and reuse existing
|
||||
`SLARun`/`EscalationEvent`/`Product`/`ProductIntegration` data — no new persisted entity.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **SLA Run List Item**: An `SLARun` projected with its ticket's `id`/`code` alongside its own
|
||||
existing fields.
|
||||
- **Escalation Event List Item**: An `EscalationEvent` projected with its ticket's `id`/`code`
|
||||
alongside its own existing fields.
|
||||
- **Product Catalog List Item**: A `Product` projected with its integration's `status`, or an
|
||||
explicit absence marker if it has none.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Every ticket's SLA state is retrievable in a single request, filterable by status,
|
||||
with zero additional per-ticket requests needed.
|
||||
- **SC-002**: Recent escalation events across every ticket are retrievable in a single request.
|
||||
- **SC-003**: The product catalog with integration status is retrievable in a single request,
|
||||
with 0% of products showing a misleading status when they have no integration at all.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **No pagination on the SLA-run or product-catalog lists** — matches 011-agent-ticket-queue's
|
||||
own precedent (bounded, realistic data volumes for this stage); the escalation-event list
|
||||
DOES cap at a default/maximum `limit` (most-recent-first), since that list only ever grows
|
||||
and has no other natural bound.
|
||||
- **These are read-only monitoring/catalog views, not a general search/filter API** — the SLA
|
||||
list's only filter is `status`; no additional filters (date range, product, priority) are
|
||||
added speculatively beyond what 001-agent-admin-ui's own User Story 6 spec asks for.
|
||||
- **Auth**: SLA-run and escalation-event lists are agent-usable (`fastify.authenticate` only,
|
||||
matching the existing single-ticket `GET /tickets/:id/sla-run`'s own agent-facing nature and
|
||||
001-agent-admin-ui's "agents and admins" wording for User Story 6); the product-catalog list
|
||||
is admin-only (`requireRole('ADMIN')`), matching every other admin-configuration read in this
|
||||
codebase.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
description: "Task list for 012-admin-list-views"
|
||||
---
|
||||
|
||||
# Tasks: Admin List Views
|
||||
|
||||
**Input**: Design documents from `specs/012-admin-list-views/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/admin-list-views-contract.md](./contracts/admin-list-views-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 SLA runs, US2 = P1 escalation
|
||||
events, US3 = P2 product catalog). All three are independent of each other.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: User Story 1 - SLA runs across every ticket (Priority: P1)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
- [x] T001 [P] [US1] Add `SLARunRepository.findAll(status?)` in
|
||||
`src/modules/orchestration/sla/repository/sla-run.repository.ts` — `include: { ticket:
|
||||
{ select: { id: true, code: true } } }`, optional `where: { status }`
|
||||
- [x] T002 [US1] Add `SLAService.listAll(status?)` (or directly on the controller if no service
|
||||
method is warranted — check existing pattern) validating `status` against
|
||||
`SLA_RUN_STATUSES` (400 on an invalid value) in
|
||||
`src/modules/orchestration/sla/service/sla.service.ts` (depends on T001)
|
||||
- [x] T003 [US1] Add `GET /admin/sla-runs` (`fastify.authenticate` only) in
|
||||
`src/modules/orchestration/sla/controller/` + `routes/`, projecting each row to
|
||||
`SlaRunListItem` (data-model.md) (depends on T002)
|
||||
- [x] T004 [US1] Integration test covering Quickstart Scenario 1 (unfiltered returns all;
|
||||
`status=breached` filters correctly; an invalid status is 400) in
|
||||
`tests/integration/admin-list-views.test.ts`
|
||||
- [x] T005 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 2 - Recent escalation events across every ticket (Priority: P1)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
- [x] T006 [P] [US2] Add `EscalationEventRepository.findRecent(limit)` in
|
||||
`src/modules/orchestration/escalation/repository/escalation-event.repository.ts` —
|
||||
`include: { ticket: { select: { id: true, code: true } } }`, `orderBy: { createdAt:
|
||||
'desc' }`, `take: limit`
|
||||
- [x] T007 [US2] Add `GET /admin/escalation-events` (`fastify.authenticate` only, `limit` query
|
||||
param `z.coerce.number().int().positive().max(200).default(50)`) in
|
||||
`src/modules/orchestration/escalation/controller/` + `routes/`, projecting to
|
||||
`EscalationEventListItem` (depends on T006)
|
||||
- [x] T008 [US2] Integration test covering Quickstart Scenario 2 (both events appear, most-
|
||||
recent-first, automatic vs manual distinguished by `ruleId`) in
|
||||
`tests/integration/admin-list-views.test.ts` (same file as T004)
|
||||
- [x] T009 [US2] Run Quickstart Scenario 2 locally and confirm it passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 3 - Product catalog with integration status (Priority: P2)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
- [x] T010 [P] [US3] Add `ProductsRepository.findAllWithIntegrationStatus()` in
|
||||
`src/modules/catalog/products/repository/products.repository.ts` — `include: {
|
||||
integration: { select: { status: true } } }`, `orderBy: { name: 'asc' }`
|
||||
- [x] T011 [US3] Add `GET /admin/products` (`fastify.authenticate` + `requireRole('ADMIN')`) in
|
||||
`src/modules/catalog/products/controller/` + `routes/`, projecting each row to
|
||||
`ProductCatalogListItem` (`integrationStatus: product.integration?.status ?? null` —
|
||||
never the full `ProductIntegration` row, research.md) (depends on T010)
|
||||
- [x] T012 [US3] Integration test covering Quickstart Scenario 3 (active + no-integration
|
||||
products both correct; non-admin gets 403) in `tests/integration/admin-list-views.test.ts`
|
||||
(same file as T004/T008)
|
||||
- [x] T013 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T014 [P] Update `specs/012-admin-list-views/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T015 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T016 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **User Stories 1-3**: Fully independent of each other and of any Foundational phase (no shared
|
||||
prerequisite beyond the existing schema) — parallelizable in any order
|
||||
- **Polish (Phase 4)**: Depends on all three user stories
|
||||
@@ -23,9 +23,11 @@ import { rootCausesRoutes } from '@/modules/problem-management/root-causes';
|
||||
import { solutionsRoutes } from '@/modules/problem-management/solutions';
|
||||
import { verificationRoutes } from '@/modules/problem-management/verification';
|
||||
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
|
||||
import { authRoutes } from '@/modules/identity/auth';
|
||||
|
||||
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(metricsRoutes);
|
||||
await app.register(productsRoutes);
|
||||
await app.register(inboundRequestRoutes);
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface JwtPayload {
|
||||
email: string;
|
||||
role: string;
|
||||
actorType: ActorType;
|
||||
jti: string; // revocation-denylist key — see specs/010-identity-auth/research.md
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { env } from './env';
|
||||
|
||||
export const authConfig = {
|
||||
jwtSecret: env.JWT_SECRET,
|
||||
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
|
||||
};
|
||||
@@ -60,6 +60,11 @@ const envSchema = z.object({
|
||||
// explicit customer confirmation before the auto-close sweep resolves it — see
|
||||
// specs/009-problem-resolution/research.md "auto-close waiting period".
|
||||
RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72),
|
||||
|
||||
// Identity and Authentication (010) — token lifetime; signing itself reuses the existing,
|
||||
// already-required JWT_SECRET above (defined since the original scaffold, never consumed
|
||||
// until now) — see specs/010-identity-auth/research.md.
|
||||
AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from './storage';
|
||||
export * from './ai';
|
||||
export * from './orchestration';
|
||||
export * from './problem-resolution';
|
||||
export * from './auth';
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { cacheService } from './cache.service';
|
||||
|
||||
const REVOKED_KEY_PREFIX = 'auth:revoked:';
|
||||
|
||||
/**
|
||||
* Explicit-logout revocation for staff session tokens (specs/010-identity-auth/research.md
|
||||
* "Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism"). A jti is
|
||||
* denylisted only until its own token would have expired anyway, so the set never grows
|
||||
* unbounded — the same shape as replay-guard.ts's hasSeenJti/markJtiSeen.
|
||||
*/
|
||||
export async function isTokenRevoked(jti: string): Promise<boolean> {
|
||||
return cacheService.exists(`${REVOKED_KEY_PREFIX}${jti}`);
|
||||
}
|
||||
|
||||
export async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
|
||||
await cacheService.set(`${REVOKED_KEY_PREFIX}${jti}`, '1', ttlSeconds);
|
||||
}
|
||||
Vendored
+1
@@ -2,3 +2,4 @@ export * from './redis.client';
|
||||
export * from './cache.service';
|
||||
export * from './replay-guard';
|
||||
export * from './rate-limiter';
|
||||
export * from './auth-revocation';
|
||||
|
||||
@@ -19,7 +19,12 @@ export function registerSlaWorker(): void {
|
||||
|
||||
void queueManager.getQueue(QueueName.SLA).add(
|
||||
'detect-breaches',
|
||||
{ jobId: 'detect-breaches', type: 'detect-breaches', payload: {}, createdAt: new Date().toISOString() },
|
||||
{
|
||||
jobId: 'detect-breaches',
|
||||
type: 'detect-breaches',
|
||||
payload: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{ repeat: { every: BREACH_DETECTION_INTERVAL_MS } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ export class KnowledgeController {
|
||||
return reply.status(201).send({ success: true, data: entry, meta: null });
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: the governance screen's own data source (every status,
|
||||
* unlike GET /knowledge/retrieve which is published-only). */
|
||||
async listForGovernance(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { externalProductId } = request.params as { externalProductId: string };
|
||||
const productId = await resolveProductId(externalProductId);
|
||||
const entries = await this.service.listForGovernance(productId);
|
||||
return reply.status(200).send({ success: true, data: entries, meta: null });
|
||||
}
|
||||
|
||||
async publish(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { code } = request.params as { code: string };
|
||||
const { effectiveDate } = publishKnowledgeEntrySchema.parse(request.body ?? {});
|
||||
|
||||
@@ -121,6 +121,16 @@ export class KnowledgeRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: every current-version entry for a product, any status —
|
||||
* `retrieve` below only ever returns `published` entries (AI-consumption path), so the
|
||||
* governance screen (which must see drafts to publish them) needs its own query. */
|
||||
async findAllForProduct(productId: string): Promise<KnowledgeEntry[]> {
|
||||
return this.prisma.knowledgeEntry.findMany({
|
||||
where: { productId, isCurrentVersion: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** research.md "Retrieval — structured filtering": filters apply before any ranking; ranking
|
||||
* is validated-first, then most-recently-effective. */
|
||||
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { knowledgeController, errorCodesController, runbooksController } from '../controller';
|
||||
|
||||
/**
|
||||
* Admin routes gated by fastify.authenticate (research.md — known limitation inherited from
|
||||
* 002/003). /knowledge/retrieve is intentionally NOT gated — it's a read path the future
|
||||
* AI-support feature will call, not an admin surface (research.md "Admin endpoint
|
||||
* authentication").
|
||||
* Admin write routes gated by fastify.authenticate + requireRole('ADMIN'), now real
|
||||
* (010-identity-auth). Reads stay agent-usable (fastify.authenticate only).
|
||||
* /knowledge/retrieve is intentionally NOT gated — it's a read path the future AI-support
|
||||
* feature will call, not an admin surface (research.md "Admin endpoint authentication").
|
||||
*/
|
||||
export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post(
|
||||
'/admin/products/:externalProductId/knowledge',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => knowledgeController.create(req, reply),
|
||||
);
|
||||
// 012-admin-list-views follow-up: the governance screen's own data source (every status).
|
||||
fastify.get(
|
||||
'/admin/products/:externalProductId/knowledge',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => knowledgeController.listForGovernance(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/knowledge/:code/publish',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => knowledgeController.publish(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/knowledge/:code/unpublish',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => knowledgeController.unpublish(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/knowledge/:code/validate',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => knowledgeController.validate(req, reply),
|
||||
);
|
||||
fastify.put('/admin/knowledge/:code', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
knowledgeController.edit(req, reply),
|
||||
fastify.put(
|
||||
'/admin/knowledge/:code',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => knowledgeController.edit(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/knowledge/:code/versions',
|
||||
@@ -39,12 +48,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
|
||||
fastify.post(
|
||||
'/admin/products/:externalProductId/error-codes',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => errorCodesController.createErrorCode(req, reply),
|
||||
);
|
||||
fastify.post(
|
||||
'/admin/products/:externalProductId/known-issues',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => errorCodesController.createKnownIssue(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
@@ -55,7 +64,7 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
|
||||
fastify.post(
|
||||
'/admin/products/:externalProductId/runbooks',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => runbooksController.create(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
@@ -65,12 +74,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
);
|
||||
fastify.put(
|
||||
'/admin/products/:externalProductId/runbooks/:key',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => runbooksController.edit(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/products/:externalProductId/runbooks/:key/deactivate',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => runbooksController.deactivate(req, reply),
|
||||
);
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ export class KnowledgeService {
|
||||
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
||||
return this.repo.retrieve(filters);
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: every entry for a product, any status — the governance
|
||||
* screen's own data source (unlike `retrieve`, which is published-only). */
|
||||
async listForGovernance(productId: string): Promise<KnowledgeEntry[]> {
|
||||
return this.repo.findAllForProduct(productId);
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeService = new KnowledgeService();
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { confidencePolicyController, sessionController } from '../controller';
|
||||
|
||||
/**
|
||||
* contracts/ai-support-contract.md: admin confidence-policy routes gated by
|
||||
* fastify.authenticate (known limitation inherited from 002/003/004). Session-turn routes are
|
||||
* not admin routes — called by the ticket-owning caller, same as 003-ticketing's
|
||||
* contracts/ai-support-contract.md: the admin confidence-policy write route is gated by
|
||||
* fastify.authenticate + requireRole('ADMIN'), now real (010-identity-auth). Session-turn
|
||||
* routes are not admin routes — called by the ticket-owning caller, same as 003-ticketing's
|
||||
* POST/GET .../messages, and carry no additional gate of their own in this feature.
|
||||
*/
|
||||
export async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.put(
|
||||
'/admin/products/:externalProductId/ai-policy',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => confidencePolicyController.upsert(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
|
||||
@@ -12,6 +12,21 @@ export class ProductsController {
|
||||
meta: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: admin catalog screen — never returns the full ProductIntegration
|
||||
* row, only its derived status (research.md). */
|
||||
async getProductsWithIntegrationStatus(_request: FastifyRequest, reply: FastifyReply) {
|
||||
const products = await this.service.listWithIntegrationStatus();
|
||||
const data = products.map((product) => ({
|
||||
id: product.id,
|
||||
externalProductId: product.externalProductId,
|
||||
name: product.name,
|
||||
status: product.status,
|
||||
supportEnabled: product.supportEnabled,
|
||||
integrationStatus: product.integration?.status ?? null,
|
||||
}));
|
||||
return reply.status(200).send({ success: true, data, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
export const productsController = new ProductsController();
|
||||
|
||||
@@ -8,6 +8,17 @@ export class ProductsRepository {
|
||||
return this.prisma.product.findMany();
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: the product catalog with each product's integration status joined
|
||||
* in — never the full ProductIntegration row (its credentialRef is a secret at rest). */
|
||||
async findAllWithIntegrationStatus(): Promise<
|
||||
(Product & { integration: { status: string } | null })[]
|
||||
> {
|
||||
return this.prisma.product.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { integration: { select: { status: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findByExternalProductId(externalProductId: string): Promise<Product | null> {
|
||||
return this.prisma.product.findUnique({ where: { externalProductId } });
|
||||
}
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { productIntegrationsController } from '../controller';
|
||||
|
||||
/**
|
||||
* Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail).
|
||||
* Gated by the existing human/admin JWT plugin (fastify.authenticate) — see
|
||||
* specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle
|
||||
* Endpoints" for the known limitation that this decorator doesn't perform real verification yet.
|
||||
* Gated by fastify.authenticate + requireRole('ADMIN') — real as of 010-identity-auth (see
|
||||
* specs/002-saas-integration/contracts/inbound-request-contract.md for the now-resolved known
|
||||
* limitation this decorator previously didn't perform real verification).
|
||||
*/
|
||||
export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post(
|
||||
'/admin/products/:externalProductId/integration',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => productIntegrationsController.register(req, reply),
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/admin/integrations/:integrationId/rotate',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => productIntegrationsController.rotate(req, reply),
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/admin/integrations/:integrationId/revoke',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => productIntegrationsController.revoke(req, reply),
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
'/admin/integrations/:integrationId/status',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => productIntegrationsController.updateStatus(req, reply),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { productsController } from '../controller';
|
||||
|
||||
export async function productsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/products', (req, reply) => productsController.getProducts(req, reply));
|
||||
|
||||
// 012-admin-list-views: admin-only — a separate route rather than a query flag on the public
|
||||
// /products above, so the auth gate stays unconditional (research.md).
|
||||
fastify.get(
|
||||
'/admin/products',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => productsController.getProductsWithIntegrationStatus(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ export class ProductsService {
|
||||
async listProducts(): Promise<unknown[]> {
|
||||
return this.repo.findAllProducts();
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: the product catalog with integration status, for the admin catalog
|
||||
* screen. */
|
||||
async listWithIntegrationStatus() {
|
||||
return this.repo.findAllWithIntegrationStatus();
|
||||
}
|
||||
}
|
||||
|
||||
export const productsService = new ProductsService();
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
AgentSkillsService,
|
||||
agentAvailabilityService,
|
||||
AgentAvailabilityService,
|
||||
usersService,
|
||||
UsersService,
|
||||
} from '../service';
|
||||
import {
|
||||
createAgentSchema,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
listAgentsQuerySchema,
|
||||
upsertAgentSkillSchema,
|
||||
upsertAgentAvailabilitySchema,
|
||||
createUserSchema,
|
||||
} from '../schema';
|
||||
|
||||
export class AgentsController {
|
||||
@@ -20,6 +23,7 @@ export class AgentsController {
|
||||
private readonly service: AgentsService = agentsService,
|
||||
private readonly skills: AgentSkillsService = agentSkillsService,
|
||||
private readonly availability: AgentAvailabilityService = agentAvailabilityService,
|
||||
private readonly users: UsersService = usersService,
|
||||
) {}
|
||||
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
@@ -73,6 +77,13 @@ export class AgentsController {
|
||||
const record = await this.availability.getForAgent(agentId);
|
||||
return reply.status(200).send({ success: true, data: record, meta: null });
|
||||
}
|
||||
|
||||
/** 010-identity-auth User Story 4: admin-only account creation. */
|
||||
async createUser(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = createUserSchema.parse(request.body);
|
||||
const user = await this.users.create(body);
|
||||
return reply.status(201).send({ success: true, data: user, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
export const agentsController = new AgentsController();
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface UpdateAgentData {
|
||||
name?: string | undefined;
|
||||
teamId?: string | undefined;
|
||||
active?: boolean | undefined;
|
||||
userId?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface FindAgentsFilter {
|
||||
@@ -44,6 +45,11 @@ export class AgentsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 011-agent-ticket-queue: resolves a logged-in session to its agent roster row. */
|
||||
async findByUserId(userId: string): Promise<Agent | null> {
|
||||
return this.prisma.agent.findUnique({ where: { userId } });
|
||||
}
|
||||
|
||||
async findAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
||||
return this.prisma.agent.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './agents.repository';
|
||||
export * from './agent-skills.repository';
|
||||
export * from './agent-availability.repository';
|
||||
export * from './users.repository';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { User } from '@prisma/client';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
|
||||
export interface CreateUserData {
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'ADMIN' | 'AGENT';
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export class UsersRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async create(data: CreateUserData): Promise<User> {
|
||||
return this.prisma.user.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
export const usersRepository = new UsersRepository();
|
||||
@@ -1,9 +1,17 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { agentsController } from '../controller';
|
||||
|
||||
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
|
||||
* (research.md "Admin endpoint authentication"). */
|
||||
/** Admin routes gated by fastify.authenticate — real as of 010-identity-auth (previously a
|
||||
* no-op stub, per that feature's own research.md). `POST /admin/users` additionally requires
|
||||
* the ADMIN role (010's own User Story 4) since account creation is more sensitive than
|
||||
* agent-roster management. */
|
||||
export async function agentsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post(
|
||||
'/admin/users',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => agentsController.createUser(req, reply),
|
||||
);
|
||||
fastify.post('/admin/teams/:teamId/agents', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
agentsController.create(req, reply),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,9 @@ export const updateAgentSchema = z
|
||||
name: z.string().min(1).optional(),
|
||||
teamId: z.string().min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
// 011-agent-ticket-queue: links this agent to the User account it authenticates as.
|
||||
// null explicitly unlinks; omitting the field leaves the existing link unchanged.
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './agents.schema';
|
||||
export * from './agent-skills.schema';
|
||||
export * from './agent-availability.schema';
|
||||
export * from './users.schema';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createUserSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
role: z.enum(['ADMIN', 'AGENT']),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateUserBody = z.infer<typeof createUserSchema>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Agent } from '@prisma/client';
|
||||
import { NotFoundError } from '@/common/errors';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/common/errors';
|
||||
import { teamsRepository } from '@/modules/identity/teams';
|
||||
import {
|
||||
agentsRepository,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CreateAgentData,
|
||||
UpdateAgentData,
|
||||
FindAgentsFilter,
|
||||
usersRepository,
|
||||
} from '../repository';
|
||||
|
||||
export class AgentsService {
|
||||
@@ -26,6 +27,20 @@ export class AgentsService {
|
||||
const team = await teamsRepository.findById(data.teamId);
|
||||
if (!team) throw new NotFoundError('Team not found.');
|
||||
}
|
||||
// 011-agent-ticket-queue FR-001: proactive existence/role/duplicate-link checks, mirroring
|
||||
// UsersService.create's own pre-check style, rather than translating a raw unique-
|
||||
// constraint error after the fact.
|
||||
if (data.userId !== undefined && data.userId !== null) {
|
||||
const user = await usersRepository.findById(data.userId);
|
||||
if (!user) throw new NotFoundError('User not found.');
|
||||
if (user.role !== 'AGENT') {
|
||||
throw new ValidationError('Only a User with role AGENT can be linked to an agent.');
|
||||
}
|
||||
const existingLink = await this.repo.findByUserId(data.userId);
|
||||
if (existingLink && existingLink.id !== agentId) {
|
||||
throw new ConflictError('This account is already linked to a different agent.');
|
||||
}
|
||||
}
|
||||
const updated = await this.repo.update(agentId, data);
|
||||
if (!updated) throw new NotFoundError('Agent not found.');
|
||||
return updated;
|
||||
@@ -37,6 +52,15 @@ export class AgentsService {
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** 011-agent-ticket-queue FR-006: resolves a logged-in session to its own agent roster row,
|
||||
* throwing a specific, distinguishable error rather than letting a caller mistake "no linked
|
||||
* agent" for "an agent with zero results." */
|
||||
async requireAgentForUser(userId: string): Promise<Agent> {
|
||||
const agent = await this.repo.findByUserId(userId);
|
||||
if (!agent) throw new NotFoundError('No agent profile is linked to this account.');
|
||||
return agent;
|
||||
}
|
||||
|
||||
async listAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
||||
return this.repo.findAll(filter);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './agents.service';
|
||||
export * from './agent-skills.service';
|
||||
export * from './agent-availability.service';
|
||||
export * from './users.service';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { User } from '@prisma/client';
|
||||
import { ConflictError } from '@/common/errors';
|
||||
import { hashPassword } from '@/modules/identity/auth';
|
||||
import { usersRepository, UsersRepository } from '../repository';
|
||||
import { CreateUserBody } from '../schema';
|
||||
|
||||
export class UsersService {
|
||||
constructor(private readonly repo: UsersRepository = usersRepository) {}
|
||||
|
||||
/** FR-008: rejects a duplicate email — never a second account silently sharing one. */
|
||||
async create(body: CreateUserBody): Promise<Omit<User, 'passwordHash'>> {
|
||||
const existing = await this.repo.findByEmail(body.email);
|
||||
if (existing) throw new ConflictError('An account with this email already exists.');
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const user = await this.repo.create({
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const usersService = new UsersService();
|
||||
@@ -1,17 +1,33 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AuthenticationError } from '@/common/errors';
|
||||
import { authService, AuthService } from '../service';
|
||||
import { AuthCredentialsInput } from '../types';
|
||||
import { loginSchema } from '../schema';
|
||||
|
||||
function bearerToken(request: FastifyRequest): string {
|
||||
const header = request.headers.authorization;
|
||||
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
||||
}
|
||||
|
||||
export class AuthController {
|
||||
constructor(private readonly service: AuthService = authService) {}
|
||||
|
||||
async handleLogin(request: FastifyRequest<{ Body: AuthCredentialsInput }>, reply: FastifyReply) {
|
||||
const user = await this.service.validateCredentials(request.body);
|
||||
return reply.status(200).send({
|
||||
success: true,
|
||||
data: user,
|
||||
meta: null,
|
||||
});
|
||||
async handleLogin(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = loginSchema.parse(request.body);
|
||||
const result = await this.service.login(body);
|
||||
return reply.status(200).send({ success: true, data: result, meta: null });
|
||||
}
|
||||
|
||||
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
|
||||
// Unreachable in practice: this route is only ever registered behind fastify.authenticate,
|
||||
// which always sets request.user on success.
|
||||
if (!request.user) throw new AuthenticationError('Session is no longer valid.');
|
||||
const user = await this.service.getCurrentUser(request.user.id);
|
||||
return reply.status(200).send({ success: true, data: user, meta: null });
|
||||
}
|
||||
|
||||
async handleLogout(request: FastifyRequest, reply: FastifyReply) {
|
||||
await this.service.logout(bearerToken(request));
|
||||
return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export { authRoutes } from './routes';
|
||||
export { AuthService, authService } from './service';
|
||||
export type { AuthCredentialsInput } from './types';
|
||||
export { requireRole } from './service';
|
||||
export type { LoginBody } from './schema';
|
||||
export type { LoginResult } from './service';
|
||||
export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper';
|
||||
export { AUTH_CONSTANTS } from './constants';
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
export class AuthMapper {
|
||||
static toResponse(user: Record<string, unknown>): Record<string, unknown> {
|
||||
return { ...user };
|
||||
}
|
||||
import { randomUUID } from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AuthUser, JwtPayload } from '@/common/types';
|
||||
import { ActorType } from '@/common/enums';
|
||||
import { authConfig } from '@/config';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
// research.md "byte-identical failure response": compared against when no user is found at
|
||||
// all, so a login's timing/shape never reveals whether the email itself was valid.
|
||||
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', SALT_ROUNDS);
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash ?? DUMMY_HASH);
|
||||
}
|
||||
|
||||
export function signToken(user: { id: string; email: string; role: string }): {
|
||||
token: string;
|
||||
jti: string;
|
||||
expiresAt: Date;
|
||||
} {
|
||||
const jti = randomUUID();
|
||||
const expiresInSeconds = authConfig.tokenLifetimeHours * 3600;
|
||||
const payload: Omit<JwtPayload, 'iat' | 'exp'> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
actorType: ActorType.USER,
|
||||
jti,
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, authConfig.jwtSecret, { expiresIn: expiresInSeconds });
|
||||
return { token, jti, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) };
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload {
|
||||
return jwt.verify(token, authConfig.jwtSecret) as JwtPayload;
|
||||
}
|
||||
|
||||
export function toAuthUser(payload: JwtPayload): AuthUser {
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
actorType: payload.actorType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { User } from '@prisma/client';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
|
||||
export class AuthRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async findByEmail(email: string): Promise<unknown> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
/** FR-002/data-model.md: only an active account can authenticate or stay authenticated. */
|
||||
async findActiveById(id: string): Promise<User | null> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user || !user.active) return null;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { authController } from '../controller';
|
||||
import { AuthCredentialsInput } from '../types';
|
||||
|
||||
export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post('/auth/login', (req: FastifyRequest<{ Body: AuthCredentialsInput }>, reply) =>
|
||||
authController.handleLogin(req, reply),
|
||||
fastify.post('/auth/login', (req, reply) => authController.handleLogin(req, reply));
|
||||
|
||||
fastify.get('/auth/me', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
authController.getCurrentUser(req, reply),
|
||||
);
|
||||
|
||||
fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
authController.handleLogout(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const authCredentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
export const loginSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type LoginBody = z.infer<typeof loginSchema>;
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
import { User } from '@prisma/client';
|
||||
import { AuthenticationError } from '@/common/errors';
|
||||
import { revokeToken } from '@/infrastructure/cache';
|
||||
import { authRepository, AuthRepository } from '../repository';
|
||||
import { AuthCredentialsInput } from '../types';
|
||||
import { verifyPassword, signToken, verifyToken } from '../mapper';
|
||||
import { LoginBody } from '../schema';
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
user: { id: string; email: string; name: string; role: string };
|
||||
}
|
||||
|
||||
function toPublicUser(user: User): LoginResult['user'] {
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(private readonly repo: AuthRepository = authRepository) {}
|
||||
|
||||
async validateCredentials(input: AuthCredentialsInput): Promise<unknown> {
|
||||
return this.repo.findByEmail(input.email);
|
||||
/**
|
||||
* FR-002/SC-003: every failure branch (no such email, inactive account, wrong password)
|
||||
* throws the identical AuthenticationError — bcrypt.compare always runs exactly once,
|
||||
* against a fixed dummy hash when no user is found, so timing never leaks which branch fired.
|
||||
*/
|
||||
async login(body: LoginBody): Promise<LoginResult> {
|
||||
const user = await this.repo.findByEmail(body.email);
|
||||
const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null);
|
||||
|
||||
if (!user || !user.active || !passwordMatches) {
|
||||
throw new AuthenticationError('Invalid email or password.');
|
||||
}
|
||||
|
||||
const { token } = signToken(user);
|
||||
return { token, user: toPublicUser(user) };
|
||||
}
|
||||
|
||||
/** User Story 3: re-validated against current account state, not just the token's claims. */
|
||||
async getCurrentUser(userId: string): Promise<LoginResult['user']> {
|
||||
const user = await this.repo.findActiveById(userId);
|
||||
if (!user) throw new AuthenticationError('Session is no longer valid.');
|
||||
return toPublicUser(user);
|
||||
}
|
||||
|
||||
async logout(token: string): Promise<void> {
|
||||
const payload = verifyToken(token);
|
||||
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
|
||||
await revokeToken(payload.jti, remainingSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './auth.service';
|
||||
export * from './require-role';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AuthorizationError } from '@/common/errors';
|
||||
|
||||
/**
|
||||
* research.md "Role-gating via a requireRole(...roles) preHandler factory": composes with
|
||||
* fastify.authenticate as a second preHandler — `{ preHandler: [fastify.authenticate,
|
||||
* requireRole('ADMIN')] }` — rather than a fixed decorator per role.
|
||||
*/
|
||||
export function requireRole(...allowedRoles: string[]) {
|
||||
return async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||
if (!request.user || !allowedRoles.includes(request.user.role)) {
|
||||
throw new AuthorizationError('You do not have permission to perform this action.');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
export interface AuthCredentialsInput {
|
||||
email: string;
|
||||
}
|
||||
export {};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './auth.types';
|
||||
export type { LoginBody } from '../schema';
|
||||
export type { LoginResult } from '../service';
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { teamsController } from '../controller';
|
||||
|
||||
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
|
||||
* (research.md "Admin endpoint authentication"). */
|
||||
/** Admin routes gated by fastify.authenticate, now real (010-identity-auth) — writes
|
||||
* additionally require the ADMIN role; reads stay agent-usable. */
|
||||
export async function teamsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post('/admin/teams', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
teamsController.create(req, reply),
|
||||
fastify.post(
|
||||
'/admin/teams',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => teamsController.create(req, reply),
|
||||
);
|
||||
fastify.patch('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
teamsController.update(req, reply),
|
||||
fastify.patch(
|
||||
'/admin/teams/:teamId',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => teamsController.update(req, reply),
|
||||
);
|
||||
fastify.get('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
teamsController.getById(req, reply),
|
||||
|
||||
@@ -152,7 +152,13 @@ export class AssignmentEngine {
|
||||
}
|
||||
|
||||
return {
|
||||
assignment: await this.persistAndTransition(ticketId, selected.id, strategyName, actor, reason),
|
||||
assignment: await this.persistAndTransition(
|
||||
ticketId,
|
||||
selected.id,
|
||||
strategyName,
|
||||
actor,
|
||||
reason,
|
||||
),
|
||||
strategy: strategyName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createEscalationRuleSchema,
|
||||
updateEscalationRuleSchema,
|
||||
manualEscalationSchema,
|
||||
listRecentEventsQuerySchema,
|
||||
} from '../schema';
|
||||
|
||||
function actorFrom(request: FastifyRequest): string {
|
||||
@@ -45,6 +46,23 @@ export class EscalationController {
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: recent escalation events across every ticket, for the monitoring
|
||||
* view. */
|
||||
async listRecentEvents(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { limit } = listRecentEventsQuerySchema.parse(request.query);
|
||||
const events = await this.service.listRecentEvents(limit);
|
||||
const data = events.map((event) => ({
|
||||
ticketId: event.ticketId,
|
||||
ticketCode: event.ticket.code,
|
||||
reason: event.reason,
|
||||
ruleId: event.ruleId,
|
||||
triggeredBy: event.triggeredBy,
|
||||
toNodeId: event.toNodeId,
|
||||
createdAt: event.createdAt,
|
||||
}));
|
||||
return reply.status(200).send({ success: true, data, meta: null });
|
||||
}
|
||||
|
||||
async escalateManually(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const body = manualEscalationSchema.parse(request.body);
|
||||
|
||||
@@ -25,6 +25,18 @@ export class EscalationEventRepository {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: recent escalation events across every ticket, most-recent-first —
|
||||
* the monitoring view's own data source. */
|
||||
async findRecent(
|
||||
limit: number,
|
||||
): Promise<(EscalationEvent & { ticket: { id: string; code: string } })[]> {
|
||||
return this.prisma.escalationEvent.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: { ticket: { select: { id: true, code: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const escalationEventRepository = new EscalationEventRepository();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EscalationPolicy, Prisma } from '@prisma/client';
|
||||
import { EscalationPolicy, EscalationRule, Prisma } from '@prisma/client';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
|
||||
export class EscalationPolicyRepository {
|
||||
@@ -17,8 +17,11 @@ export class EscalationPolicyRepository {
|
||||
return this.prisma.escalationPolicy.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findAll(): Promise<EscalationPolicy[]> {
|
||||
return this.prisma.escalationPolicy.findMany();
|
||||
/** 012-admin-list-views follow-up: includes each policy's own rules — GET
|
||||
* /admin/escalation-policies previously returned bare policies with no way to read back
|
||||
* which rules (trigger type + target node) already existed under one. */
|
||||
async findAll(): Promise<(EscalationPolicy & { rules: EscalationRule[] })[]> {
|
||||
return this.prisma.escalationPolicy.findMany({ include: { rules: true } });
|
||||
}
|
||||
|
||||
/** research.md "Escalation policy resolution": prefer a product-specific active policy, fall
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { escalationController } from '../controller';
|
||||
|
||||
/** contracts/sla-escalation-contract.md: every route gated by fastify.authenticate (known
|
||||
* limitation inherited from 002-007). */
|
||||
/** contracts/sla-escalation-contract.md: policy/rule config gated by fastify.authenticate +
|
||||
* requireRole('ADMIN'), now real (010-identity-auth); manual escalation stays agent-usable
|
||||
* (fastify.authenticate only) — it's a ticket-working action, not admin configuration. */
|
||||
export async function escalationRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post(
|
||||
'/admin/escalation-policies',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => escalationController.createPolicy(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/escalation-policies',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => escalationController.listPolicies(req, reply),
|
||||
fastify.get('/admin/escalation-policies', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
escalationController.listPolicies(req, reply),
|
||||
);
|
||||
fastify.post(
|
||||
'/admin/escalation-policies/:id/rules',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => escalationController.createRule(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/escalation-policies/:id/rules/:ruleId',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => escalationController.updateRule(req, reply),
|
||||
);
|
||||
fastify.delete(
|
||||
'/admin/escalation-policies/:id/rules/:ruleId',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => escalationController.deleteRule(req, reply),
|
||||
);
|
||||
fastify.post(
|
||||
'/tickets/:ticketId/escalate',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => escalationController.escalateManually(req, reply),
|
||||
fastify.post('/tickets/:ticketId/escalate', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
escalationController.escalateManually(req, reply),
|
||||
);
|
||||
|
||||
// 012-admin-list-views: recent escalation events across every ticket, for the monitoring
|
||||
// view — agent-usable, not admin-only, per that feature's own spec.md.
|
||||
fastify.get('/admin/escalation-events', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
escalationController.listRecentEvents(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export const manualEscalationSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** 012-admin-list-views: caps the "recent escalation events" monitoring list — see that
|
||||
* feature's own research.md. */
|
||||
export const listRecentEventsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(200).default(50),
|
||||
});
|
||||
|
||||
export type CreateEscalationPolicyBody = z.infer<typeof createEscalationPolicySchema>;
|
||||
export type CreateEscalationRuleBody = z.infer<typeof createEscalationRuleSchema>;
|
||||
export type UpdateEscalationRuleBody = z.infer<typeof updateEscalationRuleSchema>;
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
escalationEventRepository,
|
||||
EscalationEventRepository,
|
||||
} from '../repository';
|
||||
import { CreateEscalationPolicyBody, CreateEscalationRuleBody, UpdateEscalationRuleBody } from '../schema';
|
||||
import {
|
||||
CreateEscalationPolicyBody,
|
||||
CreateEscalationRuleBody,
|
||||
UpdateEscalationRuleBody,
|
||||
} from '../schema';
|
||||
|
||||
export class EscalationService {
|
||||
constructor(
|
||||
@@ -32,14 +36,23 @@ export class EscalationService {
|
||||
* active rule. Records nothing when no policy or no rule matches — the breach itself is
|
||||
* already durably recorded by the caller (SLARun.breachedAt/firstResponseBreachedAt).
|
||||
*/
|
||||
async handleBreach(ticketId: string, triggerType: 'resolution_breach' | 'first_response_breach'): Promise<void> {
|
||||
async handleBreach(
|
||||
ticketId: string,
|
||||
triggerType: 'resolution_breach' | 'first_response_breach',
|
||||
): Promise<void> {
|
||||
const ticket = await ticketsService.getById(ticketId);
|
||||
const policy = await this.policies.findApplicable(ticket.productId);
|
||||
if (!policy) return;
|
||||
|
||||
const matchingRules = await this.rules.findActiveRules(policy.id, triggerType);
|
||||
for (const rule of matchingRules) {
|
||||
await this.fire(ticketId, rule.id, rule.targetNodeId, 'system', `SLA ${triggerType} — rule ${rule.id}`);
|
||||
await this.fire(
|
||||
ticketId,
|
||||
rule.id,
|
||||
rule.targetNodeId,
|
||||
'system',
|
||||
`SLA ${triggerType} — rule ${rule.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +108,12 @@ export class EscalationService {
|
||||
return this.events.findAllForTicket(ticketId);
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: recent escalation events across every ticket, for the monitoring
|
||||
* view. */
|
||||
async listRecentEvents(limit: number) {
|
||||
return this.events.findRecent(limit);
|
||||
}
|
||||
|
||||
async createPolicy(data: CreateEscalationPolicyBody): Promise<EscalationPolicy> {
|
||||
if (data.productId) {
|
||||
const product = await productsRepository.findById(data.productId);
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
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
|
||||
* contracts/support-org-contract.md: admin hierarchy-node writes gated by fastify.authenticate +
|
||||
* requireRole('ADMIN'), now real (010-identity-auth). 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.post(
|
||||
'/admin/hierarchy-nodes',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => hierarchyController.create(req, reply),
|
||||
);
|
||||
fastify.put(
|
||||
'/admin/hierarchy-nodes/:nodeId',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => hierarchyController.update(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/hierarchy-nodes/:nodeId/activate',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => hierarchyController.activate(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/hierarchy-nodes/:nodeId/deactivate',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => hierarchyController.deactivate(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { SLAPolicy } from '@prisma/client';
|
||||
import { businessCalendarsService, BusinessCalendarsService } from '@/modules/platform/business-calendars';
|
||||
import {
|
||||
businessCalendarsService,
|
||||
BusinessCalendarsService,
|
||||
} from '@/modules/platform/business-calendars';
|
||||
|
||||
/**
|
||||
* FR-004: replaces the original naive `createdDate + targetHours` stub — every due date is
|
||||
|
||||
@@ -41,6 +41,22 @@ export class SlaController {
|
||||
const run = await this.service.getRunByTicketId(ticketId);
|
||||
return reply.status(200).send({ success: true, data: run, meta: null });
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: every SLA run across every ticket, for the monitoring view. */
|
||||
async listRuns(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { status } = request.query as { status?: string };
|
||||
const runs = await this.service.listRuns(status);
|
||||
const data = runs.map((run) => ({
|
||||
ticketId: run.ticketId,
|
||||
ticketCode: run.ticket.code,
|
||||
status: run.status,
|
||||
firstResponseDueAt: run.firstResponseDueAt,
|
||||
resolutionDueAt: run.resolutionDueAt,
|
||||
breachedAt: run.breachedAt,
|
||||
firstResponseBreachedAt: run.firstResponseBreachedAt,
|
||||
}));
|
||||
return reply.status(200).send({ success: true, data, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
export const slaController = new SlaController();
|
||||
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export * from './sla-run-status';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 008-sla-escalation's own SLARun.status vocabulary — centralized here since
|
||||
* 012-admin-list-views is the first caller needing to validate against it (previously used only
|
||||
* as free strings written by the pause/resume/breach-detection code paths). */
|
||||
export const SLA_RUN_STATUSES = ['running', 'paused', 'warning', 'breached', 'completed'] as const;
|
||||
|
||||
export type SlaRunStatus = (typeof SLA_RUN_STATUSES)[number];
|
||||
@@ -44,6 +44,15 @@ export class SlaRunRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: every SLA run across every ticket, optionally filtered by status —
|
||||
* the monitoring view's own data source. */
|
||||
async findAll(status?: string): Promise<(SLARun & { ticket: { id: string; code: string } })[]> {
|
||||
return this.prisma.sLARun.findMany({
|
||||
...(status ? { where: { status } } : {}),
|
||||
include: { ticket: { select: { id: true, code: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const slaRunRepository = new SlaRunRepository();
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { slaController } from '../controller';
|
||||
|
||||
/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate; the read
|
||||
* route is not (same "read path any caller can use" convention as 003/007). */
|
||||
/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate +
|
||||
* requireRole('ADMIN'), now real (010-identity-auth); the read route is not (same "read path
|
||||
* any caller can use" convention as 003/007). */
|
||||
export async function slaRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post(
|
||||
'/admin/sla-policies',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => slaController.createPolicy(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/sla-policies',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => slaController.listPolicies(req, reply),
|
||||
fastify.get('/admin/sla-policies', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
slaController.listPolicies(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/sla-policies/:id',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => slaController.getPolicy(req, reply),
|
||||
fastify.get('/admin/sla-policies/:id', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
slaController.getPolicy(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/sla-policies/:id',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => slaController.updatePolicy(req, reply),
|
||||
);
|
||||
fastify.delete(
|
||||
'/admin/sla-policies/:id',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => slaController.deactivatePolicy(req, reply),
|
||||
);
|
||||
fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply));
|
||||
|
||||
// 012-admin-list-views: every SLA run across every ticket, for the monitoring view —
|
||||
// agent-usable (fastify.authenticate only), not admin-only, per that feature's own spec.md.
|
||||
fastify.get('/admin/sla-runs', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
slaController.listRuns(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SLAPolicy, SLARun } from '@prisma/client';
|
||||
import { NotFoundError } from '@/common/errors';
|
||||
import { NotFoundError, ValidationError } from '@/common/errors';
|
||||
import { ticketsService } from '@/modules/ticketing/tickets';
|
||||
import { messagesService } from '@/modules/ticketing/messages';
|
||||
import { escalationService, EscalationService } from '@/modules/orchestration/escalation';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { slaPolicyResolverService, SlaPolicyResolverService } from './sla-policy-resolver.service';
|
||||
import { slaDueDateCalculator, SlaDueDateCalculator } from '../calculators/sla-due-date.calculator';
|
||||
import { CreateSlaPolicyBody, UpdateSlaPolicyBody } from '../schema';
|
||||
import { SLA_RUN_STATUSES, SlaRunStatus } from '../mapper';
|
||||
|
||||
export class SlaService {
|
||||
constructor(
|
||||
@@ -56,6 +57,14 @@ export class SlaService {
|
||||
return run;
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: every SLA run across every ticket, for the monitoring view. */
|
||||
async listRuns(status?: string) {
|
||||
if (status !== undefined && !SLA_RUN_STATUSES.includes(status as SlaRunStatus)) {
|
||||
throw new ValidationError(`Invalid status. Must be one of: ${SLA_RUN_STATUSES.join(', ')}.`);
|
||||
}
|
||||
return this.runs.findAll(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* research.md "SLA-run lifecycle is wired entirely through the existing domain-event bus":
|
||||
* subscribed to TICKET_ASSIGNED. No-ops if the ticket already has a run (SLARun.ticketId
|
||||
|
||||
@@ -99,7 +99,12 @@ export function isWithinWorkingHours(
|
||||
|
||||
const [startHour, startMinute] = window.start.split(':').map(Number);
|
||||
const [endHour, endMinute] = window.end.split(':').map(Number);
|
||||
const windowStart = zoned.set({ hour: startHour, minute: startMinute, second: 0, millisecond: 0 });
|
||||
const windowStart = zoned.set({
|
||||
hour: startHour,
|
||||
minute: startMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
const windowEnd = zoned.set({ hour: endHour, minute: endMinute, second: 0, millisecond: 0 });
|
||||
|
||||
return zoned >= windowStart && zoned < windowEnd;
|
||||
|
||||
@@ -22,7 +22,7 @@ export class BusinessCalendarsController {
|
||||
|
||||
async getById(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { id } = request.params as { id: string };
|
||||
const calendar = await this.service.getById(id);
|
||||
const calendar = await this.service.getByIdWithHolidays(id);
|
||||
return reply.status(200).send({ success: true, data: calendar, meta: null });
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export { BusinessCalendarsController, businessCalendarsController } from './business-calendars.controller';
|
||||
export {
|
||||
BusinessCalendarsController,
|
||||
businessCalendarsController,
|
||||
} from './business-calendars.controller';
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { businessCalendarsController } from '../controller';
|
||||
|
||||
/** contracts/sla-escalation-contract.md: every admin route gated by fastify.authenticate (known
|
||||
* limitation inherited from 002-007). */
|
||||
/** contracts/sla-escalation-contract.md: every admin write route gated by fastify.authenticate +
|
||||
* requireRole('ADMIN'), now real (010-identity-auth). */
|
||||
export async function businessCalendarsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post(
|
||||
'/admin/business-calendars',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => businessCalendarsController.create(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/business-calendars',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => businessCalendarsController.list(req, reply),
|
||||
fastify.get('/admin/business-calendars', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
businessCalendarsController.list(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/business-calendars/:id',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => businessCalendarsController.getById(req, reply),
|
||||
fastify.get('/admin/business-calendars/:id', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
businessCalendarsController.getById(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/business-calendars/:id',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => businessCalendarsController.update(req, reply),
|
||||
);
|
||||
fastify.post(
|
||||
'/admin/business-calendars/:id/holidays',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => businessCalendarsController.addHoliday(req, reply),
|
||||
);
|
||||
fastify.delete(
|
||||
'/admin/business-calendars/:id/holidays/:holidayId',
|
||||
{ preHandler: fastify.authenticate },
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => businessCalendarsController.removeHoliday(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,16 @@ import {
|
||||
holidayRepository,
|
||||
HolidayRepository,
|
||||
} from '../repository';
|
||||
import { addBusinessMinutes, isWithinWorkingHours, WorkingHours } from '../calculators/business-hours.calculator';
|
||||
import { CreateBusinessCalendarBody, UpdateBusinessCalendarBody, CreateHolidayBody } from '../schema';
|
||||
import {
|
||||
addBusinessMinutes,
|
||||
isWithinWorkingHours,
|
||||
WorkingHours,
|
||||
} from '../calculators/business-hours.calculator';
|
||||
import {
|
||||
CreateBusinessCalendarBody,
|
||||
UpdateBusinessCalendarBody,
|
||||
CreateHolidayBody,
|
||||
} from '../schema';
|
||||
|
||||
export class BusinessCalendarsService {
|
||||
constructor(
|
||||
@@ -26,6 +34,14 @@ export class BusinessCalendarsService {
|
||||
return calendar;
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: GET /admin/business-calendars/:id's own response — a
|
||||
* calendar's holidays had no way to be read back at all before this (only added/removed). */
|
||||
async getByIdWithHolidays(id: string): Promise<BusinessCalendar & { holidays: Holiday[] }> {
|
||||
const calendar = await this.calendars.findByIdWithHolidays(id);
|
||||
if (!calendar) throw new NotFoundError('Business calendar not found.');
|
||||
return calendar;
|
||||
}
|
||||
|
||||
async list(): Promise<BusinessCalendar[]> {
|
||||
return this.calendars.findAll();
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ export class ResolutionsController {
|
||||
}
|
||||
|
||||
await this.service.confirmByCustomer(ticketId, 'customer');
|
||||
return reply.status(200).send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null });
|
||||
return reply
|
||||
.status(200)
|
||||
.send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AuthorizationError } from '@/common/errors';
|
||||
import { AuthorizationError, NotFoundError } from '@/common/errors';
|
||||
import { agentsRepository, agentsService } from '@/modules/identity/agents';
|
||||
import { ticketsService, TicketsService } from '../service';
|
||||
import { updateTicketStatusSchema } from '../schema';
|
||||
|
||||
@@ -49,6 +50,24 @@ export class TicketsController {
|
||||
const reopened = await this.service.reopen(ticketId, 'customer');
|
||||
return reply.status(200).send({ success: true, data: reopened, meta: null });
|
||||
}
|
||||
|
||||
/** 011-agent-ticket-queue FR-004: agentId is always resolved from the caller's own session,
|
||||
* never from request input. */
|
||||
async listMyAssignedTickets(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!request.user) throw new AuthorizationError('Session has no identity.');
|
||||
const agent = await agentsService.requireAgentForUser(request.user.id);
|
||||
const tickets = await this.service.listAssignedTo(agent.id);
|
||||
return reply.status(200).send({ success: true, data: tickets, meta: null });
|
||||
}
|
||||
|
||||
/** 011-agent-ticket-queue FR-005: admin-only, explicit-agentId equivalent. */
|
||||
async listAssignedTicketsForAgent(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { agentId } = request.params as { agentId: string };
|
||||
const agent = await agentsRepository.findById(agentId);
|
||||
if (!agent) throw new NotFoundError('Agent not found.');
|
||||
const tickets = await this.service.listAssignedTo(agentId);
|
||||
return reply.status(200).send({ success: true, data: tickets, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
export const ticketsController = new TicketsController();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Assignment, CustomerReference, Product, SLARun, Ticket } from '@prisma/client';
|
||||
import { AssignedTicketSummary } from '../types';
|
||||
|
||||
type TicketWithAssignmentRelations = Ticket & {
|
||||
product: Product;
|
||||
customer: CustomerReference;
|
||||
slaRun: SLARun | null;
|
||||
assignments: Assignment[];
|
||||
};
|
||||
|
||||
/** 011-agent-ticket-queue data-model.md: the dashboard-ready projection returned by both
|
||||
* GET /agents/me/tickets and GET /admin/agents/:agentId/tickets. */
|
||||
export function toAssignedTicketSummary(
|
||||
ticket: TicketWithAssignmentRelations,
|
||||
): AssignedTicketSummary {
|
||||
const [currentAssignment] = ticket.assignments;
|
||||
return {
|
||||
id: ticket.id,
|
||||
code: ticket.code,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
severity: ticket.severity,
|
||||
product: {
|
||||
id: ticket.product.id,
|
||||
externalProductId: ticket.product.externalProductId,
|
||||
name: ticket.product.name,
|
||||
},
|
||||
customer: {
|
||||
externalUserId: ticket.customer.externalUserId,
|
||||
externalTenantId: ticket.customer.externalTenantId,
|
||||
},
|
||||
assignedAt: currentAssignment ? currentAssignment.assignedAt.toISOString() : null,
|
||||
sla: ticket.slaRun
|
||||
? {
|
||||
status: ticket.slaRun.status,
|
||||
firstResponseDueAt: ticket.slaRun.firstResponseDueAt?.toISOString() ?? null,
|
||||
resolutionDueAt: ticket.slaRun.resolutionDueAt?.toISOString() ?? null,
|
||||
breachedAt: ticket.slaRun.breachedAt?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export class TicketMapper {
|
||||
|
||||
export * from './ticket-state-machine';
|
||||
export * from './ticket-code';
|
||||
export * from './assigned-ticket-summary';
|
||||
|
||||
@@ -107,6 +107,23 @@ export class TicketsRepository {
|
||||
where: { status: 'RESOLUTION_PENDING_CUSTOMER', updatedAt: { lte: cutoff } },
|
||||
});
|
||||
}
|
||||
|
||||
/** 011-agent-ticket-queue: every ticket this agent is CURRENTLY assigned to, with the
|
||||
* product/customer/SLA-run relations a dashboard-style summary needs, in one query — no
|
||||
* further per-ticket request required (FR-003/SC-001). Backed by
|
||||
* Assignment @@index([agentId, isCurrent]). */
|
||||
async findAssignedToAgent(agentId: string) {
|
||||
return this.prisma.ticket.findMany({
|
||||
where: { assignments: { some: { agentId, isCurrent: true } } },
|
||||
include: {
|
||||
product: true,
|
||||
customer: true,
|
||||
slaRun: true,
|
||||
assignments: { where: { agentId, isCurrent: true }, take: 1 },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ticketsRepository = new TicketsRepository();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { ticketsController } from '../controller';
|
||||
|
||||
export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
@@ -6,6 +7,17 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
ticketsController.getById(req, reply),
|
||||
);
|
||||
|
||||
// 011-agent-ticket-queue: agent's-own-session query and the admin explicit-agent equivalent
|
||||
// are deliberately two routes, not one with an optional param — see research.md.
|
||||
fastify.get('/agents/me/tickets', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
ticketsController.listMyAssignedTickets(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/agents/:agentId/tickets',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => ticketsController.listAssignedTicketsForAgent(req, reply),
|
||||
);
|
||||
|
||||
fastify.patch('/tickets/:ticketId/status', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
ticketsController.updateStatus(req, reply),
|
||||
);
|
||||
@@ -13,8 +25,10 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
// 009-problem-resolution FR-017: agent-facing reopen (fastify.authenticate) and customer-
|
||||
// facing reopen (002's inbound trust boundary, research.md) both funnel through the same
|
||||
// TicketsService.reopen.
|
||||
fastify.post('/admin/tickets/:ticketId/reopen', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
ticketsController.reopen(req, reply),
|
||||
fastify.post(
|
||||
'/admin/tickets/:ticketId/reopen',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => ticketsController.reopen(req, reply),
|
||||
);
|
||||
fastify.post(
|
||||
'/v1/support/tickets/:ticketId/reopen',
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
isValidTransition,
|
||||
TicketStatus,
|
||||
} from '../mapper/ticket-state-machine';
|
||||
import { toAssignedTicketSummary } from '../mapper/assigned-ticket-summary';
|
||||
import { AssignedTicketSummary } from '../types';
|
||||
import { messagesService, MessagesService } from '@/modules/ticketing/messages';
|
||||
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||
import { eventBus, DomainEventName } from '@/events';
|
||||
@@ -191,6 +193,13 @@ export class TicketsService {
|
||||
const reopened = await this.updateStatus(ticketId, 'REOPENED', ticket.version, actor);
|
||||
return this.updateStatus(ticketId, 'IN_PROGRESS', reopened.version, actor);
|
||||
}
|
||||
|
||||
/** 011-agent-ticket-queue FR-003: every ticket currently assigned to this agent, summarized
|
||||
* for a dashboard in one call. */
|
||||
async listAssignedTo(agentId: string): Promise<AssignedTicketSummary[]> {
|
||||
const tickets = await this.ticketsRepo.findAssignedToAgent(agentId);
|
||||
return tickets.map(toAssignedTicketSummary);
|
||||
}
|
||||
}
|
||||
|
||||
export const ticketsService = new TicketsService();
|
||||
|
||||
@@ -8,3 +8,21 @@ export interface TicketDTO {
|
||||
severity: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** 011-agent-ticket-queue data-model.md: dashboard-ready summary of a currently-assigned ticket. */
|
||||
export interface AssignedTicketSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
severity: string;
|
||||
product: { id: string; externalProductId: string; name: string };
|
||||
customer: { externalUserId: string; externalTenantId: string };
|
||||
assignedAt: string | null;
|
||||
sla: {
|
||||
status: string;
|
||||
firstResponseDueAt: string | null;
|
||||
resolutionDueAt: string | null;
|
||||
breachedAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user