Schema completo con los cuatro modelos de LeadFlow. Índices explícitos en todas las foreign keys y columnas usadas en
// 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
}
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).
// 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);
}
// 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;
# 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"
Síntoma: al promover leads masivamente,
// 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 };
// 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 };
Síntoma: al crear un lead + enviar email de bienvenida, aparecía
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 }
});
});
// 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 };
Síntoma: leads marcados como eliminados aparecían en los detalles de cliente.
// 💥 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
});
// 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 };
Síntoma: el endpoint
// Expone passwordHash, deletedAt...
export async function GET(req: Request) {
const user = await prisma.user
.findUniqueOrThrow({ where: { id } });
return Response.json(user); // 💥
}
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); // ✅
}
El feed de leads de LeadFlow puede tener miles de entradas. La paginación por offset (
interface LeadsPageResult {
items: Lead[];
nextCursor: string | null;
total: number;
}
async function getLeads(params: {
agentId: string;
stage?: LeadStage;
cursor?: string;
limit?: number;
}): Promise<LeadsPageResult> {
const { agentId, stage, cursor, limit = 20 } = params;
const where = {
assignedToId: agentId,
deletedAt: null, // siempre filtrar soft-delete explícitamente
...(stage && { stage }),
};
// Fetch limit+1 para detectar siguiente página sin COUNT extra
const items = await prisma.lead.findMany({
where,
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // orden secundario único — evita paginación inestable
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
select: {
id: true, name: true, email: true,
stage: true, score: true, company: true,
createdAt: true,
},
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return {
items,
nextCursor: hasNextPage ? items[items.length - 1].id : null,
total: items.length,
};
}
migrate dev se usaba en staging — esto causó dos resets del schema en producción. La regla es sencilla:
| Comando | Entorno correcto | Peligro |
|---|---|---|
| Local solo | Puede resetear la DB en caso de drift de schema | |
| Staging / Producción / CI | Seguro: solo aplica migraciones pendientes | |
| 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 |
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
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
Tabla de referencia rápida para todo el equipo de desarrollo. Imprimir y pegar en el canal #backend.
| # | Regla | Motivo | Estado LeadFlow |
|---|---|---|---|
| 1 | Corregido | ||
| 2 | Singleton de PrismaClient con patrón |
Evita agotamiento de conexiones en Vercel/serverless | Corregido |
| 3 | Nunca retornar entidades Prisma crudas en la API | Expone |
Corregido |
| 4 | Capturar IDs antes de |
Corregido | |
| 5 | Set |
Corregido | |
| 6 | Usar |
Corregido | |
| 7 | Llamadas externas (SendGrid, HTTP) fuera de |
Timeout de 5s en modo interactivo → |
Corregido |
| 8 | Cursor pagination en feeds ( |
Offset pagination escala mal en tablas de miles de filas | Implementado |
| 9 | Sin índices, las queries escalan linealmente | Implementado | |
| 10 | Catchear |
Traducir a errores de dominio antes de llegar al handler HTTP | Implementado |