feat(entitlements,campaigns): plans/quotas + campanhas, leads, lista de bloqueio

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 12:26:47 -03:00
parent f051fe3162
commit 7b62ad3d82
35 changed files with 1674 additions and 13 deletions

53
TODO.md
View File

@@ -272,8 +272,57 @@
- [ ] TME/TMA/Service Level/Abandon Rate — dependem de CDR (fase futura) - [ ] TME/TMA/Service Level/Abandon Rate — dependem de CDR (fase futura)
- [ ] Snapshot/reconciliação ao reconectar o WebSocket - [ ] Snapshot/reconciliação ao reconectar o WebSocket
## PHASE 14+ver `agente.md` seções 56 em diante (Predictive Dialer, ## PHASE 14 — Plans/Entitlements (agente.md secao 56-62)
Recordings, AI, Billing, Frontend, Reports, Security, Tests) - [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)
--- ---

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@b2bcall/auth": "workspace:*", "@b2bcall/auth": "workspace:*",
"@b2bcall/database": "workspace:*", "@b2bcall/database": "workspace:*",
"@b2bcall/entitlements": "workspace:*",
"@b2bcall/shared": "workspace:*", "@b2bcall/shared": "workspace:*",
"@b2bcall/telephony": "workspace:*", "@b2bcall/telephony": "workspace:*",
"@fastify/cors": "11.3.0", "@fastify/cors": "11.3.0",

View File

