import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param, Patch, Post, UseGuards, } from "@nestjs/common"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { generateStrongPassword, encryptSecret, decryptSecret } from "@b2bcall/shared"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { assertQuota } from "@b2bcall/entitlements"; 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 { CreateExtensionDto, UpdateExtensionDto } from "./dto/create-extension.dto"; function toPublicExtension(ext: { id: string; number: string; name: string; domain: string; sipPasswordEnc: string; callerIdName: string | null; callerIdNumber: string | null; context: string; sofiaProfile: string; codecs: string; callGroup: string | null; maxRegistrations: number; enabled: boolean; createdAt: Date; }) { // sipPasswordEnc NUNCA sai daqui — agente.md secao 39: "Nunca mostrar // novamente a senha inteira". Destructuring explícito (não spread) pra // garantir que o campo é de fato removido, não só "esquecido" no tipo. const { sipPasswordEnc: _sipPasswordEnc, ...rest } = ext; return rest; } @UseGuards(JwtAuthGuard, PermissionGuard) @Controller("extensions") export class ExtensionsController { @RequirePermission("extensions.manage") @Post() async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateExtensionDto) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const tenant = await withTenantContext(prisma, tenantId, (tx) => tx.tenant.findUniqueOrThrow({ where: { id: tenantId } }), ); if (!tenant.telephonyDomain) { throw new BadRequestException( "Tenant ainda nao tem telephony_domain configurado — necessario antes de criar ramais", ); } const activeCount = await withTenantContext(prisma, tenantId, (tx) => tx.extension.count({ where: { tenantId, deletedAt: null } }), ); await assertQuota(tenantId, "maxExtensions", activeCount); const plainPassword = generateStrongPassword(); const extension = await withTenantContext(prisma, tenantId, (tx) => tx.extension.create({ data: { tenantId, number: dto.number, name: dto.name, domain: tenant.telephonyDomain!, sipPasswordEnc: encryptSecret(plainPassword), callerIdName: dto.callerIdName, callerIdNumber: dto.callerIdNumber, context: dto.context ?? "default", sofiaProfile: dto.sofiaProfile ?? "internal", maxRegistrations: dto.maxRegistrations ?? 1, callGroup: dto.callGroup, }, }), ); await recordAuditEvent(prisma, { action: "EXTENSION_CREATE", tenantId, userId: user.sub, entityType: "extension", entityId: extension.id, after: { number: extension.number, name: extension.name }, }); return { ...toPublicExtension(extension), // Só aqui, uma unica vez, na resposta da criacao. sipPassword: plainPassword, }; } @RequirePermission("extensions.view") @Get() async list(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const extensions = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findMany({ where: { deletedAt: null }, orderBy: { number: "asc" } }), ); return extensions.map(toPublicExtension); } @RequirePermission("extensions.view") @Get(":id") async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const extension = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirst({ where: { id, deletedAt: null } }), ); if (!extension) { throw new NotFoundException(); } return toPublicExtension(extension); } /** * Redefine a senha SIP (agente.md secao 39: nunca reexpor a senha * existente — a única forma de "editar" é gerar uma nova e mostrar * ela UMA vez, mesmo caminho da criação). `b2bcall-fs-config` resolve * o directory ao vivo por request (sem arquivo/sync intermediário, * diferente de trunks/queues) — um `UPDATE` aqui já é o suficiente, * o próximo REGISTER do ramal usa a senha nova automaticamente. */ @RequirePermission("extensions.manage") @Post(":id/reset-password") async resetPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const plainPassword = generateStrongPassword(); const result = await withTenantContext(prisma, tenantId, (tx) => tx.extension.updateMany({ where: { id, tenantId, deletedAt: null }, data: { sipPasswordEnc: encryptSecret(plainPassword) }, }), ); if (result.count === 0) { throw new NotFoundException(); } await recordAuditEvent(prisma, { action: "EXTENSION_RESET_PASSWORD", tenantId, userId: user.sub, entityType: "extension", entityId: id, }); return { sipPassword: plainPassword }; } /** * Revela a senha SIP atual (achado real reportado pelo usuário: "show * once" puro não funciona no dia a dia — reconfigurar um telefone físico * ou um softphone precisa da senha de novo, e forçar reset toda vez * derruba o registro de qualquer aparelho já configurado com a senha * antiga). Diferente de `resetPassword`: não gera senha nova, só * decifra a que já existe (`sipPasswordEnc` é criptografia reversível * AES-256-GCM, não hash — sempre foi possível decifrar, só não estava * exposto). Cada chamada fica no audit log — ver a senha de novo é uma * ação sensível, mesmo sem trocar nada. */ @RequirePermission("extensions.manage") @Post(":id/reveal-password") async revealPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const extension = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirst({ where: { id, tenantId, deletedAt: null } }), ); if (!extension) throw new NotFoundException(); await recordAuditEvent(prisma, { action: "EXTENSION_PASSWORD_REVEALED", tenantId, userId: user.sub, entityType: "extension", entityId: id, }); return { sipPassword: decryptSecret(extension.sipPasswordEnc) }; } @RequirePermission("extensions.manage") @Patch(":id") async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateExtensionDto) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const result = await withTenantContext(prisma, tenantId, (tx) => tx.extension.updateMany({ where: { id, tenantId, deletedAt: null }, data: { ...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}), ...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}), ...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}), ...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}), }, }), ); if (result.count === 0) throw new NotFoundException(); const updated = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirstOrThrow({ where: { id } })); await recordAuditEvent(prisma, { action: "EXTENSION_UPDATE", tenantId, userId: user.sub, entityType: "extension", entityId: id, after: { ...dto }, }); return toPublicExtension(updated); } @RequirePermission("extensions.manage") @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.extension.updateMany({ where: { id, deletedAt: null }, data: { deletedAt: new Date(), enabled: false }, }), ); if (result.count === 0) { throw new NotFoundException(); } await recordAuditEvent(prisma, { action: "EXTENSION_DELETE", tenantId, userId: user.sub, entityType: "extension", entityId: id, }); } }