Informe de análisis · Cliente: LeadFlow CRM · PostgreSQL 15 · 2026-06-12
SaaS B2B de gestión de leads y pipeline comercial · Backend Node.js + Prisma
SELECT * FROM leads WHERE LOWER(email) LIKE '%@gmail.com%' OR LOWER(name) LIKE '%garcia%';
SELECT * FROM leads WHERE LOWER(email) LIKE '%@gmail.com%' OR LOWER(name) LIKE '%garcia%';
SELECT id, email, name, company, status, assigned_to, score FROM leads WHERE search_vector @@ to_tsquery('gmail | garcia') ORDER BY ts_rank(search_vector, to_tsquery('gmail | garcia')) DESC LIMIT 50;
-- 1. Añadir columna tsvector (nullable al inicio) ALTER TABLE leads ADD COLUMN search_vector tsvector; -- 2. Rellenar en batches (sin bloquear la tabla) UPDATE leads SET search_vector = to_tsvector('simple', COALESCE(name, '') || ' ' || COALESCE(email, '') || ' ' || COALESCE(company, '') ); -- 3. Índice GIN (no bloquea escrituras) CREATE INDEX CONCURRENTLY idx_leads_search ON leads USING GIN(search_vector); -- 4. Trigger para mantener actualizado el vector CREATE OR REPLACE FUNCTION leads_search_update() RETURNS trigger AS $$ BEGIN NEW.search_vector := to_tsvector('simple', COALESCE(NEW.name, '') || ' ' || COALESCE(NEW.email, '') || ' ' || COALESCE(NEW.company, '')); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER leads_search_trigger BEFORE INSERT OR UPDATE ON leads FOR EACH ROW EXECUTE FUNCTION leads_search_update();
WITH weekly_leads AS ( -- Leads asignados esta semana SELECT u.id AS user_id, u.full_name, COUNT(l.id) AS total_leads, COUNT(l.id) FILTER (WHERE l.status = 'won') AS won_leads, COUNT(l.id) FILTER (WHERE l.status IN ('won','lost')) AS closed_leads, MAX(l.updated_at) FILTER (WHERE l.status = 'won') AS last_won_at FROM users u LEFT JOIN leads l ON l.assigned_to = u.id AND l.created_at >= date_trunc('week', NOW()) AND l.created_at < date_trunc('week', NOW()) + INTERVAL '7 days' WHERE u.active = TRUE AND u.role = 'sales_rep' GROUP BY u.id, u.full_name ), activities_week AS ( -- Actividades de la semana por usuario SELECT user_id, COUNT(*) AS activity_count FROM activities WHERE done_at >= date_trunc('week', NOW()) GROUP BY user_id ), last_won_lead AS ( -- Nombre del último lead ganado por comercial SELECT DISTINCT ON (assigned_to) assigned_to, name AS last_won_name, company FROM leads WHERE status = 'won' ORDER BY assigned_to, updated_at DESC ) SELECT wl.full_name AS "Comercial", wl.total_leads AS "Leads asignados", wl.won_leads AS "Leads ganados", CASE WHEN wl.closed_leads > 0 THEN ROUND(wl.won_leads::numeric / wl.closed_leads * 100, 1) ELSE 0 END AS "Tasa conversión %", COALESCE(aw.activity_count, 0) AS "Actividades", COALESCE(lwl.last_won_name, '—') AS "Último lead ganado", RANK() OVER (ORDER BY wl.won_leads DESC) AS "Ranking" FROM weekly_leads wl LEFT JOIN activities_week aw ON aw.user_id = wl.user_id LEFT JOIN last_won_lead lwl ON lwl.assigned_to = wl.user_id ORDER BY wl.won_leads DESC, wl.total_leads DESC;
| Comercial | Leads Asign. | Ganados | Conv. % | Actividades | Último ganado | Ranking |
|---|---|---|---|---|---|---|
| Laura Martínez | 28 | 9 | 42.9% | 47 | TechCorp SL | 🥇 1 |
| Carlos Jiménez | 31 | 7 | 35.0% | 38 | InnovaStart | 🥈 2 |
| Ana Rodríguez | 19 | 5 | 38.5% | 62 | Distribuidora Pons | 🥉 3 |
| Marcos Torres | 22 | 2 | 12.5% | 21 | — | 4 |
phone-- Migration: add phone varchar to leads -- Generated: 2026-06-12 ALTER TABLE leads ADD COLUMN phone VARCHAR(255);
-- Rollback migration ALTER TABLE leads DROP COLUMN phone;
score → lead_score-- Migration: rename score to lead_score -- Generated: 2026-06-12 ALTER TABLE leads RENAME COLUMN score TO lead_score;
-- Rollback migration ALTER TABLE leads RENAME COLUMN lead_score TO score;
leads(status, created_at) — CONCURRENTLY-- Migration: add index on leads(status, created_at) -- Generated: 2026-06-12 CREATE INDEX CONCURRENTLY idx_leads_status_created_at ON leads (status, created_at);
-- Rollback DROP INDEX idx_leads_status_created_at;
$queryRaw// services/leadReport.ts import { prisma } from './db'; import { Prisma } from '@prisma/client'; export async function getWeeklyReport() { const startOfWeek = new Date(); startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); startOfWeek.setHours(0,0,0,0); return prisma.$queryRaw<WeeklyReport[]>` WITH weekly_leads AS (...) SELECT ... FROM weekly_leads wl WHERE wl.last_won_at >= ${startOfWeek} ORDER BY won_leads DESC `; }
// ❌ 1 query para leads + 1 por cada uno const leads = await prisma.lead.findMany(); for (const lead of leads) { lead.activities = await prisma.activity .findMany({ where: { lead_id: lead.id } }); // 2.000.000 queries 💀 }
// ✅ 2 queries en total const leads = await prisma.lead.findMany({ where: { created_at: { gte: startOfWeek }, status: 'qualified' }, include: { activities: { orderBy: { done_at: 'desc' }, take: 5 }, assigned_user: { select: { full_name: true } } }, orderBy: { score: 'desc' }, take: 100 });
| Anti-patrón | Encontrado en | Problema | Corrección aplicada |
|---|---|---|---|
| SELECT * | Búsqueda de leads | Transfiere 15+ columnas innecesarias | Columnas explícitas + LIMIT 50 |
| LOWER() en WHERE | Filtro email/name | Seq Scan en 2M filas (4s) | Índice GIN tsvector + to_tsquery |
| LIKE '%texto%' | Búsqueda de leads | No puede usar B-tree index | Full-text search (GIN) |
| Sin LIMIT | Varias queries de listado | Riesgo de devolver millones de filas | Paginación obligatoria (LIMIT + OFFSET) |
| N+1 en ORM | Carga de leads + actividades | 1 + N round-trips a la DB | Prisma include (eager loading) |
| Dinero como FLOAT | — | Errores de redondeo en totales | DECIMAL(19,4) o centavos en INTEGER |
| Sin FK indexes | activities.lead_id, leads.assigned_to | JOINs y DELETE CASCADE lentos | CREATE INDEX CONCURRENTLY en FK columns |
| Feature | PostgreSQL 15 ✓ | MySQL 8.0 | SQLite 3.35 | SQL Server |
|---|---|---|---|---|
| tsvector / Full-text GIN | ✓ nativo | ~ FULLTEXT | ~ FTS5 | ~ Full-text catalog |
| CREATE INDEX CONCURRENTLY | ✓ | ✗ | ✗ | ✗ (ONLINE=ON) |
| CTEs (WITH) + Window Functions | ✓ | ✓ 8.0+ | ✓ 3.25+ | ✓ |
| FILTER en agregados | ✓ | ✗ (usar CASE WHEN) | ✓ 3.30+ | ✗ (usar CASE WHEN) |
| DISTINCT ON | ✓ | ✗ (usar ROW_NUMBER) | ✗ | ✗ (usar ROW_NUMBER) |