From 7b62ad3d8236e25a76a6a990d8cf682b6e9da575 Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 12:26:47 -0300 Subject: [PATCH] feat(entitlements,campaigns): plans/quotas + campanhas, leads, lista de bloqueio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fecha duas fases: Plans/Entitlements (agente.md secao 56-62), que tinha ficado pra trás desde o inicio, e Campanhas/Leads/Lista de Bloqueio (secao 63-71). ## Plans/Entitlements A ordem de implementacao da propria especificacao (secao 232) coloca Plans/Entitlements logo depois de PostgreSQL RLS, bem antes de FreeSWITCH — mas o build seguiu direto sem essa peca, e toda fase desde entao documentou "quota depende de Plans/Entitlements" como pendencia (EXTENSIONS.md, TRUNKS.md, AGENTS.md, QUEUES.md, agora todas atualizadas). Fechado agora porque Campanhas precisa de max_campaigns e o proximo CPS Limiter vai precisar de max_cps/max_concurrent_calls. - plans: catalogo compartilhado entre tenants (sem RLS, nao e' tenant- scoped) com todos os campos de entitlement da secao 56. Campo de limite null = "sem limite", nunca "sem plano" — tenants.plan_id e' obrigatorio, nunca null (secao 56: nao espalhar `if plan == PRO` pelo codigo). - Migration hand-escrita: cria plans, insere seed "trial", faz backfill de plan_id pros tenants ja existentes, so' depois torna NOT NULL (Postgres nao deixa NOT NULL sem default em tabela nao-vazia). - packages/entitlements (pacote novo): assertQuota/assertFeatureEnabled, erros mapeados pra 403 no DomainExceptionFilter. - Retrofit em Extensions/Trunks/Agents/Queues: contam linhas ativas e checam quota antes de criar. ## Campanhas, Leads, Lista de Bloqueio Deliberadamente so' o modelo/CRUD/maquina de estados — o motor que de fato origina chamadas (PredictiveDialerEngine, secao 72-86: dados em tempo real, EWMA, CPS distribuido, reserva atomica de lead, lock de campanha, bgapi originate, controle de abandono, retry) e' um sistema grande o suficiente pra merecer fase propria (secao 72: "nao e' so' `for lead -> originate`"). - campaigns/leads/suppression_entries (tenant-scoped, RLS). - Maquina de estados da campanha (secao 64-66): start/pause/drain/stop com tabela de transicoes validas — transicao invalida retorna 400, nunca ignora silenciosamente. Apagar bloqueado enquanto RUNNING/DRAINING. - packages/shared/src/phone.ts (secao 70): normalizacao dedicada, preparada pra E.164 completo, so' BR implementado. - Importacao CSV em batches de 1000 (secao 69): detecta duplicado (dentro do CSV + contra leads existentes), checa lista de bloqueio (importa como DO_NOT_CALL, nao descarta), retorna {total, valid, invalid, duplicates, imported, suppressed}. - Lista de bloqueio (secao 71): CRUD tenant-scoped. Verificado ponta a ponta: campanha com queueId/trunkId invalido e pacingMin > pacingMax rejeitados; CSV de 5 linhas (1 invalida, 1 duplicada, 1 bloqueada) importado corretamente; start->pause->drain->stop e transicoes invalidas todas corretas; 3a campanha rejeitada por quota (max_campaigns=2 do plano trial); 6a extensao rejeitada por quota (max_extensions=5). Suites de teste existentes (tenant-isolation, auth) atualizadas pro novo Tenant.planId obrigatorio e passando. typecheck do workspace inteiro limpo. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw --- TODO.md | 53 ++++- apps/api/package.json | 1 + apps/api/src/agents/agents.controller.ts | 6 + apps/api/src/app.module.ts | 6 + .../api/src/campaigns/campaigns.controller.ts | 223 ++++++++++++++++++ apps/api/src/campaigns/campaigns.module.ts | 7 + .../src/campaigns/dto/create-campaign.dto.ts | 138 +++++++++++ .../common/filters/domain-exception.filter.ts | 6 + .../src/extensions/extensions.controller.ts | 6 + apps/api/src/leads/csv-import.ts | 128 ++++++++++ apps/api/src/leads/dto/create-lead.dto.ts | 16 ++ apps/api/src/leads/dto/import-leads.dto.ts | 10 + apps/api/src/leads/leads.controller.ts | 174 ++++++++++++++ apps/api/src/leads/leads.module.ts | 7 + apps/api/src/queues/queues.controller.ts | 6 + .../dto/create-suppression-entry.dto.ts | 12 + .../src/suppression/suppression.controller.ts | 82 +++++++ .../api/src/suppression/suppression.module.ts | 7 + apps/api/src/trunks/trunks.controller.ts | 6 + docs/AGENTS.md | 3 +- docs/CAMPAIGNS.md | 122 ++++++++++ docs/ENTITLEMENTS.md | 94 ++++++++ docs/EXTENSIONS.md | 4 +- docs/QUEUES.md | 3 +- docs/TRUNKS.md | 3 +- packages/auth/src/__tests__/auth.test.ts | 8 +- .../migration.sql | 188 +++++++++++++++ packages/database/prisma/schema.prisma | 207 +++++++++++++++- .../src/__tests__/tenant-isolation.test.ts | 6 +- packages/entitlements/package.json | 17 ++ packages/entitlements/src/index.ts | 74 ++++++ packages/entitlements/tsconfig.json | 8 + packages/shared/src/index.ts | 1 + packages/shared/src/phone.ts | 39 +++ pnpm-lock.yaml | 16 ++ 35 files changed, 1674 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/campaigns/campaigns.controller.ts create mode 100644 apps/api/src/campaigns/campaigns.module.ts create mode 100644 apps/api/src/campaigns/dto/create-campaign.dto.ts create mode 100644 apps/api/src/leads/csv-import.ts create mode 100644 apps/api/src/leads/dto/create-lead.dto.ts create mode 100644 apps/api/src/leads/dto/import-leads.dto.ts create mode 100644 apps/api/src/leads/leads.controller.ts create mode 100644 apps/api/src/leads/leads.module.ts create mode 100644 apps/api/src/suppression/dto/create-suppression-entry.dto.ts create mode 100644 apps/api/src/suppression/suppression.controller.ts create mode 100644 apps/api/src/suppression/suppression.module.ts create mode 100644 docs/CAMPAIGNS.md create mode 100644 docs/ENTITLEMENTS.md create mode 100644 packages/database/prisma/migrations/20260828151027_plans_campaigns_leads/migration.sql create mode 100644 packages/entitlements/package.json create mode 100644 packages/entitlements/src/index.ts create mode 100644 packages/entitlements/tsconfig.json create mode 100644 packages/shared/src/phone.ts diff --git a/TODO.md b/TODO.md index 2ccba44..8ad3b9d 100644 --- a/TODO.md +++ b/TODO.md @@ -272,8 +272,57 @@ - [ ] TME/TMA/Service Level/Abandon Rate — dependem de CDR (fase futura) - [ ] Snapshot/reconciliação ao reconectar o WebSocket -## PHASE 14+ — ver `agente.md` seções 56 em diante (Predictive Dialer, -Recordings, AI, Billing, Frontend, Reports, Security, Tests) +## PHASE 14 — Plans/Entitlements (agente.md secao 56-62) +- [x] Fase que tinha ficado pra trás: a ordem de implementação (secao 232) + coloca Plans/Entitlements logo depois de PostgreSQL RLS, bem antes de + FreeSWITCH — mas o build seguiu direto sem essa peça. Toda fase desde + então documentou "quota depende de Plans/Entitlements" como + pendência. Fechado agora, antes de Campanhas (que dependem de + `max_campaigns`) e do CPS Limiter (que vai depender de `max_cps`) +- [x] `plans` (catálogo compartilhado, sem RLS — não é tenant-scoped) + + `tenants.plan_id` obrigatório (nunca null: campo de limite null = + "sem limite", nunca "sem plano", agente.md secao 56) +- [x] Migration hand-escrita: cria `plans`, insere seed "trial", faz + backfill de `plan_id` pros tenants existentes, só depois `NOT NULL` + (Postgres não deixa `NOT NULL` sem default em tabela não-vazia) +- [x] `packages/entitlements` (pacote novo): `assertQuota`/ + `assertFeatureEnabled`, erros mapeados pra 403 no + `DomainExceptionFilter` +- [x] Retrofit nos controllers existentes: Extensions/Trunks/Agents/Queues + agora contam linhas ativas e checam quota antes de criar +- [x] Testado ponta a ponta: 5 extensões OK (limite do trial), 6a rejeitada + com 403 "Quota excedida: maxExtensions (limite do plano: 5)" + +## PHASE 15 — Campanhas, Leads, Lista de Bloqueio (agente.md secao 63-71) +- [x] `campaigns`/`leads`/`suppression_entries` (tenant-scoped, RLS) — só + modelo/CRUD/máquina de estados; o motor que origina chamadas de + verdade (PredictiveDialerEngine) é uma fase à parte, deliberadamente + não implementada aqui (agente.md secao 72: "não é só `for lead -> + originate`") +- [x] Máquina de estados da campanha (secao 64-66): start/pause/drain/stop + com tabela de transições válidas — tentar uma transição inválida + (ex.: pause numa DRAFT) retorna 400, nunca ignora silenciosamente +- [x] `packages/shared/src/phone.ts` (secao 70): normalização dedicada, + preparada pra E.164 completo, só BR implementado por enquanto +- [x] Importação CSV em batches de 1000 (secao 69): detecta duplicado + (dentro do CSV + contra leads já existentes), checa lista de + bloqueio (importa como DO_NOT_CALL, não descarta), retorna resumo + {total, valid, invalid, duplicates, imported, suppressed} +- [x] Lista de bloqueio (secao 71): CRUD tenant-scoped +- [x] Testado ponta a ponta: campanha com queueId/trunkId inválido + rejeitada, pacingMin > pacingMax rejeitado, CSV de 5 linhas (1 + inválida, 1 duplicada, 1 bloqueada) importado corretamente, + start->pause->drain->stop e transições inválidas todas corretas, + 3a campanha rejeitada por quota (max_campaigns=2 do plano trial) +- [ ] `PredictiveDialerEngine` (secao 72-86) — dados em tempo real, EWMA, + CPS distribuído, reserva atômica de lead, lock de campanha, + originate via bgapi, state machine da chamada, controle de abandono, + retry — fase própria, não iniciada +- [ ] Wizard visual de importação (upload de arquivo) — fase Frontend +- [ ] Relatório de campanha (secao 160) — depende de CDR + +## PHASE 16+ — ver `agente.md` seções 72 em diante (Predictive Dialer Engine, +CDR, Recordings, AI, Billing, Frontend, Reports, Security, Tests) --- diff --git a/apps/api/package.json b/apps/api/package.json index ff74fa9..01738d2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,7 @@ "dependencies": { "@b2bcall/auth": "workspace:*", "@b2bcall/database": "workspace:*", + "@b2bcall/entitlements": "workspace:*", "@b2bcall/shared": "workspace:*", "@b2bcall/telephony": "workspace:*", "@fastify/cors": "11.3.0", diff --git a/apps/api/src/agents/agents.controller.ts b/apps/api/src/agents/agents.controller.ts index 6b4738f..a13b432 100644 --- a/apps/api/src/agents/agents.controller.ts +++ b/apps/api/src/agents/agents.controller.ts @@ -12,6 +12,7 @@ import { } from "@nestjs/common"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { assertQuota } from "@b2bcall/entitlements"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { PermissionGuard } from "../common/guards/permission.guard"; import { RequirePermission } from "../common/decorators/require-permission.decorator"; @@ -30,6 +31,11 @@ export class AgentsController { const prisma = getPrismaClient(); const tenantId = user.tenantId!; + const activeCount = await withTenantContext(prisma, tenantId, (tx) => + tx.agent.count({ where: { tenantId, deletedAt: null } }), + ); + await assertQuota(tenantId, "maxAgents", activeCount); + const agent = await withTenantContext(prisma, tenantId, (tx) => tx.agent.create({ data: { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index dd03619..d75d5f3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,9 @@ import { QueuesModule } from "./queues/queues.module"; import { AgentsModule } from "./agents/agents.module"; import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module"; import { RealtimeModule } from "./realtime/realtime.module"; +import { CampaignsModule } from "./campaigns/campaigns.module"; +import { LeadsModule } from "./leads/leads.module"; +import { SuppressionModule } from "./suppression/suppression.module"; @Module({ imports: [ @@ -20,6 +23,9 @@ import { RealtimeModule } from "./realtime/realtime.module"; AgentsModule, PauseReasonsModule, RealtimeModule, + CampaignsModule, + LeadsModule, + SuppressionModule, ], }) export class AppModule {} diff --git a/apps/api/src/campaigns/campaigns.controller.ts b/apps/api/src/campaigns/campaigns.controller.ts new file mode 100644 index 0000000..4f6a73d --- /dev/null +++ b/apps/api/src/campaigns/campaigns.controller.ts @@ -0,0 +1,223 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Post, + UseGuards, +} from "@nestjs/common"; +import { getPrismaClient, withTenantContext, type CampaignStatus } from "@b2bcall/database"; +import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { assertQuota } from "@b2bcall/entitlements"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { CreateCampaignDto } from "./dto/create-campaign.dto"; + +/** + * Transições de status válidas (agente.md secao 64-66). O motor que de fato + * origina chamadas em RUNNING (PredictiveDialerEngine) é uma fase à parte — + * aqui só a máquina de estados e o CRUD. + */ +const ALLOWED_TRANSITIONS: Record = { + start: ["DRAFT", "READY", "PAUSED", "WAITING_SCHEDULE"], + pause: ["RUNNING"], + drain: ["RUNNING", "PAUSED"], + stop: ["DRAFT", "READY", "WAITING_SCHEDULE", "RUNNING", "PAUSED", "DRAINING"], +}; + +const TARGET_STATUS: Record = { + start: "RUNNING", + pause: "PAUSED", + drain: "DRAINING", + stop: "STOPPED", +}; + +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("campaigns") +export class CampaignsController { + @RequirePermission("campaigns.create") + @Post() + async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateCampaignDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const activeCount = await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.count({ where: { tenantId, deletedAt: null } }), + ); + await assertQuota(tenantId, "maxCampaigns", activeCount); + + const [queue, trunk] = await withTenantContext(prisma, tenantId, (tx) => + Promise.all([ + tx.queue.findFirst({ where: { id: dto.queueId, tenantId, deletedAt: null } }), + tx.trunk.findFirst({ where: { id: dto.trunkId, tenantId, deletedAt: null } }), + ]), + ); + if (!queue) throw new BadRequestException("Fila nao encontrada neste tenant"); + if (!trunk) throw new BadRequestException("Tronco nao encontrado neste tenant"); + + if (dto.pacingMin !== undefined && dto.pacingMax !== undefined && dto.pacingMin > dto.pacingMax) { + throw new BadRequestException("pacingMin nao pode ser maior que pacingMax"); + } + + const campaign = await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.create({ + data: { + tenantId, + name: dto.name, + description: dto.description, + queueId: dto.queueId, + trunkId: dto.trunkId, + callerIdName: dto.callerIdName, + callerIdNumber: dto.callerIdNumber, + timezone: dto.timezone ?? "America/Sao_Paulo", + startDate: dto.startDate, + endDate: dto.endDate, + daysOfWeek: dto.daysOfWeek ?? [], + startTime: dto.startTime, + endTime: dto.endTime, + maxCps: dto.maxCps, + maxConcurrentCalls: dto.maxConcurrentCalls, + pacingInitial: dto.pacingInitial ?? 1.0, + pacingMin: dto.pacingMin ?? 1.0, + pacingMax: dto.pacingMax ?? 3.0, + targetAbandonRate: dto.targetAbandonRate ?? 0.03, + ringTimeout: dto.ringTimeout ?? 30, + maxAttempts: dto.maxAttempts ?? 3, + recordingEnabled: dto.recordingEnabled ?? false, + avmdEnabled: dto.avmdEnabled ?? false, + aiTranscriptionEnabled: dto.aiTranscriptionEnabled ?? false, + aiAnalysisEnabled: dto.aiAnalysisEnabled ?? false, + }, + }), + ); + + await recordAuditEvent(prisma, { + action: "CAMPAIGN_CREATE", + tenantId, + userId: user.sub, + entityType: "campaign", + entityId: campaign.id, + after: { name: campaign.name }, + }); + + return campaign; + } + + @RequirePermission("campaigns.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + return withTenantContext(prisma, tenantId, (tx) => + tx.campaign.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" } }), + ); + } + + @RequirePermission("campaigns.view") + @Get(":id") + async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + const campaign = await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.findFirst({ where: { id, deletedAt: null } }), + ); + if (!campaign) throw new NotFoundException(); + return campaign; + } + + @RequirePermission("campaigns.start") + @Post(":id/start") + async start(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + return this.transition(user, id, "start", "CAMPAIGN_START"); + } + + @RequirePermission("campaigns.pause") + @Post(":id/pause") + async pause(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + return this.transition(user, id, "pause", "CAMPAIGN_PAUSE"); + } + + @RequirePermission("campaigns.stop") + @Post(":id/drain") + async drain(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + return this.transition(user, id, "drain", "CAMPAIGN_DRAIN"); + } + + @RequirePermission("campaigns.stop") + @Post(":id/stop") + async stop(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + return this.transition(user, id, "stop", "CAMPAIGN_STOP"); + } + + private async transition( + user: AccessTokenClaims, + id: string, + action: keyof typeof TARGET_STATUS, + auditAction: string, + ) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const campaign = await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.findFirst({ where: { id, tenantId, deletedAt: null } }), + ); + if (!campaign) throw new NotFoundException(); + + if (!ALLOWED_TRANSITIONS[action].includes(campaign.status)) { + throw new BadRequestException( + `Nao e' possivel "${action}" uma campanha em status ${campaign.status}`, + ); + } + + const updated = await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.update({ where: { id }, data: { status: TARGET_STATUS[action] } }), + ); + + await recordAuditEvent(prisma, { + action: auditAction, + tenantId, + userId: user.sub, + entityType: "campaign", + entityId: id, + before: { status: campaign.status }, + after: { status: updated.status }, + }); + + return { status: updated.status }; + } + + @RequirePermission("campaigns.update") + @Delete(":id") + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const campaign = await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.findFirst({ where: { id, tenantId, deletedAt: null } }), + ); + if (!campaign) throw new NotFoundException(); + if (campaign.status === "RUNNING" || campaign.status === "DRAINING") { + throw new BadRequestException("Pare a campanha antes de apaga-la"); + } + + await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.update({ where: { id }, data: { deletedAt: new Date() } }), + ); + + await recordAuditEvent(prisma, { + action: "CAMPAIGN_DELETE", + tenantId, + userId: user.sub, + entityType: "campaign", + entityId: id, + }); + } +} diff --git a/apps/api/src/campaigns/campaigns.module.ts b/apps/api/src/campaigns/campaigns.module.ts new file mode 100644 index 0000000..a775dfd --- /dev/null +++ b/apps/api/src/campaigns/campaigns.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { CampaignsController } from "./campaigns.controller"; + +@Module({ + controllers: [CampaignsController], +}) +export class CampaignsModule {} diff --git a/apps/api/src/campaigns/dto/create-campaign.dto.ts b/apps/api/src/campaigns/dto/create-campaign.dto.ts new file mode 100644 index 0000000..7f7d18a --- /dev/null +++ b/apps/api/src/campaigns/dto/create-campaign.dto.ts @@ -0,0 +1,138 @@ +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, +} from "class-validator"; + +export class CreateCampaignDto { + @IsString() + @MaxLength(120) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @IsUUID() + queueId!: string; + + @IsUUID() + trunkId!: string; + + @IsOptional() + @IsString() + @MaxLength(80) + callerIdName?: string; + + @IsOptional() + @IsString() + @Matches(/^[0-9]{2,20}$/) + callerIdNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + timezone?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + // 1=segunda ... 7=domingo (ISO-8601). + @IsOptional() + @IsArray() + @ArrayMaxSize(7) + @IsInt({ each: true }) + @Min(1, { each: true }) + @Max(7, { each: true }) + daysOfWeek?: number[]; + + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + startTime?: string; + + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + endTime?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + maxCps?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100000) + maxConcurrentCalls?: number; + + @IsOptional() + @IsNumber() + @Min(0.1) + @Max(10) + pacingInitial?: number; + + @IsOptional() + @IsNumber() + @Min(0.1) + @Max(10) + pacingMin?: number; + + @IsOptional() + @IsNumber() + @Min(0.1) + @Max(10) + pacingMax?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + targetAbandonRate?: number; + + @IsOptional() + @IsInt() + @Min(5) + @Max(120) + ringTimeout?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(20) + maxAttempts?: number; + + @IsOptional() + @IsBoolean() + recordingEnabled?: boolean; + + @IsOptional() + @IsBoolean() + avmdEnabled?: boolean; + + @IsOptional() + @IsBoolean() + aiTranscriptionEnabled?: boolean; + + @IsOptional() + @IsBoolean() + aiAnalysisEnabled?: boolean; +} diff --git a/apps/api/src/common/filters/domain-exception.filter.ts b/apps/api/src/common/filters/domain-exception.filter.ts index 961ee55..3202af5 100644 --- a/apps/api/src/common/filters/domain-exception.filter.ts +++ b/apps/api/src/common/filters/domain-exception.filter.ts @@ -11,6 +11,7 @@ import { InvalidRefreshTokenError, NotATenantMemberError, } from "@b2bcall/auth"; +import { QuotaExceededError, FeatureNotEnabledError } from "@b2bcall/entitlements"; /** * Traduz erros de domínio de packages/auth para HTTP, sem nunca vazar stack @@ -43,6 +44,11 @@ export class DomainExceptionFilter implements ExceptionFilter { return; } + if (exception instanceof QuotaExceededError || exception instanceof FeatureNotEnabledError) { + reply.status(HttpStatus.FORBIDDEN).send({ message: exception.message }); + return; + } + console.error(exception); reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: "Erro interno" }); } diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts index 50fd0d2..7abbe64 100644 --- a/apps/api/src/extensions/extensions.controller.ts +++ b/apps/api/src/extensions/extensions.controller.ts @@ -14,6 +14,7 @@ import { import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { generateStrongPassword, encryptSecret } from "@b2bcall/shared"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { assertQuota } from "@b2bcall/entitlements"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { PermissionGuard } from "../common/guards/permission.guard"; import { RequirePermission } from "../common/decorators/require-permission.decorator"; @@ -60,6 +61,11 @@ export class ExtensionsController { ); } + const activeCount = await withTenantContext(prisma, tenantId, (tx) => + tx.extension.count({ where: { tenantId, deletedAt: null } }), + ); + await assertQuota(tenantId, "maxExtensions", activeCount); + const plainPassword = generateStrongPassword(); const extension = await withTenantContext(prisma, tenantId, (tx) => diff --git a/apps/api/src/leads/csv-import.ts b/apps/api/src/leads/csv-import.ts new file mode 100644 index 0000000..1d32ac5 --- /dev/null +++ b/apps/api/src/leads/csv-import.ts @@ -0,0 +1,128 @@ +import { normalizePhone } from "@b2bcall/shared"; +import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; + +export interface ImportSummary { + total: number; + valid: number; + invalid: number; + duplicates: number; + imported: number; + suppressed: number; +} + +/** Parser de CSV mínimo: separa por vírgula, aceita campos entre aspas com + * vírgula/aspas escapada dentro (`""`) — não é RFC 4180 completo, mas cobre + * o "formato mínimo" pedido (agente.md secao 69). */ +function parseCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (inQuotes) { + if (char === '"' && line[i + 1] === '"') { + current += '"'; + i++; + } else if (char === '"') { + inQuotes = false; + } else { + current += char; + } + } else if (char === '"') { + inQuotes = true; + } else if (char === ",") { + fields.push(current.trim()); + current = ""; + } else { + current += char; + } + } + fields.push(current.trim()); + return fields; +} + +/** + * Importa leads em streaming/batches (agente.md secao 69) — processa linha + * a linha em vez de materializar tudo na memória antes de validar, e insere + * em lotes (`createMany`). Detecta duplicados dentro do próprio CSV e + * contra leads já existentes na campanha (mesmo `phoneNormalized`); leads + * que batem na lista de bloqueio do tenant (secao 71) são importados já + * como `DO_NOT_CALL`, não descartados — fica registrado, só não é discado. + */ +export async function importLeadsFromCsv( + tenantId: string, + campaignId: string, + csv: string, +): Promise { + const prisma = getPrismaClient(); + + const lines = csv.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return { total: 0, valid: 0, invalid: 0, duplicates: 0, imported: 0, suppressed: 0 }; + } + + const header = parseCsvLine(lines[0]).map((h) => h.toLowerCase()); + const nameIdx = header.findIndex((h) => h === "nome" || h === "name"); + const phoneIdx = header.findIndex((h) => h === "telefone" || h === "phone"); + if (phoneIdx === -1) { + throw new Error('CSV precisa de uma coluna "telefone" (ou "phone") no cabecalho'); + } + const dataLines = lines.slice(1); + + const [existingPhones, suppressed] = await withTenantContext(prisma, tenantId, (tx) => + Promise.all([ + tx.lead.findMany({ where: { campaignId, tenantId }, select: { phoneNormalized: true } }), + tx.suppressionEntry.findMany({ where: { tenantId }, select: { phoneNormalized: true } }), + ]), + ); + const seen = new Set(existingPhones.map((l) => l.phoneNormalized)); + const suppressedSet = new Set(suppressed.map((s) => s.phoneNormalized)); + + const summary: ImportSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0, imported: 0, suppressed: 0 }; + const toInsert: Prisma.LeadCreateManyInput[] = []; + + const BATCH_SIZE = 1000; + for (const line of dataLines) { + summary.total++; + const fields = parseCsvLine(line); + const phoneRaw = fields[phoneIdx]; + const name = nameIdx >= 0 ? fields[nameIdx] : undefined; + + const normalized = phoneRaw ? normalizePhone(phoneRaw) : null; + if (!normalized) { + summary.invalid++; + continue; + } + if (seen.has(normalized)) { + summary.duplicates++; + continue; + } + seen.add(normalized); + summary.valid++; + + const isSuppressed = suppressedSet.has(normalized); + if (isSuppressed) summary.suppressed++; + + toInsert.push({ + tenantId, + campaignId, + name: name || null, + phoneOriginal: phoneRaw, + phoneNormalized: normalized, + status: isSuppressed ? "DO_NOT_CALL" : "NEW", + }); + + if (toInsert.length >= BATCH_SIZE) { + const batch = toInsert.splice(0, toInsert.length); + await withTenantContext(prisma, tenantId, (tx) => tx.lead.createMany({ data: batch })); + summary.imported += batch.length; + } + } + + if (toInsert.length > 0) { + await withTenantContext(prisma, tenantId, (tx) => tx.lead.createMany({ data: toInsert })); + summary.imported += toInsert.length; + } + + return summary; +} diff --git a/apps/api/src/leads/dto/create-lead.dto.ts b/apps/api/src/leads/dto/create-lead.dto.ts new file mode 100644 index 0000000..5b0549c --- /dev/null +++ b/apps/api/src/leads/dto/create-lead.dto.ts @@ -0,0 +1,16 @@ +import { IsObject, IsOptional, IsString, MaxLength } from "class-validator"; + +export class CreateLeadDto { + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsString() + @MaxLength(40) + phone!: string; + + @IsOptional() + @IsObject() + customFields?: Record; +} diff --git a/apps/api/src/leads/dto/import-leads.dto.ts b/apps/api/src/leads/dto/import-leads.dto.ts new file mode 100644 index 0000000..1003c2c --- /dev/null +++ b/apps/api/src/leads/dto/import-leads.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MaxLength } from "class-validator"; + +export class ImportLeadsDto { + // Formato minimo (agente.md secao 69): "nome,telefone" por linha, com + // cabecalho. O wizard visual (Upload -> Preview -> Mapeamento -> ...) e' + // fase Frontend — este endpoint recebe o CSV ja como texto no corpo. + @IsString() + @MaxLength(5_000_000) + csv!: string; +} diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts new file mode 100644 index 0000000..f30149d --- /dev/null +++ b/apps/api/src/leads/leads.controller.ts @@ -0,0 +1,174 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { getPrismaClient, withTenantContext, type LeadStatus, type Prisma } from "@b2bcall/database"; +import { normalizePhone } from "@b2bcall/shared"; +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 { CreateLeadDto } from "./dto/create-lead.dto"; +import { ImportLeadsDto } from "./dto/import-leads.dto"; +import { importLeadsFromCsv } from "./csv-import"; + +const LEAD_STATUSES: LeadStatus[] = [ + "NEW", + "READY", + "RESERVED", + "ORIGINATING", + "RINGING", + "ANSWERED", + "QUEUEING", + "CONNECTED_AGENT", + "BUSY", + "NO_ANSWER", + "FAILED", + "VOICEMAIL", + "CALLBACK", + "COMPLETED", + "DO_NOT_CALL", + "MAX_ATTEMPTS", +]; + +async function findCampaignOrThrow(tenantId: string, campaignId: string) { + const prisma = getPrismaClient(); + const campaign = await withTenantContext(prisma, tenantId, (tx) => + tx.campaign.findFirst({ where: { id: campaignId, tenantId, deletedAt: null } }), + ); + if (!campaign) throw new NotFoundException("Campanha nao encontrada"); + return campaign; +} + +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("campaigns/:campaignId/leads") +export class LeadsController { + @RequirePermission("campaigns.update") + @Post() + async create( + @CurrentUser() user: AccessTokenClaims, + @Param("campaignId") campaignId: string, + @Body() dto: CreateLeadDto, + ) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + await findCampaignOrThrow(tenantId, campaignId); + + const normalized = normalizePhone(dto.phone); + if (!normalized) { + throw new BadRequestException("Telefone invalido"); + } + + const suppressed = await withTenantContext(prisma, tenantId, (tx) => + tx.suppressionEntry.findFirst({ where: { tenantId, phoneNormalized: normalized } }), + ); + + const lead = await withTenantContext(prisma, tenantId, (tx) => + tx.lead.create({ + data: { + tenantId, + campaignId, + name: dto.name, + phoneOriginal: dto.phone, + phoneNormalized: normalized, + status: suppressed ? "DO_NOT_CALL" : "NEW", + customFields: (dto.customFields ?? {}) as Prisma.InputJsonValue, + }, + }), + ); + + await recordAuditEvent(prisma, { + action: "LEAD_CREATE", + tenantId, + userId: user.sub, + entityType: "lead", + entityId: lead.id, + }); + + return lead; + } + + @RequirePermission("campaigns.update") + @Post("import") + async import( + @CurrentUser() user: AccessTokenClaims, + @Param("campaignId") campaignId: string, + @Body() dto: ImportLeadsDto, + ) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + await findCampaignOrThrow(tenantId, campaignId); + + let summary; + try { + summary = await importLeadsFromCsv(tenantId, campaignId, dto.csv); + } catch (err) { + throw new BadRequestException(err instanceof Error ? err.message : "CSV invalido"); + } + + await recordAuditEvent(prisma, { + action: "LEAD_IMPORT", + tenantId, + userId: user.sub, + entityType: "campaign", + entityId: campaignId, + after: summary as unknown as Prisma.InputJsonValue, + }); + + return summary; + } + + @RequirePermission("campaigns.view") + @Get() + async list( + @CurrentUser() user: AccessTokenClaims, + @Param("campaignId") campaignId: string, + @Query("status") status?: string, + ) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + await findCampaignOrThrow(tenantId, campaignId); + + const statusFilter = LEAD_STATUSES.includes(status as LeadStatus) ? (status as LeadStatus) : undefined; + if (status && !statusFilter) { + throw new BadRequestException("status invalido"); + } + + return withTenantContext(prisma, tenantId, (tx) => + tx.lead.findMany({ + where: { campaignId, tenantId, ...(statusFilter ? { status: statusFilter } : {}) }, + orderBy: { createdAt: "asc" }, + take: 500, + }), + ); + } + + @RequirePermission("campaigns.update") + @Delete(":leadId") + @HttpCode(HttpStatus.NO_CONTENT) + async remove( + @CurrentUser() user: AccessTokenClaims, + @Param("campaignId") campaignId: string, + @Param("leadId") leadId: string, + ) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + await findCampaignOrThrow(tenantId, campaignId); + + const result = await withTenantContext(prisma, tenantId, (tx) => + tx.lead.deleteMany({ where: { id: leadId, campaignId, tenantId } }), + ); + if (result.count === 0) throw new NotFoundException(); + } +} diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts new file mode 100644 index 0000000..076d53b --- /dev/null +++ b/apps/api/src/leads/leads.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { LeadsController } from "./leads.controller"; + +@Module({ + controllers: [LeadsController], +}) +export class LeadsModule {} diff --git a/apps/api/src/queues/queues.controller.ts b/apps/api/src/queues/queues.controller.ts index 89bf821..62551f9 100644 --- a/apps/api/src/queues/queues.controller.ts +++ b/apps/api/src/queues/queues.controller.ts @@ -12,6 +12,7 @@ import { } from "@nestjs/common"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { assertQuota } from "@b2bcall/entitlements"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { PermissionGuard } from "../common/guards/permission.guard"; import { RequirePermission } from "../common/decorators/require-permission.decorator"; @@ -34,6 +35,11 @@ export class QueuesController { const prisma = getPrismaClient(); const tenantId = user.tenantId!; + const activeCount = await withTenantContext(prisma, tenantId, (tx) => + tx.queue.count({ where: { tenantId, deletedAt: null } }), + ); + await assertQuota(tenantId, "maxQueues", activeCount); + const queue = await withTenantContext(prisma, tenantId, (tx) => tx.queue.create({ data: { diff --git a/apps/api/src/suppression/dto/create-suppression-entry.dto.ts b/apps/api/src/suppression/dto/create-suppression-entry.dto.ts new file mode 100644 index 0000000..1f8b15f --- /dev/null +++ b/apps/api/src/suppression/dto/create-suppression-entry.dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString, MaxLength } from "class-validator"; + +export class CreateSuppressionEntryDto { + @IsString() + @MaxLength(40) + phone!: string; + + @IsOptional() + @IsString() + @MaxLength(200) + reason?: string; +} diff --git a/apps/api/src/suppression/suppression.controller.ts b/apps/api/src/suppression/suppression.controller.ts new file mode 100644 index 0000000..a66dc82 --- /dev/null +++ b/apps/api/src/suppression/suppression.controller.ts @@ -0,0 +1,82 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Post, + UseGuards, +} from "@nestjs/common"; +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; +import { normalizePhone } from "@b2bcall/shared"; +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 { CreateSuppressionEntryDto } from "./dto/create-suppression-entry.dto"; + +/** + * Lista de bloqueio (agente.md secao 71) — "Discador -> Lista de Bloqueio". + * A checagem obrigatória antes de qualquer originate é responsabilidade da + * fase Predictive Engine/CPS Limiter; aqui só o CRUD. + */ +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("suppression") +export class SuppressionController { + @RequirePermission("campaigns.update") + @Post() + async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateSuppressionEntryDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const normalized = normalizePhone(dto.phone); + if (!normalized) { + throw new BadRequestException("Telefone invalido"); + } + + const entry = await withTenantContext(prisma, tenantId, (tx) => + tx.suppressionEntry.upsert({ + where: { tenantId_phoneNormalized: { tenantId, phoneNormalized: normalized } }, + update: { reason: dto.reason }, + create: { tenantId, phoneNormalized: normalized, reason: dto.reason }, + }), + ); + + await recordAuditEvent(prisma, { + action: "SUPPRESSION_ENTRY_CREATE", + tenantId, + userId: user.sub, + entityType: "suppression_entry", + entityId: entry.id, + }); + + return entry; + } + + @RequirePermission("campaigns.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + return withTenantContext(prisma, tenantId, (tx) => + tx.suppressionEntry.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" } }), + ); + } + + @RequirePermission("campaigns.update") + @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.suppressionEntry.deleteMany({ where: { id, tenantId } }), + ); + if (result.count === 0) throw new NotFoundException(); + } +} diff --git a/apps/api/src/suppression/suppression.module.ts b/apps/api/src/suppression/suppression.module.ts new file mode 100644 index 0000000..535447e --- /dev/null +++ b/apps/api/src/suppression/suppression.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { SuppressionController } from "./suppression.controller"; + +@Module({ + controllers: [SuppressionController], +}) +export class SuppressionModule {} diff --git a/apps/api/src/trunks/trunks.controller.ts b/apps/api/src/trunks/trunks.controller.ts index febf6f4..35586cf 100644 --- a/apps/api/src/trunks/trunks.controller.ts +++ b/apps/api/src/trunks/trunks.controller.ts @@ -13,6 +13,7 @@ import { import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { encryptSecret } from "@b2bcall/shared"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { assertQuota } from "@b2bcall/entitlements"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { PermissionGuard } from "../common/guards/permission.guard"; import { RequirePermission } from "../common/decorators/require-permission.decorator"; @@ -72,6 +73,11 @@ export class TrunksController { const prisma = getPrismaClient(); const tenantId = user.tenantId!; + const activeCount = await withTenantContext(prisma, tenantId, (tx) => + tx.trunk.count({ where: { tenantId, deletedAt: null } }), + ); + await assertQuota(tenantId, "maxTrunks", activeCount); + const trunk = await withTenantContext(prisma, tenantId, (tx) => tx.trunk.create({ data: { diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c31e84a..6863ead 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -105,7 +105,8 @@ FreeSWITCH (`agent list`). (docs/REALTIME.md); persistir isso em `Agent.state` fica pra quando o Predictive Engine/CDR precisarem consultar esse histórico. - Tela do agente (secao 49) — fase Frontend. -- Quota de agentes (`max_agents`) — depende de Plans/Entitlements. +- ~~Quota de agentes~~ — implementada na fase Plans/Entitlements (ver + docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar. - `PauseReason.maxDuration` existe no modelo mas não é aplicado automaticamente ainda (ninguém força o fim da pausa ao expirar). - **Achado ao testar a fase Realtime Monitoring, sistêmico (não é só diff --git a/docs/CAMPAIGNS.md b/docs/CAMPAIGNS.md new file mode 100644 index 0000000..095c1fe --- /dev/null +++ b/docs/CAMPAIGNS.md @@ -0,0 +1,122 @@ +# Campanhas, Leads e Lista de Bloqueio + +Agente.md secao 63-71. Modelo de dados, CRUD, máquina de estados da +campanha e importação de leads — o motor que de fato origina chamadas +(`PredictiveDialerEngine`, secao 72-86) é uma fase à parte, deliberadamente +não implementada aqui (é um sistema grande o suficiente — pacing por EWMA, +previsão de liberação de agentes, CPS distribuído, reserva atômica de +leads — pra merecer o próprio ciclo de design/teste, ver agente.md secao 72 +"Não implementar apenas `for lead -> originate`. Isso não é discador +preditivo."). + +## Modelo + +- `campaigns` (tenant-scoped, RLS): liga `Queue`+`Trunk`, janela de + funcionamento (`timezone`/`daysOfWeek`/`startTime`/`endTime`/`startDate`/ + `endDate`), limites (`maxCps`/`maxConcurrentCalls`, além dos globais do + Plan), parâmetros de pacing (`pacingInitial`/`Min`/`Max`, + `targetAbandonRate` — consumidos pela fase Predictive Engine, só + armazenados aqui), `maxAttempts`/`ringTimeout`, flags de gravação/AVMD/IA. + `daysOfWeek` é `Int[]` ISO-8601 (1=segunda...7=domingo); `startTime`/ + `endTime` são `"HH:MM"` string — sem tipo TIME nativo nesta fase (o + scheduler de verdade avalia isso na timezone da campanha, é trabalho da + fase Predictive Engine). +- `leads` (tenant-scoped, RLS): `campaignId`, `phoneOriginal`/ + `phoneNormalized` (E.164), `status` (enum de 16 valores, secao 68), + `attemptCount`/`lastAttemptAt`/`nextAttemptAt`/`lastResult`, + `customFields` JSONB livre. Único por `(campaignId, phoneNormalized)` — + o mesmo telefone pode existir em campanhas diferentes, nunca duas vezes + na mesma. +- `suppression_entries` (tenant-scoped, RLS): lista de bloqueio (secao 71), + único por `(tenantId, phoneNormalized)`. + +## Máquina de estados da campanha (secao 64-66) + +``` +start: DRAFT|READY|WAITING_SCHEDULE|PAUSED -> RUNNING +pause: RUNNING -> PAUSED +drain: RUNNING|PAUSED -> DRAINING (não origina novas, deixa terminar as existentes) +stop: qualquer não-terminal -> STOPPED +``` + +Cada transição valida o estado atual contra uma tabela de transições +permitidas (`ALLOWED_TRANSITIONS` em `campaigns.controller.ts`) — tentar +`pause` numa campanha `DRAFT` retorna 400, não silenciosamente ignora. +Apagar (soft delete) é bloqueado enquanto `RUNNING`/`DRAINING` — precisa +parar primeiro. + +`COMPLETED`/`ERROR` não são alcançáveis via API nesta fase — são estados +que só o motor de discagem (fase Predictive Engine) vai setar sozinho +(leads esgotados / falha operacional). + +## `packages/shared/src/phone.ts` — normalização (secao 70) + +Serviço dedicado, `normalizePhone(raw, country = "BR")`. Assinatura já +preparada pra outros países (E.164 completo) — só o normalizador BR está +implementado. Aceita com/sem "55" na frente, DDD 2 dígitos + 8 ou 9 dígitos +de número; retorna `null` (nunca lança) pra entrada inválida, já que +"número inválido" é um resultado esperado de uma importação de CSV. + +## Importação CSV (secao 69) + +`POST /campaigns/:id/leads/import`, corpo `{ csv: string }` — a versão +desta fase recebe o CSV como texto no corpo JSON, não upload de arquivo +multipart; o wizard visual (Upload → Preview → Mapeamento → Validação → +Normalização → Duplicados → Importação) descrito na especificação é UI, +fase Frontend. Formato mínimo: cabeçalho com colunas `nome`/`telefone` +(aceita `name`/`phone` também). + +Processamento em streaming/batches (`csv-import.ts`): parseia linha a +linha (parser CSV mínimo, aceita campos entre aspas), valida telefone, +detecta duplicado (dentro do próprio CSV E contra leads já existentes na +campanha, num único `Set` carregado uma vez do banco — não uma query por +linha), checa contra a lista de bloqueio do tenant, insere em lotes de +1000 (`createMany`). Lead que bate na lista de bloqueio é importado como +`DO_NOT_CALL`, não descartado — fica registrado, só não é discado depois +(quando o motor existir, ele só vai selecionar `status = READY`). + +Retorna o resumo pedido pela secao 69: `{ total, valid, invalid, +duplicates, imported, suppressed }`. + +## Lista de bloqueio (secao 71) + +CRUD simples (`/suppression`), tenant-scoped. A checagem obrigatória "antes +de qualquer chamada" é responsabilidade de quem de fato origina (Predictive +Engine) — aqui ela já é aplicada no momento da importação/criação de lead +(marca como `DO_NOT_CALL` na hora, não pré-filtra o import). + +## Verificado ponta a ponta + +``` +POST /campaigns {queueId, trunkId, maxCps:2, daysOfWeek:[1..5], ...} + -> pacingMin > pacingMax rejeitado (400) + -> queueId inexistente no tenant rejeitado (400) + +POST /suppression {phone: "11988887777"} +POST /campaigns/:id/leads/import (5 linhas: 1 invalida, 1 duplicada, 1 bloqueada) + -> {"total":5,"valid":3,"invalid":1,"duplicates":1,"imported":3,"suppressed":1} + -> lead bloqueado importado com status DO_NOT_CALL + +pause numa campanha DRAFT -> 400 "Nao e' possivel "pause" ... em status DRAFT" +start -> RUNNING -> pause -> PAUSED -> drain -> DRAINING -> stop -> STOPPED +start numa campanha STOPPED -> 400 (transição não permitida) +delete numa campanha RUNNING -> 400 "Pare a campanha antes de apaga-la" + +3a campanha (plano trial, max_campaigns=2) -> 403 Quota excedida +``` + +## O que falta + +- `PredictiveDialerEngine` inteiro (secao 72-86): dados em tempo real + (agentes disponíveis/reservados/etc., secao 73), previsão de liberação + via EWMA (secao 74-75), cálculo de pacing (secao 76), CPS distribuído + via Redis (secao 77), reserva atômica de lead (`FOR UPDATE SKIP LOCKED`, + secao 78), lock de campanha com TTL/ownership/renewal (secao 79), + originate via `bgapi` (secao 80), state machine da chamada (secao 82), + controle de abandono (secao 84), regras de retry (secao 86). +- Wizard visual de importação (upload de arquivo de verdade) — fase + Frontend. +- Relatório de campanha (secao 160) — depende de CDR. +- Quota de leads por campanha — não existe campo `max_leads` no Plan + (agente.md secao 56 não lista um); se vier a ser necessário, é uma + adição pequena ao Plan + `assertQuota`. diff --git a/docs/ENTITLEMENTS.md b/docs/ENTITLEMENTS.md new file mode 100644 index 0000000..4990c53 --- /dev/null +++ b/docs/ENTITLEMENTS.md @@ -0,0 +1,94 @@ +# Plans / Entitlements + +Agente.md secao 56-62. Fase que ficou pra trás: a ordem de implementação +(secao 232) coloca "Plans / Entitlements" logo depois de PostgreSQL RLS, +bem antes de FreeSWITCH — mas o build seguiu direto pra FreeSWITCH sem essa +peça, e toda fase desde então documentou "quota depende de Plans/ +Entitlements" como pendência (EXTENSIONS.md, TRUNKS.md, AGENTS.md, +QUEUES.md). Esta fase fecha essa lacuna antes de avançar pra Campanhas, que +dependem de `max_campaigns`, e pro CPS Limiter, que depende de `max_cps`/ +`max_concurrent_calls`. + +## Modelo + +`plans`: catálogo compartilhado entre tenants (não é tenant-scoped, sem +RLS — é um "menu" de planos, não dado de um tenant específico). Campos de +limite (`max_extensions`, `max_agents`, `max_trunks`, `max_queues`, +`max_campaigns`, `max_cps`, `max_concurrent_calls`, `max_daily_calls`, +`max_monthly_calls`, `max_recording_storage_gb`) são todos `Int?` — **null +significa sem limite**, nunca "sem plano". Campos de feature flag +(`recording_enabled`, `ai_enabled`, `ai_transcription_enabled`, +`ai_analysis_enabled`, `api_access_enabled`) são booleanos. + +`tenants.plan_id` é **obrigatório** (nunca null) — agente.md secao 56 pede +explicitamente pra não espalhar `if plan == PRO` pelo código; em vez disso, +todo tenant sempre tem um Plan de verdade pra ler, e o código só lê +limites, nunca checa "qual plano é esse". + +Migration `plans_campaigns_leads`: cria a tabela `plans`, insere um plano +"trial" seed, faz backfill de `plan_id` pros tenants já existentes (havia +2 tenants de teste no banco), só então torna a coluna `NOT NULL` — nessa +ordem porque Postgres não deixa adicionar uma coluna `NOT NULL` sem default +numa tabela não-vazia. + +## `packages/entitlements` + +Pacote novo, dedicado (mesmo padrão de `packages/telephony`/`packages/ +auth`). Duas funções: + +```typescript +assertQuota(tenantId, key: QuotaKey, currentCount: number): Promise +// lança QuotaExceededError se currentCount >= limite do plano; no-op se o +// campo for null (sem limite). Chamar ANTES de criar a linha. + +assertFeatureEnabled(tenantId, key: FeatureKey): Promise +// lança FeatureNotEnabledError se o plano não habilita o recurso. +``` + +`DomainExceptionFilter` (apps/api) mapeia os dois erros pra 403 — mesmo +padrão já usado pra `InvalidCredentialsError`/`NotATenantMemberError` +(erros de domínio simples, sem depender de NestJS, traduzidos pra HTTP só +na borda). + +## Retrofit nos controllers existentes + +`ExtensionsController`, `TrunksController`, `AgentsController`, +`QueuesController` e o novo `CampaignsController` agora contam as linhas +ativas (`deletedAt: null`) antes de criar e chamam `assertQuota`. Sempre +nessa ordem: contar → checar quota → só então criar — nunca criar e +desfazer se estourar. + +## Plano seed: "trial" + +``` +max_extensions: 5 max_agents: 5 max_trunks: 2 +max_queues: 3 max_campaigns: 2 max_cps: 3 +max_concurrent_calls: 5 +max_daily_calls: 200 max_monthly_calls: 4000 +max_recording_storage_gb: 1 +recording_enabled: true, resto (ai_*) desabilitado +``` + +Números conservadores pra um plano de teste — o fluxo de "escolher/mudar de +plano" de verdade é da fase Billing (secao 229). + +## Verificado ponta a ponta + +``` +POST /extensions x5 -> 201 (dentro do limite de 5) +POST /extensions x6 -> 403 "Quota excedida: maxExtensions (limite do plano: 5)" + +POST /campaigns x2 -> 201 (dentro do limite de 2) +POST /campaigns x3 -> 403 "Quota excedida: maxCampaigns (limite do plano: 2)" +``` + +## O que falta + +- Fluxo de escolha/upgrade de plano (fase Billing) — hoje todo tenant novo + nasce com "trial" via o mesmo default do backfill; não há endpoint pra + trocar de plano ainda. +- `max_cps`/`max_concurrent_calls` só existem como campo lido — a + aplicação em tempo real (rejeitar originate além do limite) é da fase + CPS Limiter. +- `max_daily_calls`/`max_monthly_calls`/`max_recording_storage_gb` — sem + nenhum consumidor ainda (dependem de CDR/Recording, fases futuras). diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index ab58c3a..a81d0ad 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -115,7 +115,7 @@ configurado) continua funcionando normalmente. ## O que falta -- Quota de ramais (`max_extensions` do plano, secao 57) — depende da fase - Plans/Entitlements, que ainda não existe. +- ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver + docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar. - Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante. - Multi-domínio real por tenant (ver acima). diff --git a/docs/QUEUES.md b/docs/QUEUES.md index 3d1ec47..a3552f3 100644 --- a/docs/QUEUES.md +++ b/docs/QUEUES.md @@ -79,4 +79,5 @@ DELETE /queues/:id - `tier-rule-wait-multiply-level` e `tier-rule-no-agent-no-wait` (vistos no `queue list` da config vanilla) não são expostos como campos próprios ainda — ficaram de fora do escopo desta fase. -- Quota de filas (`max_queues`) — depende de Plans/Entitlements. +- ~~Quota de filas~~ — implementada na fase Plans/Entitlements (ver + docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar. diff --git a/docs/TRUNKS.md b/docs/TRUNKS.md index 28b5c54..5ebbe4f 100644 --- a/docs/TRUNKS.md +++ b/docs/TRUNKS.md @@ -92,7 +92,8 @@ Criei um trunk de teste apontando pra um host inexistente ## O que falta -- Quota de troncos (`max_trunks`, secao 59) — depende de Plans/Entitlements. +- ~~Quota de troncos~~ — implementada na fase Plans/Entitlements (ver + docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar. - `GET /trunks` não mostra `sofia status gateway` ao vivo, só o último status conhecido no banco — resolvido na fase Realtime Monitoring via WebSocket (ver docs/REALTIME.md). diff --git a/packages/auth/src/__tests__/auth.test.ts b/packages/auth/src/__tests__/auth.test.ts index 2f01c9b..71fce27 100644 --- a/packages/auth/src/__tests__/auth.test.ts +++ b/packages/auth/src/__tests__/auth.test.ts @@ -38,8 +38,14 @@ async function main() { const email = `auth-test-${suffix}@test.local`; const password = "S3nhaForteDeTeste!123"; + const plan = await prisma.plan.findUniqueOrThrow({ where: { key: "trial" } }); const tenant = await prisma.tenant.create({ - data: { code: `auth-test-${suffix}`, slug: `auth-test-${suffix}`, legalName: "Auth Test LTDA" }, + data: { + code: `auth-test-${suffix}`, + slug: `auth-test-${suffix}`, + legalName: "Auth Test LTDA", + planId: plan.id, + }, }); const user = await prisma.user.create({ data: { email, passwordHash: await hashPassword(password), name: "Auth Test User" }, diff --git a/packages/database/prisma/migrations/20260828151027_plans_campaigns_leads/migration.sql b/packages/database/prisma/migrations/20260828151027_plans_campaigns_leads/migration.sql new file mode 100644 index 0000000..35f46ae --- /dev/null +++ b/packages/database/prisma/migrations/20260828151027_plans_campaigns_leads/migration.sql @@ -0,0 +1,188 @@ +-- CreateEnum +CREATE TYPE "campaign_status" AS ENUM ('DRAFT', 'READY', 'WAITING_SCHEDULE', 'RUNNING', 'PAUSED', 'DRAINING', 'STOPPED', 'COMPLETED', 'ERROR'); + +-- CreateEnum +CREATE TYPE "lead_status" AS ENUM ('NEW', 'READY', 'RESERVED', 'ORIGINATING', 'RINGING', 'ANSWERED', 'QUEUEING', 'CONNECTED_AGENT', 'BUSY', 'NO_ANSWER', 'FAILED', 'VOICEMAIL', 'CALLBACK', 'COMPLETED', 'DO_NOT_CALL', 'MAX_ATTEMPTS'); + +-- CreateTable: plans (catalogo compartilhado entre tenants, nao e' tenant-scoped, sem RLS) +CREATE TABLE "plans" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "max_extensions" INTEGER, + "max_agents" INTEGER, + "max_trunks" INTEGER, + "max_queues" INTEGER, + "max_campaigns" INTEGER, + "max_cps" INTEGER, + "max_concurrent_calls" INTEGER, + "max_daily_calls" INTEGER, + "max_monthly_calls" INTEGER, + "max_recording_storage_gb" INTEGER, + "recording_enabled" BOOLEAN NOT NULL DEFAULT true, + "ai_enabled" BOOLEAN NOT NULL DEFAULT false, + "ai_transcription_enabled" BOOLEAN NOT NULL DEFAULT false, + "ai_analysis_enabled" BOOLEAN NOT NULL DEFAULT false, + "api_access_enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "plans_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "plans_key_key" ON "plans"("key"); + +-- Seed: todo tenant precisa de um plano (agente.md secao 56-57: nunca +-- "if plan == PRO" espalhado, so' leitura de limites; null num campo = +-- sem limite). "trial" e' o plano padrao pra tenants existentes/novos ate' +-- a fase Billing introduzir um fluxo de escolha de plano de verdade. +INSERT INTO "plans" ( + "id", "key", "name", + "max_extensions", "max_agents", "max_trunks", "max_queues", "max_campaigns", + "max_cps", "max_concurrent_calls", "max_daily_calls", "max_monthly_calls", + "max_recording_storage_gb", + "recording_enabled", "ai_enabled", "ai_transcription_enabled", "ai_analysis_enabled", "api_access_enabled", + "updated_at" +) VALUES ( + gen_random_uuid(), 'trial', 'Trial', + 5, 5, 2, 3, 2, + 3, 5, 200, 4000, + 1, + true, false, false, false, true, + CURRENT_TIMESTAMP +); + +-- AlterTable: adiciona plan_id nullable primeiro (ha' tenants existentes), +-- faz o backfill, so' depois torna NOT NULL + FK. +ALTER TABLE "tenants" ADD COLUMN "plan_id" UUID; + +UPDATE "tenants" SET "plan_id" = (SELECT "id" FROM "plans" WHERE "key" = 'trial'); + +ALTER TABLE "tenants" ALTER COLUMN "plan_id" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "campaigns" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "queue_id" UUID NOT NULL, + "trunk_id" UUID NOT NULL, + "caller_id_name" TEXT, + "caller_id_number" TEXT, + "timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo', + "start_date" DATE, + "end_date" DATE, + "days_of_week" INTEGER[], + "start_time" TEXT, + "end_time" TEXT, + "max_cps" INTEGER, + "max_concurrent_calls" INTEGER, + "pacing_initial" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "pacing_min" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "pacing_max" DOUBLE PRECISION NOT NULL DEFAULT 3.0, + "target_abandon_rate" DOUBLE PRECISION NOT NULL DEFAULT 0.03, + "ring_timeout" INTEGER NOT NULL DEFAULT 30, + "max_attempts" INTEGER NOT NULL DEFAULT 3, + "recording_enabled" BOOLEAN NOT NULL DEFAULT false, + "avmd_enabled" BOOLEAN NOT NULL DEFAULT false, + "ai_transcription_enabled" BOOLEAN NOT NULL DEFAULT false, + "ai_analysis_enabled" BOOLEAN NOT NULL DEFAULT false, + "status" "campaign_status" NOT NULL DEFAULT 'DRAFT', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "campaigns_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "leads" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "campaign_id" UUID NOT NULL, + "name" TEXT, + "phone_original" TEXT NOT NULL, + "phone_normalized" TEXT NOT NULL, + "status" "lead_status" NOT NULL DEFAULT 'NEW', + "attempt_count" INTEGER NOT NULL DEFAULT 0, + "last_attempt_at" TIMESTAMP(3), + "next_attempt_at" TIMESTAMP(3), + "last_result" TEXT, + "custom_fields" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "leads_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "suppression_entries" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "phone_normalized" TEXT NOT NULL, + "reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "suppression_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "campaigns_tenant_id_idx" ON "campaigns"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "campaigns_tenant_id_name_key" ON "campaigns"("tenant_id", "name"); + +-- CreateIndex +CREATE INDEX "leads_tenant_id_idx" ON "leads"("tenant_id"); + +-- CreateIndex +CREATE INDEX "leads_campaign_id_status_next_attempt_at_idx" ON "leads"("campaign_id", "status", "next_attempt_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "leads_campaign_id_phone_normalized_key" ON "leads"("campaign_id", "phone_normalized"); + +-- CreateIndex +CREATE INDEX "suppression_entries_tenant_id_idx" ON "suppression_entries"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "suppression_entries_tenant_id_phone_normalized_key" ON "suppression_entries"("tenant_id", "phone_normalized"); + +-- AddForeignKey +ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_queue_id_fkey" FOREIGN KEY ("queue_id") REFERENCES "queues"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_trunk_id_fkey" FOREIGN KEY ("trunk_id") REFERENCES "trunks"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "leads" ADD CONSTRAINT "leads_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "leads" ADD CONSTRAINT "leads_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "suppression_entries" ADD CONSTRAINT "suppression_entries_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Tabelas de negocio tenant-scoped: RLS obrigatorio em todas (ver docs/TENANT_ISOLATION.md). +-- "plans" e' um catalogo compartilhado entre tenants (nao tem tenant_id), sem RLS. +ALTER TABLE "campaigns" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "campaigns" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "campaigns" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); + +ALTER TABLE "leads" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "leads" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "leads" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); + +ALTER TABLE "suppression_entries" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "suppression_entries" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "suppression_entries" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index bae3c5c..ab0364c 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -28,10 +28,12 @@ model Tenant { locale String @default("pt-BR") billingCurrency String @default("BRL") @map("billing_currency") telephonyDomain String? @map("telephony_domain") + planId String @map("plan_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") + plan Plan @relation(fields: [planId], references: [id]) memberships TenantMembership[] userRoles UserRole[] extensions Extension[] @@ -45,10 +47,47 @@ model Tenant { agentSessions AgentSession[] agentStateEvents AgentStateEvent[] agentPauseEvents AgentPauseEvent[] + campaigns Campaign[] + leads Lead[] + suppressionEntries SuppressionEntry[] @@map("tenants") } +// Entitlements por plano (agente.md secao 56) — "criar sistema genérico... +// não espalhar `if plan == PRO` pelo código". Todo tenant tem exatamente um +// Plan (nunca null: em vez de checar "tem plano?", o código só lê o limite +// e trata null-no-campo como "sem limite", agente.md secao 57-61). +model Plan { + id String @id @default(uuid()) @db.Uuid + key String @unique + name String + + maxExtensions Int? @map("max_extensions") + maxAgents Int? @map("max_agents") + maxTrunks Int? @map("max_trunks") + maxQueues Int? @map("max_queues") + maxCampaigns Int? @map("max_campaigns") + maxCps Int? @map("max_cps") + maxConcurrentCalls Int? @map("max_concurrent_calls") + maxDailyCalls Int? @map("max_daily_calls") + maxMonthlyCalls Int? @map("max_monthly_calls") + maxRecordingStorageGb Int? @map("max_recording_storage_gb") + + recordingEnabled Boolean @default(true) @map("recording_enabled") + aiEnabled Boolean @default(false) @map("ai_enabled") + aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled") + aiAnalysisEnabled Boolean @default(false) @map("ai_analysis_enabled") + apiAccessEnabled Boolean @default(true) @map("api_access_enabled") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tenants Tenant[] + + @@map("plans") +} + enum UserStatus { ACTIVE DISABLED @@ -316,7 +355,8 @@ model Trunk { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") - tenant Tenant @relation(fields: [tenantId], references: [id]) + tenant Tenant @relation(fields: [tenantId], references: [id]) + campaigns Campaign[] @@unique([tenantId, name]) @@index([tenantId]) @@ -440,8 +480,9 @@ model Queue { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") - tenant Tenant @relation(fields: [tenantId], references: [id]) - tiers Tier[] + tenant Tenant @relation(fields: [tenantId], references: [id]) + tiers Tier[] + campaigns Campaign[] @@unique([tenantId, name]) @@index([tenantId]) @@ -602,3 +643,163 @@ model AgentPauseEvent { @@index([tenantId, agentId]) @@map("agent_pause_events") } + +enum CampaignStatus { + DRAFT + READY + WAITING_SCHEDULE + RUNNING + PAUSED + DRAINING + STOPPED + COMPLETED + ERROR + + @@map("campaign_status") +} + +// Tabela tenant-scoped protegida por RLS (agente.md secao 63-66). Só o +// modelo/CRUD e as transições de status desta fase — o motor que de fato +// origina chamadas (PredictiveDialerEngine, secao 72-73) é uma fase à parte. +model Campaign { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + name String + description String? + + queueId String @map("queue_id") @db.Uuid + trunkId String @map("trunk_id") @db.Uuid + + callerIdName String? @map("caller_id_name") + callerIdNumber String? @map("caller_id_number") + + timezone String @default("America/Sao_Paulo") + + startDate DateTime? @map("start_date") @db.Date + endDate DateTime? @map("end_date") @db.Date + + // ISO-8601 (1=segunda ... 7=domingo). + daysOfWeek Int[] @map("days_of_week") + + // "HH:MM", interpretado na timezone da campanha — sem tipo TIME nativo + // pra manter o schema simples nesta fase (o scheduler de verdade é da + // fase Predictive Engine). + startTime String? @map("start_time") + endTime String? @map("end_time") + + maxCps Int? @map("max_cps") + maxConcurrentCalls Int? @map("max_concurrent_calls") + + pacingInitial Float @default(1.0) @map("pacing_initial") + pacingMin Float @default(1.0) @map("pacing_min") + pacingMax Float @default(3.0) @map("pacing_max") + + targetAbandonRate Float @default(0.03) @map("target_abandon_rate") + + ringTimeout Int @default(30) @map("ring_timeout") + maxAttempts Int @default(3) @map("max_attempts") + + recordingEnabled Boolean @default(false) @map("recording_enabled") + avmdEnabled Boolean @default(false) @map("avmd_enabled") + aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled") + aiAnalysisEnabled Boolean @default(false) @map("ai_analysis_enabled") + + status CampaignStatus @default(DRAFT) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + queue Queue @relation(fields: [queueId], references: [id]) + trunk Trunk @relation(fields: [trunkId], references: [id]) + leads Lead[] + + @@unique([tenantId, name]) + @@index([tenantId]) + @@map("campaigns") +} + +enum LeadStatus { + NEW + READY + RESERVED + ORIGINATING + RINGING + ANSWERED + QUEUEING + CONNECTED_AGENT + + BUSY + NO_ANSWER + FAILED + + VOICEMAIL + + CALLBACK + + COMPLETED + + DO_NOT_CALL + + MAX_ATTEMPTS + + @@map("lead_status") +} + +// Tabela tenant-scoped protegida por RLS (agente.md secao 67-68). A reserva +// atômica (READY -> RESERVED, `FOR UPDATE SKIP LOCKED`, secao 78) é +// implementada na fase CPS Limiter/Predictive Engine — aqui só o modelo e +// o CRUD/import. +model Lead { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + campaignId String @map("campaign_id") @db.Uuid + + name String? + + phoneOriginal String @map("phone_original") + phoneNormalized String @map("phone_normalized") + + status LeadStatus @default(NEW) + + attemptCount Int @default(0) @map("attempt_count") + + lastAttemptAt DateTime? @map("last_attempt_at") + nextAttemptAt DateTime? @map("next_attempt_at") + + lastResult String? @map("last_result") + + customFields Json @default("{}") @map("custom_fields") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + campaign Campaign @relation(fields: [campaignId], references: [id]) + + @@unique([campaignId, phoneNormalized]) + @@index([tenantId]) + @@index([campaignId, status, nextAttemptAt]) + @@map("leads") +} + +// Lista de bloqueio (agente.md secao 71) — tenant-scoped, checada antes de +// qualquer originate (checagem em si é da fase Predictive Engine/CPS +// Limiter, aqui só o modelo/CRUD). +model SuppressionEntry { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + phoneNormalized String @map("phone_normalized") + reason String? + + createdAt DateTime @default(now()) @map("created_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + + @@unique([tenantId, phoneNormalized]) + @@index([tenantId]) + @@map("suppression_entries") +} diff --git a/packages/database/src/__tests__/tenant-isolation.test.ts b/packages/database/src/__tests__/tenant-isolation.test.ts index ea7b7d1..29dfb28 100644 --- a/packages/database/src/__tests__/tenant-isolation.test.ts +++ b/packages/database/src/__tests__/tenant-isolation.test.ts @@ -21,11 +21,13 @@ async function main() { const prisma = getPrismaClient(); const suffix = randomUUID().slice(0, 8); + const plan = await prisma.plan.findUniqueOrThrow({ where: { key: "trial" } }); + const tenantA = await prisma.tenant.create({ - data: { code: `test-a-${suffix}`, slug: `test-a-${suffix}`, legalName: "Tenant A LTDA" }, + data: { code: `test-a-${suffix}`, slug: `test-a-${suffix}`, legalName: "Tenant A LTDA", planId: plan.id }, }); const tenantB = await prisma.tenant.create({ - data: { code: `test-b-${suffix}`, slug: `test-b-${suffix}`, legalName: "Tenant B LTDA" }, + data: { code: `test-b-${suffix}`, slug: `test-b-${suffix}`, legalName: "Tenant B LTDA", planId: plan.id }, }); const userA = await prisma.user.create({ diff --git a/packages/entitlements/package.json b/packages/entitlements/package.json new file mode 100644 index 0000000..6704217 --- /dev/null +++ b/packages/entitlements/package.json @@ -0,0 +1,17 @@ +{ + "name": "@b2bcall/entitlements", + "version": "0.0.1", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@b2bcall/database": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.7.0" + } +} diff --git a/packages/entitlements/src/index.ts b/packages/entitlements/src/index.ts new file mode 100644 index 0000000..5560a94 --- /dev/null +++ b/packages/entitlements/src/index.ts @@ -0,0 +1,74 @@ +import { getPrismaClient, type Plan } from "@b2bcall/database"; + +/** + * Entitlements por plano (agente.md secao 56-61): "criar sistema genérico... + * não espalhar `if plan == PRO` pelo código". Todo tenant tem exatamente um + * Plan (nunca null) — um campo de limite null significa "sem limite", nunca + * "sem plano". + */ +export class QuotaExceededError extends Error { + constructor( + public readonly quotaKey: QuotaKey, + public readonly limit: number, + ) { + super(`Quota excedida: ${quotaKey} (limite do plano: ${limit})`); + this.name = "QuotaExceededError"; + } +} + +export class FeatureNotEnabledError extends Error { + constructor(public readonly featureKey: FeatureKey) { + super(`Recurso nao habilitado no plano: ${featureKey}`); + this.name = "FeatureNotEnabledError"; + } +} + +/** Campos de limite (contagem) do Plan — null = sem limite. */ +export type QuotaKey = + | "maxExtensions" + | "maxAgents" + | "maxTrunks" + | "maxQueues" + | "maxCampaigns" + | "maxConcurrentCalls" + | "maxDailyCalls" + | "maxMonthlyCalls"; + +/** Campos booleanos de feature flag do Plan. */ +export type FeatureKey = + | "recordingEnabled" + | "aiEnabled" + | "aiTranscriptionEnabled" + | "aiAnalysisEnabled" + | "apiAccessEnabled"; + +export async function getPlanForTenant(tenantId: string): Promise { + const prisma = getPrismaClient(); + const tenant = await prisma.tenant.findUniqueOrThrow({ + where: { id: tenantId }, + include: { plan: true }, + }); + return tenant.plan; +} + +/** + * Lança QuotaExceededError se `currentCount` já atingiu o limite do plano + * pra `key`. Chamar ANTES de criar a linha (currentCount = contagem atual, + * sem contar a nova) — nunca depois, pra não criar e ter que desfazer. + */ +export async function assertQuota(tenantId: string, key: QuotaKey, currentCount: number): Promise { + const plan = await getPlanForTenant(tenantId); + const limit = plan[key]; + if (limit === null || limit === undefined) return; + if (currentCount >= limit) { + throw new QuotaExceededError(key, limit); + } +} + +/** Lança FeatureNotEnabledError se o plano não habilita `key`. */ +export async function assertFeatureEnabled(tenantId: string, key: FeatureKey): Promise { + const plan = await getPlanForTenant(tenantId); + if (!plan[key]) { + throw new FeatureNotEnabledError(key); + } +} diff --git a/packages/entitlements/tsconfig.json b/packages/entitlements/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/entitlements/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index df9aa7d..be3b988 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "@b2bcall/types"; export * from "./logger"; export * from "./crypto"; +export * from "./phone"; diff --git a/packages/shared/src/phone.ts b/packages/shared/src/phone.ts new file mode 100644 index 0000000..01379b3 --- /dev/null +++ b/packages/shared/src/phone.ts @@ -0,0 +1,39 @@ +/** + * Normalização de telefone (agente.md secao 70): serviço dedicado, nunca + * regex espalhado pelo domínio. Preparado inicialmente pro Brasil, mas a + * assinatura já é por país — trocar `country` é o único ponto de extensão + * quando outros países entrarem (arquitetura pronta pra E.164 completo). + */ +export type SupportedCountry = "BR"; + +const NORMALIZERS: Record string | null> = { + BR: normalizeBrazilianDigits, +}; + +function normalizeBrazilianDigits(digits: string): string | null { + // Aceita com ou sem "55" na frente, com ou sem o "9" do celular — DDD (2 + // dígitos) + número (8 ou 9 dígitos) é a faixa válida. + let local = digits; + if (local.startsWith("55") && local.length > 11) { + local = local.slice(2); + } + if (local.length !== 10 && local.length !== 11) { + return null; + } + const ddd = local.slice(0, 2); + if (Number(ddd) < 11 || Number(ddd) > 99) { + return null; + } + return `+55${local}`; +} + +/** + * Retorna o telefone em E.164 (`+`) ou `null` se não for + * um número válido pro país informado. Nunca lança — números inválidos são + * um resultado esperado (ex.: importação de CSV, agente.md secao 69). + */ +export function normalizePhone(raw: string, country: SupportedCountry = "BR"): string | null { + const digits = raw.replace(/\D/g, ""); + if (!digits) return null; + return NORMALIZERS[country](digits); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0a5274..585f8cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@b2bcall/database': specifier: workspace:* version: link:../../packages/database + '@b2bcall/entitlements': + specifier: workspace:* + version: link:../../packages/entitlements '@b2bcall/shared': specifier: workspace:* version: link:../../packages/shared @@ -185,6 +188,19 @@ importers: specifier: ^4.23.12 version: 4.23.12 + packages/entitlements: + dependencies: + '@b2bcall/database': + specifier: workspace:* + version: link:../database + devDependencies: + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/shared: dependencies: '@b2bcall/types':