Continuação de "montar o IVR e as rotas de entrada": com a fundação de
roteamento por DID já funcionando, faltava o menu em si.
`play_and_get_digits` adicionado ao allowlist de applications (só coleta
dígitos numa variable, nunca executa nada — mesmo risco zero dos outros
já permitidos). `ALLOWED_CONDITION_FIELDS` ganhou um segundo modo:
`${variavel}` simples além dos 6 campos fixos, com a mesma garantia
anti-RCE de `data` (o regex exige identificador puro, sem parênteses,
então nunca vira `${funcname(...)}`).
Achado real construindo o primeiro menu de teste: FreeSWITCH resolve
TODAS as condições de um contexto antes de executar qualquer ação — uma
variable setada por `play_and_get_digits` nunca afeta o casamento de
OUTRA extension na mesma passada. Descoberto porque o primeiro desenho
(2 extensions separadas, uma coletando o dígito, outra checando
`${ivr_choice}`) nunca bateu num teste real com DTMF — o regex era
avaliado com a variable ainda vazia. A forma que funciona: um `transfer`
explícito, na MESMA extension, usando o dígito coletado como novo
destination_number — isso dispara uma consulta de dialplan nova (as
variables já setadas persistem), e as extensions seguintes casam por
destination_number normal.
Testado ponta a ponta com DTMF de verdade: como linphonec não tem um
comando de enviar DTMF interativo, usei `fs_cli uuid_recv_dtmf` (API do
FreeSWITCH que simula o dígito chegando como input do chamador, mesmo
caminho que RFC2833 usaria). Softphone externo ligou pro DID, o IVR
atendeu, tocou o prompt, esperou o dígito: "1" bridged com um ramal real
(atendeu, áudio PCMU bidirecional confirmado); "2" foi pra uma resposta
alternativa (tom diferente) — confirma que a ramificação distingue de
verdade, não só cai sempre na primeira opção.
Detalhes completos em docs/INBOUND_ROUTES.md. Falta só a UI dedicada de
autoria de menu (hoje construído à mão no editor genérico de dialplan) —
backend já suporta tudo que ela precisaria gerar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
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 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,
|
|
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 } }),
|
|
);
|
|
}
|
|
}
|