MÓDULO 4.1

🗄️ Stack + Modelagem do Banco

Convex, Supabase ou Postgres puro? Modele as tabelas do InboxAI, defina índices, faça migrations sem dor e popule dados de teste — base sólida pra escalar pra 100 clientes.

6
Tópicos
75
Minutos
Avançado
Nível
Backend
Tipo
1

🤔 Convex vs Supabase vs Postgres puro

A escolha do banco no dia zero define o quanto você vai sofrer nos próximos 6 meses. Não tem "melhor" — tem certo pro perfil. Pra o InboxAI da Trilha 4, vamos com Convex; mas você precisa saber comparar pra defender a escolha quando o cliente perguntar.

Critério Convex Supabase Postgres puro
Curva de setup10 min15 min2h
Reatividade nativa✓ built-inrealtime extra✗
SQL puro✗ TS✓ Postgres✓ total
RLS / segurançacode-levelSQL policyvocê implementa
Free tier realgenerosogenerosodepende host
Lock-inaltomédiobaixo
Ecossistemanovoamplouniversal

📌 Regra de bolso

Convex pra MVP em 1 fim de semana e produto reativo (chat ao vivo, dashboard). Supabase pra quando seu cliente é técnico e exige Postgres com RLS clássico. Postgres puro só quando você tem time DevOps ou previsão de escala extrema. Pro InboxAI, Convex destrava velocidade — e a Trilha 6 ensina migrar se um dia precisar.

2

📋 Schema do InboxAI

O schema é a coluna do produto. Toda tabela aqui tem motivo operacional: cada campo justificado por uma decisão real da clínica, imobiliária ou agência que vai pagar.

🗺️ Diagrama de Entidades

organizations tenant raiz users membros do time contacts leads/clientes deals pipeline de venda conversations threads messages cada mensagem

📄 convex/schema.ts

TypeScript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  organizations: defineTable({
    name: v.string(),
    plan: v.union(v.literal("free"), v.literal("pro"), v.literal("enterprise")),
    createdAt: v.number(),
  }),

  users: defineTable({
    orgId: v.id("organizations"),
    clerkId: v.string(),
    email: v.string(),
    name: v.string(),
    role: v.union(v.literal("owner"), v.literal("admin"), v.literal("agent")),
  }).index("by_org", ["orgId"]).index("by_clerk", ["clerkId"]),

  contacts: defineTable({
    orgId: v.id("organizations"),
    phone: v.string(),
    name: v.optional(v.string()),
    email: v.optional(v.string()),
    tags: v.array(v.string()),
    lastSeenAt: v.number(),
  }).index("by_org_phone", ["orgId", "phone"])
    .index("by_org_lastSeen", ["orgId", "lastSeenAt"]),

  conversations: defineTable({
    orgId: v.id("organizations"),
    contactId: v.id("contacts"),
    status: v.union(v.literal("open"), v.literal("snoozed"), v.literal("closed")),
    assignedTo: v.optional(v.id("users")),
    aiEnabled: v.boolean(),
    lastMessageAt: v.number(),
  }).index("by_org_status", ["orgId", "status"])
    .index("by_contact", ["contactId"]),

  messages: defineTable({
    orgId: v.id("organizations"),
    conversationId: v.id("conversations"),
    direction: v.union(v.literal("inbound"), v.literal("outbound")),
    sender: v.union(v.literal("contact"), v.literal("agent"), v.literal("ai")),
    body: v.string(),
    mediaUrl: v.optional(v.string()),
    mediaType: v.optional(v.string()),
    sentAt: v.number(),
  }).index("by_conversation", ["conversationId", "sentAt"]),

  deals: defineTable({
    orgId: v.id("organizations"),
    contactId: v.id("contacts"),
    title: v.string(),
    stage: v.union(v.literal("qualified"), v.literal("proposal"),
                   v.literal("won"), v.literal("lost")),
    value: v.number(),
    ownerId: v.id("users"),
  }).index("by_org_stage", ["orgId", "stage"])
    .index("by_owner", ["ownerId"]),
});

💡 A regra do orgId

