c95c6805fb678eaf3eedc94f07d9146b907c8f8d
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| b27cfaab02 |
feat(billing): rating engine, fechamento de periodo, dashboard platform (fase 22)
Fecha a orquestracao de Billing (agente.md secao 120-139) sobre o schema/ RatingEngine puro ja existentes: escritores do ledger UsageEvent (CALL_SECONDS no CDR, ACTIVE_DAY via sweep diario), closeBillingPeriod/ reopenBillingPeriod (fechamento imutavel com audit trail), e os controllers de price books/rate decks/plan versions/subscriptions/ periods/statements. Corrige 2 bugs reais de RLS achados no teste ponta a ponta (reopen sem tenant context, subscriptions sem withTenantContext) e adiciona teste unitario do RatingEngine (17 casos). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY |
|||
| 7597b35454 |
feat(ai): provider layer — abstracao, OpenAI/Anthropic, global+BYOK
Fecha agente.md secao 95-103. Primeira peca do modulo de IA — a
abstracao de provider, os adapters OpenAI/Anthropic, capabilities, e o
cadastro de providers/modelos (global + BYOK). O pipeline que aciona
isso depois de uma chamada terminar (transcricao, analise, jobs
assincronos, prompts, scorecards, usage metering — secao 104-124) fica
pra proxima fase.
## Schema completo do modulo de IA numa unica migration
Todas as tabelas das secoes 95-124 de uma vez (ai_providers/ai_models/
ai_prompt_templates+versions/ai_jobs/call_transcriptions+segments/
call_ai_analyses/quality_scorecards+items+evaluations/ai_usage_records) —
mais barato revisar o desenho relacional inteiro numa unica passada do
que fatiar em migrations pequenas que se emendam. O codigo que usa essas
tabelas vem em fases separadas, so' Provider/Model nesta.
## packages/ai
Interface AIProvider (secao 96: nunca hardcoda OpenAI no dominio).
OpenAIProvider/AnthropicProvider (secao 97-98, nomenclatura "OpenAI API"/
"Anthropic API", nunca "ChatGPT") via fetch nativo direto contra cada API
— sem SDK oficial, request/response inteiramente visivel no proprio
codigo (relevante ja' que manda dado de cliente pra fora, secao 122-123).
transcribe so' na OpenAI (Anthropic nao tem endpoint de audio, secao 102:
"nem todo provider tem todas as capacidades"); analyze/structuredGenerate
via Structured Outputs na OpenAI e "tool use" forcado na Anthropic.
SensitiveDataRedactor (secao 123): CPF/CNPJ/telefone/email/cartao.
**Nunca exercitados contra rede real** — esta sessao so' tem autorizacao
de rede pro servidor git (restricao definida desde o primeiro pedido do
usuario). Mesmo padrao de honestidade ja' usado pro S3ObjectStorageProvider
e o caminho PSTN real.
## ai_providers/ai_models — global vs. BYOK
scope=GLOBAL (platform admin, tenant_id null) visivel de qualquer tenant;
scope=TENANT (BYOK) so' do dono. RLS hibrida (tenant_id = current OR
tenant_id IS NULL, mesma tecnica de tenant_memberships no login); quem
pode ESCREVER num GLOBAL e' decidido na camada de servico
(isPlatformUser), nao pela RLS. Key nunca reexposta (so' apiKeyPreview).
## Dois bugs reais achados testando esta fase
- Delete de provider fazia hard delete, bloqueado por FK quando um
AIModel (mesmo soft-deleted) ainda referenciava — inconsistente com o
resto do sistema (tudo soft delete). Corrigido; GET /ai/providers
tambem nao filtrava desabilitados, corrigido junto.
- SensitiveDataRedactor: \b antes de \(? opcional falha quando o char
anterior tambem nao e' de palavra (espaco + "("), vazando um parenteses
solto (nenhum dado sensivel de verdade vazava). Corrigido com (?<!\w).
Verificado ponta a ponta com 2 tenants + platform admin: GLOBAL so'
platform admin cria/apaga, BYOK isolado por RLS (tenant B nunca ve' BYOK
do tenant A, 404 em id direto), modelo de provider GLOBAL visivel dos
dois tenants, redactor com 5 tipos de dado sensivel todos corretos.
typecheck do workspace inteiro limpo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
|
|||
| c24a86776c |
feat(recording): gravacao de chamadas + object storage abstraction
Fecha agente.md secao 90-94. A especificacao lista "Recording" e "Object
Storage" como dois passos separados na ordem de implementacao (secao
232), mas ficaram numa unica fase — sao acoplados o suficiente (Recording
precisa de um lugar pra guardar bytes) pra fazer sentido construir juntos.
## packages/storage — ObjectStorageProvider (secao 92)
Abstracao pequena: putObject/getObjectStream/deleteObject. Dois backends:
LocalObjectStorageProvider (filesystem, com checagem de path traversal
mesmo a key sendo sempre montada no servidor) e S3ObjectStorageProvider
(@aws-sdk/client-s3, preparado pra AWS S3 e MinIO via endpoint/
forcePathStyle customizaveis — nunca exercitado nesta sessao, sem
servidor S3 disponivel neste laboratorio). Escolhido por STORAGE_PROVIDER
env.
buildRecordingObjectKey (secao 93):
tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav, sempre montada
no servidor a partir de dados confiaveis.
## Bind mounts, nao volumes nomeados
/recordings e /data/object-storage usam bind mount pra um diretorio real
do host — apps/api roda no host, nao em Docker, e precisa enxergar os
mesmos arquivos que fs-events escreve. LOCAL_STORAGE_ROOT tem valores
diferentes por ambiente (mesmo padrao ja usado pra REDIS_URL).
## Quem grava: apps/predictive-dialer
So' chamadas originadas pelo discador com Campaign.recordingEnabled sao
gravadas nesta fase (unico caminho de originate que o sistema controla
hoje). RECORD_STEREO=true + execute_on_answer='record_session ...'
adicionados ao originate; origination_uuid pre-gerado (em vez de deixar o
provider sortear) porque o path de gravacao precisa dele antes do
comando de originate ser montado — o mesmo uuid vira Call.id no CDR.
## Quem sobe: apps/freeswitch-events/src/recording.ts
Em CALL_ENDED, encadeado depois do persistCallEvent terminar (nao em
paralelo) — uploadRecordingIfPresent le Call.talkTime/durationSeconds,
que e' exatamente o que persistCallEvent acabou de calcular no mesmo
evento (mesma classe de corrida ja corrigida uma vez na fase CDR, aqui
evitada por ordenacao). Sobe pro storage, cria Recording (retentionUntil
a partir de Plan.recordingRetentionDays), apaga o spool local.
## API + retencao
GET /recordings, GET /recordings/:id, GET /recordings/:id/audio (stream
autenticado, nunca URL direta pro storage). runRetentionSweep (secao 94)
no boot do apps/api + a cada hora — apaga o objeto, marca status=DELETED
(linha nunca apagada, fica como auditoria).
## Bug real achado testando esta fase
Recording.sizeBytes (BigInt) quebrava GET /recordings com 500 — Fastify
nao serializa BigInt nativamente (mesma classe de bug ja corrigida uma
vez no logger, fase Event Socket). Corrigido convertendo pra number na
resposta.
Verificado ponta a ponta: gravacao real criada (RIFF WAVE, PCM 16-bit,
ESTEREO 8000Hz — RECORD_STEREO confirmado), upload com path exato da
secao 93, download via API com md5 identico ao objeto original, varredura
de retencao apagando objeto + status DELETED + list/download bloqueados
depois. typecheck do workspace inteiro limpo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
|
|||
| 7b62ad3d82 |
feat(entitlements,campaigns): plans/quotas + campanhas, leads, lista de bloqueio
Fecha duas fases: Plans/Entitlements (agente.md secao 56-62), que tinha
ficado pra trás desde o inicio, e Campanhas/Leads/Lista de Bloqueio (secao
63-71).
## Plans/Entitlements
A ordem de implementacao da propria especificacao (secao 232) coloca
Plans/Entitlements logo depois de PostgreSQL RLS, bem antes de FreeSWITCH
— mas o build seguiu direto sem essa peca, e toda fase desde entao
documentou "quota depende de Plans/Entitlements" como pendencia
(EXTENSIONS.md, TRUNKS.md, AGENTS.md, QUEUES.md, agora todas atualizadas).
Fechado agora porque Campanhas precisa de max_campaigns e o proximo CPS
Limiter vai precisar de max_cps/max_concurrent_calls.
- plans: catalogo compartilhado entre tenants (sem RLS, nao e' tenant-
scoped) com todos os campos de entitlement da secao 56. Campo de limite
null = "sem limite", nunca "sem plano" — tenants.plan_id e' obrigatorio,
nunca null (secao 56: nao espalhar `if plan == PRO` pelo codigo).
- Migration hand-escrita: cria plans, insere seed "trial", faz backfill de
plan_id pros tenants ja existentes, so' depois torna NOT NULL (Postgres
nao deixa NOT NULL sem default em tabela nao-vazia).
- packages/entitlements (pacote novo): assertQuota/assertFeatureEnabled,
erros mapeados pra 403 no DomainExceptionFilter.
- Retrofit em Extensions/Trunks/Agents/Queues: contam linhas ativas e
checam quota antes de criar.
## Campanhas, Leads, Lista de Bloqueio
Deliberadamente so' o modelo/CRUD/maquina de estados — o motor que de fato
origina chamadas (PredictiveDialerEngine, secao 72-86: dados em tempo
real, EWMA, CPS distribuido, reserva atomica de lead, lock de campanha,
bgapi originate, controle de abandono, retry) e' um sistema grande o
suficiente pra merecer fase propria (secao 72: "nao e' so' `for lead ->
originate`").
- campaigns/leads/suppression_entries (tenant-scoped, RLS).
- Maquina de estados da campanha (secao 64-66): start/pause/drain/stop com
tabela de transicoes validas — transicao invalida retorna 400, nunca
ignora silenciosamente. Apagar bloqueado enquanto RUNNING/DRAINING.
- packages/shared/src/phone.ts (secao 70): normalizacao dedicada,
preparada pra E.164 completo, so' BR implementado.
- Importacao CSV em batches de 1000 (secao 69): detecta duplicado (dentro
do CSV + contra leads existentes), checa lista de bloqueio (importa como
DO_NOT_CALL, nao descarta), retorna {total, valid, invalid, duplicates,
imported, suppressed}.
- Lista de bloqueio (secao 71): CRUD tenant-scoped.
Verificado ponta a ponta: campanha com queueId/trunkId invalido e
pacingMin > pacingMax rejeitados; CSV de 5 linhas (1 invalida, 1
duplicada, 1 bloqueada) importado corretamente; start->pause->drain->stop
e transicoes invalidas todas corretas; 3a campanha rejeitada por quota
(max_campaigns=2 do plano trial); 6a extensao rejeitada por quota
(max_extensions=5). Suites de teste existentes (tenant-isolation, auth)
atualizadas pro novo Tenant.planId obrigatorio e passando.
typecheck do workspace inteiro limpo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
|
|||
| f051fe3162 |
feat(realtime): monitoramento em tempo real via WebSocket multi-tenant
Fecha agente.md secao 54-55 (infraestrutura) e 161 (WebSocket multi-tenant).
Entrega o pipeline de push em tempo real completo — o consumo visual
("Monitoramento -> Filas/Ramais") fica pra fase Frontend.
Requisito central da secao 161 ("nao transmitir tudo e filtrar so no
browser"): RealtimeGateway tem um unico ponto de emissao,
broadcastToTenant(), sempre server.to(`tenant:<id>`), nunca broadcast
global. Cada socket entra na room do proprio tenant no handshake, nunca
escolhe a room.
Autenticacao na conexao (handshake.auth.token, nao Authorization header):
valida o JWT (mesmo verifyAccessToken do JwtAuthGuard), exige tenantId no
token e a permission monitoring.view (ja existia desde RBAC, sem
consumidor ate agora) — mesmo principio de nunca confiar em tenant_id do
client, so do JWT ja emitido por /auth/select-tenant.
Origem dos eventos: canal Redis unico b2bcall:events (o mesmo desde Event
Socket). Dois produtores: b2bcall-fs-events (eventos do FreeSWITCH,
resolvendo tenantId por fan-out quando nao ha channel variable, ver
tenant-resolve.ts) e apps/api (mudancas no nosso Agent.state via
agents-me.controller, tenantId direto do JWT, sem fan-out).
Bug real achado e corrigido ao construir esta fase: nenhum evento CUSTOM do
ESL (sofia::register, sofia::gateway_state, callcenter::info) jamais
chegava em b2bcall-fs-events nesta sessao inteira. Causa: event_json(...)
mandava "CUSTOM" como ultimo token do comando `event json`, sem subclass
depois — mod_event_socket exige os subclasses logo depois do token CUSTOM
no mesmo comando pra serem entregues. Corrigido separando PLAIN_EVENTS
(viram listener .on()) de CUSTOM_SUBCLASSES (so compoem o comando de
assinatura). Resolve as lacunas ja documentadas em docs/TRUNKS.md e
docs/AGENTS.md. De quebra, corrigido um bug de nome de campo
(CC-Agent-Status, que nao existe -> CC-Agent-State) e um segundo bug real
em trunk-sync.ts (rescan nunca descarregava gateway removido -> agora roda
`killgw` antes do rescan).
Novos tipos normalizados a partir de callcenter::info, com nomes de campo
confirmados contra uma fila real: AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED,
QUEUE_MEMBER_COUNT (chamadas esperando, secao 54), QUEUE_MEMBER_LEFT (com
cause/cancelReason e timestamps — base pra Service Level/Abandon Rate
quando CDR existir).
Verificado ponta a ponta com um client socket.io real: login/pause/resume/
logout emitindo AGENT_STATE_CHANGED; chamada de teste numa fila com agente
logado emitindo QUEUE_MEMBER_COUNT/LEFT, AGENT_OFFERED_CALL,
AGENT_BRIDGE_FAILED, AGENT_STATUS_CHANGED (CC-Agent-State correto); token
ausente/invalido desconectado na hora, sem vazar nenhum evento.
Achado sistemico durante o teste (documentado, nao corrigido nesta fase):
@@unique combinado com soft delete, sem excluir deletedAt, em
Agent/Extension/Trunk/Queue/PauseReason — nao da pra reusar numero/nome/
codigo depois de apagar. Precisa de indice unico parcial em cada um, fora
do escopo desta fase.
typecheck do workspace inteiro limpo. ~144MB de memoria total nos
containers (fs-events 44MB, fs-config 45MB, freeswitch 26MB, postgres
21MB, redis 8MB).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
|
|||
| 0720a0efe3 |
feat: implement Dialplan with structured editor and versioning
- dialplan_extensions table (tenant-scoped, RLS): structured editor per agente.md secao 43 -- context, condition field/expr, actions/anti-actions (JSON), continue, order, enabled. One condition per extension (deliberate simplification vs raw FreeSWITCH's multi-condition extensions). - dialplan_versions table (tenant-scoped, RLS): generate/validate/version/ activate flow (secao 44). Reactivating an older version IS the rollback mechanism -- no separate endpoint needed. - apps/api/src/dialplan: extensions CRUD + versions/generate (builds XML, validates well-formedness with fast-xml-parser, saves as DRAFT) + versions/:id/activate (atomically flips ACTIVE, supersedes the previous one). Reused freeswitch.view/.configure permissions rather than inventing new ones not in the agente.md permission list. - packages/telephony: buildDialplanXml() plus ALLOWED_DIALPLAN_APPLICATIONS, an explicit allowlist (answer/bridge/playback/hangup/set/export/... -- deliberately no system/exec/socket) guarding against a tenant configuring a dialplan action that runs arbitrary commands on the FreeSWITCH host (agente.md secao 180) - b2bcall-fs-config resolves dialplan dynamically per call (unlike Trunks' file+rescan approach -- dialplan is fetched fresh via mod_xml_curl on every call anyway) by tenant id from the variable_b2bcall_tenant_id channel variable already injected at directory resolution, then serving whichever DialplanVersion is ACTIVE for that context - verified end-to-end: created a rule for destination_number 7000, generated and activated v1, originated a call that actually routed through the dialplan (not bypassing it via &app()) -- CALL_CREATED -> CALL_ANSWERED -> CALL_ENDED with the correct tenantId throughout. Created and activated a v2, then rolled back to v1 by reactivating it; status transitions (ACTIVE/SUPERSEDED) all confirmed via the API. CRITICAL FINDING, fixed in this same phase: deliberately testing that the application allowlist rejects 'system' got back 201 instead of 400 -- NestJS's ValidationPipe had been silently inert across all of apps/api's @Body() DTOs since the API was first created. Root cause: running via (esbuild) instead of a real build -- esbuild doesn't always resolve cross-file parameter types for design:paramtypes metadata, and Nest skips validation without any error when it can't determine the DTO class. Fixed by always building with tsc before running (tsc && tsx dist/main.js -- still via tsx because internal workspace packages aren't built to JS yet). Re-verified with two deliberate bad-input tests post-fix, both correctly rejected with 400. A stray malicious test row (dialplan action 'system') created while the bug was live was deleted; it was never baked into an activated version, so nothing could have executed it. See docs/VALIDATION_PIPE_BUG.md for the full writeup. docs/DIALPLAN.md, docs/VALIDATION_PIPE_BUG.md, docs/EXTENSIONS.md updated |
|||
| 68b403a7ff |
feat: add apps/api (NestJS + Fastify) with authentication endpoints
- POST /auth/login, /auth/refresh, /auth/logout, /auth/select-tenant, /auth/change-password, GET /auth/tenants — wired to packages/auth - JwtAuthGuard + DomainExceptionFilter (401/403 without leaking internals) - LoginRateLimitGuard: Redis-backed 5/min per IP and per email (agente.md secao 149), safe across multiple API instances - helmet + restrictive cors (deny-by-default) + global rate limit - GET /health, /health/live, /health/ready checking Postgres and Redis - changePassword() added to packages/auth for the mustChangePassword flow - fixed REDIS_HOST/POSTGRES_HOST docker-compose-only hostnames not resolving from the host process; added REDIS_URL for host-side use - verified end-to-end with curl: login, wrong password / unknown email (same generic error), authenticated route, missing token, refresh rotation, logout revocation, and the 429 rate limit kicking in after 5 attempts |