Prisma ORM · Guía Técnica

Patrones de Producción para LeadFlow

Backend TypeScript · Prisma 6.x · PostgreSQL (Supabase) · Vercel Serverless
🏢 LeadFlow SaaS
🛠 Prisma 6.x
☁️ Vercel Serverless
🐘 Supabase PostgreSQL
📅 Junio 2026
⚠️ 6 bugs críticos corregidos
📐
Schema Prisma — LeadFlow
01

Schema completo con los cuatro modelos de LeadFlow. Índices explícitos en todas las foreign keys y columnas usadas en WHERE/ORDER BY. Soft delete declarado desde el inicio con índice compuesto [deletedAt, createdAt].

prisma/schema.prisma Prisma
// LeadFlow — schema.prisma (Prisma 6.x)

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

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")  // requerido por Supabase pooler
}

enum Role {
  AGENT
  MANAGER
  ADMIN
}

enum LeadStage {
  NEW
  CONTACTED
  QUALIFIED
  PROPOSAL
  CLOSED_WON
  CLOSED_LOST
}

enum CampaignStatus {
  DRAFT
  SCHEDULED
  RUNNING
  PAUSED
  FINISHED
}

model User {
  id          String     @id @default(cuid())
  email       String     @unique                   // @unique ya crea índice — no @@index
  name        String
  role        Role       @default(AGENT)
  passwordHash String                               // NUNCA exponer en DTOs de respuesta
  leads       Lead[]
  activities  Activity[]
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
  deletedAt   DateTime?                              // soft delete desde el inicio

  @@index([createdAt])
  @@index([deletedAt, createdAt])                  // compuesto para soft-delete + sort
}

model Lead {
  id          String     @id @default(cuid())
  email       String
  name        String
  company     String?
  stage       LeadStage  @default(NEW)
  score       Int        @default(0)
  assignedTo  User?      @relation(fields: [assignedToId], references: [id])
  assignedToId String?
  campaign    Campaign?  @relation(fields: [campaignId], references: [id])
  campaignId  String?
  activities  Activity[]
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
  deletedAt   DateTime?

  @@index([assignedToId])                          // índice en FK
  @@index([campaignId])
  @@index([stage, createdAt])                     // filtros frecuentes de pipeline
  @@index([deletedAt, createdAt])
}

model Campaign {
  id          String         @id @default(cuid())
  name        String
  status      CampaignStatus @default(DRAFT)
  leads       Lead[]
  sentAt      DateTime?
  createdAt   DateTime       @default(now())
  updatedAt   DateTime       @updatedAt

  @@index([status, createdAt])
}

model Activity {
  id        String   @id @default(cuid())
  type      String                                // EMAIL_SENT, CALL, NOTE…
  body      String?
  lead      Lead     @relation(fields: [leadId], references: [id])
  leadId    String
  agent     User     @relation(fields: [agentId], references: [id])
  agentId   String
  createdAt DateTime @default(now())

  @@index([leadId])
  @@index([agentId])
  @@index([leadId, createdAt])                  // timeline de actividad por lead
}
💡
Supabase con Prisma 6.x
Supabase usa un pooler (PgBouncer) en modo transaction. Requiere directUrl apuntando al puerto 5432 directamente (no el pooler) para que migrate deploy funcione sin errores de prepared statements.
🔌
Singleton PrismaClient — Serverless
02

En Vercel cada route handler puede ejecutarse en un worker distinto. Sin el patrón singleton, cada invocación abre su propio pool y se alcanzan los límites de conexiones de Supabase (25 con plan Free, 100 con Pro).

❌ BUG ACTUAL — Nueva instancia por request
// app/api/leads/route.ts
import { PrismaClient } from '@prisma/client';

export async function GET() {
  // 💥 nueva instancia = nuevo pool
  // bajo carga → connection exhaustion
  const prisma = new PrismaClient();
  const leads = await prisma.lead.findMany();
  return Response.json(leads);
}
✅ CORRECTO — Singleton global
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma?: PrismaClient;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error']
      : ['error'],
  });

if (process.env.NODE_ENV !== 'production')
  globalForPrisma.prisma = prisma;
⚠️
Connection limit en Vercel + Supabase
Añadir ?connection_limit=1&pool_timeout=20&pgbouncer=true al DATABASE_URL (el pooler). Usar DIRECT_URL (puerto 5432) solo para migrate deploy, nunca para queries de producción.
.env.production bash
# Supabase pooler (puerto 6543) — para queries de app
DATABASE_URL="postgresql://postgres.xxxx:pass@aws-0-eu-west.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1&pool_timeout=20"

