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 stagestageV2. 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.