BACKEND v1 · NESTJS + PRISMA + POSTGRES + REDIS

Do git clone ao primeiro
endpoint em 10 minutos.

Stack escolhido em alinhamento com o PRD: TypeScript estrito, multi-tenant nativo, fila assíncrona pra IA, observabilidade desde o dia 1. Tudo Docker-ready.

01 · STACK

Decisões e por quê

Cada peça resolve uma necessidade específica do PRD. Nada inventado, nada genérico.

RUNTIME

Node 22 LTS + TypeScript 5.6

strict:true, noImplicitAny, sem any em domínio.

FRAMEWORK

NestJS 10 · modular

DI nativo, guards/interceptors, controllers REST + microservices Redis pra workers.

DB

PostgreSQL 16 + Prisma 5

Prisma Migrate pra schema; pg_partman em eventos do Pixel; pgvector pra embeddings.

CACHE / QUEUE

Redis 7 + BullMQ

Cache de sessão, rate-limit, pub/sub realtime, e filas de jobs de IA / pixel / disparos.

AUTH

Better-Auth + Magic Link

Sessões JWT + refresh em cookie HttpOnly; magic link por padrão, senha opcional.

VALIDAÇÃO

Zod + class-validator

DTOs validados em runtime; tipos derivados de Zod compartilháveis com front.

IA

Anthropic + Replicate

Claude pra texto, Flux/Kling pra imagem & vídeo; wrappers próprios com fallback e budget.

PAGAMENTO

Stripe + Pagar.me

Stripe pra cartão/internacional, Pagar.me pra PIX BR; webhooks unificados.

02 · MONOREPO

Estrutura do repositório

Turborepo + pnpm workspaces. Apps independentes, packages compartilhados, deploy isolado.

expoent/
├── apps/
│   ├── api/              # NestJS · core API REST
│   ├── workers/          # BullMQ workers · IA, pixel, e-mails
│   ├── web/              # Next.js · painel + landing
│   └── pixel/            # script público (já existe: pixel.js)
│
├── packages/
│   ├── db/               # Prisma schema + migrations + seed
│   ├── shared/           # tipos, schemas Zod, utilitários
│   ├── ai/               # wrappers Anthropic/Replicate + provider
│   ├── auth/             # Better-Auth config + middleware
│   └── eslint-config/    # preset compartilhado
│
├── infra/
│   ├── docker-compose.yml
│   ├── docker-compose.dev.yml
│   ├── Dockerfile.api
│   ├── Dockerfile.workers
│   └── nginx.conf
│
├── .github/workflows/
│   ├── ci.yml            # test + lint + typecheck
│   └── deploy.yml        # build + push + migrate
│
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
03 · DOCKER

docker-compose.dev.yml

Tudo que o dev precisa em docker compose up: Postgres + Redis + Mailhog (e-mail) + MinIO (storage local).

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: expoent
      POSTGRES_USER: expoent
      POSTGRES_PASSWORD: dev
    ports: ["5432:5432"]
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U expoent"]
      interval: 5s

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

  mailhog:
    image: mailhog/mailhog:latest
    ports: ["1025:1025", "8025:8025"]   # SMTP + Web UI

  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    ports: ["9000:9000", "9001:9001"]
    environment:
      MINIO_ROOT_USER: expoent
      MINIO_ROOT_PASSWORD: dev123456

volumes:
  pg_data:
✓ INSTALAÇÃO EM 1 COMANDO
$ docker compose -f infra/docker-compose.dev.yml up -d
$ pnpm install
$ pnpm db:migrate
$ pnpm dev
04 · DATABASE

Schema Prisma · núcleo

Multi-tenant via workspaceId em toda tabela de domínio. Índices compostos pra queries hot.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider   = "postgresql"
  url        = env("DATABASE_URL")
  extensions = [pgvector, pg_partman, uuid_ossp]
}

// ============ TENANCY ============
model Workspace {
  id        String   @id @default(uuid)
  slug      String   @unique
  name      String
  plan      Plan     @default(STARTER)
  createdAt DateTime @default(now())

  members   Membership[]
  brandKits BrandKit[]
  contents  Content[]
  leads     Lead[]
  events    PixelEvent[]
  jobs      AiJob[]
}

model User {
  id          String   @id @default(uuid)
  email       String   @unique
  name        String?
  createdAt   DateTime @default(now())
  memberships Membership[]
}

