API REST de uma central de suporte: clientes abrem chamados, uma IA (Claude, da Anthropic) sugere categoria e prioridade em segundo plano, atendentes tratam a fila e o sistema controla o SLA.
Gravado localmente pelo Swagger em modo fake (sem chave de API). Com ANTHROPIC_API_KEY, a sugestão vem do Claude.
Antes de programar, eu trabalhei do outro lado do chamado: atendimento (SAC na Concentrix) e suporte técnico. Vivi a fila sem prioridade clara, o chamado crítico escondido atrás de dez dúvidas simples, o prazo estourado que ninguém viu e o "quem mexeu nisso?" sem resposta.
Eu atendia chamados; agora construí o sistema que os organiza. Cada regra aqui vem dessa vivência:
| Dor no atendimento | Como a API resolve |
|---|---|
| Triagem manual, lenta e inconsistente | IA sugere categoria, prioridade e um resumo de 1 linha; o atendente só aceita ou corrige |
| Prazo estourado sem ninguém perceber | SLA calculado por prioridade + job que marca os vencidos automaticamente |
| "Quem mudou o status?" | Histórico de auditoria gravado na mesma transação de cada mudança |
| Chamado pulando etapas ou reaberto de qualquer jeito | Máquina de status com transições proibidas (e testadas) |
| Anotação interna vazando para o cliente | Comentários públicos × internos, filtrados por papel |
| Gestor sem números | /metrics/overview: tempos médios, % no SLA e taxa de acerto da IA |
Um monólito modular (NestJS) com dois processos a partir do mesmo código: a API (HTTP) e o worker (filas). Sem microsserviços.
flowchart LR
C[Cliente] -->|POST /tickets| API[API NestJS]
API -->|"transação: chamado + histórico + sugestão PENDING"| PG[(PostgreSQL)]
API -->|"job triage-ticketId"| R[(Redis / BullMQ)]
API -->|"201 em ~10 ms"| C
R --> W[Worker]
W -->|timeout + retry com backoff| LLM{"Claude Haiku 4.5<br/>ou modo fake"}
LLM -->|JSON| Z[Validação Zod]
Z -->|válido| PG
Z -->|inválido| R
R -->|tentativas esgotadas| DLQ[Jobs falhos<br/>GET /admin/queues/failed]
S[Job agendado de SLA] --> PG
A[Atendente] -->|aceita ou corrige| API
Status do chamado (qualquer outra transição responde 422):
stateDiagram-v2
[*] --> ABERTO
ABERTO --> EM_ATENDIMENTO
EM_ATENDIMENTO --> AGUARDANDO_CLIENTE
AGUARDANDO_CLIENTE --> EM_ATENDIMENTO
EM_ATENDIMENTO --> RESOLVIDO
AGUARDANDO_CLIENTE --> RESOLVIDO
RESOLVIDO --> EM_ATENDIMENTO: reabrir
RESOLVIDO --> FECHADO
FECHADO --> [*]
Modelo de dados
erDiagram
User ||--o{ RefreshToken : tem
User ||--o{ Ticket : "abre / atende"
Category ||--o{ Ticket : classifica
Ticket ||--o{ Comment : tem
Ticket ||--o{ TicketHistory : audita
Ticket ||--o| TriageSuggestion : recebe
Módulos de domínio (src/): auth, users, categories, tickets (chamados, status, comentários, histórico), sla, triage, metrics, além de queue, health, config e common (erros, validação, logs). Regras de negócio ficam em services e funções puras (ticket-status.ts, sla.policy.ts, triage.schema.ts, metrics.calc.ts); controllers só traduzem HTTP. DTOs de entrada e saída são separados do modelo do banco (a senha, por exemplo, nunca sai da API).
| Área | Escolha |
|---|---|
| Runtime / linguagem | Node.js 24 LTS, TypeScript strict |
| Framework | NestJS 11 (módulos, injeção de dependência, guards, pipes, filters) |
| Banco | PostgreSQL 17 + Prisma (migrations versionadas, índices, transações) |
| Filas | BullMQ + Redis (worker de triagem, job agendado de SLA, jobs falhos visíveis) |
| IA | SDK oficial @anthropic-ai/sdk, Claude Haiku 4.5, saída validada com Zod |
| Validação | class-validator nos DTOs; Zod na configuração e na saída do LLM |
| Erros | ProblemDetails (RFC 7807) com 400, 401, 403, 404, 409, 422, 429 |
| Segurança | argon2, JWT + refresh token com rotação, rate limit no login, Helmet, CORS restrito |
| Observabilidade | Pino (JSON) com correlation id (x-request-id), /health |
| Testes | Jest, Supertest, Testcontainers (PostgreSQL e Redis reais) |
| Entrega | Docker (multi-stage, usuário não root), Docker Compose, GitHub Actions |
Requisito: Docker Desktop. Nenhuma chave é necessária.
git clone https://github.com/LeopratesDev/helpdesk-api.git
cd helpdesk-api
docker compose up --buildIsso sobe PostgreSQL, Redis, aplica as migrations, popula o banco (só se estiver vazio), e inicia a API e o worker.
- Swagger: http://localhost:3000/docs (clique em Authorize e cole o
accessTokendo login) - Saúde: http://localhost:3000/health
- Porta 3000 ocupada?
API_PORT=3100 docker compose up --build - Para usar o Claude de verdade:
ANTHROPIC_API_KEY=sk-ant-... docker compose up --build
Requisitos: Node.js 24+, PostgreSQL 17 e Redis 7 rodando (instalados localmente, ou só os dois via docker compose up postgres redis).
npm ci
cp .env.example .env # ajuste DATABASE_URL, REDIS_URL e JWT_SECRET se preciso
npm run db:deploy # aplica as migrations
npm run db:seed # dados de demonstração (apaga e recria)
npm run start:dev # API em http://localhost:3000
npm run start:worker:dev # em outro terminal: worker (triagem e SLA)Todas as variáveis estão documentadas em .env.example. Se faltar uma obrigatória ou algum valor for inválido, a aplicação não sobe e diz qual é.
Sem ANTHROPIC_API_KEY, a triagem usa regras locais por palavra-chave: o fluxo completo (fila → worker → sugestão → aceitar/corrigir) funciona sem rede e sem custo. O log do worker avisa: triagem em MODO FAKE. Detalhes no ADR 003.
Todos com a senha Demo@123 (criados pelo seed; só existem no seu banco local).
| Papel | Pode | |
|---|---|---|
| Admin | admin@helpdesk.dev |
gerenciar usuários e categorias, ver jobs falhos, tudo do atendente |
| Atendente | atendente@helpdesk.dev (e bruna@helpdesk.dev) |
tratar a fila, mudar status, decidir a triagem, ver métricas |
| Cliente | cliente@helpdesk.dev (e carlos@, daniela@) |
abrir e acompanhar os próprios chamados |
O seed cria 50 chamados fictícios em todos os status, com histórico, comentários e sugestões da IA.
26 rotas no total; todas documentadas com exemplos em /docs.
| Método | Rota | Quem | Descrição |
|---|---|---|---|
| POST | /auth/login |
público | Access token (15 min) + refresh token. Rate limit: 5/min por IP |
| POST | /auth/refresh |
público | Troca o refresh token por um novo par (rotação) |
| POST | /tickets |
Cliente | Abre chamado; responde 201 na hora e enfileira a triagem |
| GET | /tickets |
todos | Paginado; filtros status, priority, categoryId, assigneeId, from, to; sortBy/sortOrder |
| PATCH | /tickets/:id/status |
Atendente, Admin | Transição validada (422 se proibida; 409 se alterado ao mesmo tempo) |
| POST | /tickets/:id/comments |
dono, Atendente, Admin | Público ou interno (cliente não cria nem vê internos) |
| GET | /tickets/:id/history |
Atendente, Admin | Auditoria completa |
| GET | /tickets/:id/triage |
Atendente, Admin | Sugestão da IA, confiança e texto enviado |
| POST | /tickets/:id/triage/accept · /correct |
Atendente, Admin | Aplica a sugestão ou a correção ao chamado |
| GET | /metrics/overview |
Atendente, Admin | Por status, tempos médios, % no SLA, acerto da IA |
| GET | /admin/queues/failed |
Admin | Jobs que esgotaram as tentativas |
| GET | /health |
público | Banco e Redis (200/503) |
Exemplo rápido:
TOKEN=$(curl -s -X POST localhost:3000/auth/login -H 'content-type: application/json' \
-d '{"email":"atendente@helpdesk.dev","password":"Demo@123"}' | jq -r .accessToken)
curl -s localhost:3000/metrics/overview -H "Authorization: Bearer $TOKEN" | jqnpm test # tudo (unitários + integração)
npm run test:unit # só unitários, sem Docker
npm run test:e2e # integração: sobe PostgreSQL e Redis reais via Testcontainers (precisa de Docker)
npm run test:cov # com relatório de cobertura em coverage/- 157 testes: 92 unitários + 65 de integração/E2E.
- Cobertura das regras de negócio: 97,4% das linhas (88% dos branches) — máquina de status, SLA, auth, triagem, métricas. Geral do projeto: 96%.
- As 25 combinações de transição de status são testadas (7 permitidas, 18 proibidas).
- O LLM é sempre mockado: nenhum teste chama a API da Anthropic nem gasta dinheiro. A integração usa o worker real com um cliente de LLM falso injetado; o cliente real é testado com o SDK simulado (prompt, parse, erros que valem retry ou não).
- Integração com banco e Redis reais, não mocks: migrations, índices, transações, concorrência e filas são exercitados de verdade.
O CI (GitHub Actions) roda lint, typecheck, testes com cobertura, build e o build da imagem Docker em cada push e PR.
Por que fila, e não chamar o LLM direto na requisição? O LLM leva segundos, pode dar timeout, limite de taxa (429) ou ficar fora do ar. Na requisição, o cliente esperaria e veria esses erros. Com fila, o POST /tickets responde em ~10 ms (mediana; p95 15 ms, medido localmente) e a triagem acontece depois, com retry e backoff. Custo: mais uma peça (Redis) e consistência eventual (a sugestão aparece segundos depois). → ADR 001
Idempotência do worker. O mesmo job pode rodar duas vezes (retry, worker que caiu no meio). Três camadas impedem dados duplicados: (1) id de job determinístico triage-<ticketId>, que o BullMQ não duplica; (2) UNIQUE(ticketId) + upsert na sugestão; (3) gravação condicional WHERE status = 'PENDING', que deixa só uma execução gravar. Há testes para reprocessamento e para duas execuções simultâneas.
Por que Prisma? Schema como fonte da verdade, migrations versionadas e tipos gerados: mudar uma coluna quebra a compilação onde ela é usada. Onde o Prisma não alcança, uso SQL: o job de SLA é um único UPDATE ... RETURNING + INSERT (atômico e idempotente) e as métricas agregam no banco. → ADR 002
Validação: class-validator + Zod. class-validator é o padrão do NestJS e gera o Swagger a partir dos mesmos decorators. Zod entra onde o dado vem de fora sem garantia: variáveis de ambiente e a resposta do LLM. Mesmo com "structured outputs", valido de novo: o helper do SDK converte enum/min/max em texto de descrição, então a API não os garante — e a categoria precisa existir no banco.
Custos e limites do LLM.
- Modelo:
claude-haiku-4-5(configurável emLLM_MODEL), o mais barato e rápido — triagem é classificação simples. Preço de tabela: US$ 1 / milhão de tokens de entrada e US$ 5 / milhão de saída. - Estimativa (não medida — rodei em modo fake): ~600 tokens de entrada e ~80 de saída por chamado ≈ US$ 0,001 por triagem, ~US$ 1 a cada mil chamados.
- Limites tratados: timeout próprio (
LLM_TIMEOUT_MS), retry com backoff exponencial para 429/5xx/timeout/saída inválida, sem retry para 400/401/403/404 e recusa do modelo,concurrency: 5no worker para não estourar o rate limit. - A IA sugere, não decide: o atendente aceita ou corrige, e a taxa de acerto vira métrica.
- O texto do chamado vai delimitado e é tratado como dado (mitiga prompt injection); a saída ainda passa pelo Zod.
Outros trade-offs conscientes
- Paginação por offset (simples, mostra total de páginas); por cursor seria melhor para páginas muito altas.
- Lock otimista (
updatedAtnoWHERE) em vez deSELECT ... FOR UPDATE: conflito é raro, não trava linhas. - Refresh token opaco guardado como hash, com detecção de reuso; access token JWT curto (não revogável, por isso 15 min).
- Se o Redis estiver fora ao abrir um chamado, o
POSTnão falha: a sugestão ficaPENDINGe uma varredura periódica republica. O padrão outbox seria a solução completa (ver próximos passos).
- Deploy público (ver etapa de deploy / issues).
- Padrão outbox para publicar jobs com garantia transacional.
- Cliente comentando em "Aguardando cliente" → voltar automaticamente para "Em atendimento".
- Notificações (e-mail/webhook) de SLA prestes a vencer, não só estourado.
- Reprocessar job falho pelo painel (
POST /admin/queues/:id/retry). - Medir custo real do LLM por chamado (
usageda resposta) e expor nas métricas. - Imagem Docker menor (base Alpine ou distroless).
- ADR 001 — Fila assíncrona para a triagem por IA
- ADR 002 — Prisma como ORM
- ADR 003 — Modo fake do LLM
Feito por Leonardo Prates · GitHub · Outro projeto: RH Manager (C#/.NET + React)
