feat: add call center queues

- packages/database: Agent, AgentSession, AgentStateEvent, AgentPauseEvent,
  PauseReason, Queue, QueueMember (agente.md secao 53)
- apps/api/src/queues: CRUD de filas gerando queues.conf pelo mesmo padrao
  do dialplan (arquivo compartilhado + module reload app_queue.so), mas
  SEM membros estaticos no arquivo — membership eh 100% dinamica via AMI
  (evita duas fontes de verdade conflitantes)
- apps/api/src/pause-reasons: CRUD de motivos de pausa
- apps/api/src/agents: CRUD administrativo de agentes (1:1 com User)
- apps/api/src/agent-console: maquina de estados do agente
  (LOGGED_IN/AVAILABLE/PAUSED), 'tela do agente' via
  login/available/pause/unpause/logout, cada transicao aciona AMI
  QueueAdd/QueuePause/QueueRemove de verdade e registra
  agent_state_events/agent_pause_events com inicio/fim
- apps/api/src/monitoring: GET /api/monitoring/queues (chamadas
  aguardando, agentes logados/pausados/disponiveis via AMI QueueStatus ao
  vivo) — metricas historicas (TME/TMA/SLA) ficam para a Fase 7

Testado ponta a ponta contra o Asterisk real: fila criada aparece via
'queue show'; agente loga, fica disponivel (QueueAdd confirmado, membro
dinamico visivel), pausa com motivo (confirmado 'paused:Almoco' ao vivo),
despausa, desloga (QueueRemove confirmado, fila volta a 'No Members').

Disposicoes de chamada e Callback adiados para a Fase 6 (dependem de
haver chamadas de campanha reais para classificar).
This commit is contained in:
2026-08-27 13:38:19 -03:00
parent cf3fc4b3ef
commit 63103d4335
29 changed files with 1627 additions and 15 deletions

View File

@@ -31,6 +31,7 @@ model User {
sessions Session[]
passwordResetTokens PasswordResetToken[]
auditLogs AuditLog[]
agent Agent?
@@map("users")
}
@@ -251,3 +252,145 @@ model DialplanVersion {
@@map("dialplan_versions")
}
// ===========================================================================
// Fase 5 — Call Center. Usuário da aplicação (User) e agente de Call Center
// (Agent) são conceitos separados (agente.md seção 16): uma pessoa pode
// possuir os dois associados 1:1.
// ===========================================================================
enum QueueStrategy {
ringall
leastrecent
fewestcalls
random
rrmemory
rrordered
linear
wrandom
}
model Queue {
id String @id @default(uuid())
name String @unique
number String @unique
strategy QueueStrategy @default(ringall)
timeout Int @default(15)
retry Int @default(5)
wrapUpTime Int @default(0) @map("wrap_up_time")
maxLen Int @default(0) @map("max_len")
musicOnHold String @default("default") @map("music_on_hold")
announce String?
serviceLevel Int @default(60) @map("service_level")
autoFill Boolean @default(true) @map("auto_fill")
ringInUse Boolean @default(true) @map("ring_in_use")
weight Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members QueueMember[]
@@map("queues")
}
model QueueMember {
id String @id @default(uuid())
queueId String @map("queue_id")
agentId String @map("agent_id")
penalty Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
queue Queue @relation(fields: [queueId], references: [id], onDelete: Cascade)
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
@@unique([queueId, agentId])
@@map("queue_members")
}
model PauseReason {
id String @id @default(uuid())
name String
code String @unique
description String?
maxDurationSeconds Int? @map("max_duration_seconds")
paid Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pauseEvents AgentPauseEvent[]
@@map("pause_reasons")
}
model Agent {
id String @id @default(uuid())
code String @unique
name String
userId String @unique @map("user_id")
active Boolean @default(true)
currentExtension String? @map("current_extension")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
sessions AgentSession[]
stateEvents AgentStateEvent[]
pauseEvents AgentPauseEvent[]
queues QueueMember[]
@@map("agents")
}
model AgentSession {
id String @id @default(uuid())
agentId String @map("agent_id")
extension String
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
@@index([agentId])
@@map("agent_sessions")
}
enum AgentState {
OFFLINE
LOGGED_IN
AVAILABLE
RINGING
IN_CALL
WRAP_UP
PAUSED
}
// Trilha de auditoria da máquina de estados do agente (agente.md seção 48).
// Sempre exatamente um registro "aberto" (endedAt = null) por agente.
model AgentStateEvent {
id String @id @default(uuid())
agentId String @map("agent_id")
state AgentState
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
@@index([agentId, endedAt])
@@map("agent_state_events")
}
model AgentPauseEvent {
id String @id @default(uuid())
agentId String @map("agent_id")
pauseReasonId String @map("pause_reason_id")
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
pauseReason PauseReason @relation(fields: [pauseReasonId], references: [id], onDelete: Restrict)
@@index([agentId, endedAt])
@@map("agent_pause_events")
}