model Membership {
  id          String      @id @default(uuid)
  workspaceId String
  userId      String
  role        Role        @default(EDITOR)
  workspace   Workspace   @relation(fields: [workspaceId], references: [id])
  user        User        @relation(fields: [userId], references: [id])
  @@unique([workspaceId, userId])
}

// ============ CONTEÚDO ============
model Content {
  id          String       @id @default(uuid)
  workspaceId String
  brandKitId  String
  type        ContentType
  title       String
  status      ContentStatus @default(DRAFT)
  viralScore  Int?
  channels    Channel[]
  publishAt   DateTime?
  createdById String
  createdAt   DateTime     @default(now())

  workspace   Workspace    @relation(fields: [workspaceId], references: [id])
  brandKit    BrandKit     @relation(fields: [brandKitId], references: [id])
  metrics     ContentMetric[]

  @@index([workspaceId, status, publishAt])
}

// ============ IA & FILAS ============
model AiJob {
  id          String   @id @default(uuid)
  workspaceId String
  type        AiJobType        // IMAGE | VIDEO | TEXT | EMBEDDING
  provider    AiProvider       // ANTHROPIC | REPLICATE | OPENAI
  model       String
  status      JobStatus @default(QUEUED)
  prompt      String
  input       Json
  output      Json?
  costCents   Int       @default(0)
  durationMs  Int?
  errorMsg    String?
  createdAt   DateTime  @default(now())
  finishedAt  DateTime?

  @@index([workspaceId, status, createdAt])
}

// ============ PIXEL ============
model PixelEvent {
  id          BigInt   @id @default(autoincrement())
  workspaceId String
  visitorId   String
  sessionId   String
  event       String
  url         String?
  path        String?
  props       Json?
  attrSource  String?
  attrCampaign String?
  device      String?
  ts          DateTime @default(now())

  @@index([workspaceId, ts])
  @@index([workspaceId, visitorId, ts])
}

// pg_partman criará partições mensais via gatilho.

// ============ ENUMS ============
enum Plan        { STARTER · PRO · AGENCY }
enum Role        { OWNER · ADMIN · EDITOR · VIEWER }
enum ContentType { POST · CAROUSEL · REEL · VIDEO · EMAIL · WHATSAPP · LANDING }
enum ContentStatus { DRAFT · SCHEDULED · PUBLISHED · ARCHIVED }
enum Channel     { INSTAGRAM · FACEBOOK · TIKTOK · WHATSAPP · EMAIL · BLOG · LINKEDIN · YOUTUBE }
enum AiJobType   { IMAGE · VIDEO · TEXT · EMBEDDING }
enum AiProvider  { ANTHROPIC · REPLICATE · OPENAI · ELEVENLABS }
enum JobStatus   { QUEUED · RUNNING · DONE · FAILED · CANCELLED }
05 · BOOTSTRAP

apps/api/src/main.ts

Compression, helmet, CORS configurado, Pino logger, Sentry, shutdown gracioso. Pronto pra prod.

import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import compression from '@fastify/compress';
import helmet from '@fastify/helmet';
import * as Sentry from '@sentry/node';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { env } from './env';

async function bootstrap() {
  Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });

  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter({ trustProxy: true, bodyLimit: 20 * 1024 * 1024 }),
    { bufferLogs: true },
  );

  app.useLogger(app.get(Logger));
  app.setGlobalPrefix('api');
  app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  app.enableCors({
    origin: env.WEB_URL.split(','),
    credentials: true,
  });

  await app.register(compression as any);
  await app.register(helmet as any, { contentSecurityPolicy: false });

  app.enableShutdownHooks();

  await app.listen(env.PORT, '0.0.0.0');
  console.log(`✓ API up · :${env.PORT}`);
}

bootstrap();
06 · MULTI-TENANT

WorkspaceGuard · isolamento por tenant

Toda rota autenticada exige X-Workspace-Id ou subdomínio. Guard injeta no request.workspace e a Prisma middleware adiciona workspaceId em toda query.

// apps/api/src/tenant/workspace.guard.ts
@Injectable()
export class WorkspaceGuard implements CanActivate {
  constructor(private prisma: PrismaService) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest<FastifyRequest>();
    const user = (req as any).user as AuthUser;
    if (!user) throw new UnauthorizedException();

