Compare commits
48 Commits
d4e2513764
...
97ef8a6ba8
| Author | SHA1 | Date | |
|---|---|---|---|
| 97ef8a6ba8 | |||
| a779a7e51f | |||
| 64ef0892ed | |||
| 738bfa4f36 | |||
| 1b05d93c48 | |||
| 26cc79cec2 | |||
| 5bbe9e6800 | |||
| 0148b926a0 | |||
| 486c39803a | |||
| 36e85c1abf | |||
| 83eb2c5aea | |||
| 7763afacc0 | |||
| ed4ae4421e | |||
| fc4f5db13a | |||
| 2cd638d26f | |||
| af46739af2 | |||
| 56f73bdb8f | |||
| 7bc4b2e819 | |||
| a15ea26cb5 | |||
| 4b927e9d9e | |||
| 798aa7b579 | |||
| 6f18b3a297 | |||
| fcf334c7c0 | |||
| cc80310cb5 | |||
| 12af12f276 | |||
| b1a409da09 | |||
| de0b632fc9 | |||
| 2fb5010283 | |||
| 52e1b4f3b7 | |||
| 4480e9265e | |||
| e9dc973aee | |||
| 03ec0d556b | |||
| c95c6805fb | |||
| a23e68b011 | |||
| 2c1269a83a | |||
| 3479dbd007 | |||
| 7f56c96454 | |||
| 75212cafe6 | |||
| 4d112fcea0 | |||
| f1cadddc91 | |||
| ca49504f0a | |||
| de1e7ac49c | |||
| 927a2623c2 | |||
| 34ef408f00 | |||
| 565110eac3 | |||
| aad16d136e | |||
| 673553975e | |||
| b27cfaab02 |
82
.env.example
@@ -1,8 +1,82 @@
|
|||||||
|
# Copie pra `.env` (gitignored) e preencha. Ver docs/QA_SETUP.md pro passo a
|
||||||
|
# passo completo de como subir o ambiente do zero numa máquina nova.
|
||||||
|
|
||||||
|
# Token de acesso ao repositório de pacotes .deb do FreeSWITCH
|
||||||
|
# (freeswitch.signalwire.com) — só existe durante o build da imagem Docker
|
||||||
|
# (BuildKit secret, nunca vira camada da imagem nem chega no container em
|
||||||
|
# runtime). Peça este valor a quem já tem uma conta SignalWire; não é gerável
|
||||||
|
# localmente.
|
||||||
FREESWITCH_PAT=
|
FREESWITCH_PAT=
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
|
||||||
POSTGRES_APP_USER=
|
# apps/api roda direto no host (fora do Docker) — fixado aqui pra nunca
|
||||||
|
# depender de quem inicia primeiro disputando a porta padrão 3000 com o
|
||||||
|
# apps/frontend.
|
||||||
|
API_PORT=3000
|
||||||
|
|
||||||
|
# --- Postgres (container b2bcall-postgres, gerar senhas com `openssl rand -base64 24`) ---
|
||||||
|
POSTGRES_USER=b2bcall
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_DB=b2bcall
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# --- Redis (container b2bcall-redis) ---
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# --- Segredos da aplicação (gerar cada um com `openssl rand -hex 32`) ---
|
||||||
|
JWT_SECRET=
|
||||||
|
JWT_REFRESH_SECRET=
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Event Socket do FreeSWITCH — só apps/api (systemd, no host, fora do Docker)
|
||||||
|
# lê isto. "freeswitch" é um nome DNS que só existe na rede interna do
|
||||||
|
# Docker; os serviços que RODAM dentro do Docker (fs-events, fs-config,
|
||||||
|
# predictive-dialer) têm ESL_HOST=freeswitch fixo no próprio
|
||||||
|
# docker-compose.yml, nunca leem esta variável. Precisa ser 127.0.0.1 (a
|
||||||
|
# porta 8021 é publicada só em loopback, ver docker-compose.yml) — nunca
|
||||||
|
# "freeswitch" aqui.
|
||||||
|
ESL_HOST=127.0.0.1
|
||||||
|
ESL_PORT=8021
|
||||||
|
ESL_PASSWORD=
|
||||||
|
|
||||||
|
# Segurança de chamada real (agente.md secao 186) — as DUAS precisam estar
|
||||||
|
# explicitamente na posição de risco pra originar uma chamada PSTN de
|
||||||
|
# verdade. Deixe como está pra um ambiente de QA/laboratório.
|
||||||
|
DIALER_SIMULATION=true
|
||||||
|
ALLOW_REAL_OUTBOUND_CALLS=false
|
||||||
|
|
||||||
|
# --- Gravações (agente.md secao 90-93) ---
|
||||||
|
STORAGE_PROVIDER=LOCAL
|
||||||
|
LOCAL_STORAGE_ROOT=/opt/b2bcall/data/object-storage-local
|
||||||
|
|
||||||
|
# --- Prompts de áudio do IVR (PHASE 59) ---
|
||||||
|
IVR_PROMPTS_HOST_ROOT=/opt/b2bcall/data/ivr-prompts
|
||||||
|
|
||||||
|
# --- S3/MinIO (só usado se STORAGE_PROVIDER=s3; deixe em branco pro padrão LOCAL) ---
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# --- Connection strings derivadas dos valores acima (preencher com os
|
||||||
|
# mesmos POSTGRES_PASSWORD/REDIS_PASSWORD já definidos) ---
|
||||||
|
DATABASE_URL=postgresql://b2bcall:@localhost:5432/b2bcall?schema=public
|
||||||
|
REDIS_URL=redis://:@localhost:6379
|
||||||
|
|
||||||
|
# Role restrito (não-superuser) que a aplicação usa em runtime pra RLS
|
||||||
|
# funcionar de verdade (agente.md secao 32) — a role em si é criada por uma
|
||||||
|
# migration do Prisma, mas a senha nunca fica num arquivo de migration
|
||||||
|
# commitado (ver scripts/db-setup-app-role.sh).
|
||||||
|
POSTGRES_APP_USER=b2bcall_app
|
||||||
POSTGRES_APP_PASSWORD=
|
POSTGRES_APP_PASSWORD=
|
||||||
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
APP_DATABASE_URL=postgresql://b2bcall_app:@localhost:5432/b2bcall?schema=public
|
||||||
REDIS_URL=redis://:password@localhost:6379
|
|
||||||
|
# Credenciais que b2bcall-fs-config usa pra autenticar no Event Socket
|
||||||
|
# quando o PRÓPRIO FreeSWITCH chama de volta (mod_xml_curl) — role de app
|
||||||
|
# dedicado, nada a ver com FS_CONFIG_USER de um tenant.
|
||||||
FS_CONFIG_USER=
|
FS_CONFIG_USER=
|
||||||
FS_CONFIG_PASSWORD=
|
FS_CONFIG_PASSWORD=
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
FIRST_LOGIN.txt
|
FIRST_LOGIN.txt
|
||||||
|
ACESSO_TESTE.md
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.next/
|
.next/
|
||||||
.turbo/
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Impeccable per-developer overrides (see .claude/skills/impeccable)
|
||||||
|
.impeccable/config.local.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@b2bcall/ai": "workspace:*",
|
"@b2bcall/ai": "workspace:*",
|
||||||
"@b2bcall/auth": "workspace:*",
|
"@b2bcall/auth": "workspace:*",
|
||||||
|
"@b2bcall/billing": "workspace:*",
|
||||||
"@b2bcall/database": "workspace:*",
|
"@b2bcall/database": "workspace:*",
|
||||||
"@b2bcall/entitlements": "workspace:*",
|
"@b2bcall/entitlements": "workspace:*",
|
||||||
"@b2bcall/shared": "workspace:*",
|
"@b2bcall/shared": "workspace:*",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"@b2bcall/telephony": "workspace:*",
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"@fastify/cors": "11.3.0",
|
"@fastify/cors": "11.3.0",
|
||||||
"@fastify/helmet": "13.1.1",
|
"@fastify/helmet": "13.1.1",
|
||||||
|
"@fastify/multipart": "^10.1.1",
|
||||||
"@fastify/rate-limit": "11.2.0",
|
"@fastify/rate-limit": "11.2.0",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.0.1",
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { decryptSecret } from "@b2bcall/shared";
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { PauseDto } from "./dto/pause.dto";
|
import { PauseDto } from "./dto/pause.dto";
|
||||||
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
|
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
|
||||||
import { publishAgentStateChanged } from "../realtime/realtime-publish.helper";
|
import { publishAgentStateChanged } from "../realtime/realtime-publish.helper";
|
||||||
|
import { WEBRTC_PROXY_SETTING_KEY } from "../platform/platform-webrtc-proxy.controller";
|
||||||
|
|
||||||
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
|
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
|
||||||
const agent = await tx.agent.findFirst({
|
const agent = await tx.agent.findFirst({
|
||||||
@@ -35,6 +38,87 @@ async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userI
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller("agents/me")
|
@Controller("agents/me")
|
||||||
export class AgentsMeController {
|
export class AgentsMeController {
|
||||||
|
/**
|
||||||
|
* Achado real reportado pelo usuário: "não achei como deixar o agente
|
||||||
|
* online" — os endpoints de login/pausa/logout sempre existiram, mas
|
||||||
|
* não tinha nenhum jeito do frontend saber SE o usuário logado tem um
|
||||||
|
* Agent vinculado (pra mostrar o controle) nem qual o estado atual.
|
||||||
|
* 404 aqui = usuário sem Agent neste tenant, não um erro — é assim que
|
||||||
|
* o widget da topbar decide se aparece ou não.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async me(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
state: agent.state,
|
||||||
|
enabled: agent.enabled,
|
||||||
|
hasExtension: agent.extensionId != null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credenciais SIP + endereço do proxy WebRTC pro softphone embutido
|
||||||
|
* (Handphone, PHASE 66) — sempre o ramal do PRÓPRIO agente, nunca um
|
||||||
|
* agentId/extensionId arbitrário do client (mesmo principio de
|
||||||
|
* `findMyAgent`). Diferente de `POST /extensions/:id/reveal-password`
|
||||||
|
* (que exige `extensions.manage`, permissão que um agente comum nunca
|
||||||
|
* tem): aqui não há permissão nenhuma além de "sou um agente logado
|
||||||
|
* com ramal vinculado" — é o próprio agente pegando a própria senha
|
||||||
|
* pra usar no softphone, não uma ação administrativa sobre o ramal de
|
||||||
|
* outra pessoa. Cada acesso fica no audit log (mesma lógica de
|
||||||
|
* `revealPassword`: decifrar de novo é sensível mesmo sem trocar nada).
|
||||||
|
*/
|
||||||
|
@Get("softphone-config")
|
||||||
|
async softphoneConfig(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.agent.findFirst({ where: { tenantId, userId: user.sub, deletedAt: null }, include: { extension: true } }),
|
||||||
|
);
|
||||||
|
if (!agent) {
|
||||||
|
throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant");
|
||||||
|
}
|
||||||
|
if (!agent.extension) {
|
||||||
|
return { hasExtension: false as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "AGENT_SOFTPHONE_CONFIG_ACCESSED",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "extension",
|
||||||
|
entityId: agent.extension.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasExtension: true as const,
|
||||||
|
username: agent.extension.number,
|
||||||
|
domain: agent.extension.domain,
|
||||||
|
password: decryptSecret(agent.extension.sipPasswordEnc),
|
||||||
|
displayName: agent.name,
|
||||||
|
proxyUrl: setting?.value ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Motivos de pausa pro próprio agente escolher — sem exigir
|
||||||
|
* `agents.view` (que listaria TODOS os agentes do tenant, permissão
|
||||||
|
* que o role "agent" nunca precisou ter até aqui). */
|
||||||
|
@Get("pause-reasons")
|
||||||
|
async pauseReasons(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.pauseReason.findMany({ where: { tenantId, enabled: true }, orderBy: { name: "asc" } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal,
|
/** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal,
|
||||||
* cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */
|
* cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */
|
||||||
@Post("login")
|
@Post("login")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -36,6 +37,26 @@ export class AgentsController {
|
|||||||
);
|
);
|
||||||
await assertQuota(tenantId, "maxAgents", activeCount);
|
await assertQuota(tenantId, "maxAgents", activeCount);
|
||||||
|
|
||||||
|
// Bug real, achado construindo a tela de Agentes (PHASE 29): nada
|
||||||
|
// aqui verificava que `dto.userId`/`dto.extensionId` pertencem a este
|
||||||
|
// tenant antes do create — um FK constraint no Postgres não passa
|
||||||
|
// pela RLS de visibilidade (só checa existência da linha), então um
|
||||||
|
// id de outro tenant seria aceito silenciosamente. O frontend já só
|
||||||
|
// oferece opções do próprio tenant, mas o backend é a autoridade
|
||||||
|
// (secao 31/146), nunca confia só no que o client filtrou.
|
||||||
|
const [membership, extension] = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: dto.userId } } }),
|
||||||
|
dto.extensionId ? tx.extension.findFirst({ where: { id: dto.extensionId, tenantId } }) : Promise.resolve(null),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new BadRequestException("userId nao pertence a este tenant");
|
||||||
|
}
|
||||||
|
if (dto.extensionId && !extension) {
|
||||||
|
throw new BadRequestException("extensionId nao pertence a este tenant");
|
||||||
|
}
|
||||||
|
|
||||||
const agent = await withTenantContext(prisma, tenantId, (tx) =>
|
const agent = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
tx.agent.create({
|
tx.agent.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
|
||||||
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
@@ -23,6 +23,16 @@ export class AIModelsController {
|
|||||||
);
|
);
|
||||||
if (!provider) throw new NotFoundException("Provider nao encontrado");
|
if (!provider) throw new NotFoundException("Provider nao encontrado");
|
||||||
|
|
||||||
|
// Achado real (mesma classe já corrigida em `AIProvidersController`):
|
||||||
|
// a RLS híbrida (secao 100, `OR tenant_id IS NULL`) deixa qualquer
|
||||||
|
// tenant ENXERGAR um provider GLOBAL, mas escrever um modelo nele é
|
||||||
|
// catálogo de plataforma — sem este check, qualquer Tenant Admin com
|
||||||
|
// `ai.manage` (permission de escopo TENANT) conseguia injetar um
|
||||||
|
// modelo no catálogo visível por TODOS os tenants.
|
||||||
|
if (provider.scope === "GLOBAL" && !(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode adicionar modelo a um provider GLOBAL");
|
||||||
|
}
|
||||||
|
|
||||||
const model = await withTenantContext(prisma, tenantId, (tx) =>
|
const model = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
tx.aIModel.create({
|
tx.aIModel.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -67,14 +77,26 @@ export class AIModelsController {
|
|||||||
const prisma = getPrismaClient();
|
const prisma = getPrismaClient();
|
||||||
const tenantId = user.tenantId!;
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
const model = await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.findFirst({ where: { id } }));
|
||||||
tx.aIModel.updateMany({ where: { id }, data: { enabled: false } }),
|
if (!model) throw new NotFoundException();
|
||||||
);
|
|
||||||
if (result.count === 0) throw new NotFoundException();
|
// Mesmo achado do create(): sem este check, qualquer Tenant Admin com
|
||||||
|
// `ai.manage` conseguia desabilitar um modelo GLOBAL (visível e
|
||||||
|
// usado por todos os tenants) só por conhecer o id.
|
||||||
|
if (model.tenantId == null && !(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode remover um modelo GLOBAL");
|
||||||
|
}
|
||||||
|
if (model.tenantId != null && model.tenantId !== tenantId) {
|
||||||
|
// Nunca deveria acontecer (RLS ja' filtra), mas nunca custa checar
|
||||||
|
// explicitamente antes de uma escrita (secao 31/146).
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.update({ where: { id }, data: { enabled: false } }));
|
||||||
|
|
||||||
await recordAuditEvent(prisma, {
|
await recordAuditEvent(prisma, {
|
||||||
action: "AI_MODEL_DELETE",
|
action: "AI_MODEL_DELETE",
|
||||||
tenantId,
|
tenantId: model.tenantId,
|
||||||
userId: user.sub,
|
userId: user.sub,
|
||||||
entityType: "ai_model",
|
entityType: "ai_model",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { HealthModule } from "./health/health.module";
|
|||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||||
import { TrunksModule } from "./trunks/trunks.module";
|
import { TrunksModule } from "./trunks/trunks.module";
|
||||||
|
import { InboundRoutesModule } from "./inbound-routes/inbound-routes.module";
|
||||||
|
import { IvrModule } from "./ivr/ivr.module";
|
||||||
import { DialplanModule } from "./dialplan/dialplan.module";
|
import { DialplanModule } from "./dialplan/dialplan.module";
|
||||||
import { QueuesModule } from "./queues/queues.module";
|
import { QueuesModule } from "./queues/queues.module";
|
||||||
import { AgentsModule } from "./agents/agents.module";
|
import { AgentsModule } from "./agents/agents.module";
|
||||||
|
import { UsersModule } from "./users/users.module";
|
||||||
import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module";
|
import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module";
|
||||||
import { RealtimeModule } from "./realtime/realtime.module";
|
import { RealtimeModule } from "./realtime/realtime.module";
|
||||||
import { CampaignsModule } from "./campaigns/campaigns.module";
|
import { CampaignsModule } from "./campaigns/campaigns.module";
|
||||||
@@ -17,6 +20,10 @@ import { ReportsModule } from "./reports/reports.module";
|
|||||||
import { RecordingsModule } from "./recordings/recordings.module";
|
import { RecordingsModule } from "./recordings/recordings.module";
|
||||||
import { AIModule } from "./ai/ai.module";
|
import { AIModule } from "./ai/ai.module";
|
||||||
import { QualityModule } from "./quality/quality.module";
|
import { QualityModule } from "./quality/quality.module";
|
||||||
|
import { PlatformModule } from "./platform/platform.module";
|
||||||
|
import { BillingModule } from "./billing/billing.module";
|
||||||
|
import { TenantsModule } from "./tenants/tenants.module";
|
||||||
|
import { PlansModule } from "./plans/plans.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,9 +31,12 @@ import { QualityModule } from "./quality/quality.module";
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
ExtensionsModule,
|
ExtensionsModule,
|
||||||
TrunksModule,
|
TrunksModule,
|
||||||
|
InboundRoutesModule,
|
||||||
|
IvrModule,
|
||||||
DialplanModule,
|
DialplanModule,
|
||||||
QueuesModule,
|
QueuesModule,
|
||||||
AgentsModule,
|
AgentsModule,
|
||||||
|
UsersModule,
|
||||||
PauseReasonsModule,
|
PauseReasonsModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
CampaignsModule,
|
CampaignsModule,
|
||||||
@@ -38,6 +48,10 @@ import { QualityModule } from "./quality/quality.module";
|
|||||||
RecordingsModule,
|
RecordingsModule,
|
||||||
AIModule,
|
AIModule,
|
||||||
QualityModule,
|
QualityModule,
|
||||||
|
PlatformModule,
|
||||||
|
BillingModule,
|
||||||
|
TenantsModule,
|
||||||
|
PlansModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Get, HttpCode, HttpStatus, NotFoundException, Post, Req, UseGuards } from "@nestjs/common";
|
||||||
import type { FastifyRequest } from "fastify";
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
import {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
|
getUserPermissionKeys,
|
||||||
|
isPlatformUser,
|
||||||
listUserTenants,
|
listUserTenants,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
@@ -44,6 +47,37 @@ export class AuthController {
|
|||||||
await logout(user.sessionId);
|
await logout(user.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get("me")
|
||||||
|
async me(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: user.sub },
|
||||||
|
select: { id: true, email: true, name: true },
|
||||||
|
});
|
||||||
|
if (!dbUser) throw new NotFoundException();
|
||||||
|
|
||||||
|
// Nome do tenant ativo pra topbar (frontend, PHASE 23) — `tenants` não
|
||||||
|
// tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md), leitura direta
|
||||||
|
// sem `withTenantContext` é segura aqui.
|
||||||
|
const tenant = user.tenantId
|
||||||
|
? await prisma.tenant.findUnique({
|
||||||
|
where: { id: user.tenantId },
|
||||||
|
select: { id: true, code: true, tradeName: true, legalName: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dbUser,
|
||||||
|
isPlatformUser: await isPlatformUser(user.sub),
|
||||||
|
tenant: tenant ? { id: tenant.id, code: tenant.code, name: tenant.tradeName ?? tenant.legalName } : null,
|
||||||
|
// "Itens sem permissão não aparecem" (secao 169) — só UX de menu, o
|
||||||
|
// PermissionGuard em cada endpoint continua sendo a única fonte
|
||||||
|
// confiável de autorização.
|
||||||
|
permissionKeys: await getUserPermissionKeys(user.sub, user.tenantId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get("tenants")
|
@Get("tenants")
|
||||||
async tenants(@CurrentUser() user: AccessTokenClaims) {
|
async tenants(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
|||||||
74
apps/api/src/billing/active-day-sweep.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||||
|
import { createLogger } from "@b2bcall/shared";
|
||||||
|
|
||||||
|
const logger = createLogger("b2bcall-api");
|
||||||
|
|
||||||
|
type Meter = "EXTENSION_ACTIVE_DAY" | "AGENT_ACTIVE_DAY" | "TRUNK_ACTIVE_DAY";
|
||||||
|
|
||||||
|
async function recordDailyMeter(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
tenantId: string,
|
||||||
|
meter: Meter,
|
||||||
|
sourceType: string,
|
||||||
|
rows: Array<{ id: string }>,
|
||||||
|
todayStart: Date,
|
||||||
|
todayEnd: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const row of rows) {
|
||||||
|
// Sem constraint unica no banco pra (tenantId, meter, sourceId, dia) —
|
||||||
|
// checagem explicita antes do insert. Corrida real possivel se a
|
||||||
|
// varredura rodar 2x em paralelo pro mesmo tenant (nao acontece hoje,
|
||||||
|
// um unico processo apps/api chama isso num setInterval sequencial),
|
||||||
|
// documentado como limitacao conhecida em docs/BILLING.md.
|
||||||
|
const exists = await tx.usageEvent.findFirst({
|
||||||
|
where: { tenantId, meter, sourceId: row.id, occurredAt: { gte: todayStart, lt: todayEnd } },
|
||||||
|
});
|
||||||
|
if (exists) continue;
|
||||||
|
|
||||||
|
await tx.usageEvent.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
meter,
|
||||||
|
quantity: 1,
|
||||||
|
unit: "day",
|
||||||
|
sourceType,
|
||||||
|
sourceId: row.id,
|
||||||
|
occurredAt: todayStart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "EXTENSION_ACTIVE_DAY"/"AGENT_ACTIVE_DAY"/"TRUNK_ACTIVE_DAY" (agente.md
|
||||||
|
* secao 131) — 1 `UsageEvent` por recurso ativo por dia, consumido pelo
|
||||||
|
* `BillingEngineService` no fechamento (`rateActiveDaysProrated`). Roda no
|
||||||
|
* boot + de hora em hora (mesmo padrão de `runRetentionSweep`) — idempotente
|
||||||
|
* dentro do mesmo dia (não duplica se já rodou hoje pra aquele recurso).
|
||||||
|
*/
|
||||||
|
export async function runActiveDaySweep(): Promise<void> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const todayEnd = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } });
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
try {
|
||||||
|
await withTenantContext(prisma, tenant.id, async (tx) => {
|
||||||
|
const [extensions, agents, trunks] = await Promise.all([
|
||||||
|
tx.extension.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
|
||||||
|
tx.agent.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
|
||||||
|
tx.trunk.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordDailyMeter(tx, tenant.id, "EXTENSION_ACTIVE_DAY", "extension", extensions, todayStart, todayEnd);
|
||||||
|
await recordDailyMeter(tx, tenant.id, "AGENT_ACTIVE_DAY", "agent", agents, todayStart, todayEnd);
|
||||||
|
await recordDailyMeter(tx, tenant.id, "TRUNK_ACTIVE_DAY", "trunk", trunks, todayStart, todayEnd);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("falha na varredura de uso diario (billing)", { error: String(err), tenantId: tenant.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/api/src/billing/billing-consumo.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Billing > Consumo" (agente.md secao 169) — a mesma agregação de
|
||||||
|
* `/reports/consumo` (tenant, mês corrente), só que em TODOS os tenants
|
||||||
|
* de uma vez. Nunca calcula valor em dinheiro (isso é o RatingEngine/
|
||||||
|
* BillingStatement — Billing > Relatórios), só a quantidade bruta dos 2
|
||||||
|
* ledgers imutáveis. Diferente de Clientes > Quotas: aqui é "quanto cada
|
||||||
|
* tenant consumiu", lá é "quanto sobra até o limite do plano".
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/consumo")
|
||||||
|
export class BillingConsumoController {
|
||||||
|
@RequirePermission("billing.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver consumo de todos os tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const now = new Date();
|
||||||
|
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({ where: { deletedAt: null }, orderBy: { legalName: "asc" } });
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
tenants.map(async (tenant) => {
|
||||||
|
const [usageByMeter, aiUsageByType] = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||||
|
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usage: Record<string, number> = {};
|
||||||
|
for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0;
|
||||||
|
const aiUsage: Record<string, number> = {};
|
||||||
|
for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
legalName: tenant.legalName,
|
||||||
|
usage: {
|
||||||
|
callCount: usage["CALL_COUNT"] ?? 0,
|
||||||
|
callSeconds: usage["CALL_SECONDS"] ?? 0,
|
||||||
|
extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0,
|
||||||
|
agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0,
|
||||||
|
trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0,
|
||||||
|
recordingBytes: usage["RECORDING_BYTES"] ?? 0,
|
||||||
|
},
|
||||||
|
aiUsage: {
|
||||||
|
transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0,
|
||||||
|
analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0,
|
||||||
|
inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0,
|
||||||
|
outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
330
apps/api/src/billing/billing-engine.service.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext, type Prisma, type PriceItemType } from "@b2bcall/database";
|
||||||
|
import {
|
||||||
|
resolvePriceBookItem,
|
||||||
|
rateCallFlatFallback,
|
||||||
|
rateGenericUsage,
|
||||||
|
rateActiveDaysProrated,
|
||||||
|
rateTranscriptionSeconds,
|
||||||
|
rateRecordingBytes,
|
||||||
|
type PriceBookItemLike,
|
||||||
|
} from "@b2bcall/billing";
|
||||||
|
import { recordAuditEvent } from "@b2bcall/auth";
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** UsageMeter -> PriceItemType, pra métricas do tipo "N dias ativo" (agente.md
|
||||||
|
* secao 131) — preço mensal do item, prorateado por `rateActiveDaysProrated`. */
|
||||||
|
const ACTIVE_DAY_METER_TO_PRICE_TYPE: Record<string, PriceItemType> = {
|
||||||
|
EXTENSION_ACTIVE_DAY: "EXTENSION_MONTH",
|
||||||
|
AGENT_ACTIVE_DAY: "AGENT_MONTH",
|
||||||
|
TRUNK_ACTIVE_DAY: "TRUNK_MONTH",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** AIUsageType -> PriceItemType (secao 124/131 — nomes não batem 1:1,
|
||||||
|
* "SECONDS"/"REQUEST"/plural de token na origem viram "MINUTE"/"CALL"/
|
||||||
|
* singular no catálogo de preço). */
|
||||||
|
const AI_USAGE_TYPE_TO_PRICE_TYPE: Record<string, PriceItemType> = {
|
||||||
|
AI_TRANSCRIPTION_SECONDS: "AI_TRANSCRIPTION_MINUTE",
|
||||||
|
AI_ANALYSIS_REQUEST: "AI_ANALYSIS_CALL",
|
||||||
|
AI_INPUT_TOKENS: "AI_INPUT_TOKEN",
|
||||||
|
AI_OUTPUT_TOKENS: "AI_OUTPUT_TOKEN",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PriceItemType -> BillingStatementCategory, pra agrupar `RatedUsageItem`s
|
||||||
|
* na linha do statement (agente.md secao 136). */
|
||||||
|
const PRICE_TYPE_TO_CATEGORY: Record<PriceItemType, string> = {
|
||||||
|
BASE_SUBSCRIPTION: "PLAN_BASE",
|
||||||
|
EXTENSION_MONTH: "EXTENSIONS",
|
||||||
|
AGENT_MONTH: "AGENTS",
|
||||||
|
TRUNK_MONTH: "TRUNKS",
|
||||||
|
CALL: "CALLS",
|
||||||
|
CALL_MINUTE: "MINUTES",
|
||||||
|
FIXED_MINUTE: "MINUTES",
|
||||||
|
MOBILE_MINUTE: "MINUTES",
|
||||||
|
INTERNATIONAL_MINUTE: "MINUTES",
|
||||||
|
AI_TRANSCRIPTION_MINUTE: "AI_TRANSCRIPTION",
|
||||||
|
AI_ANALYSIS_CALL: "AI_ANALYSIS",
|
||||||
|
AI_INPUT_TOKEN: "AI_TOKENS",
|
||||||
|
AI_OUTPUT_TOKEN: "AI_TOKENS",
|
||||||
|
RECORDING_GB_MONTH: "STORAGE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
|
PLAN_BASE: "Assinatura do plano",
|
||||||
|
EXTENSIONS: "Ramais ativos",
|
||||||
|
AGENTS: "Agentes ativos",
|
||||||
|
TRUNKS: "Troncos ativos",
|
||||||
|
CALLS: "Chamadas",
|
||||||
|
MINUTES: "Minutos de chamada",
|
||||||
|
AI_TRANSCRIPTION: "Transcricao (IA)",
|
||||||
|
AI_ANALYSIS: "Analise de chamada (IA)",
|
||||||
|
AI_TOKENS: "Tokens (IA)",
|
||||||
|
STORAGE: "Armazenamento de gravacoes",
|
||||||
|
ADJUSTMENT: "Ajuste",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RatingEngine é matemática pura (packages/billing, sem I/O — ver
|
||||||
|
* comentário em rating-engine.ts); este service faz a orquestração real
|
||||||
|
* (agente.md secao 130-137): resolve catálogos vigentes, lê os 2 ledgers
|
||||||
|
* imutáveis (`UsageEvent`+`AIUsageRecord`), grava `RatedUsageItem` (1 por
|
||||||
|
* evento — nunca agrega antes de ratear, "immutable usage ledger" secao
|
||||||
|
* 233) e fecha em `BillingStatement`/`BillingStatementItem` (agregado por
|
||||||
|
* categoria, o que o tenant efetivamente vê).
|
||||||
|
*
|
||||||
|
* **Lacuna real, conhecida**: `Call.calledNumber` ainda não é populado
|
||||||
|
* pelo CDR (ver TODO.md PHASE 17) — não dá pra fazer o longest-prefix
|
||||||
|
* match do RateDeck (secao 129) por destino real. `CALL_SECONDS` sempre
|
||||||
|
* usa `rateCallFlatFallback` (PriceBookItem `CALL_MINUTE`) por enquanto;
|
||||||
|
* `RateDeck`/`longestPrefixMatch` ficam cadastráveis e testados
|
||||||
|
* isoladamente (packages/billing tem teste unitário), só não são
|
||||||
|
* exercitados ponta a ponta até essa lacuna fechar.
|
||||||
|
*/
|
||||||
|
export async function closeBillingPeriod(opts: {
|
||||||
|
tenantId: string;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<{ periodId: string; statementId: string; total: number }> {
|
||||||
|
const { tenantId, periodStart, periodEnd, userId } = opts;
|
||||||
|
if (periodStart >= periodEnd) {
|
||||||
|
throw new BadRequestException("periodStart deve ser anterior a periodEnd");
|
||||||
|
}
|
||||||
|
const daysInPeriod = (periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY;
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
const existing = await tx.billingPeriod.findUnique({
|
||||||
|
where: { tenantId_periodStart_periodEnd: { tenantId, periodStart, periodEnd } },
|
||||||
|
});
|
||||||
|
if (existing?.status === "CLOSED") {
|
||||||
|
throw new ConflictException("Periodo ja fechado (secao 137) — reabra explicitamente antes de recalcular");
|
||||||
|
}
|
||||||
|
if (existing?.status === "CALCULATING") {
|
||||||
|
throw new ConflictException("Fechamento ja em andamento para este periodo");
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = existing
|
||||||
|
? await tx.billingPeriod.update({ where: { id: existing.id }, data: { status: "CALCULATING" } })
|
||||||
|
: await tx.billingPeriod.create({ data: { tenantId, periodStart, periodEnd, status: "CALCULATING" } });
|
||||||
|
|
||||||
|
const tenant = await tx.tenant.findUniqueOrThrow({ where: { id: tenantId } });
|
||||||
|
const priceBook = await tx.priceBook.findFirst({
|
||||||
|
where: tenant.priceBookId ? { id: tenant.priceBookId } : { isDefault: true },
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
if (!priceBook) {
|
||||||
|
throw new ConflictException("Nenhum PriceBook configurado (nem default) para tarifar este tenant");
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceItems: PriceBookItemLike[] = priceBook.items;
|
||||||
|
const callMinuteItem = resolvePriceBookItem(priceItems, "CALL_MINUTE", periodEnd);
|
||||||
|
|
||||||
|
const [usageEvents, aiUsageRecords] = await Promise.all([
|
||||||
|
tx.usageEvent.findMany({
|
||||||
|
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
|
||||||
|
}),
|
||||||
|
tx.aIUsageRecord.findMany({
|
||||||
|
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ratedItemsData: Prisma.RatedUsageItemCreateManyInput[] = [];
|
||||||
|
|
||||||
|
for (const ev of usageEvents) {
|
||||||
|
if (ev.meter === "CALL_SECONDS") {
|
||||||
|
if (!callMinuteItem) continue; // sem preco/minuto configurado — nao cobra, nao inventa preco
|
||||||
|
const rated = rateCallFlatFallback(ev.quantity, callMinuteItem);
|
||||||
|
ratedItemsData.push({
|
||||||
|
tenantId,
|
||||||
|
usageEventId: ev.id,
|
||||||
|
callId: ev.callId,
|
||||||
|
priceBookItemId: callMinuteItem.id,
|
||||||
|
quantity: rated.ratedMinutes,
|
||||||
|
unitPrice: rated.destinationRate,
|
||||||
|
amount: rated.ratedAmount,
|
||||||
|
currency: priceBook.currency,
|
||||||
|
billingPeriodId: period.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceType = ACTIVE_DAY_METER_TO_PRICE_TYPE[ev.meter];
|
||||||
|
if (!priceType) continue; // RECORDING_BYTES e os meters de IA nao viram UsageEvent (ver cdr.ts / process-*.ts)
|
||||||
|
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
|
||||||
|
if (!item) continue;
|
||||||
|
const amount = rateActiveDaysProrated(ev.quantity, item.unitPrice, daysInPeriod);
|
||||||
|
ratedItemsData.push({
|
||||||
|
tenantId,
|
||||||
|
usageEventId: ev.id,
|
||||||
|
priceBookItemId: item.id,
|
||||||
|
quantity: ev.quantity,
|
||||||
|
unitPrice: daysInPeriod > 0 ? item.unitPrice / daysInPeriod : 0,
|
||||||
|
amount,
|
||||||
|
currency: priceBook.currency,
|
||||||
|
billingPeriodId: period.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rec of aiUsageRecords) {
|
||||||
|
const priceType = AI_USAGE_TYPE_TO_PRICE_TYPE[rec.type];
|
||||||
|
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
|
||||||
|
if (!item) continue;
|
||||||
|
const amount =
|
||||||
|
rec.type === "AI_TRANSCRIPTION_SECONDS"
|
||||||
|
? rateTranscriptionSeconds(rec.quantity, item.unitPrice)
|
||||||
|
: rateGenericUsage(rec.quantity, item.unitPrice);
|
||||||
|
ratedItemsData.push({
|
||||||
|
tenantId,
|
||||||
|
aiUsageRecordId: rec.id,
|
||||||
|
priceBookItemId: item.id,
|
||||||
|
quantity: rec.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
amount,
|
||||||
|
currency: priceBook.currency,
|
||||||
|
billingPeriodId: period.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// RECORDING_BYTES (secao 131): sem ledger de eventos próprio (ver
|
||||||
|
// comentário em rating-engine.ts) — usa os bytes armazenados AGORA como
|
||||||
|
// proxy do consumo do período inteiro, decisão documentada em
|
||||||
|
// docs/BILLING.md.
|
||||||
|
const storageItem = resolvePriceBookItem(priceItems, "RECORDING_GB_MONTH", periodEnd);
|
||||||
|
if (storageItem) {
|
||||||
|
const recordingAgg = await tx.recording.aggregate({
|
||||||
|
where: { tenantId, status: "AVAILABLE" },
|
||||||
|
_sum: { sizeBytes: true },
|
||||||
|
});
|
||||||
|
const bytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
|
||||||
|
if (bytes > 0) {
|
||||||
|
const amount = rateRecordingBytes(bytes, storageItem.unitPrice);
|
||||||
|
ratedItemsData.push({
|
||||||
|
tenantId,
|
||||||
|
priceBookItemId: storageItem.id,
|
||||||
|
quantity: bytes / 1_000_000_000,
|
||||||
|
unitPrice: storageItem.unitPrice,
|
||||||
|
amount,
|
||||||
|
currency: priceBook.currency,
|
||||||
|
billingPeriodId: period.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratedItemsData.length > 0) {
|
||||||
|
await tx.ratedUsageItem.createMany({ data: ratedItemsData });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratedItems = await tx.ratedUsageItem.findMany({
|
||||||
|
where: { billingPeriodId: period.id },
|
||||||
|
include: { priceBookItem: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryTotals = new Map<string, number>();
|
||||||
|
for (const item of ratedItems) {
|
||||||
|
const category = item.priceBookItem ? PRICE_TYPE_TO_CATEGORY[item.priceBookItem.type] : "ADJUSTMENT";
|
||||||
|
categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + item.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await tx.tenantSubscription.findFirst({
|
||||||
|
where: { tenantId, status: { in: ["ACTIVE", "TRIALING"] } },
|
||||||
|
include: { planVersion: true },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
});
|
||||||
|
if (subscription && subscription.planVersion.basePrice > 0) {
|
||||||
|
categoryTotals.set("PLAN_BASE", (categoryTotals.get("PLAN_BASE") ?? 0) + subscription.planVersion.basePrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal = [...categoryTotals.values()].reduce((sum, v) => sum + v, 0);
|
||||||
|
const currency = subscription?.currency ?? priceBook.currency;
|
||||||
|
|
||||||
|
const statement = await tx.billingStatement.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
billingPeriodId: period.id,
|
||||||
|
currency,
|
||||||
|
subtotal,
|
||||||
|
adjustments: 0,
|
||||||
|
total: subtotal,
|
||||||
|
items: {
|
||||||
|
create: [...categoryTotals.entries()].map(([category, amount]) => ({
|
||||||
|
tenantId,
|
||||||
|
category: category as never,
|
||||||
|
description: CATEGORY_LABEL[category] ?? category,
|
||||||
|
amount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.billingPeriod.update({
|
||||||
|
where: { id: period.id },
|
||||||
|
data: { status: "CLOSED", closedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { periodId: period.id, statementId: statement.id, total: statement.total, ratedCount: ratedItems.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "BILLING_PERIOD_CLOSE",
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
entityType: "billing_period",
|
||||||
|
entityId: result.periodId,
|
||||||
|
after: { total: result.total, ratedItemCount: result.ratedCount },
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Reabrir" um período fechado pra corrigir e recalcular (secao 137): nunca
|
||||||
|
* volta direto pra OPEN — vira REOPENED, com audit trail (user+motivo),
|
||||||
|
* deixando visível no histórico que esse período já foi fechado antes.
|
||||||
|
* `closeBillingPeriod` aceita rodar de novo em cima de um período
|
||||||
|
* REOPENED (só bloqueia CLOSED/CALCULATING).
|
||||||
|
*
|
||||||
|
* **Bug real, achado no teste desta fase**: a primeira versão lia o
|
||||||
|
* período com `prisma.billingPeriod.findUniqueOrThrow({ where: { id } })`
|
||||||
|
* SEM tenant context pra descobrir o `tenantId` — mas `billing_periods` tem
|
||||||
|
* FORCE ROW LEVEL SECURITY (agente.md secao 30), então a leitura sem
|
||||||
|
* `app.current_tenant_id` não vê a linha, e o "not found" virava 500 (P2025
|
||||||
|
* não mapeado, nunca um 404 de verdade). `tenantId` precisa vir explícito
|
||||||
|
* no request (mesma exceção já aplicada em `closeBillingPeriod`), nunca
|
||||||
|
* descoberto lendo a própria tabela protegida por RLS.
|
||||||
|
*/
|
||||||
|
export async function reopenBillingPeriod(opts: {
|
||||||
|
periodId: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
reason: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const period = await withTenantContext(prisma, opts.tenantId, (tx) =>
|
||||||
|
tx.billingPeriod.findUnique({ where: { id: opts.periodId } }),
|
||||||
|
);
|
||||||
|
if (!period || period.tenantId !== opts.tenantId) {
|
||||||
|
throw new NotFoundException("Periodo de billing nao encontrado para este tenant");
|
||||||
|
}
|
||||||
|
if (period.status !== "CLOSED") {
|
||||||
|
throw new ConflictException("So' e' possivel reabrir um periodo CLOSED");
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTenantContext(prisma, opts.tenantId, (tx) =>
|
||||||
|
tx.billingPeriod.update({
|
||||||
|
where: { id: period.id },
|
||||||
|
data: { status: "REOPENED", reopenedAt: new Date() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "BILLING_PERIOD_REOPEN",
|
||||||
|
tenantId: period.tenantId,
|
||||||
|
userId: opts.userId,
|
||||||
|
entityType: "billing_period",
|
||||||
|
entityId: period.id,
|
||||||
|
after: { reason: opts.reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
72
apps/api/src/billing/billing-periods.controller.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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 { ClosePeriodDto } from "./dto/close-period.dto";
|
||||||
|
import { ReopenPeriodDto } from "./dto/reopen-period.dto";
|
||||||
|
import { closeBillingPeriod, reopenBillingPeriod } from "./billing-engine.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Fechamentos" (PRODUCT.md, menu Platform > Billing). Fechar/reabrir um
|
||||||
|
* período é uma ação de platform admin sobre um tenant arbitrário — por
|
||||||
|
* isso `tenantId` vem no body em vez de vir só do JWT (mesma exceção já
|
||||||
|
* aplicada a GLOBAL em AIProvider/AIPromptTemplate: `isPlatformUser`
|
||||||
|
* checado explicitamente na camada de serviço, nunca confiado só na
|
||||||
|
* permission). Ver histórico do fechamento em `GET /billing/periods` (o
|
||||||
|
* próprio tenant, escopo do seu JWT).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/periods")
|
||||||
|
export class BillingPeriodsController {
|
||||||
|
@RequirePermission("billing.manage")
|
||||||
|
@Post("close")
|
||||||
|
async close(@CurrentUser() user: AccessTokenClaims, @Body() dto: ClosePeriodDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode fechar um periodo de billing");
|
||||||
|
}
|
||||||
|
return closeBillingPeriod({
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
periodStart: new Date(dto.periodStart),
|
||||||
|
periodEnd: new Date(dto.periodEnd),
|
||||||
|
userId: user.sub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("billing.manage")
|
||||||
|
@Post(":id/reopen")
|
||||||
|
async reopen(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: ReopenPeriodDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode reabrir um periodo de billing");
|
||||||
|
}
|
||||||
|
await reopenBillingPeriod({ periodId: id, tenantId: dto.tenantId, userId: user.sub, reason: dto.reason });
|
||||||
|
return { reopened: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("billing.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.billingPeriod.findMany({ where: { tenantId }, orderBy: { periodStart: "desc" } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platform admin olhando um tenant arbitrário (secao 168, "Billing >
|
||||||
|
* Fechamentos") — `GET /billing/periods` acima só serve o próprio
|
||||||
|
* tenant do JWT, que um platform admin não tem. */
|
||||||
|
@RequirePermission("billing.manage")
|
||||||
|
@Get("by-tenant/:tenantId")
|
||||||
|
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver fechamentos de outro tenant");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.billingPeriod.findMany({ where: { tenantId }, orderBy: { periodStart: "desc" } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/api/src/billing/billing-statements.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, NotFoundException, Param, Query, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Billing Statement" / "Relatorio de Consumo" (agente.md secao 138-139) —
|
||||||
|
* nunca chamado de "invoice"/"nota fiscal" na UI (PRODUCT.md, Operating
|
||||||
|
* Context). Sempre escopado ao próprio tenant do JWT (secao 31) — nunca um
|
||||||
|
* id de statement de outro tenant, RLS + WHERE tenantId garantem os dois.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/statements")
|
||||||
|
export class BillingStatementsController {
|
||||||
|
@RequirePermission("billing.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.billingStatement.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: { billingPeriod: true },
|
||||||
|
orderBy: { generatedAt: "desc" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platform admin olhando um tenant arbitrário (secao 168, "Billing >
|
||||||
|
* Relatórios") — `GET /billing/statements` acima só serve o próprio
|
||||||
|
* tenant do JWT, que um platform admin não tem. */
|
||||||
|
@RequirePermission("billing.manage")
|
||||||
|
@Get("by-tenant/:tenantId")
|
||||||
|
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver relatorios de outro tenant");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.billingStatement.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: { billingPeriod: true },
|
||||||
|
orderBy: { generatedAt: "desc" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `tenantId` na query só é aceito de quem tem role de plataforma (secao
|
||||||
|
* 31: nunca confiar em tenant vindo do client sem checar) — um tenant
|
||||||
|
* admin comum sempre olha só o próprio, do JWT, mesmo que tente mandar
|
||||||
|
* outro. */
|
||||||
|
@RequirePermission("billing.view")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Query("tenantId") queryTenantId?: string) {
|
||||||
|
let tenantId = user.tenantId!;
|
||||||
|
if (queryTenantId && queryTenantId !== tenantId) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver statement de outro tenant");
|
||||||
|
}
|
||||||
|
tenantId = queryTenantId;
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const statement = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.billingStatement.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: { billingPeriod: true, items: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!statement) throw new NotFoundException();
|
||||||
|
return statement;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/api/src/billing/billing.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PriceBooksController } from "./price-books.controller";
|
||||||
|
import { RateDecksController } from "./rate-decks.controller";
|
||||||
|
import { PlanVersionsController } from "./plan-versions.controller";
|
||||||
|
import { SubscriptionsController } from "./subscriptions.controller";
|
||||||
|
import { BillingPeriodsController } from "./billing-periods.controller";
|
||||||
|
import { BillingStatementsController } from "./billing-statements.controller";
|
||||||
|
import { BillingConsumoController } from "./billing-consumo.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
PriceBooksController,
|
||||||
|
RateDecksController,
|
||||||
|
PlanVersionsController,
|
||||||
|
SubscriptionsController,
|
||||||
|
BillingPeriodsController,
|
||||||
|
BillingStatementsController,
|
||||||
|
BillingConsumoController,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BillingModule {}
|
||||||
12
apps/api/src/billing/dto/close-period.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsDateString, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export class ClosePeriodDto {
|
||||||
|
@IsUUID()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
periodStart!: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
periodEnd!: string;
|
||||||
|
}
|
||||||
22
apps/api/src/billing/dto/create-plan-version.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { IsDateString, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class CreatePlanVersionDto {
|
||||||
|
@IsUUID()
|
||||||
|
planId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
basePrice!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
effectiveFrom!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
effectiveUntil?: string;
|
||||||
|
}
|
||||||
55
apps/api/src/billing/dto/create-price-book.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { IsArray, IsBoolean, IsDateString, IsIn, IsNumber, IsOptional, IsString, MaxLength, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
const PRICE_ITEM_TYPES = [
|
||||||
|
"BASE_SUBSCRIPTION",
|
||||||
|
"EXTENSION_MONTH",
|
||||||
|
"AGENT_MONTH",
|
||||||
|
"TRUNK_MONTH",
|
||||||
|
"CALL",
|
||||||
|
"CALL_MINUTE",
|
||||||
|
"FIXED_MINUTE",
|
||||||
|
"MOBILE_MINUTE",
|
||||||
|
"INTERNATIONAL_MINUTE",
|
||||||
|
"AI_TRANSCRIPTION_MINUTE",
|
||||||
|
"AI_ANALYSIS_CALL",
|
||||||
|
"AI_INPUT_TOKEN",
|
||||||
|
"AI_OUTPUT_TOKEN",
|
||||||
|
"RECORDING_GB_MONTH",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class CreatePriceBookItemDto {
|
||||||
|
@IsIn(PRICE_ITEM_TYPES)
|
||||||
|
type!: (typeof PRICE_ITEM_TYPES)[number];
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
unitPrice!: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
effectiveFrom!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
effectiveUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatePriceBookDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreatePriceBookItemDto)
|
||||||
|
items?: CreatePriceBookItemDto[];
|
||||||
|
}
|
||||||
60
apps/api/src/billing/dto/create-rate-deck.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { IsArray, IsBoolean, IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
const DESTINATION_TYPES = ["FIXED", "MOBILE", "INTERNATIONAL"] as const;
|
||||||
|
|
||||||
|
export class CreateRateDeckEntryDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
prefix!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
destinationName!: string;
|
||||||
|
|
||||||
|
@IsIn(DESTINATION_TYPES)
|
||||||
|
destinationType!: (typeof DESTINATION_TYPES)[number];
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
pricePerMinute!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(3600)
|
||||||
|
billingIncrementSeconds?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(3600)
|
||||||
|
minimumSeconds?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
connectionFee?: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
validFrom!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
validUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateRateDeckDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateRateDeckEntryDto)
|
||||||
|
entries?: CreateRateDeckEntryDto[];
|
||||||
|
}
|
||||||
23
apps/api/src/billing/dto/create-subscription.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateSubscriptionDto {
|
||||||
|
@IsUUID()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
planVersionId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startedAt?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(28)
|
||||||
|
billingCycleAnchor!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
11
apps/api/src/billing/dto/reopen-period.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, IsUUID, MaxLength, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ReopenPeriodDto {
|
||||||
|
@IsUUID()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(500)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
66
apps/api/src/billing/plan-versions.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, 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 { CreatePlanVersionDto } from "./dto/create-plan-version.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "plan_versions" (agente.md secao 126: "Precos e limites devem ser
|
||||||
|
* versionados"). Versiona só o preço base — ver comentário no schema.
|
||||||
|
* `version` é sempre a proxima sequencial do plano (nunca escolhida pelo
|
||||||
|
* client, agente.md secao 233: nunca confiar em input do client pra
|
||||||
|
* invariante do sistema).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/plan-versions")
|
||||||
|
export class PlanVersionsController {
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePlanVersionDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode versionar planos");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const plan = await prisma.plan.findUnique({ where: { id: dto.planId } });
|
||||||
|
if (!plan) throw new ForbiddenException("Plano nao encontrado");
|
||||||
|
|
||||||
|
const lastVersion = await prisma.planVersion.findFirst({
|
||||||
|
where: { planId: dto.planId },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
const nextVersion = (lastVersion?.version ?? 0) + 1;
|
||||||
|
|
||||||
|
const planVersion = await prisma.planVersion.create({
|
||||||
|
data: {
|
||||||
|
planId: dto.planId,
|
||||||
|
version: nextVersion,
|
||||||
|
basePrice: dto.basePrice,
|
||||||
|
currency: dto.currency ?? "BRL",
|
||||||
|
effectiveFrom: new Date(dto.effectiveFrom),
|
||||||
|
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PLAN_VERSION_CREATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "plan_version",
|
||||||
|
entityId: planVersion.id,
|
||||||
|
after: { planId: plan.id, version: planVersion.version, basePrice: planVersion.basePrice },
|
||||||
|
});
|
||||||
|
|
||||||
|
return planVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get("by-plan/:planId")
|
||||||
|
async listByPlan(@Param("planId") planId: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return prisma.planVersion.findMany({ where: { planId }, orderBy: { version: "desc" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/api/src/billing/price-books.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, 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 { CreatePriceBookDto } from "./dto/create-price-book.dto";
|
||||||
|
|
||||||
|
async function assertPlatformUser(userId: string): Promise<void> {
|
||||||
|
if (!(await isPlatformUser(userId))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar price books");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "price_books"/"price_book_items" (agente.md secao 128) — catálogo
|
||||||
|
* global da plataforma, sem tenant_id (mesmo padrão de `Plan`), gerenciado
|
||||||
|
* só por platform admin. Um `Tenant` escolhe qual usar via
|
||||||
|
* `Tenant.priceBookId` (null = o que tiver `isDefault=true`); a atribuição
|
||||||
|
* em si é uma ação de `tenants.manage`, fora do escopo deste controller.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/price-books")
|
||||||
|
export class PriceBooksController {
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePriceBookDto) {
|
||||||
|
await assertPlatformUser(user.sub);
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const priceBook = await prisma.priceBook.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
currency: dto.currency ?? "BRL",
|
||||||
|
isDefault: dto.isDefault ?? false,
|
||||||
|
items: dto.items
|
||||||
|
? {
|
||||||
|
create: dto.items.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
effectiveFrom: new Date(item.effectiveFrom),
|
||||||
|
effectiveUntil: item.effectiveUntil ? new Date(item.effectiveUntil) : null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PRICE_BOOK_CREATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "price_book",
|
||||||
|
entityId: priceBook.id,
|
||||||
|
after: { name: priceBook.name, isDefault: priceBook.isDefault },
|
||||||
|
});
|
||||||
|
|
||||||
|
return priceBook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return prisma.priceBook.findMany({ include: { items: true }, orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const priceBook = await prisma.priceBook.findUnique({ where: { id }, include: { items: true } });
|
||||||
|
if (!priceBook) throw new NotFoundException();
|
||||||
|
return priceBook;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/api/src/billing/rate-decks.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, 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 { CreateRateDeckDto } from "./dto/create-rate-deck.dto";
|
||||||
|
|
||||||
|
async function assertPlatformUser(userId: string): Promise<void> {
|
||||||
|
if (!(await isPlatformUser(userId))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar rate decks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "rate_decks"/"rate_deck_entries" (agente.md secao 129) — catálogo global
|
||||||
|
* de tarifas por prefixo de destino, mesmo padrão de escopo de
|
||||||
|
* `PriceBooksController`. `RatingEngine.longestPrefixMatch`
|
||||||
|
* (packages/billing) consome `entries` pra tarifar `CALL_SECONDS` por
|
||||||
|
* destino — hoje o fallback plano é sempre usado (ver
|
||||||
|
* `BillingEngineService`, `Call.calledNumber` ainda não é populado pelo
|
||||||
|
* CDR), mas o cadastro fica disponível pra quando essa lacuna for fechada.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/rate-decks")
|
||||||
|
export class RateDecksController {
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateRateDeckDto) {
|
||||||
|
await assertPlatformUser(user.sub);
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const rateDeck = await prisma.rateDeck.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
isDefault: dto.isDefault ?? false,
|
||||||
|
entries: dto.entries
|
||||||
|
? {
|
||||||
|
create: dto.entries.map((entry) => ({
|
||||||
|
prefix: entry.prefix,
|
||||||
|
destinationName: entry.destinationName,
|
||||||
|
destinationType: entry.destinationType,
|
||||||
|
pricePerMinute: entry.pricePerMinute,
|
||||||
|
billingIncrementSeconds: entry.billingIncrementSeconds ?? 60,
|
||||||
|
minimumSeconds: entry.minimumSeconds ?? 0,
|
||||||
|
connectionFee: entry.connectionFee ?? 0,
|
||||||
|
validFrom: new Date(entry.validFrom),
|
||||||
|
validUntil: entry.validUntil ? new Date(entry.validUntil) : null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: { entries: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "RATE_DECK_CREATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "rate_deck",
|
||||||
|
entityId: rateDeck.id,
|
||||||
|
after: { name: rateDeck.name, isDefault: rateDeck.isDefault },
|
||||||
|
});
|
||||||
|
|
||||||
|
return rateDeck;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return prisma.rateDeck.findMany({ include: { entries: true }, orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const rateDeck = await prisma.rateDeck.findUnique({ where: { id }, include: { entries: true } });
|
||||||
|
if (!rateDeck) throw new NotFoundException();
|
||||||
|
return rateDeck;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/api/src/billing/subscriptions.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, 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 { CreateSubscriptionDto } from "./dto/create-subscription.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "tenant_subscriptions" (agente.md secao 127) — qual `PlanVersion` (preço
|
||||||
|
* vigente) um tenant assinou e em que dia do mês fecha o período de
|
||||||
|
* billing dele (`billingCycleAnchor`). Ação de platform admin (o tenant
|
||||||
|
* não escolhe o próprio preço); um tenant pode ter várias linhas ao longo
|
||||||
|
* do tempo (histórico de mudança de plano/preço), nunca UPDATE no preço de
|
||||||
|
* uma assinatura já ativa — sempre uma nova linha.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/subscriptions")
|
||||||
|
export class SubscriptionsController {
|
||||||
|
@RequirePermission("billing.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateSubscriptionDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar assinaturas");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const planVersion = await prisma.planVersion.findUnique({ where: { id: dto.planVersionId } });
|
||||||
|
if (!planVersion) throw new ForbiddenException("PlanVersion nao encontrada");
|
||||||
|
|
||||||
|
// `tenant_subscriptions` tem RLS (tenant-scoped) mesmo essa sendo uma
|
||||||
|
// ação de platform admin sobre um tenant arbitrário — precisa do
|
||||||
|
// contexto igual a qualquer outra escrita tenant-scoped (secao 30).
|
||||||
|
const subscription = await withTenantContext(prisma, dto.tenantId, (tx) =>
|
||||||
|
tx.tenantSubscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
planVersionId: dto.planVersionId,
|
||||||
|
status: "ACTIVE",
|
||||||
|
startedAt: dto.startedAt ? new Date(dto.startedAt) : new Date(),
|
||||||
|
billingCycleAnchor: dto.billingCycleAnchor,
|
||||||
|
currency: dto.currency ?? planVersion.currency,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TENANT_SUBSCRIPTION_CREATE",
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tenant_subscription",
|
||||||
|
entityId: subscription.id,
|
||||||
|
after: { planVersionId: subscription.planVersionId, billingCycleAnchor: subscription.billingCycleAnchor },
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("billing.manage")
|
||||||
|
@Get("by-tenant/:tenantId")
|
||||||
|
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
|
||||||
|
if (!(await isPlatformUser(user.sub)) && user.tenantId !== tenantId) {
|
||||||
|
throw new ForbiddenException("Sem acesso as assinaturas deste tenant");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.tenantSubscription.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: { planVersion: true },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,21 @@ import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
|
|||||||
import type { AuthenticatedRequest } from "./jwt-auth.guard";
|
import type { AuthenticatedRequest } from "./jwt-auth.guard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado
|
* Roda depois do JwtAuthGuard. Exige que o usuário tenha a permission
|
||||||
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só
|
* marcada via @RequirePermission() (secao 145/146), resolvida sempre a
|
||||||
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada
|
* partir do JWT (secao 31: nunca confiar em tenant_id do frontend).
|
||||||
* via @RequirePermission() (secao 145/146).
|
*
|
||||||
|
* Um usuário PLATFORM puro (sem NENHUMA TenantMembership — ex.: o platform
|
||||||
|
* super admin recém-criado) nunca tem um tenant pra selecionar em
|
||||||
|
* `/auth/select-tenant` (`/auth/tenants` retorna vazio pra ele), então
|
||||||
|
* `user.tenantId` legitimamente nunca vai existir no token dele. Bug real
|
||||||
|
* encontrado testando `PlatformOverviewController` (agente.md secao 163):
|
||||||
|
* antes desta correção, exigir `tenantId` incondicionalmente deixava
|
||||||
|
* QUALQUER endpoint com @RequirePermission inacessível pra esse usuário,
|
||||||
|
* mesmo os platform-only. Corrigido: sem tenantId, ainda tenta a permission
|
||||||
|
* em escopo PLATFORM (`userHasPermission` com tenantId undefined já filtra
|
||||||
|
* só roles com tenantId null); só barra de fato quem não tem a permission
|
||||||
|
* em nenhum escopo.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionGuard implements CanActivate {
|
export class PermissionGuard implements CanActivate {
|
||||||
@@ -29,13 +40,14 @@ export class PermissionGuard implements CanActivate {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new ForbiddenException("Nao autenticado");
|
throw new ForbiddenException("Nao autenticado");
|
||||||
}
|
}
|
||||||
if (!user.tenantId) {
|
|
||||||
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId);
|
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId ?? undefined);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`);
|
throw new ForbiddenException(
|
||||||
|
user.tenantId
|
||||||
|
? `Permissao necessaria: ${permissionKey}`
|
||||||
|
: `Permissao necessaria: ${permissionKey} (nenhum tenant selecionado, checado so' em escopo PLATFORM — use /auth/select-tenant se a permissao for de tenant)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
import { XMLValidator } from "fast-xml-parser";
|
import { XMLValidator } from "fast-xml-parser";
|
||||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
import {
|
import { buildDialplanXml, type AllowedDialplanApplication } from "@b2bcall/telephony";
|
||||||
buildDialplanXml,
|
|
||||||
type AllowedConditionField,
|
|
||||||
type AllowedDialplanApplication,
|
|
||||||
} from "@b2bcall/telephony";
|
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
@@ -47,7 +43,7 @@ export class DialplanVersionsController {
|
|||||||
context,
|
context,
|
||||||
extensions.map((e) => ({
|
extensions.map((e) => ({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
conditionField: e.conditionField as AllowedConditionField,
|
conditionField: e.conditionField,
|
||||||
conditionExpr: e.conditionExpr,
|
conditionExpr: e.conditionExpr,
|
||||||
actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[],
|
actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[],
|
||||||
antiActions: e.antiActions as unknown as
|
antiActions: e.antiActions as unknown as
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony";
|
import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony";
|
||||||
|
import { IsSafeDialplanData } from "./safe-dialplan-data.validator";
|
||||||
|
|
||||||
export class ActionDto {
|
export class ActionDto {
|
||||||
@IsIn(ALLOWED_DIALPLAN_APPLICATIONS)
|
@IsIn(ALLOWED_DIALPLAN_APPLICATIONS)
|
||||||
@@ -8,5 +9,6 @@ export class ActionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
|
@IsSafeDialplanData({ message: "data usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
|
||||||
data?: string;
|
data?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { ALLOWED_CONDITION_FIELDS, type AllowedConditionField } from "@b2bcall/telephony";
|
|
||||||
import { ActionDto } from "./action.dto";
|
import { ActionDto } from "./action.dto";
|
||||||
|
import { IsAllowedConditionField } from "./is-allowed-condition-field.validator";
|
||||||
|
|
||||||
export class CreateDialplanExtensionDto {
|
export class CreateDialplanExtensionDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -24,8 +23,10 @@ export class CreateDialplanExtensionDto {
|
|||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsIn(ALLOWED_CONDITION_FIELDS)
|
@IsString()
|
||||||
conditionField!: AllowedConditionField;
|
@MaxLength(80)
|
||||||
|
@IsAllowedConditionField()
|
||||||
|
conditionField!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { registerDecorator, type ValidationOptions } from "class-validator";
|
||||||
|
import { isAllowedConditionField } from "@b2bcall/telephony";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 56 (IVR): `conditionField` era um `@IsIn` fechado (6 valores
|
||||||
|
* fixos). Continua aceitando esses, mas agora também `${variavel}`
|
||||||
|
* simples (ex.: `${ivr_choice}`) — necessário pra ramificar um menu de
|
||||||
|
* IVR pelo dígito que `play_and_get_digits` coletou. Delegado pra
|
||||||
|
* `isAllowedConditionField` (packages/telephony) pra manter as duas
|
||||||
|
* pontas (o que a API aceita e o que o XML de fato usa) sincronizadas.
|
||||||
|
*/
|
||||||
|
export function IsAllowedConditionField(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: "isAllowedConditionField",
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
validator: {
|
||||||
|
validate(value: unknown) {
|
||||||
|
return typeof value === "string" && isAllowedConditionField(value);
|
||||||
|
},
|
||||||
|
defaultMessage() {
|
||||||
|
return "conditionField deve ser um dos campos fixos ou uma variável simples como ${ivr_choice}";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
31
apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { registerDecorator, type ValidationOptions, type ValidationArguments } from "class-validator";
|
||||||
|
import { findDisallowedInlineFunctionCalls } from "@b2bcall/telephony";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloqueia `${nome(args)}` que não esteja no allowlist de APIs seguras
|
||||||
|
* (`ALLOWED_INLINE_API_FUNCTIONS`) — achado real de segurança (secao 180):
|
||||||
|
* o allowlist de `application` (set/export/playback/...) não impedia RCE
|
||||||
|
* via `${system(...)}`/`${bg_system(...)}` embutido no `data` de uma
|
||||||
|
* application já permitida (`mod_commands` está carregado no FreeSWITCH
|
||||||
|
* desta implantação). `${variavel}` sem parênteses nunca é bloqueado.
|
||||||
|
*/
|
||||||
|
export function IsSafeDialplanData(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: "isSafeDialplanData",
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
validator: {
|
||||||
|
validate(value: unknown) {
|
||||||
|
if (typeof value !== "string") return true; // @IsString() já cobre o tipo
|
||||||
|
return findDisallowedInlineFunctionCalls(value).length === 0;
|
||||||
|
},
|
||||||
|
defaultMessage(args: ValidationArguments) {
|
||||||
|
const disallowed = findDisallowedInlineFunctionCalls(String(args.value));
|
||||||
|
return `data usa função não permitida: ${disallowed.join(", ")} (nunca system/bg_system/curl/db/lua/shell — RCE no FreeSWITCH)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,4 +33,37 @@ export class CreateExtensionDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(10)
|
@Max(10)
|
||||||
maxRegistrations?: number;
|
maxRegistrations?: number;
|
||||||
|
|
||||||
|
/** Grupo de captura (secao 178) — ramais no mesmo grupo podem capturar
|
||||||
|
* a chamada um do outro (*8 no dialplan); ramais fora do grupo, não.
|
||||||
|
* Sem grupo (null) = ninguém mais captura essa chamada. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(60)
|
||||||
|
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
|
||||||
|
callGroup?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateExtensionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
callerIdName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[0-9]{2,20}$/)
|
||||||
|
callerIdNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10)
|
||||||
|
maxRegistrations?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(60)
|
||||||
|
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
|
||||||
|
callGroup?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,19 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared";
|
import { generateStrongPassword, encryptSecret, decryptSecret } from "@b2bcall/shared";
|
||||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
import { assertQuota } from "@b2bcall/entitlements";
|
import { assertQuota } from "@b2bcall/entitlements";
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { CreateExtensionDto } from "./dto/create-extension.dto";
|
import { CreateExtensionDto, UpdateExtensionDto } from "./dto/create-extension.dto";
|
||||||
|
|
||||||
function toPublicExtension(ext: {
|
function toPublicExtension(ext: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,7 +33,9 @@ function toPublicExtension(ext: {
|
|||||||
context: string;
|
context: string;
|
||||||
sofiaProfile: string;
|
sofiaProfile: string;
|
||||||
codecs: string;
|
codecs: string;
|
||||||
|
callGroup: string | null;
|
||||||
maxRegistrations: number;
|
maxRegistrations: number;
|
||||||
|
registeredAt: Date | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}) {
|
}) {
|
||||||
@@ -81,6 +84,7 @@ export class ExtensionsController {
|
|||||||
context: dto.context ?? "default",
|
context: dto.context ?? "default",
|
||||||
sofiaProfile: dto.sofiaProfile ?? "internal",
|
sofiaProfile: dto.sofiaProfile ?? "internal",
|
||||||
maxRegistrations: dto.maxRegistrations ?? 1,
|
maxRegistrations: dto.maxRegistrations ?? 1,
|
||||||
|
callGroup: dto.callGroup,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -126,6 +130,108 @@ export class ExtensionsController {
|
|||||||
return toPublicExtension(extension);
|
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")
|
@RequirePermission("extensions.manage")
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
68
apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { IsBoolean, IsIn, IsOptional, IsString, Matches, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
const DESTINATION_TYPES = ["EXTENSION", "IVR", "QUEUE", "CALL_GROUP"] as const;
|
||||||
|
|
||||||
|
export class CreateInboundRouteDto {
|
||||||
|
// Numero como o provedor de troncos manda no INVITE (destination_number) —
|
||||||
|
// normalmente so digitos (E.164 sem "+" ou o formato local do provedor).
|
||||||
|
// Unico entre TODOS os tenants (ver InboundRoute no schema): dois tenants
|
||||||
|
// nunca podem reivindicar o mesmo DID.
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[0-9]{2,20}$/, { message: "didNumber deve ter só dígitos (2 a 20)" })
|
||||||
|
didNumber!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// PHASE 62 — decide como `destinationNumber`/`destinationContext` são
|
||||||
|
// interpretados na resolução de entrada (ver dialplan-xml.ts):
|
||||||
|
// EXTENSION/IVR continuam discando pelo dialplan do tenant; QUEUE/
|
||||||
|
// CALL_GROUP são resolvidos direto, sem tocar no dialplan.
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(DESTINATION_TYPES)
|
||||||
|
destinationType?: (typeof DESTINATION_TYPES)[number];
|
||||||
|
|
||||||
|
// Contexto de dialplan do PRÓPRIO tenant que recebe a chamada depois da
|
||||||
|
// resolução — só usado quando destinationType é EXTENSION/IVR.
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
destinationContext?: string;
|
||||||
|
|
||||||
|
// Interpretação depende de destinationType: número de ramal (EXTENSION),
|
||||||
|
// IVR_ENTRY_DESTINATION fixo (IVR), Queue.id (QUEUE), ou
|
||||||
|
// Extension.callGroup (CALL_GROUP).
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
||||||
|
destinationNumber!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateInboundRouteDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(DESTINATION_TYPES)
|
||||||
|
destinationType?: (typeof DESTINATION_TYPES)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
destinationContext?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
||||||
|
destinationNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
155
apps/api/src/inbound-routes/inbound-routes.controller.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
ConflictException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext, 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 { CreateInboundRouteDto, UpdateInboundRouteDto } from "./dto/create-inbound-route.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotas de entrada por DID (PHASE 56, docs/INBOUND_ROUTES.md) — achado
|
||||||
|
* real: nenhuma chamada de tronco carregava `b2bcall_tenant_id` até aqui,
|
||||||
|
* então uma chamada de entrada não tinha como saber de qual tenant é.
|
||||||
|
* `didNumber` é @unique GLOBAL de propósito (mesma exceção já aceita em
|
||||||
|
* `Tenant.telephonyDomain`) — por isso o conflito de duplicidade só
|
||||||
|
* aparece no INSERT (a constraint do banco), nunca por uma pré-checagem
|
||||||
|
* cross-tenant: `InboundRoute` tem RLS de verdade (FORCE ROW LEVEL
|
||||||
|
* SECURITY), então uma query sem contexto de tenant não veria a linha de
|
||||||
|
* outro tenant mesmo se tentasse.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("inbound-routes")
|
||||||
|
export class InboundRoutesController {
|
||||||
|
@RequirePermission("inbound_routes.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateInboundRouteDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const route = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.inboundRoute.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
didNumber: dto.didNumber,
|
||||||
|
description: dto.description,
|
||||||
|
destinationType: dto.destinationType ?? "EXTENSION",
|
||||||
|
destinationContext: dto.destinationContext ?? "default",
|
||||||
|
destinationNumber: dto.destinationNumber,
|
||||||
|
enabled: dto.enabled ?? true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "INBOUND_ROUTE_CREATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "inbound_route",
|
||||||
|
entityId: route.id,
|
||||||
|
after: { didNumber: route.didNumber, destinationContext: route.destinationContext, destinationNumber: route.destinationNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
return route;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||||
|
throw new ConflictException("Este número (DID) já está em uso por outra rota de entrada");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("inbound_routes.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.inboundRoute.findMany({ where: { deletedAt: null }, orderBy: { didNumber: "asc" } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("inbound_routes.view")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const route = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.inboundRoute.findFirst({ where: { id, deletedAt: null } }),
|
||||||
|
);
|
||||||
|
if (!route) throw new NotFoundException();
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("inbound_routes.manage")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateInboundRouteDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.inboundRoute.updateMany({
|
||||||
|
where: { id, tenantId, deletedAt: null },
|
||||||
|
data: {
|
||||||
|
...(dto.description !== undefined ? { description: dto.description } : {}),
|
||||||
|
...(dto.destinationType !== undefined ? { destinationType: dto.destinationType } : {}),
|
||||||
|
...(dto.destinationContext !== undefined ? { destinationContext: dto.destinationContext } : {}),
|
||||||
|
...(dto.destinationNumber !== undefined ? { destinationNumber: dto.destinationNumber } : {}),
|
||||||
|
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (result.count === 0) throw new NotFoundException();
|
||||||
|
|
||||||
|
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.inboundRoute.findFirstOrThrow({ where: { id } }));
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "INBOUND_ROUTE_UPDATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "inbound_route",
|
||||||
|
entityId: id,
|
||||||
|
after: { ...dto },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("inbound_routes.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.inboundRoute.updateMany({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
data: { deletedAt: new Date(), enabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (result.count === 0) throw new NotFoundException();
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "INBOUND_ROUTE_DELETE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "inbound_route",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/inbound-routes/inbound-routes.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { InboundRoutesController } from "./inbound-routes.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [InboundRoutesController],
|
||||||
|
})
|
||||||
|
export class InboundRoutesModule {}
|
||||||
87
apps/api/src/ivr/dto/create-ivr-menu.dto.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
||||||
|
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
||||||
|
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
||||||
|
|
||||||
|
export class IvrMenuOptionDto {
|
||||||
|
@IsIn(ALLOWED_IVR_DIGITS)
|
||||||
|
digit!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
||||||
|
destinationNumber!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
destinationContext?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
// Posição do nó no editor visual (PHASE 61) — puramente de
|
||||||
|
// apresentação, nunca afeta o dialplan compilado.
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
positionX?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
positionY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateIvrMenuDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
// Vira o `context` do dialplan compilado — derivado do nome no
|
||||||
|
// frontend (slug), mas validado aqui como qualquer outro context.
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9-]{1,60}$/, { message: "context deve ser minúsculo, com letras/números/hífen (1 a 60 caracteres)" })
|
||||||
|
context!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
|
||||||
|
greeting?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(12)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => IvrMenuOptionDto)
|
||||||
|
options!: IvrMenuOptionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateIvrMenuDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
|
||||||
|
greeting?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(12)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => IvrMenuOptionDto)
|
||||||
|
options?: IvrMenuOptionDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
entryPositionX?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
entryPositionY?: number;
|
||||||
|
}
|
||||||
416
apps/api/src/ivr/ivr-menus.controller.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
ConflictException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { XMLValidator } from "fast-xml-parser";
|
||||||
|
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { buildDialplanXml, buildIvrDialplanExtensions, type IvrMenuOptionInput } 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";
|
||||||
|
import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto";
|
||||||
|
|
||||||
|
// Path do HOST (apps/api roda fora do Docker) — o mesmo diretório
|
||||||
|
// aparece como /ivr-prompts dentro do container freeswitch (ver
|
||||||
|
// docker-compose.yml). `IvrMenu.greeting` grava o path como o
|
||||||
|
// FreeSWITCH enxerga (container), nunca o path do host.
|
||||||
|
const IVR_PROMPTS_HOST_ROOT = process.env.IVR_PROMPTS_HOST_ROOT ?? "/opt/b2bcall/data/ivr-prompts";
|
||||||
|
const IVR_PROMPTS_CONTAINER_ROOT = "/ivr-prompts";
|
||||||
|
|
||||||
|
function isValidWavHeader(buf: Buffer): boolean {
|
||||||
|
// RIFF....WAVE — cabeçalho mínimo, suficiente pra rejeitar qualquer
|
||||||
|
// coisa que não seja WAV antes de gravar no disco compartilhado com o
|
||||||
|
// FreeSWITCH (mod_sndfile está carregado e resample sozinho qualquer
|
||||||
|
// sample rate/canais válidos; não há mod_shout nesta implantação, então
|
||||||
|
// MP3 nunca funcionaria — melhor rejeitar cedo com mensagem clara).
|
||||||
|
return buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WAVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptHostPath(tenantId: string, menuId: string): string {
|
||||||
|
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do
|
||||||
|
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
|
||||||
|
* recompila e reativa uma versão nova do contexto correspondente, o
|
||||||
|
* mesmo fluxo generate+activate que o editor manual faz, só que
|
||||||
|
* automático. Uma `InboundRoute` aponta pra cá com
|
||||||
|
* `destinationContext = IvrMenu.context` e
|
||||||
|
* `destinationNumber = IVR_ENTRY_DESTINATION` ("ivr_entry").
|
||||||
|
*/
|
||||||
|
async function compileAndActivateIvrDialplan(
|
||||||
|
prisma: ReturnType<typeof getPrismaClient>,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
menu: { context: string; greeting: string | null },
|
||||||
|
options: IvrMenuOptionInput[],
|
||||||
|
): Promise<void> {
|
||||||
|
// Substitui as linhas compiladas anteriores desse contexto — nunca
|
||||||
|
// acumula lixo de compilações antigas (mesmo padrão de "editar" já
|
||||||
|
// usado em reset-password/reveal-password: nunca reexpor/reaproveitar
|
||||||
|
// o estado velho, sempre um recorte limpo do estado atual).
|
||||||
|
await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.dialplanExtension.updateMany({
|
||||||
|
where: { tenantId, context: menu.context, deletedAt: null },
|
||||||
|
data: { deletedAt: new Date(), enabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const compiled = buildIvrDialplanExtensions(menu, options);
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
for (const ext of compiled) {
|
||||||
|
await tx.dialplanExtension.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
context: menu.context,
|
||||||
|
name: ext.name,
|
||||||
|
conditionField: ext.conditionField,
|
||||||
|
conditionExpr: ext.conditionExpr,
|
||||||
|
actions: ext.actions as unknown as Prisma.InputJsonValue,
|
||||||
|
continueOnFalse: ext.continueOnFalse,
|
||||||
|
order: ext.order,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = buildDialplanXml(menu.context, compiled);
|
||||||
|
const validation = XMLValidator.validate(xml);
|
||||||
|
if (validation !== true) {
|
||||||
|
throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.dialplanVersion.findFirst({ where: { tenantId, context: menu.context }, orderBy: { version: "desc" } }),
|
||||||
|
);
|
||||||
|
const nextVersion = (last?.version ?? 0) + 1;
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await tx.dialplanVersion.updateMany({
|
||||||
|
where: { tenantId, context: menu.context, status: "ACTIVE" },
|
||||||
|
data: { status: "SUPERSEDED" },
|
||||||
|
});
|
||||||
|
await tx.dialplanVersion.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
context: menu.context,
|
||||||
|
version: nextVersion,
|
||||||
|
generatedXml: xml,
|
||||||
|
status: "ACTIVE",
|
||||||
|
createdByUserId: userId,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("ivr-menus")
|
||||||
|
export class IvrMenusController {
|
||||||
|
@RequirePermission("ivr.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateIvrMenuDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
let menu;
|
||||||
|
try {
|
||||||
|
menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
const created = await tx.ivrMenu.create({
|
||||||
|
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
|
||||||
|
});
|
||||||
|
await tx.ivrMenuOption.createMany({
|
||||||
|
data: dto.options.map((opt) => ({
|
||||||
|
tenantId,
|
||||||
|
ivrMenuId: created.id,
|
||||||
|
digit: opt.digit,
|
||||||
|
destinationNumber: opt.destinationNumber,
|
||||||
|
destinationContext: opt.destinationContext ?? "default",
|
||||||
|
label: opt.label,
|
||||||
|
positionX: opt.positionX,
|
||||||
|
positionY: opt.positionY,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||||
|
throw new ConflictException("Já existe um menu de IVR com esse contexto neste tenant");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext ?? "default",
|
||||||
|
}));
|
||||||
|
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "IVR_MENU_CREATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "ivr_menu",
|
||||||
|
entityId: menu.id,
|
||||||
|
after: { name: menu.name, context: menu.context, optionCount: dto.options.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.get(user, menu.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("ivr.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.ivrMenu.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: { options: { orderBy: { digit: "asc" } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("ivr.view")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const menu = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.ivrMenu.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: { options: { orderBy: { digit: "asc" } } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!menu) throw new NotFoundException();
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("ivr.manage")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateIvrMenuDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const existing = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
|
||||||
|
);
|
||||||
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
const updated = await tx.ivrMenu.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
|
...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}),
|
||||||
|
...(dto.entryPositionX !== undefined ? { entryPositionX: dto.entryPositionX } : {}),
|
||||||
|
...(dto.entryPositionY !== undefined ? { entryPositionY: dto.entryPositionY } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (dto.options) {
|
||||||
|
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
||||||
|
await tx.ivrMenuOption.createMany({
|
||||||
|
data: dto.options.map((opt) => ({
|
||||||
|
tenantId,
|
||||||
|
ivrMenuId: id,
|
||||||
|
digit: opt.digit,
|
||||||
|
destinationNumber: opt.destinationNumber,
|
||||||
|
destinationContext: opt.destinationContext ?? "default",
|
||||||
|
label: opt.label,
|
||||||
|
positionX: opt.positionX,
|
||||||
|
positionY: opt.positionY,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const options: IvrMenuOptionInput[] = dto.options
|
||||||
|
? dto.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext ?? "default" }))
|
||||||
|
: existing.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext }));
|
||||||
|
|
||||||
|
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "IVR_MENU_UPDATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "ivr_menu",
|
||||||
|
entityId: id,
|
||||||
|
after: { name: menu.name, greeting: menu.greeting, optionCount: options.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.get(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("ivr.manage")
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
|
||||||
|
if (!menu) throw new NotFoundException();
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await tx.ivrMenu.update({ where: { id }, data: { deletedAt: new Date(), enabled: false } });
|
||||||
|
await tx.dialplanExtension.updateMany({
|
||||||
|
where: { tenantId, context: menu.context, deletedAt: null },
|
||||||
|
data: { deletedAt: new Date(), enabled: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
|
||||||
|
try {
|
||||||
|
await unlink(promptHostPath(tenantId, id));
|
||||||
|
} catch {
|
||||||
|
// arquivo já não existia — sem problema, o menu já foi apagado.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "IVR_MENU_DELETE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "ivr_menu",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload do prompt de áudio (PHASE 59, docs/INBOUND_ROUTES.md) —
|
||||||
|
* único endpoint desta API que recebe um arquivo binário. Grava no
|
||||||
|
* disco compartilhado com o FreeSWITCH (nunca no object storage de
|
||||||
|
* gravações — aquele é lido por humanos depois da chamada via proxy
|
||||||
|
* autenticado; este precisa ser lido pelo PRÓPRIO FreeSWITCH ao vivo
|
||||||
|
* durante `play_and_get_digits`, então tem que ser um arquivo local
|
||||||
|
* de verdade, não uma URL de rede) e recompila o dialplan do menu com
|
||||||
|
* o novo `greeting` apontando pro path que o FreeSWITCH enxerga.
|
||||||
|
*/
|
||||||
|
@RequirePermission("ivr.manage")
|
||||||
|
@Post(":id/prompt")
|
||||||
|
async uploadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Req() request: FastifyRequest) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const menu = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
|
||||||
|
);
|
||||||
|
if (!menu) throw new NotFoundException();
|
||||||
|
|
||||||
|
const data = await request.file();
|
||||||
|
if (!data) throw new BadRequestException("Nenhum arquivo enviado");
|
||||||
|
|
||||||
|
const buffer = await data.toBuffer();
|
||||||
|
if (!isValidWavHeader(buffer)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Arquivo não é um WAV válido — só WAV é aceito (esta implantação do FreeSWITCH não tem suporte a MP3)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostPath = promptHostPath(tenantId, id);
|
||||||
|
await mkdir(join(IVR_PROMPTS_HOST_ROOT, tenantId), { recursive: true });
|
||||||
|
await writeFile(hostPath, buffer);
|
||||||
|
|
||||||
|
const containerPath = `${IVR_PROMPTS_CONTAINER_ROOT}/${tenantId}/${id}.wav`;
|
||||||
|
const updated = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.ivrMenu.update({ where: { id }, data: { greeting: containerPath } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext,
|
||||||
|
}));
|
||||||
|
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "IVR_MENU_PROMPT_UPLOAD",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "ivr_menu",
|
||||||
|
entityId: id,
|
||||||
|
after: { filename: data.filename, sizeBytes: buffer.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.get(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("ivr.manage")
|
||||||
|
@Delete(":id/prompt")
|
||||||
|
async removePrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const menu = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
|
||||||
|
);
|
||||||
|
if (!menu) throw new NotFoundException();
|
||||||
|
|
||||||
|
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.update({ where: { id }, data: { greeting: null } }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(promptHostPath(tenantId, id));
|
||||||
|
} catch {
|
||||||
|
// arquivo já não existia — nada a fazer, greeting já voltou a null.
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext,
|
||||||
|
}));
|
||||||
|
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "IVR_MENU_PROMPT_DELETE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "ivr_menu",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.get(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview autenticado do prompt — mesmo princípio do player de
|
||||||
|
* gravações (nunca uma URL direta pro storage/disco). */
|
||||||
|
@RequirePermission("ivr.view")
|
||||||
|
@Get(":id/prompt")
|
||||||
|
async downloadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Res() reply: FastifyReply) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
|
||||||
|
if (!menu || !menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header("Content-Type", "audio/wav");
|
||||||
|
reply.header("Content-Disposition", "inline");
|
||||||
|
reply.send(createReadStream(promptHostPath(tenantId, id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/ivr/ivr.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { IvrMenusController } from "./ivr-menus.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [IvrMenusController],
|
||||||
|
})
|
||||||
|
export class IvrModule {}
|
||||||
76
apps/api/src/leads/callbacks.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Patch, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } 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 { UpdateCallbackDto } from "./dto/update-callback.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Discador > Callbacks" (agente.md secao 169) — visão tenant-wide (todas
|
||||||
|
* as campanhas) dos leads que o `PredictiveDialerEngine` marcou como
|
||||||
|
* `CALLBACK` (secao 78-79: agente pediu pra ligar de volta em outro
|
||||||
|
* horário). Antes desta tela não existia forma nenhuma de ver ou agir
|
||||||
|
* sobre esses leads — só ficavam esperando `nextAttemptAt` chegar, sem
|
||||||
|
* ninguém poder adiantar, adiar ou cancelar.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("leads/callbacks")
|
||||||
|
export class CallbacksController {
|
||||||
|
@RequirePermission("campaigns.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.lead.findMany({
|
||||||
|
where: { tenantId, status: "CALLBACK" },
|
||||||
|
include: { campaign: { select: { id: true, name: true } } },
|
||||||
|
orderBy: { nextAttemptAt: "asc" },
|
||||||
|
take: 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("campaigns.update")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: AccessTokenClaims,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateCallbackDto,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const lead = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.lead.findFirst({ where: { id, tenantId, status: "CALLBACK" } }),
|
||||||
|
);
|
||||||
|
if (!lead) throw new NotFoundException("Callback nao encontrado (ou o lead ja saiu do status CALLBACK)");
|
||||||
|
|
||||||
|
if (dto.action === "RESCHEDULE" && new Date(dto.nextAttemptAt!).getTime() <= Date.now()) {
|
||||||
|
throw new BadRequestException("nextAttemptAt precisa ser no futuro");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data =
|
||||||
|
dto.action === "RESCHEDULE"
|
||||||
|
? { nextAttemptAt: new Date(dto.nextAttemptAt!) }
|
||||||
|
: dto.action === "REQUEUE"
|
||||||
|
? { status: "READY" as const, nextAttemptAt: null }
|
||||||
|
: { status: "DO_NOT_CALL" as const, nextAttemptAt: null };
|
||||||
|
|
||||||
|
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.lead.update({ where: { id }, data }));
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "LEAD_CALLBACK_UPDATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "lead",
|
||||||
|
entityId: id,
|
||||||
|
after: { action: dto.action, nextAttemptAt: dto.nextAttemptAt ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/leads/dto/update-callback.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsIn, IsISO8601, ValidateIf } from "class-validator";
|
||||||
|
|
||||||
|
const CALLBACK_ACTIONS = ["RESCHEDULE", "REQUEUE", "CANCEL"];
|
||||||
|
|
||||||
|
export class UpdateCallbackDto {
|
||||||
|
@IsIn(CALLBACK_ACTIONS)
|
||||||
|
action!: "RESCHEDULE" | "REQUEUE" | "CANCEL";
|
||||||
|
|
||||||
|
@ValidateIf((dto) => dto.action === "RESCHEDULE")
|
||||||
|
@IsISO8601()
|
||||||
|
nextAttemptAt?: string;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { LeadsController } from "./leads.controller";
|
import { LeadsController } from "./leads.controller";
|
||||||
|
import { CallbacksController } from "./callbacks.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [LeadsController],
|
controllers: [LeadsController, CallbacksController],
|
||||||
})
|
})
|
||||||
export class LeadsModule {}
|
export class LeadsModule {}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { ValidationPipe } from "@nestjs/common";
|
|||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import multipart from "@fastify/multipart";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
|
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
|
||||||
import { runRetentionSweep } from "./recordings/retention-sweep";
|
import { runRetentionSweep } from "./recordings/retention-sweep";
|
||||||
|
import { runActiveDaySweep } from "./billing/active-day-sweep";
|
||||||
|
|
||||||
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
const ACTIVE_DAY_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@@ -36,6 +39,14 @@ async function bootstrap() {
|
|||||||
timeWindow: "1 minute",
|
timeWindow: "1 minute",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload de prompt de áudio do IVR (PHASE 59) — único endpoint desta
|
||||||
|
// API que recebe um arquivo binário; limite de tamanho aqui evita um
|
||||||
|
// upload gigante travar o processo (ver ivr-menus.controller.ts pra
|
||||||
|
// validação de formato/conteúdo).
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: { fileSize: 8 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
@@ -63,6 +74,13 @@ async function bootstrap() {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
|
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
|
||||||
}, RETENTION_SWEEP_INTERVAL_MS);
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Usage metering diario (agente.md secao 131: EXTENSION/AGENT/TRUNK
|
||||||
|
// ACTIVE_DAY) — mesmo padrao da varredura de retencao acima.
|
||||||
|
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario (boot)", err));
|
||||||
|
setInterval(() => {
|
||||||
|
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario", err));
|
||||||
|
}, ACTIVE_DAY_SWEEP_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ export class PauseReasonsController {
|
|||||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
const prisma = getPrismaClient();
|
const prisma = getPrismaClient();
|
||||||
const tenantId = user.tenantId!;
|
const tenantId = user.tenantId!;
|
||||||
return withTenantContext(prisma, tenantId, (tx) => tx.pauseReason.findMany({ orderBy: { name: "asc" } }));
|
// Bug real, achado testando o frontend: sem o filtro `enabled: true`
|
||||||
|
// aqui, um motivo "removido" (soft delete, ver `remove` abaixo) nunca
|
||||||
|
// sumia da lista — único endpoint deste arquivo sem esse filtro.
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.pauseReason.findMany({ where: { enabled: true }, orderBy: { name: "asc" } }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequirePermission("agents.manage")
|
@RequirePermission("agents.manage")
|
||||||
|
|||||||
169
apps/api/src/plans/dto/create-plan.dto.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, Matches, MaxLength, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class CreatePlanDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
@Matches(/^[a-z0-9_-]+$/, { message: "key deve ser minusculo, so letras/numeros/hifen/underscore" })
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxExtensions?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxAgents?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxTrunks?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxQueues?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCampaigns?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCps?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxConcurrentCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxDailyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxMonthlyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxRecordingStorageGb?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
recordingRetentionDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
transcriptionRetentionDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
recordingEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiTranscriptionEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiAnalysisEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
apiAccessEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePlanDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxExtensions?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxAgents?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxTrunks?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxQueues?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCampaigns?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCps?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxConcurrentCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxDailyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxMonthlyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxRecordingStorageGb?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
recordingEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiTranscriptionEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiAnalysisEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
apiAccessEnabled?: boolean;
|
||||||
|
}
|
||||||
87
apps/api/src/plans/plans.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, 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 { CreatePlanDto, UpdatePlanDto } from "./dto/create-plan.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catálogo de planos (agente.md secao 56, 126) — global, sem RLS (mesmo
|
||||||
|
* critério de `plans` desde a PHASE 14: "sem tenant_id, catálogo
|
||||||
|
* compartilhado"). Só platform admin gerencia; qualquer campo de limite
|
||||||
|
* `null` significa "sem limite" (nunca "sem plano" — `Tenant.planId` é
|
||||||
|
* obrigatório desde a migration que fez o backfill).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("plans")
|
||||||
|
export class PlansController {
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePlanDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode criar planos");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const existing = await prisma.plan.findUnique({ where: { key: dto.key } });
|
||||||
|
if (existing) {
|
||||||
|
throw new ForbiddenException(`Ja existe um plano com key "${dto.key}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await prisma.plan.create({ data: dto });
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PLAN_CREATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "plan",
|
||||||
|
entityId: plan.id,
|
||||||
|
after: { key: plan.key, name: plan.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return prisma.plan.findMany({ orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const plan = await prisma.plan.findUnique({ where: { id } });
|
||||||
|
if (!plan) throw new NotFoundException();
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdatePlanDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode editar planos");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const existing = await prisma.plan.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
const plan = await prisma.plan.update({ where: { id }, data: dto });
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PLAN_UPDATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "plan",
|
||||||
|
entityId: plan.id,
|
||||||
|
after: { ...dto },
|
||||||
|
});
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/plans/plans.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PlansController } from "./plans.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlansController],
|
||||||
|
})
|
||||||
|
export class PlansModule {}
|
||||||
6
apps/api/src/platform/dto/update-webrtc-proxy.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUrl } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateWebrtcProxyDto {
|
||||||
|
@IsUrl({ protocols: ["ws", "wss"], require_protocol: true, require_tld: false })
|
||||||
|
url!: string;
|
||||||
|
}
|
||||||
76
apps/api/src/platform/platform-ai-usage.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "IA > Uso/Custos" (agente.md secao 169) — visão de plataforma, todos os
|
||||||
|
* tenants, diferente do BYOK do tenant (`/ai/providers`, `/ai/models`,
|
||||||
|
* escopo TENANT/GLOBAL). Uso = quantidade bruta do ledger `AIUsageRecord`
|
||||||
|
* (mesmo ledger que `/reports/consumo` usa por tenant, aqui somado em
|
||||||
|
* todos). Custo = uso × preço unitário do `AIModel` correspondente
|
||||||
|
* (`providerId` + `model` batendo com `externalModelId`) — quando não dá
|
||||||
|
* pra casar um registro de uso com um AIModel cadastrado (ex.: provider
|
||||||
|
* apagado, nome de modelo mudou), o custo daquele registro fica de fora
|
||||||
|
* da soma e o tenant é marcado `costIncomplete: true`, nunca um número
|
||||||
|
* inventado (agente.md secao 138/233: null > estimativa disfarçada de
|
||||||
|
* número fechado).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/ai-usage")
|
||||||
|
export class PlatformAiUsageController {
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver uso de IA de todos os tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const now = new Date();
|
||||||
|
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({ where: { deletedAt: null }, orderBy: { legalName: "asc" } });
|
||||||
|
|
||||||
|
const models = await prisma.aIModel.findMany({ select: { providerId: true, externalModelId: true, inputCost: true, outputCost: true, audioCost: true } });
|
||||||
|
const modelByKey = new Map(models.map((m) => [`${m.providerId}:${m.externalModelId}`, m]));
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
tenants.map(async (tenant) => {
|
||||||
|
const records = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
tx.aIUsageRecord.findMany({ where: { tenantId: tenant.id, occurredAt: { gte: monthStart } } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usage = { transcriptionSeconds: 0, analysisRequests: 0, inputTokens: 0, outputTokens: 0 };
|
||||||
|
let cost = 0;
|
||||||
|
let costIncomplete = false;
|
||||||
|
|
||||||
|
for (const r of records) {
|
||||||
|
if (r.type === "AI_TRANSCRIPTION_SECONDS") usage.transcriptionSeconds += r.quantity;
|
||||||
|
if (r.type === "AI_ANALYSIS_REQUEST") usage.analysisRequests += r.quantity;
|
||||||
|
if (r.type === "AI_INPUT_TOKENS") usage.inputTokens += r.quantity;
|
||||||
|
if (r.type === "AI_OUTPUT_TOKENS") usage.outputTokens += r.quantity;
|
||||||
|
|
||||||
|
const model = r.providerId && r.model ? modelByKey.get(`${r.providerId}:${r.model}`) : undefined;
|
||||||
|
if (!model) {
|
||||||
|
if (r.type !== "AI_ANALYSIS_REQUEST") costIncomplete = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r.type === "AI_TRANSCRIPTION_SECONDS" && model.audioCost != null) cost += r.quantity * model.audioCost;
|
||||||
|
else if (r.type === "AI_INPUT_TOKENS" && model.inputCost != null) cost += r.quantity * model.inputCost;
|
||||||
|
else if (r.type === "AI_OUTPUT_TOKENS" && model.outputCost != null) cost += r.quantity * model.outputCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
legalName: tenant.legalName,
|
||||||
|
usage,
|
||||||
|
estimatedCost: records.length === 0 ? null : cost,
|
||||||
|
costIncomplete,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/api/src/platform/platform-audit.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, Query, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sistema > Auditoria" (agente.md secao 150-151, 168) — `audit_logs` não
|
||||||
|
* tem RLS (linha imutável de auditoria, precisa sobreviver mesmo que o
|
||||||
|
* tenant seja apagado — decisão do schema desde a PHASE 04), então uma
|
||||||
|
* consulta cross-tenant direta é segura aqui; só platform admin acessa
|
||||||
|
* este endpoint (um tenant admin vê o próprio audit trail por outro
|
||||||
|
* caminho, se/quando existir).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/audit-log")
|
||||||
|
export class PlatformAuditController {
|
||||||
|
@RequirePermission("audit.view")
|
||||||
|
@Get()
|
||||||
|
async list(
|
||||||
|
@CurrentUser() user: AccessTokenClaims,
|
||||||
|
@Query("action") action?: string,
|
||||||
|
@Query("tenantId") tenantId?: string,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o audit log da plataforma");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const entries = await prisma.auditLog.findMany({
|
||||||
|
where: {
|
||||||
|
...(action ? { action: { contains: action, mode: "insensitive" } } : {}),
|
||||||
|
...(tenantId ? { tenantId } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userIds = Array.from(new Set(entries.map((e) => e.userId).filter((id): id is string => id != null)));
|
||||||
|
const tenantIds = Array.from(new Set(entries.map((e) => e.tenantId).filter((id): id is string => id != null)));
|
||||||
|
const [users, tenants] = await Promise.all([
|
||||||
|
userIds.length ? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true } }) : [],
|
||||||
|
tenantIds.length ? prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, legalName: true } }) : [],
|
||||||
|
]);
|
||||||
|
const userEmail = new Map(users.map((u) => [u.id, u.email]));
|
||||||
|
const tenantName = new Map(tenants.map((t) => [t.id, t.legalName]));
|
||||||
|
|
||||||
|
return entries.map((e) => ({
|
||||||
|
...e,
|
||||||
|
userEmail: e.userId ? (userEmail.get(e.userId) ?? e.userId) : null,
|
||||||
|
tenantName: e.tenantId ? (tenantName.get(e.tenantId) ?? e.tenantId) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/api/src/platform/platform-freeswitch.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { FreeSwitchTelephonyProvider } 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infraestrutura > FreeSWITCH/SIP Profiles/Nodes (agente.md secao 168-169)
|
||||||
|
* — introspecção ESL real, mesmo padrão de conexão avulsa (connect →
|
||||||
|
* comando → disconnect) já usado em `PlatformHealthController`. Igual ao
|
||||||
|
* health check, nunca deixa a indisponibilidade do ESL virar 500/503 pro
|
||||||
|
* cliente — devolve `{ ok: false, error }` (mesma filosofia do `timed()`
|
||||||
|
* de lá), porque nesta VM o Event Socket (8021) é deliberadamente não
|
||||||
|
* publicado no host (agente.md secao 184) e `apps/api` roda fora do
|
||||||
|
* Docker: essas 3 telas SEMPRE vão mostrar essa mensagem aqui, mesmo
|
||||||
|
* endpoint funcionando corretamente em produção (mesma network do
|
||||||
|
* FreeSWITCH). "Nodes" é sempre 1 node nesta implantação (container
|
||||||
|
* único, sem clustering) — honesto em vez de fingir uma lista.
|
||||||
|
*/
|
||||||
|
async function withEsl<T>(fn: (provider: FreeSwitchTelephonyProvider) => Promise<T>): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
|
||||||
|
const host = process.env.ESL_HOST;
|
||||||
|
const port = Number(process.env.ESL_PORT ?? 8021);
|
||||||
|
const password = process.env.ESL_PASSWORD;
|
||||||
|
if (!host || !password) return { ok: false, error: "ESL nao configurado (ESL_HOST/ESL_PASSWORD ausentes)" };
|
||||||
|
|
||||||
|
const provider = new FreeSwitchTelephonyProvider({ host, port, password });
|
||||||
|
try {
|
||||||
|
provider.connect();
|
||||||
|
const connected = await provider.waitUntilConnected(2500);
|
||||||
|
if (!connected) return { ok: false, error: "Timeout conectando no ESL do FreeSWITCH" };
|
||||||
|
return { ok: true, data: await fn(provider) };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : "Erro desconhecido falando com o ESL" };
|
||||||
|
} finally {
|
||||||
|
await provider.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePlatform(user: AccessTokenClaims): Promise<boolean> {
|
||||||
|
return isPlatformUser(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/freeswitch")
|
||||||
|
export class PlatformFreeswitchController {
|
||||||
|
@RequirePermission("freeswitch.view")
|
||||||
|
@Get("channels")
|
||||||
|
async channels(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver o FreeSWITCH");
|
||||||
|
return withEsl(async (p) => ({ channels: await p.getChannels(), calls: await p.getCalls() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("freeswitch.view")
|
||||||
|
@Get("profiles")
|
||||||
|
async profiles(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver SIP profiles");
|
||||||
|
return withEsl(async (p) => ({
|
||||||
|
sofiaStatus: await p.runApi("sofia status"),
|
||||||
|
registrations: await p.getRegistrations(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("freeswitch.view")
|
||||||
|
@Get("nodes")
|
||||||
|
async nodes(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver os nodes");
|
||||||
|
return withEsl(async (p) => ({
|
||||||
|
nodeCount: 1,
|
||||||
|
status: await p.runApi("status"),
|
||||||
|
gateways: await p.getGateways(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/api/src/platform/platform-health.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { FreeSwitchTelephonyProvider } 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";
|
||||||
|
import { getRedisClient } from "../common/redis";
|
||||||
|
|
||||||
|
type CheckStatus = "ok" | "fail";
|
||||||
|
|
||||||
|
async function timed<T>(fn: () => Promise<T>): Promise<{ status: CheckStatus; latencyMs: number }> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
return { status: "ok", latencyMs: Date.now() - start };
|
||||||
|
} catch {
|
||||||
|
return { status: "fail", latencyMs: Date.now() - start };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Infraestrutura > Saúde" (agente.md secao 168, 187). `apps/api` não
|
||||||
|
* mantém uma conexão ESL permanente (isso é trabalho de fs-events/fs-
|
||||||
|
* config/predictive-dialer, secao 21) — pra não adicionar esse estado só
|
||||||
|
* pra um health check de tela de admin, abre uma conexão ESL avulsa,
|
||||||
|
* espera conectar, e derruba. Aceitável aqui: endpoint de baixo tráfego,
|
||||||
|
* chamado por um humano olhando a tela, não um hot path.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/health")
|
||||||
|
export class PlatformHealthController {
|
||||||
|
@RequirePermission("freeswitch.view")
|
||||||
|
@Get()
|
||||||
|
async check(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver a saude da infraestrutura");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [postgres, redis, freeswitch] = await Promise.all([
|
||||||
|
timed(() => getPrismaClient().$queryRaw`SELECT 1`),
|
||||||
|
timed(async () => {
|
||||||
|
await getRedisClient().ping();
|
||||||
|
}),
|
||||||
|
timed(async () => {
|
||||||
|
const host = process.env.ESL_HOST;
|
||||||
|
const port = Number(process.env.ESL_PORT ?? 8021);
|
||||||
|
const password = process.env.ESL_PASSWORD;
|
||||||
|
if (!host || !password) throw new Error("ESL nao configurado");
|
||||||
|
|
||||||
|
const provider = new FreeSwitchTelephonyProvider({ host, port, password });
|
||||||
|
try {
|
||||||
|
provider.connect();
|
||||||
|
const connected = await provider.waitUntilConnected(2500);
|
||||||
|
if (!connected) throw new Error("timeout conectando no ESL");
|
||||||
|
} finally {
|
||||||
|
await provider.disconnect();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { postgres, redis, freeswitch, checkedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/api/src/platform/platform-overview.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Dashboard Platform" (agente.md secao 163) — só platform admin (mesmo
|
||||||
|
* padrão de isPlatformUser já usado pra escrita GLOBAL em AIProvider/
|
||||||
|
* AIPromptTemplate). Consulta direto (sem withTenantContext — precisa
|
||||||
|
* agregar TODOS os tenants, não faz sentido sob RLS de um tenant só).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform")
|
||||||
|
export class PlatformOverviewController {
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get("overview")
|
||||||
|
async overview(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const isPlatform = await isPlatformUser(user.sub);
|
||||||
|
if (!isPlatform) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o dashboard da plataforma");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
|
||||||
|
|
||||||
|
const [tenantsActive, tenantsTotal, extensionsTotal, agentsTotal, callsCurrent, callsToday, cpsCapacity] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }),
|
||||||
|
prisma.tenant.count({ where: { deletedAt: null } }),
|
||||||
|
prisma.extension.count({ where: { deletedAt: null } }),
|
||||||
|
prisma.agent.count({ where: { deletedAt: null } }),
|
||||||
|
prisma.call.count({ where: { endAt: null } }),
|
||||||
|
prisma.call.count({ where: { createdAt: { gte: todayStart } } }),
|
||||||
|
prisma.plan.aggregate({ _sum: { maxCps: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// `ai_usage_records`/`recordings` têm FORCE RLS (secao 32) — as duas
|
||||||
|
// agregações acima (achado real, corrigido aqui) rodavam direto no
|
||||||
|
// Prisma sem `app.current_tenant_id` nenhum setado, então SEMPRE
|
||||||
|
// devolviam 0 linhas (zero silencioso, sem erro nenhum), nunca o
|
||||||
|
// número real, não importa quanto uso existisse nos tenants. Mesma
|
||||||
|
// classe de bug já corrigida em `TenantsController.list` — só um
|
||||||
|
// loop `withTenantContext` por tenant enxerga as linhas de verdade.
|
||||||
|
const activeTenantIds = await prisma.tenant.findMany({ where: { deletedAt: null }, select: { id: true } });
|
||||||
|
const [aiUsagePerTenant, recordingBytesPerTenant] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
activeTenantIds.map((t) =>
|
||||||
|
withTenantContext(prisma, t.id, (tx) =>
|
||||||
|
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: t.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Promise.all(
|
||||||
|
activeTenantIds.map((t) =>
|
||||||
|
withTenantContext(prisma, t.id, (tx) => tx.recording.aggregate({ where: { tenantId: t.id }, _sum: { sizeBytes: true } })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const aiUsageThisMonth: Record<string, number> = {};
|
||||||
|
for (const rows of aiUsagePerTenant) {
|
||||||
|
for (const row of rows) aiUsageThisMonth[row.type] = (aiUsageThisMonth[row.type] ?? 0) + (row._sum.quantity ?? 0);
|
||||||
|
}
|
||||||
|
const recordingStorageBytes = recordingBytesPerTenant.reduce((sum, agg) => sum + Number(agg._sum.sizeBytes ?? 0n), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantsActive,
|
||||||
|
tenantsTotal,
|
||||||
|
extensionsTotal,
|
||||||
|
agentsTotal,
|
||||||
|
callsCurrent,
|
||||||
|
callsToday,
|
||||||
|
// Deployment desta lab tem 1 unico container FreeSWITCH — sem tabela
|
||||||
|
// de nodes ainda pra descobrir isso dinamicamente (nao existe
|
||||||
|
// clustering multi-node nesta fase).
|
||||||
|
freeswitchNodes: 1,
|
||||||
|
// Soma dos tetos de CPS configurados por plano em todos os tenants —
|
||||||
|
// capacidade OUTORGADA, nao consumo em tempo real (isso vive no
|
||||||
|
// token bucket do Redis do predictive-dialer, apps/api nao le de
|
||||||
|
// la ainda).
|
||||||
|
cpsCapacityConfigured: cpsCapacity._sum.maxCps ?? null,
|
||||||
|
aiUsageThisMonth,
|
||||||
|
recordingStorageBytes,
|
||||||
|
// Precisa de BillingPeriod/BillingStatement fechados de verdade
|
||||||
|
// (fase Billing, em construcao) — nenhum periodo foi fechado ainda
|
||||||
|
// nesta lab, entao nao ha numero real pra mostrar. null e' honesto,
|
||||||
|
// nao 0.
|
||||||
|
monthlyConsumption: null as number | null,
|
||||||
|
estimatedRevenue: null as number | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/api/src/platform/platform-quotas.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Clientes > Quotas" (agente.md secao 169) — uso vs. limite do plano em
|
||||||
|
* TODOS os tenants, pra platform admin achar quem está perto de estourar
|
||||||
|
* sem precisar abrir um por um. Mesmo cálculo de `/reports/consumo`
|
||||||
|
* (tenant), só que looping por tenant (RLS, secao 32) em vez de um só.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/quotas")
|
||||||
|
export class PlatformQuotasController {
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver quotas de todos os tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const now = new Date();
|
||||||
|
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: { plan: true },
|
||||||
|
orderBy: { legalName: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
tenants.map(async (tenant) => {
|
||||||
|
const [callCountAgg, recordingAgg, extensionCount, agentCount, trunkCount, queueCount, campaignCount] =
|
||||||
|
await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.usageEvent.aggregate({ where: { tenantId: tenant.id, meter: "CALL_COUNT", occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||||
|
tx.recording.aggregate({ where: { tenantId: tenant.id }, _sum: { sizeBytes: true } }),
|
||||||
|
tx.extension.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||||
|
tx.agent.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||||
|
tx.trunk.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||||
|
tx.queue.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||||
|
tx.campaign.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callCount = callCountAgg._sum.quantity ?? 0;
|
||||||
|
const recordingBytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
|
||||||
|
const recordingGb = recordingBytes / 1024 ** 3;
|
||||||
|
|
||||||
|
const ratio = (used: number, max: number | null) => (max ? used / max : null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
legalName: tenant.legalName,
|
||||||
|
planName: tenant.plan.name,
|
||||||
|
status: tenant.status,
|
||||||
|
items: [
|
||||||
|
{ key: "extensions", label: "Ramais", used: extensionCount, max: tenant.plan.maxExtensions, ratio: ratio(extensionCount, tenant.plan.maxExtensions) },
|
||||||
|
{ key: "agents", label: "Agentes", used: agentCount, max: tenant.plan.maxAgents, ratio: ratio(agentCount, tenant.plan.maxAgents) },
|
||||||
|
{ key: "trunks", label: "Troncos", used: trunkCount, max: tenant.plan.maxTrunks, ratio: ratio(trunkCount, tenant.plan.maxTrunks) },
|
||||||
|
{ key: "queues", label: "Filas", used: queueCount, max: tenant.plan.maxQueues, ratio: ratio(queueCount, tenant.plan.maxQueues) },
|
||||||
|
{ key: "campaigns", label: "Campanhas", used: campaignCount, max: tenant.plan.maxCampaigns, ratio: ratio(campaignCount, tenant.plan.maxCampaigns) },
|
||||||
|
{ key: "monthlyCalls", label: "Chamadas/mês", used: callCount, max: tenant.plan.maxMonthlyCalls, ratio: ratio(callCount, tenant.plan.maxMonthlyCalls) },
|
||||||
|
{ key: "recordingGb", label: "Armazenamento (GB)", used: Number(recordingGb.toFixed(3)), max: tenant.plan.maxRecordingStorageGb, ratio: ratio(recordingGb, tenant.plan.maxRecordingStorageGb) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/api/src/platform/platform-roles.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sistema > Permissões" (agente.md secao 142-145, 168) — só leitura por
|
||||||
|
* enquanto: RBAC é system-defined (roles/permissions vêm do seed,
|
||||||
|
* `packages/auth/src/seed.ts`), sem UI de criar role customizada ainda.
|
||||||
|
* `roles`/`permissions` não têm RLS (catálogo global do sistema).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/roles")
|
||||||
|
export class PlatformRolesController {
|
||||||
|
@RequirePermission("roles.manage")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o catalogo de roles/permissoes");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const [roles, permissions] = await Promise.all([
|
||||||
|
prisma.role.findMany({
|
||||||
|
include: { rolePermissions: { include: { permission: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.permission.findMany({ orderBy: { key: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
roles: roles.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
key: r.key,
|
||||||
|
name: r.name,
|
||||||
|
scope: r.scope,
|
||||||
|
isSystem: r.isSystem,
|
||||||
|
permissionKeys: r.rolePermissions.map((rp) => rp.permission.key),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/api/src/platform/platform-system-config.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { isPlatformUser, 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sistema > Configurações" (agente.md secao 169) — nunca teve escopo
|
||||||
|
* definido na especificação. Decisão desta implementação: painel
|
||||||
|
* read-only das flags de segurança/infra que já existem como variável de
|
||||||
|
* ambiente (`.env`, compartilhado por `apps/api` e os workers via
|
||||||
|
* systemd/docker-compose — ver `infrastructure/systemd/README.md` e
|
||||||
|
* `docker-compose.yml`), nunca editável por aqui — mudar exige editar o
|
||||||
|
* `.env` e reiniciar o serviço (documentado, não um botão de UI que
|
||||||
|
* fingiria aplicar na hora). Nunca expõe segredo nenhum (senha, chave,
|
||||||
|
* connection string) — só booleans/enums que já são público conhecimento
|
||||||
|
* de quem administra a infraestrutura.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/system-config")
|
||||||
|
export class PlatformSystemConfigController {
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get()
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver a configuracao do sistema");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialerSimulation = (process.env.DIALER_SIMULATION ?? "true") === "true";
|
||||||
|
const allowRealOutboundCalls = (process.env.ALLOW_REAL_OUTBOUND_CALLS ?? "false") === "true";
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||||
|
dialerSimulation,
|
||||||
|
allowRealOutboundCalls,
|
||||||
|
// As DUAS precisam estar explicitamente ligadas (secao 186) — nunca
|
||||||
|
// basta uma pra originar PSTN de verdade.
|
||||||
|
realOutboundCallsActive: !dialerSimulation && allowRealOutboundCalls,
|
||||||
|
storageProvider: process.env.STORAGE_PROVIDER ?? "local",
|
||||||
|
eslConfigured: Boolean(process.env.ESL_HOST) && Boolean(process.env.ESL_PASSWORD),
|
||||||
|
corsOrigin: process.env.CORS_ORIGIN ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/api/src/platform/platform-users.controller.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, UseGuards } from "@nestjs/common";
|
||||||
|
import { IsIn } from "class-validator";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, 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";
|
||||||
|
|
||||||
|
class UpdateUserStatusDto {
|
||||||
|
@IsIn(["ACTIVE", "DISABLED"])
|
||||||
|
status!: "ACTIVE" | "DISABLED";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sistema > Usuários" (agente.md secao 168) — visão cross-tenant, só
|
||||||
|
* platform admin. `users` não tem RLS (identidade global, secao 148),
|
||||||
|
* então lida direto com `getPrismaClient()`; as memberships de cada
|
||||||
|
* usuário (que tenants ele pertence) É que têm RLS — resolvidas uma por
|
||||||
|
* uma via `withTenantContext`, mesmo padrão já usado em
|
||||||
|
* `TenantsController.list` pro `memberCount`.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/users")
|
||||||
|
export class PlatformUsersController {
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode listar usuarios da plataforma");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const [users, platformAdmins] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: { id: true, email: true, name: true, status: true, mustChangePassword: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.userRole.findMany({ where: { tenantId: null, role: { key: "platform_super_admin" } }, select: { userId: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const platformAdminIds = new Set(platformAdmins.map((r) => r.userId));
|
||||||
|
|
||||||
|
return users.map((u) => ({ ...u, isPlatformUser: platformAdminIds.has(u.id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Patch(":id/status")
|
||||||
|
async updateStatus(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateUserStatusDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode desabilitar usuarios");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const existing = await prisma.user.findFirst({ where: { id, deletedAt: null } });
|
||||||
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({ where: { id }, data: { status: dto.status } });
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "USER_STATUS_UPDATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "user",
|
||||||
|
entityId: id,
|
||||||
|
after: { status: dto.status },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: updated.id, status: updated.status };
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/api/src/platform/platform-webrtc-proxy.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, Put, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, 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 { UpdateWebrtcProxyDto } from "./dto/update-webrtc-proxy.dto";
|
||||||
|
|
||||||
|
export const WEBRTC_PROXY_SETTING_KEY = "webrtc_proxy_url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sistema > Softphone WebRTC" — endereço WSS do proxy OpenSIPS que faz a
|
||||||
|
* ponte WebRTC↔SIP pro widget Handphone embutido no app do tenant (PHASE
|
||||||
|
* 66, ver docs/SOFTPHONE.md). Config global (não por tenant): um único
|
||||||
|
* OpenSIPS atende todos os tenants, cada ramal continua puro SIP — o
|
||||||
|
* FreeSWITCH deste projeto nunca fala WebRTC diretamente.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/webrtc-proxy")
|
||||||
|
export class PlatformWebrtcProxyController {
|
||||||
|
@RequirePermission("freeswitch.view")
|
||||||
|
@Get()
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver esta configuracao");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
|
||||||
|
return { url: setting?.value ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("freeswitch.configure")
|
||||||
|
@Put()
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateWebrtcProxyDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode editar esta configuracao");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const setting = await prisma.platformSetting.upsert({
|
||||||
|
where: { key: WEBRTC_PROXY_SETTING_KEY },
|
||||||
|
create: { key: WEBRTC_PROXY_SETTING_KEY, value: dto.url, updatedBy: user.sub },
|
||||||
|
update: { value: dto.url, updatedBy: user.sub },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PLATFORM_WEBRTC_PROXY_UPDATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "platform_setting",
|
||||||
|
entityId: WEBRTC_PROXY_SETTING_KEY,
|
||||||
|
after: { url: dto.url },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url: setting.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/api/src/platform/platform.module.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PlatformOverviewController } from "./platform-overview.controller";
|
||||||
|
import { PlatformUsersController } from "./platform-users.controller";
|
||||||
|
import { PlatformAuditController } from "./platform-audit.controller";
|
||||||
|
import { PlatformHealthController } from "./platform-health.controller";
|
||||||
|
import { PlatformRolesController } from "./platform-roles.controller";
|
||||||
|
import { PlatformQuotasController } from "./platform-quotas.controller";
|
||||||
|
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
||||||
|
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
||||||
|
import { PlatformSystemConfigController } from "./platform-system-config.controller";
|
||||||
|
import { PlatformWebrtcProxyController } from "./platform-webrtc-proxy.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
PlatformOverviewController,
|
||||||
|
PlatformUsersController,
|
||||||
|
PlatformAuditController,
|
||||||
|
PlatformHealthController,
|
||||||
|
PlatformRolesController,
|
||||||
|
PlatformQuotasController,
|
||||||
|
PlatformFreeswitchController,
|
||||||
|
PlatformAiUsageController,
|
||||||
|
PlatformSystemConfigController,
|
||||||
|
PlatformWebrtcProxyController,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PlatformModule {}
|
||||||
@@ -86,3 +86,80 @@ export class CreateQueueDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
recordingEnabled?: boolean;
|
recordingEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateQueueDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STRATEGIES)
|
||||||
|
strategy?: (typeof STRATEGIES)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
mohSound?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
announceSound?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(3600)
|
||||||
|
announceFrequency?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(3600)
|
||||||
|
maxWaitTime?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(3600)
|
||||||
|
maxWaitTimeWithNoAgent?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
agentNoAnswerStatus?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
tierRulesApply?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(3600)
|
||||||
|
tierRuleWaitSecond?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(3600)
|
||||||
|
discardAbandonedAfter?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
abandonedResumeAllowed?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
skipAgentsWithExternalCalls?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
recordingEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -18,7 +19,7 @@ import { PermissionGuard } from "../common/guards/permission.guard";
|
|||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { getRedisClient } from "../common/redis";
|
import { getRedisClient } from "../common/redis";
|
||||||
import { CreateQueueDto } from "./dto/create-queue.dto";
|
import { CreateQueueDto, UpdateQueueDto } from "./dto/create-queue.dto";
|
||||||
|
|
||||||
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
||||||
|
|
||||||
@@ -101,6 +102,58 @@ export class QueuesController {
|
|||||||
return queue;
|
return queue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Achado real reportado pelo usuário: "na fila nao tem opcao de editar
|
||||||
|
* a fila apos a criacao" — só existia create/delete até aqui.
|
||||||
|
*/
|
||||||
|
@RequirePermission("queues.manage")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateQueueDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.queue.updateMany({
|
||||||
|
where: { id, tenantId, deletedAt: null },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
|
...(dto.description !== undefined ? { description: dto.description } : {}),
|
||||||
|
...(dto.strategy !== undefined ? { strategy: dto.strategy } : {}),
|
||||||
|
...(dto.mohSound !== undefined ? { mohSound: dto.mohSound } : {}),
|
||||||
|
...(dto.announceSound !== undefined ? { announceSound: dto.announceSound } : {}),
|
||||||
|
...(dto.announceFrequency !== undefined ? { announceFrequency: dto.announceFrequency } : {}),
|
||||||
|
...(dto.maxWaitTime !== undefined ? { maxWaitTime: dto.maxWaitTime } : {}),
|
||||||
|
...(dto.maxWaitTimeWithNoAgent !== undefined ? { maxWaitTimeWithNoAgent: dto.maxWaitTimeWithNoAgent } : {}),
|
||||||
|
...(dto.agentNoAnswerStatus !== undefined ? { agentNoAnswerStatus: dto.agentNoAnswerStatus } : {}),
|
||||||
|
...(dto.tierRulesApply !== undefined ? { tierRulesApply: dto.tierRulesApply } : {}),
|
||||||
|
...(dto.tierRuleWaitSecond !== undefined ? { tierRuleWaitSecond: dto.tierRuleWaitSecond } : {}),
|
||||||
|
...(dto.discardAbandonedAfter !== undefined ? { discardAbandonedAfter: dto.discardAbandonedAfter } : {}),
|
||||||
|
...(dto.abandonedResumeAllowed !== undefined ? { abandonedResumeAllowed: dto.abandonedResumeAllowed } : {}),
|
||||||
|
...(dto.skipAgentsWithExternalCalls !== undefined ? { skipAgentsWithExternalCalls: dto.skipAgentsWithExternalCalls } : {}),
|
||||||
|
...(dto.recordingEnabled !== undefined ? { recordingEnabled: dto.recordingEnabled } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (result.count === 0) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.queue.findFirstOrThrow({ where: { id } }));
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "QUEUE_UPDATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "queue",
|
||||||
|
entityId: id,
|
||||||
|
after: { ...dto },
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyQueuesChanged();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission("queues.manage")
|
@RequirePermission("queues.manage")
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -30,6 +30,75 @@ function topCounts(lists: string[][], limit: number): { value: string; count: nu
|
|||||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
@Controller("reports")
|
@Controller("reports")
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
|
/**
|
||||||
|
* Dashboard do tenant (agente.md secao 162) — leitura ao vivo, não um
|
||||||
|
* relatório de período (por isso não aceita `from`/`to`, diferente dos
|
||||||
|
* outros endpoints deste controller). "Em andamento"/"Esperando agente"
|
||||||
|
* são o estado AGORA (`endAt: null`), o resto ("hoje", TME/TMA/rates) é
|
||||||
|
* escopado ao dia corrente (UTC, mesmo corte de
|
||||||
|
* `PlatformOverviewController`).
|
||||||
|
*
|
||||||
|
* "Consumo do plano" (secao 162) é ambíguo na especificação — decisão
|
||||||
|
* desta implementação: chamadas de hoje contra `Plan.maxDailyCalls`
|
||||||
|
* (número real disponível sem nenhuma inferência), não uma cifra
|
||||||
|
* monetária. "Valor estimado no mês" seria essa cifra monetária — fica
|
||||||
|
* `null` até existir um `BillingStatement` fechado no mês corrente
|
||||||
|
* (packages/billing, PHASE 22): nunca um valor calculado ad hoc fora do
|
||||||
|
* RatingEngine (docs/BILLING.md).
|
||||||
|
*/
|
||||||
|
@RequirePermission("dashboard.view")
|
||||||
|
@Get("dashboard")
|
||||||
|
async dashboard(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
|
||||||
|
|
||||||
|
const [tenant, agentsByState, callsToday, callsAnsweredToday, callsInProgress, callsWaitingForAgent, answeredToday, abandonedToday, latestStatement] =
|
||||||
|
await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.tenant.findUniqueOrThrow({ where: { id: tenantId }, include: { plan: true } }),
|
||||||
|
tx.agent.groupBy({ by: ["state"], where: { tenantId, deletedAt: null }, _count: true }),
|
||||||
|
tx.call.count({ where: { tenantId, createdAt: { gte: todayStart } } }),
|
||||||
|
tx.call.count({ where: { tenantId, createdAt: { gte: todayStart }, agentAnswerAt: { not: null } } }),
|
||||||
|
tx.call.count({ where: { tenantId, endAt: null } }),
|
||||||
|
tx.call.count({ where: { tenantId, endAt: null, queueEnterAt: { not: null }, agentAnswerAt: null } }),
|
||||||
|
tx.call.findMany({
|
||||||
|
where: { tenantId, createdAt: { gte: todayStart }, agentAnswerAt: { not: null } },
|
||||||
|
select: { waitTime: true, talkTime: true },
|
||||||
|
}),
|
||||||
|
tx.call.count({
|
||||||
|
where: { tenantId, createdAt: { gte: todayStart }, queueEnterAt: { not: null }, agentAnswerAt: null, endAt: { not: null } },
|
||||||
|
}),
|
||||||
|
tx.billingStatement.findFirst({
|
||||||
|
where: { tenantId, billingPeriod: { periodStart: { gte: monthStart } } },
|
||||||
|
orderBy: { generatedAt: "desc" },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateCount = (state: string) => agentsByState.find((row) => row.state === state)?._count ?? 0;
|
||||||
|
const waitTimes = answeredToday.map((c) => c.waitTime).filter((v): v is number => v != null);
|
||||||
|
const talkTimes = answeredToday.map((c) => c.talkTime).filter((v): v is number => v != null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
callsToday,
|
||||||
|
callsAnsweredToday,
|
||||||
|
callsInProgress,
|
||||||
|
callsWaitingForAgent,
|
||||||
|
agentsAvailable: stateCount("AVAILABLE"),
|
||||||
|
agentsBusy: stateCount("RESERVED") + stateCount("RINGING") + stateCount("IN_CALL") + stateCount("WRAP_UP"),
|
||||||
|
agentsPaused: stateCount("PAUSED"),
|
||||||
|
tmeSeconds: average(waitTimes),
|
||||||
|
tmaSeconds: average(talkTimes),
|
||||||
|
answerRate: callsToday > 0 ? callsAnsweredToday / callsToday : null,
|
||||||
|
abandonRate: callsToday > 0 ? abandonedToday / callsToday : null,
|
||||||
|
dailyCallQuota: { used: callsToday, max: tenant.plan.maxDailyCalls },
|
||||||
|
monthlyConsumption: latestStatement ? { amount: latestStatement.total, currency: latestStatement.currency } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/
|
/** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/
|
||||||
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
|
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
|
||||||
* um limiar configurável via query (`slThresholdSeconds`, default 20s —
|
* um limiar configurável via query (`slThresholdSeconds`, default 20s —
|
||||||
@@ -274,4 +343,67 @@ export class ReportsController {
|
|||||||
bottomAgents: agentRanking.slice(-5).reverse(),
|
bottomAgents: agentRanking.slice(-5).reverse(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Relatórios > Consumo" (secao 169) — uso bruto (minutos, dias
|
||||||
|
* ativos, armazenamento, tokens de IA), nunca valor em dinheiro: isso é
|
||||||
|
* trabalho do RatingEngine/BillingStatement (packages/billing,
|
||||||
|
* platform-only), não deste endpoint. Sem `from`/`to`, olha o mês
|
||||||
|
* corrente (mesmo corte usado por `Tenant.billingCurrency`/período de
|
||||||
|
* fechamento) — é "quanto eu já usei este mês", não um relatório
|
||||||
|
* histórico livre. Os 2 ledgers imutáveis (`UsageEvent`+
|
||||||
|
* `AIUsageRecord`) são a mesma fonte que o billing usa, então o número
|
||||||
|
* aqui bate exatamente com o que vira fatura depois. */
|
||||||
|
@RequirePermission("reports.view")
|
||||||
|
@Get("consumo")
|
||||||
|
async consumo(@CurrentUser() user: AccessTokenClaims, @Query("from") from?: string, @Query("to") to?: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const defaultFrom = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
const range = { gte: from ? new Date(from) : defaultFrom, lte: to ? new Date(to) : now };
|
||||||
|
|
||||||
|
const [usageByMeter, aiUsageByType, tenant] = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId, occurredAt: range }, _sum: { quantity: true } }),
|
||||||
|
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId, occurredAt: range }, _sum: { quantity: true } }),
|
||||||
|
tx.tenant.findFirst({ where: { id: tenantId }, select: { plan: true } }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usage: Record<string, number> = {};
|
||||||
|
for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0;
|
||||||
|
const aiUsage: Record<string, number> = {};
|
||||||
|
for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0;
|
||||||
|
|
||||||
|
const plan = tenant?.plan ?? null;
|
||||||
|
const callCount = usage["CALL_COUNT"] ?? 0;
|
||||||
|
const recordingBytes = usage["RECORDING_BYTES"] ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { from: range.gte.toISOString(), to: range.lte.toISOString() },
|
||||||
|
usage: {
|
||||||
|
callCount,
|
||||||
|
callSeconds: usage["CALL_SECONDS"] ?? 0,
|
||||||
|
extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0,
|
||||||
|
agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0,
|
||||||
|
trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0,
|
||||||
|
recordingBytes,
|
||||||
|
},
|
||||||
|
aiUsage: {
|
||||||
|
transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0,
|
||||||
|
analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0,
|
||||||
|
inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0,
|
||||||
|
outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0,
|
||||||
|
},
|
||||||
|
limits: plan && {
|
||||||
|
maxMonthlyCalls: plan.maxMonthlyCalls,
|
||||||
|
maxRecordingStorageGb: plan.maxRecordingStorageGb,
|
||||||
|
callCountUsedRatio: plan.maxMonthlyCalls ? callCount / plan.maxMonthlyCalls : null,
|
||||||
|
recordingStorageUsedRatio: plan.maxRecordingStorageGb
|
||||||
|
? recordingBytes / (plan.maxRecordingStorageGb * 1024 ** 3)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
apps/api/src/tenants/dto/create-tenant.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { IsEmail, IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
const TENANT_STATUSES = ["TRIAL", "ACTIVE", "SUSPENDED", "PAST_DUE", "CANCELLED"];
|
||||||
|
|
||||||
|
export class CreateTenantDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
@Matches(/^[a-z0-9-]+$/, { message: "code deve ser minusculo, so letras/numeros/hifen (vira tambem o slug)" })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
legalName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
tradeName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
taxId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
planId!: string;
|
||||||
|
|
||||||
|
/** Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — achado
|
||||||
|
* real reportado pelo usuário: antes disto todo tenant nascia com o
|
||||||
|
* mesmo domínio fixo ("b2bcall.local"), quebrando isolamento de PABX
|
||||||
|
* (call groups, filas, IVR não conseguem distinguir um tenant do
|
||||||
|
* outro). A tela sugere `{code}.b2bcall.net`, mas quem cria pode
|
||||||
|
* digitar outro — só precisa ser único (constraint no banco) e ter
|
||||||
|
* cara de domínio (não precisa resolver de verdade por DNS: o
|
||||||
|
* telefone/softphone aponta pro IP do servidor via `proxy=`, este
|
||||||
|
* campo só serve pra identificar o tenant no REGISTER). */
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
@Matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/, {
|
||||||
|
message: "telephonyDomain precisa ter formato de domínio (ex.: minhaempresa.b2bcall.net)",
|
||||||
|
})
|
||||||
|
telephonyDomain!: string;
|
||||||
|
|
||||||
|
/** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa
|
||||||
|
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
|
||||||
|
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo
|
||||||
|
* padrão de `SecretReveal` já usado pra senha SIP de ramal. */
|
||||||
|
@IsEmail()
|
||||||
|
adminEmail!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
adminName!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateTenantDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TENANT_STATUSES)
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
planId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
tradeName?: string;
|
||||||
|
}
|
||||||
29
apps/api/src/tenants/dto/update-tenant-settings.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
const AI_PRIVACY_LEVELS = ["AI_OFF", "TRANSCRIPTION_ONLY", "TRANSCRIPTION_AND_ANALYSIS"];
|
||||||
|
|
||||||
|
export class UpdateTenantSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
tradeName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
taxId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(60)
|
||||||
|
timezone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
locale?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(AI_PRIVACY_LEVELS)
|
||||||
|
aiPrivacyLevel?: string;
|
||||||
|
}
|
||||||
65
apps/api/src/tenants/tenant-settings.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Body, Controller, Get, NotFoundException, Patch, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, type AIPrivacyLevel } 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 { UpdateTenantSettingsDto } from "./dto/update-tenant-settings.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Administração > Configurações" (agente.md secao 169) — self-service do
|
||||||
|
* próprio tenant, diferente de `TenantsController` (que é platform-only e
|
||||||
|
* mexe em campos que só a plataforma controla: status, plano, code).
|
||||||
|
* Nunca recebe `:id` — sempre `user.tenantId` das claims, então não existe
|
||||||
|
* forma de um Tenant Admin editar outro tenant por aqui (a checagem
|
||||||
|
* `isPlatformUser` do `TenantsController` não se aplica porque este
|
||||||
|
* controller nem aceita um tenantId arbitrário).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("tenant-settings")
|
||||||
|
export class TenantSettingsController {
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Get()
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenant = await prisma.tenant.findFirst({
|
||||||
|
where: { id: user.tenantId!, deletedAt: null },
|
||||||
|
include: { plan: { select: { key: true, name: true } } },
|
||||||
|
});
|
||||||
|
if (!tenant) throw new NotFoundException();
|
||||||
|
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Patch()
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateTenantSettingsDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.update({
|
||||||
|
where: { id: tenantId },
|
||||||
|
data: {
|
||||||
|
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
|
||||||
|
...(dto.taxId !== undefined ? { taxId: dto.taxId } : {}),
|
||||||
|
...(dto.timezone !== undefined ? { timezone: dto.timezone } : {}),
|
||||||
|
...(dto.locale !== undefined ? { locale: dto.locale } : {}),
|
||||||
|
...(dto.aiPrivacyLevel !== undefined ? { aiPrivacyLevel: dto.aiPrivacyLevel as AIPrivacyLevel } : {}),
|
||||||
|
},
|
||||||
|
include: { plan: { select: { key: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TENANT_SETTINGS_UPDATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tenant",
|
||||||
|
entityId: tenantId,
|
||||||
|
after: { ...dto },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
apps/api/src/tenants/tenants.controller.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext, type Prisma, type TenantStatus } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { generateStrongPassword } from "@b2bcall/shared";
|
||||||
|
import { buildDialplanXml, DEFAULT_DIALPLAN_EXTENSIONS } 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";
|
||||||
|
import { CreateTenantDto, UpdateTenantDto } from "./dto/create-tenant.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD de tenants (agente.md secao 29, 141, 168 "Clientes > Tenants") —
|
||||||
|
* a peça que faltava desde a PHASE 01: até aqui só existia via script/API
|
||||||
|
* direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md),
|
||||||
|
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
|
||||||
|
*
|
||||||
|
* `telephonyDomain` (secao 178, docs/EXTENSIONS.md) vem do `dto` agora,
|
||||||
|
* único por tenant (constraint no banco) — achado real reportado pelo
|
||||||
|
* usuário testando: até a PHASE 50 todo tenant novo nascia com o mesmo
|
||||||
|
* domínio fixo ("b2bcall.local"), quebrando isolamento de PABX de verdade
|
||||||
|
* (call groups, filas, IVR não tinham como distinguir um tenant do outro
|
||||||
|
* no REGISTER). O FreeSWITCH em si já aceita qualquer domínio dinamicamente
|
||||||
|
* (`<domain name="all" alias="true".../>` no sofia profile vanilla,
|
||||||
|
* confirmado lendo o container) — o bug era só este valor fixo aqui.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("tenants")
|
||||||
|
export class TenantsController {
|
||||||
|
@RequirePermission("tenants.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateTenantDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode criar tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const [existingCode, existingDomain, existingEmail, plan] = await Promise.all([
|
||||||
|
prisma.tenant.findFirst({ where: { code: dto.code } }),
|
||||||
|
prisma.tenant.findFirst({ where: { telephonyDomain: dto.telephonyDomain } }),
|
||||||
|
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
|
||||||
|
prisma.plan.findUnique({ where: { id: dto.planId } }),
|
||||||
|
]);
|
||||||
|
if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`);
|
||||||
|
if (existingDomain) throw new ForbiddenException(`Ja existe um tenant usando o dominio "${dto.telephonyDomain}"`);
|
||||||
|
if (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`);
|
||||||
|
if (!plan) throw new ForbiddenException("Plano nao encontrado");
|
||||||
|
|
||||||
|
const temporaryPassword = generateStrongPassword();
|
||||||
|
const passwordHash = await hashPassword(temporaryPassword);
|
||||||
|
|
||||||
|
const { tenant, admin } = await prisma.$transaction(async (tx) => {
|
||||||
|
const tenant = await tx.tenant.create({
|
||||||
|
data: {
|
||||||
|
code: dto.code,
|
||||||
|
slug: dto.code,
|
||||||
|
legalName: dto.legalName,
|
||||||
|
tradeName: dto.tradeName,
|
||||||
|
taxId: dto.taxId,
|
||||||
|
planId: dto.planId,
|
||||||
|
telephonyDomain: dto.telephonyDomain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await tx.user.create({
|
||||||
|
data: { email: dto.adminEmail, passwordHash, name: dto.adminName, mustChangePassword: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// RLS de tenant_memberships (secao 32) exige app.current_tenant_id —
|
||||||
|
// já dentro da mesma transação, sem precisar de withTenantContext
|
||||||
|
// (que abriria uma transação aninhada em cima de um PrismaClient
|
||||||
|
// plano, não de um TransactionClient).
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenant.id}, true)`;
|
||||||
|
await tx.tenantMembership.create({ data: { tenantId: tenant.id, userId: admin.id } });
|
||||||
|
|
||||||
|
const tenantAdminRole = await tx.role.findUniqueOrThrow({ where: { key: "tenant_admin" } });
|
||||||
|
await tx.userRole.create({ data: { userId: admin.id, roleId: tenantAdminRole.id, tenantId: tenant.id } });
|
||||||
|
|
||||||
|
// Discagem interna + pickup de grupo (PHASE 57, docs/DIALPLAN.md)
|
||||||
|
// — achado real: nenhum tenant nascia com isso, então nenhum
|
||||||
|
// conseguia discar entre os próprios ramais até alguém configurar a
|
||||||
|
// regra manualmente em Telefonia > Dialplan. Mesmas 2 regras já
|
||||||
|
// testadas ponta a ponta com chamada real (PHASE 53).
|
||||||
|
for (const ext of DEFAULT_DIALPLAN_EXTENSIONS) {
|
||||||
|
await tx.dialplanExtension.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
context: "default",
|
||||||
|
name: ext.name,
|
||||||
|
conditionField: ext.conditionField,
|
||||||
|
conditionExpr: ext.conditionExpr,
|
||||||
|
actions: ext.actions as unknown as Prisma.InputJsonValue,
|
||||||
|
continueOnFalse: ext.continueOnFalse,
|
||||||
|
order: ext.order,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.dialplanVersion.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
context: "default",
|
||||||
|
version: 1,
|
||||||
|
generatedXml: buildDialplanXml("default", DEFAULT_DIALPLAN_EXTENSIONS),
|
||||||
|
status: "ACTIVE",
|
||||||
|
createdByUserId: user.sub,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tenant, admin };
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TENANT_CREATE",
|
||||||
|
tenantId: tenant.id,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tenant",
|
||||||
|
entityId: tenant.id,
|
||||||
|
after: { code: tenant.code, legalName: tenant.legalName, planId: tenant.planId, adminEmail: admin.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tenant, admin: { email: admin.email, temporaryPassword } };
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode listar tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: { plan: { select: { id: true, key: true, name: true } } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// `tenant_memberships` tem FORCE RLS — nem platform admin enxerga
|
||||||
|
// linha nenhuma numa query sem `app.current_tenant_id` setado (deny-
|
||||||
|
// by-default, secao 32). Sem uma policy própria pra platform admin
|
||||||
|
// "ver tudo", a única forma correta é abrir o contexto de cada tenant
|
||||||
|
// um de cada vez — aceitável aqui, é uma tela de administração, não
|
||||||
|
// um hot path.
|
||||||
|
const memberCounts = await Promise.all(
|
||||||
|
tenants.map((t) => withTenantContext(prisma, t.id, (tx) => tx.tenantMembership.count({ where: { tenantId: t.id } }))),
|
||||||
|
);
|
||||||
|
const memberCount = new Map(tenants.map((t, i) => [t.id, memberCounts[i]]));
|
||||||
|
return tenants.map((t) => ({ ...t, memberCount: memberCount.get(t.id) ?? 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver detalhe de tenant");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenant = await prisma.tenant.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
if (!tenant) throw new NotFoundException();
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("tenants.manage")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateTenantDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode editar tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const existing = await prisma.tenant.findFirst({ where: { id, deletedAt: null } });
|
||||||
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
if (dto.planId) {
|
||||||
|
const plan = await prisma.plan.findUnique({ where: { id: dto.planId } });
|
||||||
|
if (!plan) throw new ForbiddenException("Plano nao encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.status ? { status: dto.status as TenantStatus } : {}),
|
||||||
|
...(dto.planId ? { planId: dto.planId } : {}),
|
||||||
|
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
|
||||||
|
},
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TENANT_UPDATE",
|
||||||
|
tenantId: tenant.id,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tenant",
|
||||||
|
entityId: tenant.id,
|
||||||
|
after: { ...dto },
|
||||||
|
});
|
||||||
|
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/tenants/tenants.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TenantsController } from "./tenants.controller";
|
||||||
|
import { TenantSettingsController } from "./tenant-settings.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TenantsController, TenantSettingsController],
|
||||||
|
})
|
||||||
|
export class TenantsModule {}
|
||||||
20
apps/api/src/users/dto/invite-user.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { IsEmail, IsIn, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
const TENANT_ROLE_KEYS = ["tenant_admin", "supervisor", "agent"];
|
||||||
|
|
||||||
|
export class InviteUserDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsIn(TENANT_ROLE_KEYS)
|
||||||
|
roleKey!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUserRoleDto {
|
||||||
|
@IsIn(TENANT_ROLE_KEYS)
|
||||||
|
roleKey!: string;
|
||||||
|
}
|
||||||
35
apps/api/src/users/roles.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Administração > Perfis" (agente.md secao 169) — versão tenant-facing
|
||||||
|
* do catálogo de roles, só as de escopo TENANT (Tenant Admin/Supervisor/
|
||||||
|
* Agente). Diferente de `/platform/roles` (todas as roles, inclusive
|
||||||
|
* `platform_super_admin`, só platform admin) — um tenant não precisa
|
||||||
|
* saber que role de plataforma existe, só o que pode atribuir aos
|
||||||
|
* próprios usuários. `roles`/`permissions` não têm RLS (catálogo global
|
||||||
|
* do seed), mas o filtro por `scope: "TENANT"` já é suficiente aqui.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("roles")
|
||||||
|
export class RolesController {
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const roles = await prisma.role.findMany({
|
||||||
|
where: { scope: "TENANT" },
|
||||||
|
include: { rolePermissions: { include: { permission: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return roles.map((r) => ({
|
||||||
|
key: r.key,
|
||||||
|
name: r.name,
|
||||||
|
permissionKeys: r.rolePermissions.map((rp) => rp.permission.key),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
217
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { Body, ConflictException, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { generateStrongPassword } from "@b2bcall/shared";
|
||||||
|
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 { InviteUserDto, UpdateUserRoleDto } from "./dto/invite-user.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuários do tenant ativo (agente.md secao 169 "Administração > Usuários/
|
||||||
|
* Perfis"). Convite (secao 141) fecha a lacuna documentada desde a PHASE
|
||||||
|
* 22/29: até aqui só dava pra criar usuário junto com o tenant inteiro
|
||||||
|
* (Platform > Clientes > Tenants) ou via script — nenhuma forma de um
|
||||||
|
* Tenant Admin adicionar um colega ao próprio tenant.
|
||||||
|
*/
|
||||||
|
async function isLastTenantAdmin(tenantId: string, userId: string): Promise<boolean> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const currentRole = await prisma.userRole.findFirst({ where: { userId, tenantId }, include: { role: true } });
|
||||||
|
if (currentRole?.role.key !== "tenant_admin") return false;
|
||||||
|
const otherAdmins = await prisma.userRole.count({
|
||||||
|
where: { tenantId, userId: { not: userId }, role: { key: "tenant_admin" } },
|
||||||
|
});
|
||||||
|
return otherAdmins === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("users")
|
||||||
|
export class UsersController {
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const memberships = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.tenantMembership.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: { user: { select: { id: true, email: true, name: true, status: true, deletedAt: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const activeMemberships = memberships.filter((m) => m.user.deletedAt == null);
|
||||||
|
|
||||||
|
const userIds = activeMemberships.map((m) => m.user.id);
|
||||||
|
const roles = userIds.length
|
||||||
|
? await prisma.userRole.findMany({
|
||||||
|
where: { userId: { in: userIds }, tenantId },
|
||||||
|
include: { role: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const roleByUserId = new Map(roles.map((r) => [r.userId, { key: r.role.key, name: r.role.name }]));
|
||||||
|
|
||||||
|
return activeMemberships.map((m) => ({
|
||||||
|
id: m.user.id,
|
||||||
|
email: m.user.email,
|
||||||
|
name: m.user.name,
|
||||||
|
status: m.user.status,
|
||||||
|
role: roleByUserId.get(m.user.id) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convidar = criar o usuário se o e-mail ainda não existe (senha
|
||||||
|
* gerada, revelada uma vez só na resposta — mesmo padrão de
|
||||||
|
* `TenantsController.create`) ou só adicionar a membership+role se o
|
||||||
|
* e-mail já é de um usuário existente (sem tocar na senha dele). */
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Post()
|
||||||
|
async invite(@CurrentUser() user: AccessTokenClaims, @Body() dto: InviteUserDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const role = await prisma.role.findUnique({ where: { key: dto.roleKey } });
|
||||||
|
if (!role || role.scope !== "TENANT") {
|
||||||
|
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({ where: { email: dto.email } });
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
const existingMembership = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: existingUser.id } } }),
|
||||||
|
);
|
||||||
|
if (existingMembership) {
|
||||||
|
throw new ConflictException("Este usuário já é membro deste tenant");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
|
||||||
|
await tx.tenantMembership.create({ data: { tenantId, userId: existingUser.id } });
|
||||||
|
await tx.userRole.create({ data: { userId: existingUser.id, roleId: role.id, tenantId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "USER_ADDED_TO_TENANT",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "user",
|
||||||
|
entityId: existingUser.id,
|
||||||
|
after: { email: existingUser.email, roleKey: role.key },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: { id: existingUser.id, email: existingUser.email, name: existingUser.name }, temporaryPassword: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryPassword = generateStrongPassword();
|
||||||
|
const passwordHash = await hashPassword(temporaryPassword);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
const newUser = await tx.user.create({
|
||||||
|
data: { email: dto.email, passwordHash, name: dto.name, mustChangePassword: true },
|
||||||
|
});
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
|
||||||
|
await tx.tenantMembership.create({ data: { tenantId, userId: newUser.id } });
|
||||||
|
await tx.userRole.create({ data: { userId: newUser.id, roleId: role.id, tenantId } });
|
||||||
|
return newUser;
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "USER_INVITE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "user",
|
||||||
|
entityId: created.id,
|
||||||
|
after: { email: created.email, roleKey: role.key },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: { id: created.id, email: created.email, name: created.name }, temporaryPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Patch(":id/role")
|
||||||
|
async updateRole(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateUserRoleDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const membership = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: id } } }),
|
||||||
|
);
|
||||||
|
if (!membership) throw new NotFoundException("Usuário não pertence a este tenant");
|
||||||
|
|
||||||
|
const role = await prisma.role.findUnique({ where: { key: dto.roleKey } });
|
||||||
|
if (!role || role.scope !== "TENANT") {
|
||||||
|
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.key !== "tenant_admin" && (await isLastTenantAdmin(tenantId, id))) {
|
||||||
|
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de trocar o papel dele(a)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplificação deliberada: 1 role por usuário por tenant — trocar
|
||||||
|
// substitui, não acumula (o schema permite várias, mas a UI não
|
||||||
|
// oferece combinar papéis nesta primeira versão).
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.userRole.deleteMany({ where: { userId: id, tenantId } }),
|
||||||
|
prisma.userRole.create({ data: { userId: id, roleId: role.id, tenantId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "USER_ROLE_UPDATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "user",
|
||||||
|
entityId: id,
|
||||||
|
after: { roleKey: role.key },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id, role: { key: role.key, name: role.name } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove só a membership+role deste tenant (secao 169) — nunca a conta
|
||||||
|
* `User` em si, que pode pertencer a outros tenants. Bloqueia remover a
|
||||||
|
* si mesmo (evita um Tenant Admin se trancar fora sem querer) e remover
|
||||||
|
* o último Tenant Admin (o tenant ficaria sem ninguém que possa
|
||||||
|
* convidar/gerenciar gente). */
|
||||||
|
@RequirePermission("users.manage")
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
if (id === user.sub) {
|
||||||
|
throw new ForbiddenException("Você não pode remover a si mesmo deste tenant");
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: id } } }),
|
||||||
|
);
|
||||||
|
if (!membership) throw new NotFoundException("Usuário não pertence a este tenant");
|
||||||
|
|
||||||
|
if (await isLastTenantAdmin(tenantId, id)) {
|
||||||
|
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de remove-lo(a)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// `tenant_memberships` tem FORCE RLS (secao 32) — precisa de
|
||||||
|
// `app.current_tenant_id` setado na mesma transação, senão o delete
|
||||||
|
// não enxerga a linha e Prisma devolve P2025 ("not found") mesmo com
|
||||||
|
// a linha existindo (mesma classe de bug já corrigida em
|
||||||
|
// TenantsController.create). `user_roles` não tem RLS, então o
|
||||||
|
// deleteMany funciona em qualquer contexto.
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
|
||||||
|
await tx.userRole.deleteMany({ where: { userId: id, tenantId } });
|
||||||
|
await tx.tenantMembership.delete({ where: { tenantId_userId: { tenantId, userId: id } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "USER_REMOVED_FROM_TENANT",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "user",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { UsersController } from "./users.controller";
|
||||||
|
import { RolesController } from "./roles.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController, RolesController],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
@@ -4,7 +4,7 @@ import formbody from "@fastify/formbody";
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
import { decryptSecret } from "@b2bcall/shared";
|
import { decryptSecret } from "@b2bcall/shared";
|
||||||
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
import { buildDirectoryUserXml, buildInboundRouteXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
import { syncTrunks } from "./trunk-sync";
|
import { syncTrunks } from "./trunk-sync";
|
||||||
import { syncQueues } from "./queue-sync";
|
import { syncQueues } from "./queue-sync";
|
||||||
@@ -52,9 +52,11 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
|
|||||||
|
|
||||||
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
|
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
|
||||||
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
|
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
|
||||||
// tenant especifico depois de achar o dono do dominio.
|
// tenant especifico depois de achar o dono do dominio. `telephonyDomain`
|
||||||
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } });
|
// e' @unique desde a PHASE 50 (achado real: antes disso todo tenant
|
||||||
if (!tenant) {
|
// tinha o mesmo dominio fixo) — `findUnique` reflete essa garantia.
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { telephonyDomain: domain } });
|
||||||
|
if (!tenant || tenant.status !== "ACTIVE") {
|
||||||
return NOT_FOUND_XML;
|
return NOT_FOUND_XML;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,16 +77,82 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
|
|||||||
callerIdNumber: extension.callerIdNumber ?? undefined,
|
callerIdNumber: extension.callerIdNumber ?? undefined,
|
||||||
tenantId: extension.tenantId,
|
tenantId: extension.tenantId,
|
||||||
extensionId: extension.id,
|
extensionId: extension.id,
|
||||||
|
callGroup: extension.callGroup,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve o tenant dono de um DID pra uma chamada de ENTRADA (PHASE 56,
|
||||||
|
* docs/INBOUND_ROUTES.md) — achado real: uma chamada chegando pelo profile
|
||||||
|
* "external" (contexto "inbound", renomeado do "public" vanilla no
|
||||||
|
* Dockerfile) nunca carrega `variable_b2bcall_tenant_id`, porque essa
|
||||||
|
* variable só é setada em REGISTER de ramal ou originate de discagem
|
||||||
|
* (nunca em chamada recebida de tronco). `InboundRoute.didNumber` é a
|
||||||
|
* ÚNICA forma de descobrir de qual tenant é ANTES de saber o tenant —
|
||||||
|
* por isso é único GLOBAL (mesma exceção de `Tenant.telephonyDomain`).
|
||||||
|
* Fan-out sobre tenants ativos com `withTenantContext` (nunca uma query
|
||||||
|
* sem contexto — `InboundRoute` tem RLS de verdade, FORCE ROW LEVEL
|
||||||
|
* SECURITY), mesmo padrão já usado em `updateTrunkStatusFromGatewayEvent`.
|
||||||
|
*/
|
||||||
|
async function resolveInboundRouteXml(didNumber: string | undefined): Promise<string> {
|
||||||
|
if (!didNumber) return NOT_FOUND_XML;
|
||||||
|
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { status: "ACTIVE", telephonyDomain: { not: null } },
|
||||||
|
select: { id: true, telephonyDomain: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
const route = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
tx.inboundRoute.findFirst({ where: { tenantId: tenant.id, didNumber, enabled: true, deletedAt: null } }),
|
||||||
|
);
|
||||||
|
if (route) {
|
||||||
|
const domain = tenant.telephonyDomain!;
|
||||||
|
|
||||||
|
// PHASE 62: fila e grupo nunca "discam um número" no dialplan do
|
||||||
|
// tenant — a resolução de entrada já emite a action final.
|
||||||
|
if (route.destinationType === "QUEUE") {
|
||||||
|
return buildInboundRouteXml({ tenantId: tenant.id, domain, destinationType: "QUEUE", queueId: route.destinationNumber });
|
||||||
|
}
|
||||||
|
if (route.destinationType === "CALL_GROUP") {
|
||||||
|
// Resolvido AGORA, nunca um snapshot salvo — trocar quem está no
|
||||||
|
// grupo depois de criar a rota já vale na PRÓXIMA chamada.
|
||||||
|
const members = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
tx.extension.findMany({
|
||||||
|
where: { tenantId: tenant.id, callGroup: route.destinationNumber, enabled: true, deletedAt: null },
|
||||||
|
select: { number: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return buildInboundRouteXml({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
domain,
|
||||||
|
destinationType: "CALL_GROUP",
|
||||||
|
groupMembers: members.map((m) => m.number),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildInboundRouteXml({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
domain,
|
||||||
|
destinationType: route.destinationType,
|
||||||
|
destinationNumber: route.destinationNumber,
|
||||||
|
destinationContext: route.destinationContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NOT_FOUND_XML;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
|
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
|
||||||
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml).
|
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml) ou,
|
||||||
* Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra
|
* pra chamada de ENTRADA, pela própria resolução de rota acima (que seta
|
||||||
* todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o
|
* a variable antes de transferir — o `transfer` dispara esta função de
|
||||||
* dialplan já tem essa variável disponível na própria chamada, então nem
|
* novo, já com o tenant presente). Ao contrário do directory (resolvido
|
||||||
* sofre da mesma ambiguidade.
|
* por domain — cada tenant tem o seu agora, único no banco, PHASE 50/
|
||||||
|
* docs/EXTENSIONS.md), o dialplan já tem essa variável disponível na
|
||||||
|
* própria chamada, então nem depende de domain nenhum.
|
||||||
*
|
*
|
||||||
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
|
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
|
||||||
* — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as
|
* — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as
|
||||||
@@ -96,6 +164,10 @@ async function resolveDialplanXml(body: XmlCurlBody): Promise<string> {
|
|||||||
const context = (body["Caller-Context"] ?? body.context ?? "default") as string;
|
const context = (body["Caller-Context"] ?? body.context ?? "default") as string;
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
|
if (context === "inbound") {
|
||||||
|
const did = (body["Caller-Destination-Number"] ?? body["Hunt-Destination-Number"]) as string | undefined;
|
||||||
|
return resolveInboundRouteXml(did);
|
||||||
|
}
|
||||||
return NOT_FOUND_XML;
|
return NOT_FOUND_XML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export async function persistCallEvent(normalized: NormalizedEvent): Promise<voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (normalized.type === "CALL_ENDED") {
|
if (normalized.type === "CALL_ENDED") {
|
||||||
await finalizeCall(tx, callId);
|
await finalizeCall(tx, callId, tenantId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -155,9 +155,10 @@ function buildPatch(normalized: NormalizedEvent): CallPatch {
|
|||||||
|
|
||||||
/** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada
|
/** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada
|
||||||
* terminou — nunca antes, pra não gravar valores parciais. */
|
* terminou — nunca antes, pra não gravar valores parciais. */
|
||||||
async function finalizeCall(tx: Prisma.TransactionClient, callId: string): Promise<void> {
|
async function finalizeCall(tx: Prisma.TransactionClient, callId: string, tenantId: string): Promise<void> {
|
||||||
const call = await tx.call.findUniqueOrThrow({ where: { id: callId } });
|
const call = await tx.call.findUniqueOrThrow({ where: { id: callId } });
|
||||||
const talkTime = seconds(call.bridgeAt, call.endAt);
|
const talkTime = seconds(call.bridgeAt, call.endAt);
|
||||||
|
const billableSeconds = talkTime ?? 0;
|
||||||
|
|
||||||
await tx.call.update({
|
await tx.call.update({
|
||||||
where: { id: callId },
|
where: { id: callId },
|
||||||
@@ -166,7 +167,27 @@ async function finalizeCall(tx: Prisma.TransactionClient, callId: string): Promi
|
|||||||
waitTime: seconds(call.queueEnterAt, call.agentAnswerAt),
|
waitTime: seconds(call.queueEnterAt, call.agentAnswerAt),
|
||||||
talkTime,
|
talkTime,
|
||||||
durationSeconds: seconds(call.createdAt, call.endAt),
|
durationSeconds: seconds(call.createdAt, call.endAt),
|
||||||
billableSeconds: talkTime ?? 0,
|
billableSeconds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Chamada faturável" (agente.md secao 133) gera o UsageEvent que o
|
||||||
|
// RatingEngine (packages/billing) consome no fechamento do período —
|
||||||
|
// nunca calcula o valor aqui, só registra o fato bruto (segundos
|
||||||
|
// faturáveis). Chamada sem talk time (nunca bridgeou) não gera evento —
|
||||||
|
// nada a cobrar.
|
||||||
|
if (billableSeconds > 0) {
|
||||||
|
await tx.usageEvent.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
callId,
|
||||||
|
meter: "CALL_SECONDS",
|
||||||
|
quantity: billableSeconds,
|
||||||
|
unit: "seconds",
|
||||||
|
sourceType: "call",
|
||||||
|
sourceId: callId,
|
||||||
|
occurredAt: call.endAt ?? new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
87
apps/freeswitch-events/src/extension-status.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { createLogger } from "@b2bcall/shared";
|
||||||
|
import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
|
||||||
|
|
||||||
|
const logger = createLogger("b2bcall-fs-events");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sofia::register/unregister/expire não carregam `b2bcall_tenant_id` (só
|
||||||
|
* existe como channel variable de CHAMADA, nunca de REGISTER) — resolve
|
||||||
|
* por domínio (from-host), único por tenant desde a PHASE 52/53. Mesmo
|
||||||
|
* padrão de fan-out do `updateTrunkStatusFromGatewayEvent`, mas aqui o
|
||||||
|
* domínio já identifica o tenant direto, sem precisar tentar cada um.
|
||||||
|
*/
|
||||||
|
export async function updateExtensionRegistrationStatus(
|
||||||
|
user: string | undefined,
|
||||||
|
host: string | undefined,
|
||||||
|
registered: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!user || !host) return;
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: host }, select: { id: true } });
|
||||||
|
if (!tenant) {
|
||||||
|
logger.debug("nenhum tenant encontrado pro dominio do REGISTER", { host, user });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
tx.extension.updateMany({
|
||||||
|
where: { number: user, tenantId: tenant.id, deletedAt: null },
|
||||||
|
data: { registeredAt: registered ? new Date() : null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (result.count > 0) {
|
||||||
|
logger.info("status de registro do ramal atualizado", { user, host, registered });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciliação ao conectar/reconectar no ESL (secao 55): sem isto, um
|
||||||
|
* ramal que já estava registrado ANTES do fs-events subir (ou durante uma
|
||||||
|
* queda do serviço) ficaria com `registered_at` desatualizado até o
|
||||||
|
* próximo REGISTER natural do aparelho — minutos de atraso, dependendo do
|
||||||
|
* `registration-expires` configurado no softphone. `show registrations`
|
||||||
|
* é a fonte de verdade ao vivo: zera todo mundo do tenant primeiro,
|
||||||
|
* depois marca só quem está de fato na lista agora.
|
||||||
|
*/
|
||||||
|
export async function reconcileExtensionRegistrations(provider: FreeSwitchTelephonyProvider): Promise<void> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await provider.getRegistrations();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("falha ao consultar registrations pra reconciliacao", { error: String(err) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (raw as { rows?: Array<{ reg_user?: string; realm?: string }> } | undefined)?.rows ?? [];
|
||||||
|
const registeredByDomain = new Map<string, Set<string>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.reg_user || !row.realm) continue;
|
||||||
|
if (!registeredByDomain.has(row.realm)) registeredByDomain.set(row.realm, new Set());
|
||||||
|
registeredByDomain.get(row.realm)!.add(row.reg_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { status: "ACTIVE", telephonyDomain: { not: null } },
|
||||||
|
select: { id: true, telephonyDomain: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
const registeredNumbers = Array.from(registeredByDomain.get(tenant.telephonyDomain!) ?? []);
|
||||||
|
await withTenantContext(prisma, tenant.id, async (tx) => {
|
||||||
|
await tx.extension.updateMany({
|
||||||
|
where: { tenantId: tenant.id, deletedAt: null, number: { notIn: registeredNumbers } },
|
||||||
|
data: { registeredAt: null },
|
||||||
|
});
|
||||||
|
if (registeredNumbers.length > 0) {
|
||||||
|
await tx.extension.updateMany({
|
||||||
|
where: { tenantId: tenant.id, deletedAt: null, number: { in: registeredNumbers } },
|
||||||
|
data: { registeredAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info("reconciliacao de registrations de ramais concluida", { tenantsChecked: tenants.length });
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { FreeSwitchEventData } from "esl";
|
|||||||
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
|
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
|
||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
||||||
|
import { updateExtensionRegistrationStatus, reconcileExtensionRegistrations } from "./extension-status";
|
||||||
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
||||||
import { persistCallEvent } from "./cdr";
|
import { persistCallEvent } from "./cdr";
|
||||||
import { uploadRecordingIfPresent } from "./recording";
|
import { uploadRecordingIfPresent } from "./recording";
|
||||||
@@ -90,6 +91,10 @@ async function main() {
|
|||||||
call.on(eventName, (raw) => handleEvent(eventName, raw));
|
call.on(eventName, (raw) => handleEvent(eventName, raw));
|
||||||
}
|
}
|
||||||
call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw));
|
call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw));
|
||||||
|
|
||||||
|
reconcileExtensionRegistrations(provider).catch((err) => {
|
||||||
|
logger.error("falha na reconciliacao de registrations de ramais", { error: String(err) });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("reconnecting", (retryMs) => {
|
client.on("reconnecting", (retryMs) => {
|
||||||
@@ -170,6 +175,14 @@ async function main() {
|
|||||||
logger.error("falha ao atualizar status do trunk", { error: String(err), gateway });
|
logger.error("falha ao atualizar status do trunk", { error: String(err), gateway });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.type === "EXTENSION_REGISTERED" || normalized.type === "EXTENSION_UNREGISTERED") {
|
||||||
|
const user = normalized.data.user as string | undefined;
|
||||||
|
const host = normalized.data.host as string | undefined;
|
||||||
|
updateExtensionRegistrationStatus(user, host, normalized.type === "EXTENSION_REGISTERED").catch((err) => {
|
||||||
|
logger.error("falha ao atualizar status de registro do ramal", { error: String(err), user, host });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.connect();
|
provider.connect();
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/admin-perfis-desktop.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
apps/frontend/.impeccable/review/admin-usuarios-desktop.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/frontend/.impeccable/review/admin-usuarios-list-desktop.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/agentes-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/agentes-form-desktop.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/frontend/.impeccable/review/agentes-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/frontend/.impeccable/review/assinaturas-created-desktop.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/frontend/.impeccable/review/assinaturas-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/frontend/.impeccable/review/assinaturas-form-desktop.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/frontend/.impeccable/review/billing-consumo-desktop.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/frontend/.impeccable/review/bloqueio-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/frontend/.impeccable/review/bloqueio-form-desktop.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/frontend/.impeccable/review/bloqueio-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
apps/frontend/.impeccable/review/branding-login-desktop.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/frontend/.impeccable/review/branding-tenant-app-desktop.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 89 KiB |