# Supabase directo (puerto 5432) — solo para migrate deploy en CI
DIRECT_URL="postgresql://postgres.xxxx:pass@aws-0-eu-west.pooler.supabase.com:5432/postgres"
🐛
Bugs Críticos Corregidos
03
Bug #1
updateMany devuelve count, no registros
🔥 Producción

Síntoma: al promover leads masivamente, leads[0] era undefined y el endpoint devolvía una respuesta vacía. updateMany siempre devuelve { count: n } — nunca los registros actualizados.

❌ BUG
// Promover leads de NEW → CONTACTED
const leads = await prisma.lead.updateMany({
  where: { stage: 'NEW', assignedToId },
  data:  { stage: 'CONTACTED' },
});
// leads = { count: 14 }
// leads[0] === undefined 💥
return { updated: leads };
✅ CORRECTO
// 1. Capturar IDs antes de actualizar
const targets = await prisma.lead.findMany({
  where: { stage: 'NEW', assignedToId },
  select: { id: true },
});
const ids = targets.map((l) => l.id);

// 2. Actualizar + timestamps manuales
await prisma.lead.updateMany({
  where: { id: { in: ids } },
  data:  { stage: 'CONTACTED', updatedAt: new Date() },
});

// 3. Fetch solo los afectados
const updated = await prisma.lead.findMany({
  where: { id: { in: ids } },
});
return { updated };
⚠️
@updatedAt no dispara en updateMany
El decorador @updatedAt solo aplica en update y upsert. En updateMany hay que pasar updatedAt: new Date() explícitamente.
Bug #2
$transaction con SendGrid → timeout 5s
🔥 Producción

Síntoma: al crear un lead + enviar email de bienvenida, aparecía Transaction already closed. La llamada HTTP a SendGrid superaba el timeout de 5s del modo interactivo.

❌ BUG
await prisma.$transaction(async (tx) => {
  const lead = await tx.lead.create({ data });
  // 💥 HTTP externo dentro de tx
  // supera 5s → "Transaction already closed"
  await sendWelcomeEmail(lead.email);
  await tx.activity.create({
    data: { type: 'EMAIL_SENT', leadId: lead.id, agentId }
  });
});
✅ CORRECTO
// Llamadas externas FUERA de la transacción
const [lead, activity] = await prisma.$transaction([
  prisma.lead.create({ data }),
  prisma.activity.create({
    data: { type: 'EMAIL_SENT',
            leadId: data.id, agentId }
  }),
]);

// Email DESPUÉS de confirmar la tx
await sendWelcomeEmail(lead.email);

return { lead, activity };
Bug #3
Soft delete + findUniqueOrThrow devuelve leads eliminados
🔥 Producción

Síntoma: leads marcados como eliminados aparecían en los detalles de cliente. findUniqueOrThrow solo falla si la fila no existe en base de datos; una fila con deletedAt != null se devuelve sin error.

❌ BUG
// 💥 devuelve leads "eliminados"
const lead = await prisma.lead
  .findUniqueOrThrow({ where: { id } });

// 💥 Error de tipos Prisma —
// {id, deletedAt} no es constraint única
const lead = await prisma.lead
  .findUniqueOrThrow({
    where: { id, deletedAt: null }  // TypeScript error
  });
✅ CORRECTO
// findFirstOrThrow acepta where arbitrario
const lead = await prisma.lead
  .findFirstOrThrow({
    where: { id, deletedAt: null },
    include: {
      activities: {
        orderBy: { createdAt: 'desc' },
        take: 10,
      },
    },
  });

// Mapear a DTO — nunca exponer crudo
return { id: lead.id, name: lead.name,
         stage: lead.stage, email: lead.email,
         activities: lead.activities };
Bug #4
Entidades Prisma crudas en respuestas API
⚡ Seguridad

Síntoma: el endpoint GET /api/users/me devolvía passwordHash y deletedAt en la respuesta JSON. Los endpoints deben mapear siempre a DTOs explícitos.

❌ BUG — Filtra campos internos
// Expone passwordHash, deletedAt...
export async function GET(req: Request) {
  const user = await prisma.user
    .findUniqueOrThrow({ where: { id } });
  return Response.json(user);  // 💥
}
✅ CORRECTO — DTO explícito
export async function GET(req: Request) {
  const user = await prisma.user
    .findFirstOrThrow({
      where: { id, deletedAt: null },
      select: { id: true, name: true,
                email: true, role: true },
    });
  return Response.json(user);  // ✅
}
🏗️
Migraciones — Reglas para LeadFlow
05