    const wsHeader = req.headers['x-workspace-id'] as string | undefined;
    const slug = (req.hostname.split('.')[0]) ?? null;

    const ws = await this.prisma.workspace.findFirst({
      where: {
        OR: [{ id: wsHeader }, { slug }],
        members: { some: { userId: user.id } },
      },
    });
    if (!ws) throw new ForbiddenException('no_workspace_access');

    (req as any).workspace = ws;
    return true;
  }
}
// packages/db/src/middleware/tenant.ts
prisma.$extends({
  query: {
    $allModels: {
      async $allOperations({ args, query, model, operation }) {
        const ws = workspaceContext.get();
        if (!ws) return query(args);
        const hasField = ['Content','Lead','AiJob','PixelEvent'].includes(model);
        if (!hasField) return query(args);
        if (operation.startsWith('find') || operation === 'count') {
          args.where = { ...args.where, workspaceId: ws.id };
        }
        if (operation === 'create') {
          args.data = { ...args.data, workspaceId: ws.id };
        }
        return query(args);
      },
    },
  },
});
07 · AUTH

Better-Auth + Magic Link

Padrão magic link (e-mail). Senha opcional via campo extra. JWT em cookie HttpOnly + SameSite=Lax. Refresh rotation com janela de 14 dias.

// packages/auth/src/index.ts
import { betterAuth } from 'better-auth';
import { magicLink } from 'better-auth/plugins';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { prisma } from '@expoent/db';

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
  plugins: [
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        await sendEmail({
          to: email,
          subject: 'Seu link de acesso · expoent',
          html: renderTemplate('magic-link', { url }),
        });
      },
    }),
  ],
  session: {
    expiresIn: 14 * 24 * 60 * 60,       // 14 dias
    updateAge: 24 * 60 * 60,            // rotaciona diariamente
  },
  advanced: {
    cookies: {
      sessionToken: {
        attributes: { httpOnly: true, sameSite: 'lax', secure: true },
      },
    },
  },
});
08 · FILAS

BullMQ · workers de IA e pixel

Geração de imagem/vídeo nunca bloqueia o request. Worker pega da fila, chama provider, salva resultado, emite evento via Redis Pub/Sub pro frontend (websocket).

// apps/workers/src/queues/ai-image.worker.ts
import { Worker } from 'bullmq';
import { redis } from '../redis';
import { generateImage } from '@expoent/ai/image';

const worker = new Worker(
  'ai-image',
  async (job) => {
    const { jobId, workspaceId, prompt, model } = job.data;
    const start = Date.now();

    await prisma.aiJob.update({
      where: { id: jobId },
      data: { status: 'RUNNING' },
    });

    try {
      const out = await generateImage({ prompt, model });
      await prisma.aiJob.update({
        where: { id: jobId },
        data: {
          status: 'DONE',
          output: { url: out.url, meta: out.meta },
          costCents: out.costCents,
          durationMs: Date.now() - start,
          finishedAt: new Date(),
        },
      });
      // notifica frontend via pub/sub
      await redis.publish(`ws:${workspaceId}`, JSON.stringify({
        kind: 'ai_job_done', jobId,
      }));
    } catch (err) {
      await prisma.aiJob.update({
        where: { id: jobId },
        data: { status: 'FAILED', errorMsg: (err as Error).message },
      });
      throw err;
    }
  },
  { connection: redis, concurrency: 10 },
);
09 · OBSERVABILIDADE

Pino + Sentry + Health

Logs estruturados

nestjs-pino com correlation-id por request, sanitização automática de PII, exportação em JSON.

Erros

Sentry capture em filtros globais; performance tracing 10% sample em prod, 100% em dev.

Health checks

GET /api/healthz verifica DB + Redis + workers em @nestjs/terminus.

Métricas

Prometheus em /metrics com histogramas de latência e contadores de jobs.

10 · DEPLOY

Estratégia recomendada

Fly.io ou Railway pra começar; AWS ECS Fargate quando passar de 5k usuários ativos.

v1

Fly.io · 0–5k usuários

2 apps (api + workers), Neon Postgres, Upstash Redis. ~U$80/mês.

v2

Railway · 5k–25k

Auto-scale por CPU, Postgres dedicado, Redis cluster. ~U$300/mês.

v3

AWS ECS · 25k+

Fargate + RDS Multi-AZ + ElastiCache + CloudFront. Quando justificar.