Toda tabela tem orgId. Sempre. É a base do multi-tenant da Trilha 4.4 — sem isso, RLS é placebo. Acostume agora: cliente A nunca consegue, sob nenhuma circunstância, ver dado do cliente B.

3

🔗 Relacionamentos e Índices

90% dos travamentos em SaaS são query sem índice rodando full table scan. Aqui você vê quais colunas indexar no InboxAI e por que cada uma.

RELACIONAMENTO 1:N
🌳

contact → messages

Um contato tem várias mensagens. Index by_conversation permite carregar últimas 50 em ms.

RELACIONAMENTO N:N
🕸️

contacts ↔ tags

Tags como array no contact resolve N:N sem tabela pivô — Convex lida nativo. Em Postgres, vire join table.

📊 Índices que importam no InboxAI

by_org_phone Buscar contato por telefone na chegada de webhook. Sem isso, full scan a cada mensagem.
by_conversation Listar mensagens da thread em ordem cronológica. Composto com sentAt evita ordenação custosa.
by_org_status Inbox de "abertas" do cliente. Filtra dezenas de milhares de fechadas em ms.
by_org_lastSeen Ranking de "quem falou recente". Painel principal carrega já ordenado.

⚠️ Sintoma de índice faltando

Se uma tela demorar mais de 500ms com banco quase vazio, é índice. Se piora linearmente conforme entra dado, é índice errado. Adicione índice ao perceber, não em "otimização futura" — Convex faz hot-reload do schema sem downtime.

4

🚧 Migrations sem quebrar dado

Cliente pagando R$ 2k/mês não aceita 10 minutos fora. Padrão expand-then-contract deixa a migração invisível: usuário nem percebe.

🔄 Fluxo Expand-then-Contract

1

EXPAND — adiciona o novo

Cria a coluna nova (ex: stageV2). Schema aceita as duas. Código antigo ignora; código novo já lê e escreve nas duas.

2

BACKFILL — popula histórico

Mutation em batches que copia stage → stageV2. Roda em background, não bloqueia ninguém.

3

SWITCH — leitura passa pra nova

Deploy onde a UI lê só de stageV2. Escrita ainda dupla. Se der ruim, rollback no painel da Vercel = volta tudo.

4

CONTRACT — remove o velho

Depois de 7 dias estável, remove stage do schema. Última migration limpa o legado.

💡 Como pedir ao agente

"Quero renomear stage para pipelineStage em deals seguindo expand-then-contract. Crie 4 PRs separadas, cada uma com um passo, e me explique o que verificar antes de mergear cada uma." O Codex executa isso direitinho — você só revisa o diff.

5

🌱 Seed: dados de teste

Demo pra cliente com banco vazio é tiro no pé. Seed bom faz a UI parecer "cheia de gente usando" no primeiro screenshot. Aqui está o script que popula 50 contatos, 200 mensagens e 10 deals em segundos.

📄 convex/seed.ts

TypeScript
import { internalMutation } from "./_generated/server";

const NOMES_BR = ["Ana Silva","João Santos","Maria Oliveira","Carlos Pereira",
                  "Beatriz Costa","Rafael Souza","Juliana Lima","Pedro Rocha"];
const TAGS = ["lead-quente","cliente","follow-up","perdido","vip"];
const FRASES = [
  "Oi, tenho interesse no plano Pro.",
  "Bom dia, qual o valor?",
  "Você consegue passar a apresentação?",
  "Vou falar com meu sócio e retorno.",
  "Fechado, manda o boleto."
];

const rand = <T>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
const phone = () => "55119" + Math.floor(10000000 + Math.random() * 89999999);

