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
This commit is contained in:
@@ -36,6 +36,8 @@ model Tenant {
|
||||
userRoles UserRole[]
|
||||
extensions Extension[]
|
||||
trunks Trunk[]
|
||||
dialplanExtensions DialplanExtension[]
|
||||
dialplanVersions DialplanVersion[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -311,3 +313,69 @@ model Trunk {
|
||||
@@index([tenantId])
|
||||
@@map("trunks")
|
||||
}
|
||||
|
||||
enum DialplanVersionStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
SUPERSEDED
|
||||
|
||||
@@map("dialplan_version_status")
|
||||
}
|
||||
|
||||
// Editor estruturado (agente.md secao 43) — fonte editável. As versões
|
||||
// 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
|
||||
|
||||
context String @default("default")
|
||||
name String
|
||||
|
||||
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")
|
||||
|
||||
continueOnFalse Boolean @default(false) @map("continue_on_false")
|
||||
order Int @default(0)
|
||||
|
||||
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])
|
||||
|
||||
@@index([tenantId, context])
|
||||
@@map("dialplan_extensions")
|
||||
}
|
||||
|
||||
// Versionamento (agente.md secao 44): gerar -> validar -> versionar ->
|
||||
// ativar -> reloadxml -> verificar -> rollback se necessário. "Ativar" uma
|
||||
// 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
|
||||
|
||||
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")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@unique([tenantId, context, version])
|
||||
@@index([tenantId, context, status])
|
||||
@@map("dialplan_versions")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user