Files
Matheus 7b62ad3d82 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
2026-08-28 12:26:47 -03:00

189 lines
7.5 KiB
SQL

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