@@ -12,6 +12,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
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 { 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";
@@ -30,6 +31,11 @@ export class AgentsController {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; 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) => const agent = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.create({ tx.agent.create({
data: { data: {

View File

@@ -8,6 +8,9 @@ import { QueuesModule } from "./queues/queues.module";
import { AgentsModule } from "./agents/agents.module"; import { AgentsModule } from "./agents/agents.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 { LeadsModule } from "./leads/leads.module";
import { SuppressionModule } from "./suppression/suppression.module";
@Module({ @Module({
imports: [ imports: [
@@ -20,6 +23,9 @@ import { RealtimeModule } from "./realtime/realtime.module";
AgentsModule, AgentsModule,
PauseReasonsModule, PauseReasonsModule,
RealtimeModule, RealtimeModule,
CampaignsModule,
LeadsModule,
SuppressionModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -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<string, CampaignStatus[]> = {
start: ["DRAFT", "READY", "PAUSED", "WAITING_SCHEDULE"],
pause: ["RUNNING"],
drain: ["RUNNING", "PAUSED"],
stop: ["DRAFT", "READY", "WAITING_SCHEDULE", "RUNNING", "PAUSED", "DRAINING"],
};
const TARGET_STATUS: Record<string, CampaignStatus> = {
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,
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { CampaignsController } from "./campaigns.controller";
@Module({
controllers: [CampaignsController],
})
export class CampaignsModule {}

View File

@@ -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;
}

View File

@@ -11,6 +11,7 @@ import {
InvalidRefreshTokenError, InvalidRefreshTokenError,
NotATenantMemberError, NotATenantMemberError,
} from "@b2bcall/auth"; } from "@b2bcall/auth";
import { QuotaExceededError, FeatureNotEnabledError } from "@b2bcall/entitlements";
/** /**
* Traduz erros de domínio de packages/auth para HTTP, sem nunca vazar stack * Traduz erros de domínio de packages/auth para HTTP, sem nunca vazar stack
@@ -43,6 +44,11 @@ export class DomainExceptionFilter implements ExceptionFilter {
return; return;
} }
if (exception instanceof QuotaExceededError || exception instanceof FeatureNotEnabledError) {
reply.status(HttpStatus.FORBIDDEN).send({ message: exception.message });
return;
}
console.error(exception); console.error(exception);
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: "Erro interno" }); reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: "Erro interno" });
} }

View File

@@ -14,6 +14,7 @@ import {
import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared"; import { generateStrongPassword, encryptSecret } from "@b2bcall/shared";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
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";
@@ -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 plainPassword = generateStrongPassword();
const extension = await withTenantContext(prisma, tenantId, (tx) => const extension = await withTenantContext(prisma, tenantId, (tx) =>

View File

@@ -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<ImportSummary> {
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;
}

View File

@@ -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<string, unknown>;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { LeadsController } from "./leads.controller";
@Module({
controllers: [LeadsController],
})
export class LeadsModule {}

View File

@@ -12,6 +12,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
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 { 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";
@@ -34,6 +35,11 @@ export class QueuesController {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; 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) => const queue = await withTenantContext(prisma, tenantId, (tx) =>
tx.queue.create({ tx.queue.create({
data: { data: {

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { SuppressionController } from "./suppression.controller";
@Module({
controllers: [SuppressionController],
})
export class SuppressionModule {}

View File

@@ -13,6 +13,7 @@ import {
import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { encryptSecret } from "@b2bcall/shared"; import { encryptSecret } from "@b2bcall/shared";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
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";
@@ -72,6 +73,11 @@ export class TrunksController {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; 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) => const trunk = await withTenantContext(prisma, tenantId, (tx) =>
tx.trunk.create({ tx.trunk.create({
data: { data: {

View File

@@ -105,7 +105,8 @@ FreeSWITCH (`agent list`).
(docs/REALTIME.md); persistir isso em `Agent.state` fica pra quando o (docs/REALTIME.md); persistir isso em `Agent.state` fica pra quando o
Predictive Engine/CDR precisarem consultar esse histórico. Predictive Engine/CDR precisarem consultar esse histórico.
- Tela do agente (secao 49) — fase Frontend. - 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 - `PauseReason.maxDuration` existe no modelo mas não é aplicado
automaticamente ainda (ninguém força o fim da pausa ao expirar). automaticamente ainda (ninguém força o fim da pausa ao expirar).
- **Achado ao testar a fase Realtime Monitoring, sistêmico (não é só - **Achado ao testar a fase Realtime Monitoring, sistêmico (não é só

122
docs/CAMPAIGNS.md Normal file
View File

@@ -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`.

94
docs/ENTITLEMENTS.md Normal file
View File

@@ -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<void>
// 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<void>
// 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).

View File

@@ -115,7 +115,7 @@ configurado) continua funcionando normalmente.
## O que falta ## O que falta
- Quota de ramais (`max_extensions` do plano, secao 57) — depende da fase - ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver
Plans/Entitlements, que ainda não existe. docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar.
- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante. - Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
- Multi-domínio real por tenant (ver acima). - Multi-domínio real por tenant (ver acima).

View File

@@ -79,4 +79,5 @@ DELETE /queues/:id
- `tier-rule-wait-multiply-level` e `tier-rule-no-agent-no-wait` (vistos no - `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 `queue list` da config vanilla) não são expostos como campos próprios
ainda — ficaram de fora do escopo desta fase. 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.

View File

@@ -92,7 +92,8 @@ Criei um trunk de teste apontando pra um host inexistente
## O que falta ## 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 - `GET /trunks` não mostra `sofia status gateway` ao vivo, só o último
status conhecido no banco — resolvido na fase Realtime Monitoring via status conhecido no banco — resolvido na fase Realtime Monitoring via
WebSocket (ver docs/REALTIME.md). WebSocket (ver docs/REALTIME.md).

View File

@@ -38,8 +38,14 @@ async function main() {
const email = `auth-test-${suffix}@test.local`; const email = `auth-test-${suffix}@test.local`;
const password = "S3nhaForteDeTeste!123"; const password = "S3nhaForteDeTeste!123";
const plan = await prisma.plan.findUniqueOrThrow({ where: { key: "trial" } });
const tenant = await prisma.tenant.create({ 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({ const user = await prisma.user.create({
data: { email, passwordHash: await hashPassword(password), name: "Auth Test User" }, data: { email, passwordHash: await hashPassword(password), name: "Auth Test User" },

View File

@@ -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);

View File

@@ -28,10 +28,12 @@ model Tenant {
locale String @default("pt-BR") locale String @default("pt-BR")
billingCurrency String @default("BRL") @map("billing_currency") billingCurrency String @default("BRL") @map("billing_currency")
telephonyDomain String? @map("telephony_domain") telephonyDomain String? @map("telephony_domain")
planId String @map("plan_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
plan Plan @relation(fields: [planId], references: [id])
memberships TenantMembership[] memberships TenantMembership[]
userRoles UserRole[] userRoles UserRole[]
extensions Extension[] extensions Extension[]
@@ -45,10 +47,47 @@ model Tenant {
agentSessions AgentSession[] agentSessions AgentSession[]
agentStateEvents AgentStateEvent[] agentStateEvents AgentStateEvent[]
agentPauseEvents AgentPauseEvent[] agentPauseEvents AgentPauseEvent[]
campaigns Campaign[]
leads Lead[]
suppressionEntries SuppressionEntry[]
@@map("tenants") @@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 { enum UserStatus {
ACTIVE ACTIVE
DISABLED DISABLED
@@ -316,7 +355,8 @@ model Trunk {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_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]) @@unique([tenantId, name])
@@index([tenantId]) @@index([tenantId])
@@ -440,8 +480,9 @@ model Queue {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
tiers Tier[] tiers Tier[]
campaigns Campaign[]
@@unique([tenantId, name]) @@unique([tenantId, name])
@@index([tenantId]) @@index([tenantId])
@@ -602,3 +643,163 @@ model AgentPauseEvent {
@@index([tenantId, agentId]) @@index([tenantId, agentId])
@@map("agent_pause_events") @@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")
}

View File

@@ -21,11 +21,13 @@ async function main() {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const suffix = randomUUID().slice(0, 8); const suffix = randomUUID().slice(0, 8);
const plan = await prisma.plan.findUniqueOrThrow({ where: { key: "trial" } });
const tenantA = await prisma.tenant.create({ 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({ 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({ const userA = await prisma.user.create({

View File

@@ -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"
}
}

View File

@@ -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<Plan> {
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<void> {
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<void> {
const plan = await getPlanForTenant(tenantId);
if (!plan[key]) {
throw new FeatureNotEnabledError(key);
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -1,3 +1,4 @@
export * from "@b2bcall/types"; export * from "@b2bcall/types";
export * from "./logger"; export * from "./logger";
export * from "./crypto"; export * from "./crypto";
export * from "./phone";

View File

@@ -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<SupportedCountry, (digits: string) => 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 (`+<country><number>`) 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);
}

16
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@b2bcall/database': '@b2bcall/database':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/database version: link:../../packages/database
'@b2bcall/entitlements':
specifier: workspace:*
version: link:../../packages/entitlements
'@b2bcall/shared': '@b2bcall/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
@@ -185,6 +188,19 @@ importers:
specifier: ^4.23.12 specifier: ^4.23.12
version: 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: packages/shared:
dependencies: dependencies:
'@b2bcall/types': '@b2bcall/types':