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:
@@ -3,8 +3,9 @@ import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||
import { TrunksModule } from "./trunks/trunks.module";
|
||||
import { DialplanModule } from "./dialplan/dialplan.module";
|
||||
|
||||
@Module({
|
||||
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule],
|
||||
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
97
apps/api/src/dialplan/dialplan-extensions.controller.ts
Normal file
97
apps/api/src/dialplan/dialplan-extensions.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
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";
|
||||
import { CreateDialplanExtensionDto } from "./dto/create-dialplan-extension.dto";
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("dialplan/extensions")
|
||||
export class DialplanExtensionsController {
|
||||
@RequirePermission("freeswitch.configure")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateDialplanExtensionDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const extension = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanExtension.create({
|
||||
data: {
|
||||
tenantId,
|
||||
context: dto.context ?? "default",
|
||||
name: dto.name,
|
||||
conditionField: dto.conditionField,
|
||||
conditionExpr: dto.conditionExpr,
|
||||
actions: dto.actions as unknown as Prisma.InputJsonValue,
|
||||
antiActions: dto.antiActions as unknown as Prisma.InputJsonValue | undefined,
|
||||
continueOnFalse: dto.continueOnFalse ?? false,
|
||||
order: dto.order ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "DIALPLAN_EXTENSION_CREATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "dialplan_extension",
|
||||
entityId: extension.id,
|
||||
after: { name: extension.name, context: extension.context },
|
||||
});
|
||||
|
||||
return extension;
|
||||
}
|
||||
|
||||
@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.dialplanExtension.findMany({
|
||||
where: { deletedAt: null, ...(context ? { context } : {}) },
|
||||
orderBy: [{ context: "asc" }, { order: "asc" }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission("freeswitch.configure")
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanExtension.updateMany({
|
||||
where: { id, deletedAt: null },
|
||||
data: { deletedAt: new Date(), enabled: false },
|
||||
}),
|
||||
);
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "DIALPLAN_EXTENSION_DELETE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "dialplan_extension",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
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 } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
8
apps/api/src/dialplan/dialplan.module.ts
Normal file
8
apps/api/src/dialplan/dialplan.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DialplanExtensionsController } from "./dialplan-extensions.controller";
|
||||
import { DialplanVersionsController } from "./dialplan-versions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [DialplanExtensionsController, DialplanVersionsController],
|
||||
})
|
||||
export class DialplanModule {}
|
||||
12
apps/api/src/dialplan/dto/action.dto.ts
Normal file
12
apps/api/src/dialplan/dto/action.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony";
|
||||
|
||||
export class ActionDto {
|
||||
@IsIn(ALLOWED_DIALPLAN_APPLICATIONS)
|
||||
application!: AllowedDialplanApplication;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
data?: string;
|
||||
}
|
||||
55
apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts
Normal file
55
apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ALLOWED_CONDITION_FIELDS, type AllowedConditionField } from "@b2bcall/telephony";
|
||||
import { ActionDto } from "./action.dto";
|
||||
|
||||
export class CreateDialplanExtensionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
context?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsIn(ALLOWED_CONDITION_FIELDS)
|
||||
conditionField!: AllowedConditionField;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
conditionExpr!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(20)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ActionDto)
|
||||
actions!: ActionDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ActionDto)
|
||||
antiActions?: ActionDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
continueOnFalse?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
order?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user