👤 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
Marca a chave primária do modelo.
Garante unicidade no banco via constraint.
Default gera UUID v4 — não sequencial, sem leak.
Listas (Post[]) declaram one-to-many.
🔑 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
Valores fechados validados no banco e no TS.
Cascade apaga contas quando o user é apagado.
Mesmo handle no mesmo user+platform só uma vez.
Índice físico para queries frequentes.
📝 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
Array nativo Postgres — ordem preservada.
Estado finito impede combinações inválidas.
Tipo nativo Postgres sem limite de chars.
[userId, status] serve listagem por aba.
🎯 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
attemptspara corte de retry exponencial. - ✓Salvar
errorcompleto — 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
platformaqui — vem dosocialAccountId. - ✗Apagar
errorao tentar de novo — perde histórico. - ✗Indexar tudo: índice extra custa em INSERT (e isso é a hot path).
Conceitos-chave
Many-to-many com atributos próprios.
Ponte entre o seu mundo e o da rede.
Contador para backoff e dead-letter.
@@unique evita duplicata no retry.
🖼️ 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
S3/R2 guarda binários; banco guarda ponteiros.
Permite filtrar imagem vs vídeo sem baixar.
width/height evita 2ª chamada na UI.
Reuso entre posts e delete independente.
🚀 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.
Primeira migration em dev
Compara schema com o banco, gera SQL, aplica e regenera o client TypeScript.
npx prisma migrate dev --name init
Adicionar uma coluna
Edita o schema, roda migrate dev com nome descritivo, commita.
npx prisma migrate dev --name add_post_scheduled_index
Aplicar em produção
deploy só executa migrations já existentes — nunca gera nem modifica.
npx prisma migrate deploy
Reset (só dev)
Apaga TUDO, recria do zero e roda seed. Comando perigoso — bloqueado em prod por default.
npx prisma migrate reset
Status e drift
Mostra se há migration pendente ou se alguém mexeu manualmente no banco.
npx prisma migrate status
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
Gera + aplica em dev; nunca em prod.
Aplica migrations existentes em prod.
DB temporário que o Prisma usa em dev.
Diff entre schema, migrations e banco real.
🎯 Resumo do Módulo
migrate 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