migrate dev se usaba en staging — esto causó dos resets del schema en producción. La regla es sencilla: migrate dev solo en local; migrate deploy en todos los demás entornos.

Comando Entorno correcto Peligro
prisma migrate dev Local solo Puede resetear la DB en caso de drift de schema
prisma migrate deploy Staging / Producción / CI Seguro: solo aplica migraciones pendientes
prisma migrate diff Revisión previa Solo lee, no escribe — ideal antes de un deploy
Editar archivo .sql de migración Nunca Prisma checksum mismatch P3006 en todos los entornos
.github/workflows/deploy.yml YAML
jobs:
  deploy:
    steps:
      - name: Apply pending migrations
        run: npx prisma migrate deploy
        env:
          # Usar DIRECT_URL (puerto 5432) para migraciones
          DATABASE_URL: ${{ secrets.DIRECT_URL }}

      - name: Generate Prisma client
        run: npx prisma generate

      - name: Deploy to Vercel
        run: vercel --prod
🚦
Manejo de Errores Prisma
06
services/lead.service.ts TypeScript
import { Prisma } from '@prisma/client';

async function createLead(data: CreateLeadDto) {
  try {
    return await prisma.lead.create({ data });
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError) {
      switch (e.code) {
        case 'P2002':  // unique constraint violation
          throw new ConflictError('Lead con ese email ya existe');
        case 'P2025':  // record not found
          throw new NotFoundError('Agente asignado no encontrado');
        case 'P2003':  // foreign key violation
          throw new BadRequestError('Campaña referenciada no existe');
      }
    }
    throw e;  // re-throw errores no mapeados
  }
}

// Códigos más frecuentes en LeadFlow:
// P2002 — email duplicado en users/leads
// P2025 — lead/agent/campaign no encontrado
// P2003 — FK: assignedToId o campaignId inexistente
🏆
Reglas de Oro — Equipo LeadFlow
07

Tabla de referencia rápida para todo el equipo de desarrollo. Imprimir y pegar en el canal #backend.

# Regla Motivo Estado LeadFlow
1 migrate deploy en CI/CD; migrate dev solo en local migrate dev puede resetear la DB en staging/prod Corregido
2 Singleton de PrismaClient con patrón globalThis Evita agotamiento de conexiones en Vercel/serverless Corregido
3 Nunca retornar entidades Prisma crudas en la API Expone passwordHash, deletedAt y campos internos Corregido
4 Capturar IDs antes de updateMany, fetch después updateMany devuelve { count: n }, no registros Corregido
5 Set updatedAt: new Date() en updateMany @updatedAt no dispara en operaciones bulk Corregido
6 Usar findFirstOrThrow para soft-delete + filtro findUniqueOrThrow no acepta deletedAt: null con id Corregido
7 Llamadas externas (SendGrid, HTTP) fuera de $transaction Timeout de 5s en modo interactivo → Transaction already closed Corregido
8 Cursor pagination en feeds (limit+1 + pop) Offset pagination escala mal en tablas de miles de filas Implementado
9 @@index en todas las FKs y columnas de WHERE/ORDER BY Sin índices, las queries escalan linealmente Implementado
10 Catchear PrismaClientKnownRequestError en capa de servicio Traducir a errores de dominio antes de llegar al handler HTTP Implementado
PRISMA 6.x
relationJoins — benchmark requerido
Prisma 6 carga relaciones via JOIN por defecto. En relaciones 1:N grandes puede multiplicar el tamaño del result set. Benchmarkar include vs consultas separadas cuando una relación devuelva 100+ filas por padre.
SEGURIDAD
select explícito en endpoints públicos
Usar siempre select (allowlist) en lugar de include en endpoints accesibles sin autenticación o con roles bajos. Previene leaks accidentales de campos sensibles añadidos al schema en el futuro.
SERVERLESS
connection_limit=1 en Vercel
Cada worker de Vercel debe usar máximo 1 conexión al pool externo. Con Supabase, el pooler (puerto 6543) con pgbouncer=true gestiona el multiplex. Sin esto, 100 workers = 100 conexiones abiertas.
DATOS
deleteMany SIEMPRE con where
prisma.lead.deleteMany() sin where elimina toda la tabla silenciosamente. Requerir revisión de código en cualquier deleteMany sin cláusula where explícita.