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:
168
apps/api/src/dialplan/dialplan-versions.controller.ts
Normal file
168
apps/api/src/dialplan/dialplan-versions.controller.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { XMLValidator } from "fast-xml-parser";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import {
|
||||
buildDialplanXml,
|
||||
type AllowedConditionField,
|
||||
type AllowedDialplanApplication,
|
||||
} from "@b2bcall/telephony";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("dialplan/versions")
|
||||
export class DialplanVersionsController {
|
||||
/**
|
||||
* "Gerar" + "validar" (agente.md secao 44): monta o XML a partir das
|
||||
* linhas atuais de dialplan_extensions pro context, valida
|
||||
* bem-formação, salva como nova versão DRAFT (não afeta chamadas até
|
||||
* ser ativada).
|
||||
*/
|
||||
@RequirePermission("freeswitch.configure")
|
||||
@Post("generate")
|
||||
async generate(@CurrentUser() user: AccessTokenClaims, @Query("context") context = "default") {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const extensions = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanExtension.findMany({
|
||||
where: { context, enabled: true, deletedAt: null },
|
||||
orderBy: { order: "asc" },
|
||||
}),
|
||||
);
|
||||
|
||||
const xml = buildDialplanXml(
|
||||
context,
|
||||
extensions.map((e) => ({
|
||||
name: e.name,
|
||||
conditionField: e.conditionField as AllowedConditionField,
|
||||
conditionExpr: e.conditionExpr,
|
||||
actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[],
|
||||
antiActions: e.antiActions as unknown as
|
||||
| { application: AllowedDialplanApplication; data?: string }[]
|
||||
| undefined,
|
||||
continueOnFalse: e.continueOnFalse,
|
||||
order: e.order,
|
||||
})),
|
||||
);
|
||||
|
||||
const validation = XMLValidator.validate(xml);
|
||||
if (validation !== true) {
|
||||
// Bug nosso (o XML é gerado por template escapado, nunca deveria
|
||||
// acontecer) — melhor falhar alto do que salvar XML quebrado.
|
||||
throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`);
|
||||
}
|
||||
|
||||
const last = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanVersion.findFirst({
|
||||
where: { tenantId, context },
|
||||
orderBy: { version: "desc" },
|
||||
}),
|
||||
);
|
||||
const nextVersion = (last?.version ?? 0) + 1;
|
||||
|
||||
const version = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanVersion.create({
|
||||
data: {
|
||||
tenantId,
|
||||
context,
|
||||
version: nextVersion,
|
||||
generatedXml: xml,
|
||||
status: "DRAFT",
|
||||
createdByUserId: user.sub,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "DIALPLAN_VERSION_GENERATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "dialplan_version",
|
||||
entityId: version.id,
|
||||
after: { context, version: nextVersion, extensionCount: extensions.length },
|
||||
});
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
@RequirePermission("freeswitch.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims, @Query("context") context?: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
return withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanVersion.findMany({
|
||||
where: context ? { context } : {},
|
||||
orderBy: [{ context: "asc" }, { version: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
context: true,
|
||||
version: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
activatedAt: true,
|
||||
createdByUserId: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ativar" (secao 44). Reativar uma versão antiga É o rollback — não
|
||||
* existe endpoint separado. Como o dialplan é resolvido por chamada via
|
||||
* mod_xml_curl (não é config estática lida uma vez no boot), não precisa
|
||||
* de reloadxml/rescan: a próxima chamada já enxerga a versão ativa.
|
||||
*/
|
||||
@RequirePermission("freeswitch.configure")
|
||||
@Post(":id/activate")
|
||||
async activate(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const target = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanVersion.findFirst({ where: { id, tenantId } }),
|
||||
);
|
||||
if (!target) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Já roda dentro da transação de withTenantContext — as duas operações
|
||||
// são atômicas sem precisar de um $transaction aninhado.
|
||||
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
await tx.dialplanVersion.updateMany({
|
||||
where: { tenantId, context: target.context, status: "ACTIVE" },
|
||||
data: { status: "SUPERSEDED" },
|
||||
});
|
||||
await tx.dialplanVersion.update({
|
||||
where: { id: target.id },
|
||||
data: { status: "ACTIVE", activatedAt: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "DIALPLAN_VERSION_ACTIVATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "dialplan_version",
|
||||
entityId: target.id,
|
||||
after: { context: target.context, version: target.version },
|
||||
});
|
||||
|
||||
return withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanVersion.findUniqueOrThrow({ where: { id: target.id } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user