feat(agents): agentes, tiers e pausas — call center completo

Fecha agente.md secao 45-49/52. Depois desta fase, um usuario autenticado
consegue logar como agente, entrar numa fila real, se pausar e voltar,
tudo refletido de verdade no FreeSWITCH.

Schema (migration 20260828124245_agents):
- agents (tenant-scoped, RLS): User -> Extension -> identidade de agente,
  state (enum AgentState de 8 valores) espelhando o estado real, so
  alterado via login/logout/pause/resume, nunca escrito direto pela API.
- tiers: Queue<->Agent (level/position 1:1 com mod_callcenter).
- agent_sessions: um ciclo login->logout por linha.
- agent_state_events: historico de transicoes de estado.
- pause_reasons / agent_pause_events (secao 48).

Dois bugs reais corrigidos em FreeSwitchTelephonyProvider, presentes desde
a fase de Event Socket original:
- "queue add/del member" nao existe no mod_callcenter — membership de
  fila usa tier add/tier del. So foi pego agora ao confirmar de novo a
  sintaxe via `help callcenter_config` antes de codar esta fase.
- addAgent/removeAgent nao existiam ainda (agent add/del).

Mecanismo de sync: agentes e tiers nao tem representacao em XML, so
comando ESL direto — diferente do padrao "regenera todos os arquivos"
usado em Trunks/Queues. apps/api publica uma mensagem por acao com
payload (b2bcall:agents:sync, b2bcall:tiers:sync); b2bcall-fs-config
aplica o comando correspondente (agent-sync.ts).

Achados confirmados manualmente contra o FreeSWITCH real antes de codar:
- `agent add`/`tier add` nao sao idempotentes (erro em duplicata) — sync
  ignora esse erro (.catch), condicao esperada em resync.
- `agent del`/`tier del` em algo inexistente nao da erro — seguro chamar
  sem checar existencia antes.
- `agent set status` so aceita 3 valores exatos (Available/On Break/
  Logged Out) — testado deliberadamente com valor invalido.
- Corrida real: atribuir tier antes do primeiro login do agente falha
  silenciosamente do lado do FreeSWITCH (agente so existe la a partir do
  `agent add` no login). Login sempre re-sincroniza todos os tiers do
  agente depois de garantir que ele existe — auto-correcao confirmada no
  teste ponta a ponta.

apps/api: AgentsController (CRUD), AgentsMeController (login/logout/
pause/resume — sempre resolve o agente via JWT, nunca um agentId
arbitrario do client), PauseReasonsController (CRUD), QueueAgentsController
(POST/DELETE de tier em /queues/:id/agents).

Verificado ponta a ponta via curl + fs_cli contra o FreeSWITCH real:
login -> Available, pause -> On Break, resume -> Available, logout ->
Logged Out, todos batendo entre Agent.state (banco) e `agent list`
(FreeSWITCH). typecheck do workspace inteiro limpo. ~350MB de memoria
total (docker stats).

Documentado em docs/AGENTS.md, incluindo lacuna conhecida: estados
derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) dependem do
evento CUSTOM callcenter::info, ainda nao comprovado chegando em
fs-events nesta sessao (mesma lacuna de sofia::gateway_state ja
documentada em docs/TRUNKS.md) — precisa de uma chamada real passando
pela fila pra investigar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 09:58:45 -03:00
parent 6628578c42
commit a05104a05f
21 changed files with 1402 additions and 140 deletions

View File