export const seed = internalMutation({
  args: {},
  handler: async (ctx) => {
    const orgId = await ctx.db.insert("organizations", {
      name: "Clínica Demo", plan: "pro", createdAt: Date.now(),
    });

    const ownerId = await ctx.db.insert("users", {
      orgId, clerkId: "demo_owner", email: "demo@inbox.ai",
      name: "Demo Owner", role: "owner",
    });

    const contactIds = [];
    for (let i = 0; i < 50; i++) {
      const id = await ctx.db.insert("contacts", {
        orgId, phone: phone(), name: rand(NOMES_BR),
        tags: [rand(TAGS)], lastSeenAt: Date.now() - i * 3600_000,
      });
      contactIds.push(id);
    }

    for (const contactId of contactIds.slice(0, 40)) {
      const convId = await ctx.db.insert("conversations", {
        orgId, contactId, status: "open", aiEnabled: true,
        lastMessageAt: Date.now(),
      });
      for (let j = 0; j < 5; j++) {
        await ctx.db.insert("messages", {
          orgId, conversationId: convId,
          direction: j % 2 === 0 ? "inbound" : "outbound",
          sender: j % 2 === 0 ? "contact" : "ai",
          body: rand(FRASES), sentAt: Date.now() - (5 - j) * 60_000,
        });
      }
    }

    for (let i = 0; i < 10; i++) {
      await ctx.db.insert("deals", {
        orgId, contactId: rand(contactIds), title: "Deal #" + (i + 1),
        stage: rand(["qualified","proposal","won","lost"] as const),
        value: 1000 + Math.random() * 9000, ownerId,
      });
    }

    return { contacts: 50, conversations: 40, deals: 10 };
  },
});

🚀 Rodar o seed

# dev local
npx convex run seed:seed

# limpar e re-seedar
npx convex run reset:wipe && npx convex run seed:seed

⚠️ LGPD no seed

Não use número de telefone real, mesmo de "amigo seu". Use prefixo 5511 9 + dígitos aleatórios. Se cair em produção, ninguém recebe SMS. Mesma lógica pra email — sempre demo+id@inbox.test.

6

💾 Backups e ambiente prod

Perder dado de cliente é fim de relacionamento e processo no horizonte. Backup só vale se você já testou restore — senão é placebo digital.

✓ Setup mínimo

  • ✓Convex Cloud: backup automático a cada 24h, retenção 30 dias
  • ✓Snapshot manual antes de cada deploy crítico
  • ✓Export semanal pra storage externo (S3 ou R2)
  • ✓Drill de restore a cada 30 dias em ambiente staging
  • ✓Documento curto explicando como restaurar (passo a passo)

✗ Erros que destroem cliente

  • ✗"Tem backup automático" sem testar restore
  • ✗Backup no mesmo provedor que pode cair junto
  • ✗Sem snapshot pré-deploy de migration arriscada
  • ✗Senha do backup junto com a do banco — ransomware leva os dois
  • ✗Confiar que "rollback do deploy" desfaz dado

📋 Checklist antes de cada deploy de migration

☐ Snapshot manual feito e ID anotado
☐ Migration testada em staging com cópia da prod
☐ Plano de rollback escrito (qual comando executar)
☐ Janela de menor uso confirmada (geralmente domingo cedo)
☐ Cliente avisado se for perceptível
☐ Monitor aberto pra detectar erro nos primeiros 30 min

💡 RTO e RPO traduzidos

RTO (Recovery Time Objective): em quanto tempo você volta ao ar — meta sã pra micro-SaaS BR é 4h. RPO (Recovery Point Objective): quanto dado pode perder no pior cenário — meta sã é 1h. Vender SLA mais agressivo sem infra adequada é cilada que cobra caro depois.

✅ O que Aprendemos

✓
Convex pra MVP rápido, Supabase pra cliente técnico, Postgres puro só com DevOps — escolha por perfil, não por hype.
✓
Schema do InboxAI com 6 tabelas core e orgId em todas — multi-tenant é decisão do dia zero, não retrofit.
✓
Índices em campos de busca evitam full scan — 500ms é sintoma; resolva ao perceber.
✓
Expand-then-contract = migration invisível — cliente pagante não aceita downtime de 10 min.
✓
Seed realista vende demo — 50 contatos com nome BR e mensagens em PT, sem dado real.
✓
Backup só conta se restore foi testado — drill mensal e snapshot pré-deploy crítico.

Próximo Módulo:

4.2 — Integrando Evolution API: Docker, QR code, webhook recebendo mensagem real do WhatsApp e função de envio com texto, áudio e imagem.