Mapa da trilha
Conteúdo detalhado
⚙️ Stack e setup
As escolhas de stack que vão te perseguir por anos. NestJS, Prisma, BullMQ e monorepo com pnpm.
NestJS é um framework Node.js opinativo, com arquitetura modular (controllers, services, modules), DI nativa e suporte first-class a TypeScript. Express é minimalista — você monta tudo.
Para um agendador multi-rede com workers, providers e jobs, NestJS impõe estrutura que evita virar espaguete em 3 meses. Decorators (@Cron, @Process), guards e interceptors já vêm prontos.
DI container, módulos, providers (injectables), pipes/guards, integração nativa com BullMQ via @nestjs/bullmq.
Prisma é um ORM/query builder que gera client TypeScript a partir do schema.prisma. Sintaxe declarativa, autocomplete real, migrations versionadas. Postgres é o banco relacional padrão para SaaS.
Postar em N redes envolve relações (User → Post → PostTarget → SocialAccount). Prisma resolve isso com includes tipados — você não escreve JOIN à mão e o TS pega erro antes de runtime.
schema.prisma, prisma generate, prisma migrate dev/deploy, client.$transaction, includes vs select.
BullMQ é uma fila de jobs em Redis: você enfileira um job com payload + delay, e um worker em outro processo consome quando chega a hora. Suporta retry, backoff, prioridade.
Sem fila, você teria que rodar cron checando "tem post para agora?" a cada minuto. BullMQ resolve com delay nativo: enfileirou com delay=2h, o job só dispara em 2h. E sobrevive a restart.
Queue, Worker, Job, delay, attempts, backoff exponencial, removeOnComplete, QueueEvents.
Monorepo: um repo com múltiplos pacotes/apps. Layout clássico: apps/api (NestJS), apps/worker (BullMQ), apps/web (Next.js), packages/db (Prisma client), packages/types (tipos compartilhados).
Você precisa compartilhar tipos entre API e front, e o Prisma client entre API e worker. Sem monorepo, vira duplicação e drift de tipos. Com monorepo, refatora num lugar só.
workspace, internal packages, hoisting de deps, turborepo (opcional), import "@mkblogs/db".
pnpm é alternativa ao npm/yarn com content-addressable store: deps ficam num único local, hard-linked no node_modules. workspaces define pacotes locais via pnpm-workspace.yaml.
Em monorepo node_modules cresce rápido. pnpm economiza GBs, instala mais rápido e bloqueia phantom dependencies (deps que você usa mas não declarou).
pnpm-workspace.yaml, pnpm --filter api dev, deps internas com workspace:*, store global em ~/.local/share/pnpm.
DATABASE_URL, REDIS_URL, JWT_SECRET, S3_BUCKET, ENCRYPTION_KEY... Variáveis de ambiente carregadas via dotenv ou @nestjs/config, validadas com Zod ou class-validator.
Subir produção e descobrir que esqueceu de setar ENCRYPTION_KEY é o pesadelo clássico. Validação no boot do app evita: se variável obrigatória faltou, app não sobe.
.env.example commitado, .env no .gitignore, ConfigModule.forRoot({ validationSchema }), separar segredos do app config.
🗄️ Schema do banco
O modelo de dados que aguenta 1 post → N redes sem virar caos. Tokens, mídia e migrations.
Entidade central: id (cuid), email único, passwordHash (bcrypt), createdAt, plan (free/pro). Relação 1:N com SocialAccount e Post.
Toda autorização gira em torno de User. Antes de buscar um post, você confirma user.id == post.userId. Sem isso, qualquer um vê posts de qualquer um.
cuid vs uuid vs autoincrement, índice em email, never expose passwordHash em DTO, soft delete vs hard delete.
Conta conectada de uma rede: userId, provider (bluesky, x, mastodon), handle, accessToken, refreshToken, expiresAt. Tokens armazenados criptografados (AES-256-GCM).
Token vazado é acesso à conta do usuário. Banco vazado sem encryption = LGPD/multa + reputação destruída. Encryption-at-rest é não-negociável.
ENCRYPTION_KEY em env, encrypt/decrypt helpers, unique (userId, provider, handle), refresh antes de expirar.
O conteúdo "canônico": id, userId, text, scheduledAt, status (draft/scheduled/publishing/published/failed). Relação N:N com Media via tabela pivot. 1:N com PostTarget.
Separar o "conteúdo desejado" (Post) do "execução por rede" (PostTarget) permite postar mesma coisa em 5 redes e ter status independente de cada uma. Bluesky publicou, X falhou — sem perder o histórico.
enum PostStatus, índice em scheduledAt, índice composto (userId, status), text como TEXT (não VARCHAR).
Cada "destino" de um post: postId, socialAccountId, status, providerPostId (URI do post na rede), publishedAt, errorMessage, attemptCount.
É aqui que mora a verdade do que rolou em cada rede. Sem PostTarget, você não sabe se o post chegou no X — só se "tentou postar". Com PostTarget, dá pra mostrar links clicáveis pro usuário.
providerPostId guardado após sucesso, status independente por target, retry só do target que falhou, índice (postId, socialAccountId) único.
Arquivo de mídia anexado: id, userId, key (path no S3), url (CDN), mimeType, sizeBytes, width, height. Banco guarda metadados; arquivo bruto vive no S3/R2.
Guardar imagens no Postgres como bytea é receita de banco lento. S3 é projetado pra isso: URLs públicas/assinadas, CDN na frente, custo baixo.
key (não URL completa) no banco, presigned URL pra upload direto, mime sniffing no backend, cleanup de órfãs.
prisma migrate dev cria um SQL versionado em prisma/migrations/ a partir das mudanças no schema.prisma. prisma migrate deploy aplica em produção.
Sem migrations, mudar uma coluna em prod é manual e propenso a erro. Com migrations versionadas, o histórico fica no Git e o deploy aplica na ordem certa.
migrate dev vs deploy, never edit applied migration, prisma db push para protótipo, backup antes de prod.
🔌 Padrão Provider
A abstração que faz seu código não saber a diferença entre Bluesky, X, Mastodon ou Threads.
Contrato TS que toda rede social implementa: authenticate(credentials), refreshToken(token), post(text, media), deletePost(id). Tipos de entrada/saída fixos, implementação varia.
É o coração da extensibilidade. Adicionar Mastodon vira criar um arquivo novo que implementa a interface — zero mudança no resto do app.
interface vs abstract class, Result type pra erros, normalização de respostas (sempre retorna { uri, publishedAt }).
ProviderFactory.get(provider: 'bluesky' | 'x') retorna a instância certa. Pode ser um Map registrado no boot ou um decorator @Injectable do Nest.
Sem factory, você cai num switch gigante toda vez que precisa postar. Com factory, só pega o provider pelo nome e chama .post(). Limpo e testável.
DI no NestJS, Map<string, SocialProvider>, erro claro quando provider não existe.
SDK oficial @atproto/api. Login com identifier + app password, agent.post({ text, embed }) para publicar, agent.uploadBlob para mídia.
Bluesky é a rede mais aberta hoje, sem rate limits agressivos pra apps pequenos e tem app password (não precisa OAuth completo). Bom primeiro provider para implementar.
AtpAgent, app passwords, BskyAgent.post(), embed images via uploadBlob, parsing de URI at://...
X (Twitter) API v2 com OAuth 2.0 PKCE. POST /2/tweets com bearer token. Upload de mídia ainda via v1.1 (/media/upload). Plano básico custa USD 100/mês.
É a maior dor de cabeça (OAuth, refresh com rotação, custo, rate limits). Implementar bem aqui prova que sua arquitetura aguenta provider chato. Os outros são moleza depois.
OAuth 2.0 PKCE, refresh token rotation, media_id em upload v1.1, rate limit 429 + Retry-After.
Classes de erro de domínio: RefreshTokenError (token expirou e refresh falhou), RateLimitError (com retryAfter), ProviderTransientError (rede), ProviderPermanentError (conteúdo inválido).
Sem classes de erro, o worker não sabe se deve retry ou abandonar. Retry de erro permanente = job preso. Não-retry de erro transiente = falha boba.
extends Error, instanceof no worker pra decidir, mensagem amigável pro usuário ("reconecte sua conta"), notificar via email se reconnect necessário.
Para cada provider, teste cobre: post() de sucesso retorna URI, post() com 429 lança RateLimitError, refresh() que falha lança RefreshTokenError. Mockar @atproto/api e fetch.
Provider quebrar em produção é constrangedor. Teste unitário pega regressão antes do deploy. Vitest + msw (mock service worker) é o combo padrão.
vi.mock(), MSW para HTTP, golden file de payload, cobertura mínima 80% nos providers críticos.
⏰ Fila e agendamento
BullMQ com delay nativo, retry exponencial, DLQ e quando migrar para Temporal.
3 primitivas: Queue (produtor enfileira), Worker (consumidor processa), Job (a unidade com payload + opções). Toda comunicação roda no Redis via lua scripts atômicos.
É o estado-da-arte de filas em Node hoje. Sucessor maduro do Bull, com tipagem boa e API limpa. Pronto pra produção em milhares de empresas.
queue.add(name, data, opts), new Worker(queueName, fn), concurrency, prefix de keys por env.
queue.add('publish-post', { postTargetId }, { delay: scheduledAt - now }). BullMQ guarda no Redis e só move o job pra "active" quando o delay vence.
Resolve agendamento sem cron. "Postar dia 25 às 14h" vira um delay de N ms — preciso, escalável e sobrevive a restart porque está no Redis.
delay em ms, jobId determinístico para idempotência, queue.getDelayed(), removeOnComplete: { age: 7d }.
Função async (job) => { busca PostTarget no banco → ProviderFactory.get(provider).post(...) → grava providerPostId no PostTarget → marca status = published }.
É o ponto onde fila + banco + provider se encontram. Erro aqui = post não chega ou chega duplicado. Idempotência é vital: checar status antes de postar de novo.
@Processor do @nestjs/bullmq, transaction pra atualizar status, idempotência via providerPostId, log com correlationId.
attempts: 5, backoff: { type: 'exponential', delay: 5000 }. Falhas transientes (rede, 5xx) reentram a fila com delay crescente (5s, 10s, 20s, 40s, 80s).
Sem retry, qualquer hiccup do X deixa post como failed. Com backoff exponencial, a API tem espaço pra se recuperar e não você não bombardeia ela durante outage.
só fazer retry de erro transiente, throw vs Unrecoverable error, attempts.attemptsMade, log de cada tentativa.
Após todos os attempts esgotados, mover o job para uma "failed queue" separada. Alertar dev, marcar PostTarget como failed, enviar email ao usuário.
Sem DLQ, falhas terminais somem no console. Com DLQ, você tem dashboard de "o que deu ruim e precisa investigar" e pode reprocessar manualmente quando consertar bug.
QueueEvents 'failed', failedQueue.add(jobData), Bull Board ou Arena UI, alerta no Slack via webhook.
Temporal.io: orquestrador de workflows com estado durável, replay automático, sagas multi-step. Substitui BullMQ quando você precisa de coreografia complexa entre passos.
BullMQ é ótimo para jobs isolados. Quando seu fluxo vira "se A falhar depois de B sucesso, desfazer B" — você precisa de workflows com compensação. Temporal nasceu pra isso.
workflow vs activity, replay determinístico, sinais, Temporal Cloud vs self-hosted, custo: BullMQ < Temporal.
🖼️ Upload de mídia
Multer, S3/R2, sharp para resize e limpeza de órfãs. Mídia certa, sem inflar storage.
Multer parseia multipart/form-data de uploads HTTP. NestJS tem integração via @nestjs/platform-express com @UseInterceptors(FileInterceptor('file')).
JSON não carrega binário sem base64 (33% overhead). multipart é o padrão da web para upload. Sem entender, você acaba enviando arquivo como base64 e estourando body limit.
memory storage vs disk storage, fileSize limit, dest temp folder, file.buffer no controller.
@aws-sdk/client-s3 com PutObjectCommand. Cloudflare R2 é compatível com a mesma API e tem egress grátis — ideal para servir mídia pública.
No S3, USD ~0.023/GB/mês + custo de egress. No R2, mesma coisa de storage mas zero de egress. Para um app que serve imagens pra usuários, R2 sai 5-10x mais barato.
endpoint customizado (R2), key com prefixo userId/, ACL public-read vs presigned, region.
getSignedUrl(client, GetObjectCommand, { expiresIn: 3600 }) gera URL temporária assinada. Cliente baixa direto do S3, sem passar pelo seu servidor.
Sem presigned, ou você deixa bucket público (vazamento) ou faz proxy no servidor (banda cara). Presigned: temporário + seguro + servido direto pela infra do S3/R2.
expiresIn em segundos, presigned PUT pra upload direto do browser, cache de URL no Redis.
sharp é lib Node baseada em libvips: resize, format webp, strip metadata, gerar thumbnails. Roda direto em buffer ou stream, sem subprocess.
Usuário envia foto de 12MB do celular. Bluesky aceita 1MB. Sem resize, post falha. Com sharp, redimensiona, comprime para webp e envia 200KB de qualidade visual idêntica.
sharp(buffer).resize(2048).webp({ quality: 80 }).toBuffer(), strip exif, generate thumbnail 300x300.
Não confiar no Content-Type do header. Usar file-type para detectar pelos magic bytes do buffer. Limit fileSize no Multer + double-check no serviço.
Aceitar "image/jpeg" pelo header é receita de RCE: usuário envia .exe renomeado. Magic bytes não mentem. Sem validação real, vira vetor de upload malicioso.
file-type lib, allowlist de mimes (image/jpeg, image/png, image/webp), maxSize 10MB, 413 Payload Too Large.
Job diário (BullMQ repeat) que busca Media sem postId associado e mais antigas que 24h, deleta do S3 e do banco. Mídia órfã = upload iniciado, post nunca finalizado.
Sem cleanup, storage cresce indefinidamente. Em 6 meses você paga storage de coisa que ninguém vê. Cron simples resolve, mas precisa cuidar de race com upload em andamento.
queue.add com repeat: { cron: '0 3 * * *' }, soft delete antes de hard delete, S3 lifecycle policy como fallback.