@@ -17,28 +17,34 @@ enum TenantStatus {
}
model Tenant {
id String @id @default(uuid()) @db.Uuid
code String @unique
slug String @unique
legalName String @map("legal_name")
tradeName String? @map("trade_name")
taxId String? @map("tax_id")
status TenantStatus @default(TRIAL)
timezone String @default("America/Sao_Paulo")
locale String @default("pt-BR")
billingCurrency String @map("billing_currency") @default("BRL")
telephonyDomain String? @map("telephony_domain")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
id String @id @default(uuid()) @db.Uuid
code String @unique
slug String @unique
legalName String @map("legal_name")
tradeName String? @map("trade_name")
taxId String? @map("tax_id")
status TenantStatus @default(TRIAL)
timezone String @default("America/Sao_Paulo")
locale String @default("pt-BR")
billingCurrency String @default("BRL") @map("billing_currency")
telephonyDomain String? @map("telephony_domain")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
trunks Trunk[]
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
trunks Trunk[]
dialplanExtensions DialplanExtension[]
dialplanVersions DialplanVersion[]
queues Queue[]
agents Agent[]
pauseReasons PauseReason[]
tiers Tier[]
agentSessions AgentSession[]
agentStateEvents AgentStateEvent[]
agentPauseEvents AgentPauseEvent[]
@@map("tenants")
}
@@ -53,19 +59,20 @@ enum UserStatus {
// Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant
// é sempre resolvido via TenantMembership (agente.md secao 31).
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
passwordHash String @map("password_hash")
name String
status UserStatus @default(ACTIVE)
mustChangePassword Boolean @default(false) @map("must_change_password")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
id String @id @default(uuid()) @db.Uuid
email String @unique
passwordHash String @map("password_hash")
name String
status UserStatus @default(ACTIVE)
mustChangePassword Boolean @default(false) @map("must_change_password")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
memberships TenantMembership[]
userRoles UserRole[]
sessions Session[]
memberships TenantMembership[]
userRoles UserRole[]
sessions Session[]
agents Agent[]
@@map("users")
}
@@ -179,8 +186,8 @@ model TenantMembership {
userId String @map("user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
user User @relation(fields: [userId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([tenantId, userId])
@@index([tenantId])
@@ -191,31 +198,32 @@ model TenantMembership {
// guarda a senha SIP cifrada (AES-256-GCM, ver packages/shared/src/crypto.ts)
// — nunca texto puro (agente.md secao 178).
model Extension {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
number String
name String
domain String
number String
name String
domain String
sipPasswordEnc String @map("sip_password_enc")
sipPasswordEnc String @map("sip_password_enc")
callerIdName String? @map("caller_id_name")
callerIdNumber String? @map("caller_id_number")
callerIdName String? @map("caller_id_name")
callerIdNumber String? @map("caller_id_number")
context String @default("default")
sofiaProfile String @default("internal") @map("sofia_profile")
codecs String @default("PCMU,PCMA,OPUS") @map("codecs")
context String @default("default")
sofiaProfile String @default("internal") @map("sofia_profile")
codecs String @default("PCMU,PCMA,OPUS") @map("codecs")
maxRegistrations Int @default(1) @map("max_registrations")
maxRegistrations Int @default(1) @map("max_registrations")
enabled Boolean @default(true)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
agents Agent[]
@@unique([tenantId, number])
@@index([tenantId])
@@ -254,61 +262,61 @@ enum SipTransport {
// a senha do tronco cifrada (AES-256-GCM), como sipPasswordEnc em Extension
// (agente.md secao 41, 178).
model Trunk {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
description String?
name String
description String?
sofiaProfile String @default("external") @map("sofia_profile")
sofiaProfile String @default("external") @map("sofia_profile")
host String
proxy String?
realm String?
host String
proxy String?
realm String?
register Boolean @default(true)
register Boolean @default(true)
username String?
passwordEnc String? @map("password_enc")
username String?
passwordEnc String? @map("password_enc")
fromUser String? @map("from_user")
fromDomain String? @map("from_domain")
fromUser String? @map("from_user")
fromDomain String? @map("from_domain")
registerProxy String? @map("register_proxy")
outboundProxy String? @map("outbound_proxy")
registerProxy String? @map("register_proxy")
outboundProxy String? @map("outbound_proxy")
expireSeconds Int @default(3600) @map("expire_seconds")
retrySeconds Int @default(30) @map("retry_seconds")
expireSeconds Int @default(3600) @map("expire_seconds")
retrySeconds Int @default(30) @map("retry_seconds")
callerIdName String? @map("caller_id_name")
callerIdNumber String? @map("caller_id_number")
callerIdName String? @map("caller_id_name")
callerIdNumber String? @map("caller_id_number")
codecs String @default("PCMU,PCMA,OPUS")
codecs String @default("PCMU,PCMA,OPUS")
dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode")
dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode")
ping Boolean @default(true)
pingFrequency Int @default(30) @map("ping_frequency")
ping Boolean @default(true)
pingFrequency Int @default(30) @map("ping_frequency")
transport SipTransport @default(UDP)
transport SipTransport @default(UDP)
inboundContext String @default("default") @map("inbound_context")
inboundContext String @default("default") @map("inbound_context")
maxCps Int? @map("max_cps")
maxChannels Int? @map("max_channels")
maxCps Int? @map("max_cps")
maxChannels Int? @map("max_channels")
enabled Boolean @default(true)
enabled Boolean @default(true)
// Refletido pelos eventos sofia::gateway_state (agente.md secao 42) —
// b2bcall-fs-events atualiza isso, nunca escrito manualmente pela API.
status TrunkStatus @default(UNKNOWN)
statusUpdatedAt DateTime? @map("status_updated_at")
status TrunkStatus @default(UNKNOWN)
statusUpdatedAt DateTime? @map("status_updated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, name])
@@index([tenantId])
@@ -327,31 +335,31 @@ enum DialplanVersionStatus {
// publicadas (DialplanVersion) são um snapshot gerado a partir destas
// linhas, não o que o FreeSWITCH consulta diretamente.
model DialplanExtension {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
context String @default("default")
name String
context String @default("default")
name String
conditionField String @map("condition_field")
conditionExpr String @map("condition_expr")
conditionField String @map("condition_field")
conditionExpr String @map("condition_expr")
// Array de { application, data } — validado contra uma allowlist de
// applications seguras na camada de API (agente.md secao 180: nunca
// deixar input de usuário virar comando arbitrário no FreeSWITCH).
actions Json
antiActions Json? @map("anti_actions")
actions Json
antiActions Json? @map("anti_actions")
continueOnFalse Boolean @default(false) @map("continue_on_false")
order Int @default(0)
continueOnFalse Boolean @default(false) @map("continue_on_false")
order Int @default(0)
enabled Boolean @default(true)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
@@index([tenantId, context])
@@map("dialplan_extensions")
@@ -362,19 +370,19 @@ model DialplanExtension {
// versão anterior é o próprio mecanismo de rollback — não existe endpoint
// separado.
model DialplanVersion {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
context String
version Int
generatedXml String @map("generated_xml")
status DialplanVersionStatus @default(DRAFT)
context String
version Int
generatedXml String @map("generated_xml")
status DialplanVersionStatus @default(DRAFT)
createdByUserId String? @map("created_by_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
activatedAt DateTime? @map("activated_at")
createdByUserId String? @map("created_by_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
activatedAt DateTime? @map("activated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, context, version])
@@index([tenantId, context, status])
@@ -399,42 +407,198 @@ enum QueueStrategy {
// um namespace unico compartilhado entre tenants (nao ha equivalente ao
// diretorio por-arquivo do Sofia pra isolar por tenant, ver docs/QUEUES.md).
model Queue {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
description String?
name String
description String?
strategy QueueStrategy @default(LONGEST_IDLE_AGENT)
strategy QueueStrategy @default(LONGEST_IDLE_AGENT)
mohSound String? @map("moh_sound")
announceSound String? @map("announce_sound")
announceFrequency Int @default(0) @map("announce_frequency")
mohSound String? @map("moh_sound")
announceSound String? @map("announce_sound")
announceFrequency Int @default(0) @map("announce_frequency")
maxWaitTime Int @default(0) @map("max_wait_time")
maxWaitTimeWithNoAgent Int @default(0) @map("max_wait_time_with_no_agent")
maxWaitTime Int @default(0) @map("max_wait_time")
maxWaitTimeWithNoAgent Int @default(0) @map("max_wait_time_with_no_agent")
agentNoAnswerStatus String? @map("agent_no_answer_status")
agentNoAnswerStatus String? @map("agent_no_answer_status")
tierRulesApply Boolean @default(false) @map("tier_rules_apply")
tierRuleWaitSecond Int @default(300) @map("tier_rule_wait_second")
tierRulesApply Boolean @default(false) @map("tier_rules_apply")
tierRuleWaitSecond Int @default(300) @map("tier_rule_wait_second")
discardAbandonedAfter Int @default(60) @map("discard_abandoned_after")
abandonedResumeAllowed Boolean @default(false) @map("abandoned_resume_allowed")
discardAbandonedAfter Int @default(60) @map("discard_abandoned_after")
abandonedResumeAllowed Boolean @default(false) @map("abandoned_resume_allowed")
skipAgentsWithExternalCalls Boolean @default(true) @map("skip_agents_with_external_calls")
skipAgentsWithExternalCalls Boolean @default(true) @map("skip_agents_with_external_calls")
recordingEnabled Boolean @default(false) @map("recording_enabled")
recordingEnabled Boolean @default(false) @map("recording_enabled")
enabled Boolean @default(true)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
tiers Tier[]
@@unique([tenantId, name])
@@index([tenantId])
@@map("queues")
}
enum AgentState {
OFFLINE
LOGGED_IN
AVAILABLE
RESERVED
RINGING
IN_CALL
WRAP_UP
PAUSED
@@map("agent_state")
}
// Tabela tenant-scoped protegida por RLS. Separa User (login) / Agent
// (identidade de call center) / Extension (ramal SIP usado como contato) —
// agente.md secao 45. O nome no FreeSWITCH é `<agent.id>@<tenant.telephonyDomain>`,
// mesma convenção UUID das outras entidades (agente.md secao 47).
model Agent {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
userId String @map("user_id") @db.Uuid
extensionId String? @map("extension_id") @db.Uuid
name String
maxNoAnswer Int @default(3) @map("max_no_answer")
wrapUpTime Int @default(10) @map("wrap_up_time")
rejectDelayTime Int @default(10) @map("reject_delay_time")
busyDelayTime Int @default(60) @map("busy_delay_time")
noAnswerDelayTime Int @default(10) @map("no_answer_delay_time")
// Espelha o estado real (secao 46) — nunca escrito diretamente pela API,
// só por login/logout/pause/resume ou por eventos do FreeSWITCH.
state AgentState @default(OFFLINE)
stateUpdatedAt DateTime? @map("state_updated_at")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
user User @relation(fields: [userId], references: [id])
extension Extension? @relation(fields: [extensionId], references: [id])
tiers Tier[]
sessions AgentSession[]
stateEvents AgentStateEvent[]
pauseEvents AgentPauseEvent[]
@@unique([tenantId, userId])
@@index([tenantId])
@@map("agents")
}
// Queue <-> Agent (agente.md secao 52). O nome da fila/agente no FreeSWITCH
// já é o "<id>@<domain>" — level/position espelham 1:1 os mesmos conceitos
// do mod_callcenter.
model Tier {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
queueId String @map("queue_id") @db.Uuid
agentId String @map("agent_id") @db.Uuid
level Int @default(1)
position Int @default(1)
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
queue Queue @relation(fields: [queueId], references: [id])
agent Agent @relation(fields: [agentId], references: [id])
@@unique([queueId, agentId])
@@index([tenantId])
@@map("tiers")
}
// Uma "sessão" = do login até o logout do agente (agente.md secao 45, 47).
model AgentSession {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
agentId String @map("agent_id") @db.Uuid
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
agent Agent @relation(fields: [agentId], references: [id])
@@index([tenantId, agentId])
@@map("agent_sessions")
}
// Histórico de transições de estado (agente.md secao 46) — nunca deletado,
// serve de auditoria e insumo pra relatórios (secao 158).
model AgentStateEvent {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
agentId String @map("agent_id") @db.Uuid
state AgentState
occurredAt DateTime @default(now()) @map("occurred_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
agent Agent @relation(fields: [agentId], references: [id])
@@index([tenantId, agentId, occurredAt])
@@map("agent_state_events")
}
// agente.md secao 48.
model PauseReason {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
code String
description String?
maxDuration Int? @map("max_duration")
paid Boolean @default(false)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
pauseEvents AgentPauseEvent[]
@@unique([tenantId, code])
@@index([tenantId])
@@map("pause_reasons")
}
model AgentPauseEvent {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
agentId String @map("agent_id") @db.Uuid
pauseReasonId String @map("pause_reason_id") @db.Uuid
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
agent Agent @relation(fields: [agentId], references: [id])
pauseReason PauseReason @relation(fields: [pauseReasonId], references: [id])
@@index([tenantId, agentId])
@@map("agent_pause_events")
}