Plano Base — Sistema de Disparo em Massa (ChatSkills Broadcast)

Estimativa total: 16 horas · Serviço independente que recebe uma requisição via API e dispara uma mensagem WhatsApp para todos os membros de um grupo, validando o owner da requisição e sua permissão sobre o grupo.


1. Contexto e Objetivo

O que existe hoje (chatskills-api)

O serviço atual (C:\Users\yuri_\projects\chatskills\chatskills-api) é um chatbot reativo: recebe webhooks do WhatsApp (Meta Cloud API) e responde a usuários que iniciaram a conversa.

Componentes reaproveitáveis como referência (não dependência direta):

Componente Arquivo O que reaproveitamos
Envio WhatsApp app/services/whatsapp/sender.py Padrão de POST para graph.facebook.com/v19.0/{phone_number_id}/messages com Bearer token
Auth/owner/grupo app/services/auth/user_auth.py Modelo de dados: users, groups, group_members, teams, team_members(role=LEADER)
Config app/core/config.py Pydantic Settings + .env (token Meta, DSN Postgres, Redis)
Migrations app/core/migrations.py + app/migrations/*.sql Migrations SQL idempotentes rodadas no startup
Infra docker-compose.yml, Dockerfile, captain-definition FastAPI + Postgres + Redis, deploy CapRover

O que vamos construir (chatskills-broadcast)

Serviço proativo e separado, com seu próprio app FastAPI, banco/tabelas próprias, fila e worker. Fluxo macro:

[Sistema externo: "novo conteúdo publicado p/ grupo X"]
        │  POST /api/v1/broadcasts  (API key + owner)
        ▼
  [API ingestão] ──► valida owner + permissão sobre o grupo
        │
        ▼
  [Resolve destinatários] ──► membros ACTIVE do grupo → telefones
        │
        ▼
  [Enfileira na Redis] ──► broadcast_recipients (status=pending)
        │
        ▼
  [Worker assíncrono] ──► envia Template Message (Meta API), com pacing/retry
        │
        ▼
  [Webhook de status] ──► sent / delivered / read / failed por destinatário

Restrição crítica de domínio (decisão de arquitetura)

Mensagens iniciadas pela empresa fora da janela de 24h de atendimento só podem ser enviadas como Template Messages (HSM) pré-aprovados pela Meta. Disparo de "nova atualização" é sempre business-initiated → o sistema envia templates aprovados, não texto livre. Implicações que o plano cobre:


2. Arquitetura do Serviço

chatskills-broadcast/
├── app/
│   ├── main.py                      # FastAPI app + lifespan (DB/Redis/migrations)
│   ├── core/
│   │   ├── config.py                # Settings (token Meta, DSN, Redis, API keys)
│   │   ├── database.py              # asyncpg pool (PROD read + broadcast write)
│   │   ├── redis.py                 # cliente Redis (fila + rate-limit)
│   │   └── migrations.py            # runner idempotente
│   ├── migrations/
│   │   └── 001_broadcast.sql        # tabelas do serviço
│   ├── models/
│   │   └── schemas.py               # Pydantic: BroadcastIn, RecipientOut, etc.
│   ├── routers/
│   │   ├── broadcasts.py            # POST/GET /api/v1/broadcasts
│   │   └── status_webhook.py        # POST /webhooks/whatsapp-status
│   ├── services/
│   │   ├── auth/api_key.py          # autentica owner via API key
│   │   ├── permissions/resolver.py  # owner tem permissão sobre o grupo?
│   │   ├── audience/resolver.py     # grupo → telefones (membros ACTIVE)
│   │   ├── whatsapp/template_sender.py  # envio de Template Message + parse de erro
│   │   ├── queue/dispatcher.py      # enfileira + worker de consumo
│   │   └── broadcast/manager.py     # CRUD de campanhas e destinatários
├── tests/
├── docker-compose.yml
├── Dockerfile
├── captain-definition
├── requirements.txt
├── .env.example
└── docs/PLANO_DISPARO_MASSA.md

Decisões técnicas


3. Modelo de Dados (migration 001_broadcast.sql)

-- Campanha de disparo
CREATE TABLE IF NOT EXISTS public.broadcasts (
    id              varchar(50) PRIMARY KEY,
    public_id       varchar(8)  NOT NULL UNIQUE,
    owner_user_id   varchar(50) NOT NULL,        -- quem disparou (resolvido da API key)
    group_id        varchar(50) NOT NULL,        -- grupo alvo
    template_name   varchar(120) NOT NULL,       -- HSM aprovado na Meta
    language        varchar(10)  NOT NULL DEFAULT 'pt_BR',
    variables       jsonb,                       -- parâmetros do template
    status          varchar(20)  NOT NULL DEFAULT 'queued', -- queued|sending|completed|failed|canceled
    total_count     int NOT NULL DEFAULT 0,
    sent_count      int NOT NULL DEFAULT 0,
    delivered_count int NOT NULL DEFAULT 0,
    failed_count    int NOT NULL DEFAULT 0,
    idempotency_key varchar(80),
    created_at      timestamp NOT NULL DEFAULT NOW(),
    updated_at      timestamp NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS broadcasts_idem_idx
    ON public.broadcasts(owner_user_id, idempotency_key)
    WHERE idempotency_key IS NOT NULL;

-- 1 linha por destinatário, com status individual
CREATE TABLE IF NOT EXISTS public.broadcast_recipients (
    id            varchar(50) PRIMARY KEY,
    broadcast_id  varchar(50) NOT NULL REFERENCES public.broadcasts(id) ON DELETE CASCADE,
    user_id       varchar(50),
    phone         varchar(20) NOT NULL,
    status        varchar(20) NOT NULL DEFAULT 'pending', -- pending|sent|delivered|read|failed
    wa_message_id varchar(80),                  -- id retornado pela Meta (correlaciona webhook)
    error_code    varchar(40),
    error_detail  text,
    attempts      int NOT NULL DEFAULT 0,
    updated_at    timestamp NOT NULL DEFAULT NOW(),
    UNIQUE (broadcast_id, phone)
);
CREATE INDEX IF NOT EXISTS broadcast_recipients_bid_idx ON public.broadcast_recipients(broadcast_id, status);
CREATE INDEX IF NOT EXISTS broadcast_recipients_wamid_idx ON public.broadcast_recipients(wa_message_id);

-- API keys + escopo de permissão do owner
CREATE TABLE IF NOT EXISTS public.broadcast_api_keys (
    id          varchar(50) PRIMARY KEY,
    owner_user_id varchar(50) NOT NULL,
    key_hash    varchar(80) NOT NULL UNIQUE,     -- hash da chave (nunca em texto)
    scopes      jsonb NOT NULL DEFAULT '["broadcast:send"]',
    active      boolean NOT NULL DEFAULT true,
    created_at  timestamp NOT NULL DEFAULT NOW()
);

Modelo de permissão: o owner pode disparar para um grupo se for LEADER ACTIVE do team dono do grupo (ou regra de ownership equivalente em groups/group_members). A query reaproveita a lógica de user_auth.py (team_members.role='LEADER' AND status='ACTIVE').


4. Contrato da API

POST /api/v1/broadcasts

Headers: Authorization: Bearer <api_key>, Idempotency-Key: <uuid> (opcional)

// Request
{
  "group_id": "grp_123",
  "template_name": "novo_conteudo_v1",
  "language": "pt_BR",
  "variables": { "1": "Curso de Python", "2": "https://app/curso/123" }
}
// Response 202 Accepted
{ "broadcast_id": "bc_...", "public_id": "8x4kT", "status": "queued", "total_recipients": 42 }

Validações: API key válida → resolve owner_user_id → permissão sobre group_id → resolve audiência → cria campanha + recipients (pending) → enfileira → responde 202.

GET /api/v1/broadcasts/{id} — progresso/contadores

GET /api/v1/broadcasts/{id}/recipients?status=failed — auditoria por destinatário

POST /webhooks/whatsapp-status — recebe status da Meta (sent/delivered/read/failed)


5. Cronograma de 16 Horas

# Fase Entregável Horas
0 Scaffold & infra Estrutura de pastas, config.py, database.py, redis.py, main.py com lifespan, Dockerfile, docker-compose.yml, captain-definition, .env.example, requirements.txt. App sobe em /health. 1.5
1 Modelo de dados 001_broadcast.sql + runner de migrations idempotente. Tabelas criadas no startup. 2.0
2 Auth do owner (API key) services/auth/api_key.py: gera/valida key (hash), resolve owner_user_id. Dependency FastAPI que injeta o owner. 2.0
3 Permissão sobre o grupo services/permissions/resolver.py: query read-only (LEADER ACTIVE / ownership). Nega 403 quando owner não tem direito ao grupo. 2.0
4 Resolver audiência services/audience/resolver.py: grupo → telefones de membros ACTIVE, dedupe + validação de formato E.164. 1.5
5 API de ingestão routers/broadcasts.py: POST /api/v1/broadcasts (idempotência, cria campanha+recipients, enfileira) + GET de status/recipients. 2.0
6 Worker de disparo services/queue/dispatcher.py + whatsapp/template_sender.py: consome fila, envia Template Message, token-bucket (rate limit), retry/backoff, atualiza recipient. 2.5
7 Webhook de status routers/status_webhook.py: correlaciona wa_message_id → atualiza status do recipient + contadores da campanha. 1.0
8 Testes & docs Testes (permissão negada, idempotência, pacing, parse de webhook) + README/.env.example. 1.0
TOTAL 16.0

Detalhamento por fase

Fase 0 — Scaffold & infra (1.5h)

Fase 1 — Modelo de dados (2h)

Fase 2 — Auth do owner (2h)

Fase 3 — Permissão sobre o grupo (2h)

Fase 4 — Resolver audiência (1.5h)

Fase 5 — API de ingestão (2h)

Fase 6 — Worker de disparo (2.5h)

Fase 7 — Webhook de status (1h)

Fase 8 — Testes & docs (1h)


6. Riscos & Mitigações

Risco Impacto Mitigação
Template não aprovado pela Meta Disparo falha 100% Validar template_name aprovado antes de aceitar a campanha; status de erro claro
Estouro de tier (rate limit Meta) Mensagens rejeitadas (131049) Token-bucket + pacing configurável; backoff em 131049
Telefones inválidos/duplicados Falhas e custo desnecessário Validação E.164 + dedupe na audiência
Reenvio acidental da mesma campanha Spam ao grupo Idempotency-Key + UNIQUE(broadcast_id, phone)
Acesso indevido a grupo de terceiros Vazamento/abuso Permissão obrigatória (LEADER ACTIVE) + API key com escopo
Webhook de status duplicado/fora de ordem Contadores errados Atualização idempotente por wa_message_id + transição de estado monotônica

7. Fora de Escopo (v1) / Próximos Passos


8. Pré-requisitos para começar


Plano base — ChatSkills Broadcast · revisão inicial · 2026-05-21