⚡ AutomationsAI|Portal de Cursos →

Verificando acesso...

MÓDULO 5.2

🗄️ Schema do banco

Modelar o domínio de um agendador de redes sociais em Prisma: User, SocialAccount, Post, PostTarget, Media e migrations.

6
Tópicos
40
Minutos
Avançado
Nível
Prático
Tipo
1

👤 Modelo User

O User é a raiz do grafo. Tudo (contas sociais, posts, mídia) pertence a um usuário. Use uuid como id — IDs sequenciais expõem volume de cadastros e facilitam enumeração. O email precisa de @unique e o passwordHash nunca é exposto na API — só o servidor lê.

Antes de escrever modelos, declare o datasource e o generator. O Prisma só sabe gerar o client se essa configuração existir no topo do schema.prisma.

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id           String   @id @default(uuid())
  email        String   @unique
  passwordHash String
  name         String?
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  socialAccounts SocialAccount[]
  posts          Post[]
  media          Media[]

  @@index([createdAt])
}

💡 Dica prática

Nunca chame a coluna de password. O nome passwordHash serve de lembrete: o valor armazenado é sempre o hash (argon2/bcrypt), nunca a senha em claro. Se o nome estiver errado, vai aparecer "password" em log, em select, em response — e um dia vaza.

Conceitos-chave

@id

Marca a chave primária do modelo.

@unique

Garante unicidade no banco via constraint.

uuid()

Default gera UUID v4 — não sequencial, sem leak.

Relações

Listas (Post[]) declaram one-to-many.

2

🔑 Modelo SocialAccount

Cada usuário conecta N contas — Twitter, LinkedIn, Instagram. O SocialAccount guarda o token OAuth de cada uma. Os campos accessToken e refreshToken são criptografados em repouso (AES-GCM com chave fora do banco) antes do INSERT — o Prisma só vê o ciphertext.

Use enum Platform em vez de string livre. Enum previne typo ("twiter" vs "twitter") e dá autocomplete no client gerado.

enum Platform {
  TWITTER
  LINKEDIN
  INSTAGRAM
  FACEBOOK
  THREADS
  BLUESKY
}

model SocialAccount {
  id           String   @id @default(uuid())
  userId       String
  platform     Platform
  handle       String
  accessToken  String   // ciphertext AES-GCM
  refreshToken String?  // ciphertext AES-GCM
  expiresAt    DateTime?
  scope        String?
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  user    User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  targets PostTarget[]

  @@unique([userId, platform, handle])
  @@index([userId])
  @@index([expiresAt])
}

🔐 Por que criptografar em repouso?

  • Backup vazado: dump de Postgres sem ciphertext entrega todas as contas conectadas.
  • SQL injection: mesmo se um SELECT vaze, o atacante recebe lixo.
  • Princípio do menor privilégio: a chave AES vive em KMS/secret manager, não no banco.
  • expiresAt indexado: permite scan barato de tokens vencendo para refresh em batch.

Conceitos-chave

enum

Valores fechados validados no banco e no TS.

onDelete

Cascade apaga contas quando o user é apagado.

@@unique composto

Mesmo handle no mesmo user+platform só uma vez.

@@index

Índice físico para queries frequentes.

3

📝 Modelo Post

O Post é o conteúdo na visão do usuário — não na visão da rede. Um único Post pode disparar publicações em várias plataformas (isso é o PostTarget, próximo tópico). O campo mediaUrls é String[] nativo do Postgres — array é mais barato que tabela de junção quando só importa ordem.

O status é um enum de máquina de estado: DRAFT → SCHEDULED → PUBLISHING → PUBLISHED, com FAILED como ramo de erro. scheduledAt indexado é o que o worker lê a cada minuto.

enum PostStatus {
  DRAFT
  SCHEDULED
  PUBLISHING
  PUBLISHED
  FAILED
  CANCELLED
}

model Post {
  id          String     @id @default(uuid())
  userId      String
  text        String     @db.Text
  mediaUrls   String[]
  scheduledAt DateTime?
  publishedAt DateTime?
  status      PostStatus @default(DRAFT)
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  user    User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  targets PostTarget[]

  @@index([userId, status])
  @@index([scheduledAt])
}

Dica prática

Use @db.Text em vez do default VARCHAR. Twitter/X cresceu para 25 mil chars em conta paga; Threads tem 500; LinkedIn 3 mil. TEXT no Postgres não tem custo extra vs VARCHAR(n) e te livra de migration quando algum limite mudar.

Conceitos-chave

String[]

Array nativo Postgres — ordem preservada.

Enum status

Estado finito impede combinações inválidas.

@db.Text

Tipo nativo Postgres sem limite de chars.

Índice composto

[userId, status] serve listagem por aba.

4

🎯 Modelo PostTarget

Esse é o modelo que separa amador de produção. Um Post dispara N publicações reais — uma por rede. Cada PostTarget tem seu próprio status, sua mensagem de erro e o platformPostId retornado pela API da rede.

Sem essa tabela, falha no Twitter cascateia para o LinkedIn no log do mesmo Post — e fica impossível fazer retry seletivo. 1 Post → N PostTarget é o jeito certo.

enum TargetStatus {
  PENDING
  PUBLISHING
  PUBLISHED
  FAILED
  SKIPPED
}

model PostTarget {
  id              String       @id @default(uuid())
  postId          String
  socialAccountId String
  status          TargetStatus @default(PENDING)
  platformPostId  String?      // id retornado pela rede (ex.: tweet id)
  platformUrl     String?      // URL pública do post na rede
  error           String?      @db.Text
  attempts        Int          @default(0)
  lastAttemptAt   DateTime?
  publishedAt     DateTime?

  post          Post          @relation(fields: [postId], references: [id], onDelete: Cascade)
  socialAccount SocialAccount @relation(fields: [socialAccountId], references: [id], onDelete: Cascade)

  @@unique([postId, socialAccountId])
  @@index([status])
  @@index([postId])
}

✓ O que FAZER

  • Guardar platformPostId — é o que permite editar/deletar lá depois.
  • Contar attempts para corte de retry exponencial.
  • Salvar error completo — o stack trace explica rate limit vs auth.
  • @@unique([postId, socialAccountId]) impede publicação dupla.

✗ O que NÃO fazer

  • Misturar status do Post com status do Target — quebra retry parcial.
  • Colocar platform aqui — vem do socialAccountId.
  • Apagar error ao tentar de novo — perde histórico.
  • Indexar tudo: índice extra custa em INSERT (e isso é a hot path).

Conceitos-chave

Tabela de junção rica

Many-to-many com atributos próprios.

platformPostId

Ponte entre o seu mundo e o da rede.

attempts

Contador para backoff e dead-letter.

Idempotência

@@unique evita duplicata no retry.

5

🖼️ Modelo Media

Imagens e vídeos não vão no banco. Vão para S3 (ou R2/Spaces) e o banco guarda só a url, o mimeType e o size. Persistir bytes de mídia no Postgres custa caro: backup explode, MVCC sofre, vacuum trava.

O Media aqui é o registro do upload feito pelo usuário. O Post.mediaUrls referencia essas URLs, mas não há FK — o usuário pode anexar a mesma mídia em N posts e excluir o Post sem perder a Media.

model Media {
  id        String   @id @default(uuid())
  userId    String
  url       String   // s3://bucket/uploads/uuid.jpg ou https://cdn.../...
  key       String   // chave S3 — usar para delete
  mimeType  String   // image/jpeg, video/mp4, image/png
  size      Int      // bytes
  width     Int?
  height    Int?
  duration  Int?     // segundos, só vídeo
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, createdAt])
  @@index([key])
}

🧹 Dica prática

Guarde key separado da url. A URL pode mudar (CDN nova, domínio próprio), mas a chave S3 é estável e é o que você usa no DeleteObject. Sem isso, garbage collection de mídia órfã vira parsing de URL — frágil.

Conceitos-chave

Object storage

S3/R2 guarda binários; banco guarda ponteiros.

mimeType

Permite filtrar imagem vs vídeo sem baixar.

Dimensions

width/height evita 2ª chamada na UI.

Sem FK no Post

Reuso entre posts e delete independente.

6

🚀 Migrations com Prisma

Schema escrito não vira tabela sozinho. prisma migrate gera SQL versionado em prisma/migrations/ — arquivos que você commita e que rodam idênticos em dev, staging e produção.

1

Primeira migration em dev

Compara schema com o banco, gera SQL, aplica e regenera o client TypeScript.

npx prisma migrate dev --name init
2

Adicionar uma coluna

Edita o schema, roda migrate dev com nome descritivo, commita.

npx prisma migrate dev --name add_post_scheduled_index
3

Aplicar em produção

deploy só executa migrations já existentes — nunca gera nem modifica.

npx prisma migrate deploy
4

Reset (só dev)

Apaga TUDO, recria do zero e roda seed. Comando perigoso — bloqueado em prod por default.

npx prisma migrate reset
5

Status e drift

Mostra se há migration pendente ou se alguém mexeu manualmente no banco.

npx prisma migrate status
6

Inspecionar dados

GUI web local para ler/editar registros sem precisar de pgAdmin.

npx prisma studio
# Fluxo típico após editar schema.prisma
npx prisma format                    # arruma indentação
npx prisma validate                  # checa sintaxe
npx prisma migrate dev --name xxx    # gera SQL + aplica
git add prisma/migrations/ prisma/schema.prisma
git commit -m "feat(db): xxx"

# No CI/CD da produção (no entrypoint do container)
npx prisma migrate deploy
npx prisma generate

⚠️ Dica prática

NUNCA edite uma migration depois que ela já rodou em qualquer ambiente — nem de virgula. O Prisma calcula um checksum e qualquer mudança quebra o migrate deploy em produção. Errou? Crie uma migration NOVA que corrige (ALTER, DROP, novo INDEX).

Conceitos-chave

migrate dev

Gera + aplica em dev; nunca em prod.

migrate deploy

Aplica migrations existentes em prod.

Shadow database

DB temporário que o Prisma usa em dev.

Drift

Diff entre schema, migrations e banco real.

🎯 Resumo do Módulo

User modelado — uuid, email unique, passwordHash, timestamps automáticos e relações para o resto.
SocialAccount com OAuth — tokens criptografados em repouso, enum Platform, expiresAt indexado.
Post com agendamento — texto, array de mídias, scheduledAt e enum PostStatus de máquina de estado.
PostTarget fan-out — 1 Post → N redes, com status, platformPostId, error e attempts independentes.
Media em S3 — banco guarda url, key, mimeType e size; binários ficam no object storage.
Migrations versionadasmigrate dev em dev, migrate deploy em prod, nunca editar migration aplicada.

Próximo Módulo:

5.3 — Autenticação e gestão de usuários