feat(mejoras): 14 mejoras al CRM Frasa

Fase 1 - UI: dark mode, skeletons, toasts
Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K
Fase 3 - Datos: export Excel, notas apartados, auditoría
Fase 4 - Dashboard: gráficas vendedor + métricas uso
Fase 5 - Responsive: tablets optimizadas
Fase 6 - PWA: IndexedDB offline, push notifications

Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos
721 API tests + fronts verdes.
This commit is contained in:
2026-09-07 06:04:49 +00:00
parent 388e1da338
commit c77b4c0628
170 changed files with 5802 additions and 637 deletions

View File

@@ -16,6 +16,7 @@
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.6.0",
"web-push": "^3.6.7",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -26,6 +27,7 @@
"@types/multer": "^2.0.0",
"@types/node": "^22.15.0",
"@types/node-cron": "^3.0.11",
"@types/web-push": "^3.6.4",
"prisma": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
@@ -1118,6 +1120,16 @@
"@types/node": "*"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -1246,12 +1258,33 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1271,6 +1304,12 @@
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -2040,6 +2079,15 @@
"node": ">= 0.4"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2060,6 +2108,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -2275,6 +2336,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3369,6 +3445,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

@@ -30,6 +30,7 @@
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.6.0",
"web-push": "^3.6.7",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -40,6 +41,7 @@
"@types/multer": "^2.0.0",
"@types/node": "^22.15.0",
"@types/node-cron": "^3.0.11",
"@types/web-push": "^3.6.4",
"prisma": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",

View File

@@ -0,0 +1,26 @@
-- Notas de apartado y bitácora de auditoría.
-- Patrón manual: db execute + migrate resolve. NUNCA migrate dev.
-- CreateTable NotaApartado
CREATE TABLE IF NOT EXISTS "NotaApartado" (
"id" TEXT NOT NULL PRIMARY KEY,
"apartadoId" TEXT NOT NULL,
"usuarioId" TEXT NOT NULL,
"contenido" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "NotaApartado_apartadoId_idx" ON "NotaApartado"("apartadoId");
-- CreateTable AuditoriaLog
CREATE TABLE IF NOT EXISTS "AuditoriaLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"usuarioId" TEXT NOT NULL,
"accion" TEXT NOT NULL,
"entidad" TEXT NOT NULL,
"entidadId" TEXT NOT NULL,
"detalle" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "AuditoriaLog_usuarioId_idx" ON "AuditoriaLog"("usuarioId");
CREATE INDEX IF NOT EXISTS "AuditoriaLog_entidad_entidadId_idx" ON "AuditoriaLog"("entidad", "entidadId");
CREATE INDEX IF NOT EXISTS "AuditoriaLog_createdAt_idx" ON "AuditoriaLog"("createdAt");

View File

@@ -387,3 +387,40 @@ model AsignacionLocal {
@@unique([leadCapturaId])
@@index([usuarioId])
}
model NotaApartado {
id String @id @default(uuid())
apartadoId String
usuarioId String
contenido String
createdAt DateTime @default(now())
@@index([apartadoId])
}
model AuditoriaLog {
id String @id @default(uuid())
usuarioId String
accion String
entidad String
entidadId String
detalle String?
createdAt DateTime @default(now())
@@index([usuarioId])
@@index([entidad, entidadId])
@@index([createdAt])
}
// Suscripciones push de los navegadores (Web Push API)
model PushSubscription {
id String @id @default(uuid())
usuarioId String
endpoint String
p256dh String
auth String
createdAt DateTime @default(now())
@@unique([usuarioId])
@@index([usuarioId])
}

View File

@@ -91,6 +91,11 @@ export const config = {
// aceptada por jsonwebtoken, p. ej. '72h')
expiresIn: env('LIGA_REMOTA_EXP', '72h'),
},
push: {
vapidPublicKey: env('VAPID_PUBLIC_KEY', ''),
vapidPrivateKey: env('VAPID_PRIVATE_KEY', ''),
vapidEmail: env('VAPID_EMAIL', 'mailto:admin@frasa.com'),
},
} as const
export type AppConfig = typeof config

View File

@@ -28,6 +28,9 @@ const prismaMock = vi.hoisted(() => ({
syncPendiente: {
create: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(),
}))

View File

@@ -12,6 +12,7 @@ import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
import * as auditoria from '../services/auditoria.service.js'
/**
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
@@ -76,6 +77,7 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
}
}
res.json({ itemId, estado: body.estado })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'apartado', itemId, body.estado)
}
// ---------- Precarga de documentos del expediente (desde "Mis apartados") ----------

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError } from '../utils/errors.js'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
auditoriaLog: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { listarAuditoriaEndpoint } from './auditoria.controller.js'
function reqFake(query?: Record<string, string>, token?: { sub: string; rol: string }): Request {
return { query, usuarioToken: token } as unknown as Request
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
it('retorna logs de auditoría para admin', async () => {
const logsFake = [{ id: 'log-1', usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'l1', detalle: null, createdAt: new Date() }]
prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake)
const { res, json } = resFake()
await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res)
expect(json).toHaveBeenCalledWith(logsFake)
})
it('aplica filtros de entidad y usuarioId', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
const { res } = resFake()
await listarAuditoriaEndpoint(
reqFake({ entidad: 'lead', usuarioId: 'u1' }, { sub: 'admin1', rol: 'admin' }),
res,
)
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ entidad: 'lead', usuarioId: 'u1' }),
}),
)
})
it('aplica filtro de fechas', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
const { res } = resFake()
await listarAuditoriaEndpoint(
reqFake({ desde: '2026-09-01', hasta: '2026-09-07' }, { sub: 'admin1', rol: 'admin' }),
res,
)
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }),
}),
}),
)
})
it('respeta el límite personalizado', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
const { res } = resFake()
await listarAuditoriaEndpoint(
reqFake({ limite: '10' }, { sub: 'admin1', rol: 'admin' }),
res,
)
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 10 }),
)
})
it('retorna array vacío si no hay logs', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
const { res, json } = resFake()
await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res)
expect(json).toHaveBeenCalledWith([])
})
})

View File

@@ -0,0 +1,31 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { buscar } from '../services/auditoria.service.js'
const buscarSchema = z.object({
entidad: z.string().optional(),
entidadId: z.string().optional(),
usuarioId: z.string().optional(),
desde: z.string().optional(),
hasta: z.string().optional(),
limite: z.coerce.number().int().min(1).max(500).optional(),
})
/** GET /api/auditoria — admin only: listar logs de auditoría con filtros */
export async function listarAuditoriaEndpoint(req: Request, res: Response): Promise<void> {
const query = buscarSchema.parse(req.query)
const filtros: {
entidad?: string
entidadId?: string
usuarioId?: string
desde?: Date
hasta?: Date
} = {}
if (query.entidad !== undefined) filtros.entidad = query.entidad
if (query.entidadId !== undefined) filtros.entidadId = query.entidadId
if (query.usuarioId !== undefined) filtros.usuarioId = query.usuarioId
if (query.desde !== undefined) filtros.desde = new Date(query.desde)
if (query.hasta !== undefined) filtros.hasta = new Date(query.hasta)
const logs = await buscar(filtros, query.limite ?? 100)
res.json(logs)
}

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Service mockeado ----------
const buscarMock = vi.hoisted(() => vi.fn())
vi.mock('../services/buscar.service.js', () => ({ buscar: buscarMock }))
import { buscarEndpoint } from './buscar.controller.js'
function req(query: Record<string, string> = {}): Request {
return { query } as unknown as Request
}
function res(): Response {
const json = vi.fn()
return { json } as unknown as Response
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('buscarEndpoint (GET /api/buscar)', () => {
it('extrae q del query string y llama buscar', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'María' }), r)
expect(buscarMock).toHaveBeenCalledWith('María', 20)
expect(r.json).toHaveBeenCalledWith([])
})
it('q vacío (no string) → termino vacío', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({}), r)
expect(buscarMock).toHaveBeenCalledWith('', 20)
})
it('limite válido del query string', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'x', limite: '10' }), r)
expect(buscarMock).toHaveBeenCalledWith('x', 10)
})
it('limite inválido o > 50 se clampa a 20', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r)
expect(buscarMock).toHaveBeenCalledWith('x', 20)
const r2 = res()
await buscarEndpoint(req({ q: 'x', limite: '100' }), r2)
expect(buscarMock).toHaveBeenCalledWith('x', 50)
})
it('devuelve los resultados del service como JSON', async () => {
const fakeResults = [
{ tipo: 'lead', id: 'l1', nombre: 'Test', detalle: '', url: '/app/#leads' },
]
buscarMock.mockResolvedValue(fakeResults)
const r = res()
await buscarEndpoint(req({ q: 'Test' }), r)
expect(r.json).toHaveBeenCalledWith(fakeResults)
})
})

View File

@@ -0,0 +1,13 @@
import type { Request, Response } from 'express'
import { buscar } from '../services/buscar.service.js'
/**
* GET /api/buscar?q=... — búsqueda global (leads, contactos, cotizaciones,
* unidades). Requiere autenticación; devuelve array de BuscadoItem[].
*/
export async function buscarEndpoint(req: Request, res: Response): Promise<void> {
const q = typeof req.query.q === 'string' ? req.query.q : ''
const limite = typeof req.query.limite === 'string' ? Math.min(parseInt(req.query.limite, 10) || 20, 50) : 20
const resultados = await buscar(q, limite)
res.json(resultados)
}

View File

@@ -17,9 +17,12 @@ const prismaMock = vi.hoisted(() => ({
usuario: {
findUnique: vi.fn(),
},
syncPendiente: {
syncPendiente: {
create: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
}))
const mondayMock = vi.hoisted(() => ({

View File

@@ -13,6 +13,7 @@ import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
// ---------- Alta de clientes ----------
@@ -420,6 +421,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
}
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id)
}
// ---------- Alta progresiva (guardado etapa por etapa del wizard) ----------

View File

@@ -12,7 +12,7 @@ const prismaMock = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
syncPendiente: {
create: vi.fn(),
},
}))
@@ -30,6 +30,10 @@ const funnelMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
vi.mock('../services/auditoria.service.js', () => ({
registrar: vi.fn(),
buscar: vi.fn(),
}))
import { crearCotizacion } from './cotizaciones.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'

View File

@@ -8,9 +8,12 @@ const prismaMock = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
syncPendiente: {
create: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
}))
const mondayMock = vi.hoisted(() => ({

View File

@@ -11,6 +11,7 @@ import {
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro } from '../services/crm-local.service.js'
import { NotFoundError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
const crearCotizacionSchema = z.object({
@@ -130,6 +131,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
}
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
}
/** GET /api/cotizaciones/mias — últimas 50 del vendedor autenticado (sub del JWT) */

View File

@@ -47,6 +47,9 @@ const prismaMock = vi.hoisted(() => ({
cliente: {
findMany: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(),
}))
@@ -473,6 +476,7 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
describe('crearLeadEndpoint (POST /api/leads)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
// El singleton Monday responde como si creara el lead en el tablero Leads
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
@@ -569,6 +573,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
describe('crearAfluenciaEndpoint (POST /api/afluencias)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
// El singleton Monday responde como si creara la afluencia en Oportunidades
vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9')
@@ -747,6 +752,7 @@ describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo"
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.automatizacionState.upsert.mockResolvedValue({})
@@ -827,6 +833,7 @@ describe('ruteo modo local (Monday caído o fallback 502)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([])

View File

@@ -7,6 +7,7 @@ import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../ser
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
@@ -500,6 +501,7 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
}
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_LEAD', 'lead', resultado.id)
}
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
@@ -620,6 +622,7 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
}
}
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado)
}
/** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */
@@ -651,6 +654,7 @@ export async function descartarLeadEndpoint(req: Request, res: Response): Promis
}
}
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo)
}
// ---------- Conversión lead → oportunidad (botón "Agendo" de Mis leads) ----------
@@ -742,15 +746,18 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
return
}
try {
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
} catch (err) {
if (!esFalloMonday(err)) throw err
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
}
}

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
notaApartado: {
create: vi.fn(),
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { crearNotaEndpoint, listarNotasEndpoint } from './notas.controller.js'
function reqFake(params: Record<string, string>, body?: unknown, token?: { sub: string }): Request {
return { params, body, usuarioToken: token } as unknown as Request
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('crearNotaEndpoint (POST /api/apartados/:id/notas)', () => {
it('crea una nota y retorna 201', async () => {
const notaFake = { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Nota test', createdAt: new Date() }
prismaMock.notaApartado.create.mockResolvedValue(notaFake)
const { res, status, json } = resFake()
await crearNotaEndpoint(reqFake({ id: 'apt-1' }, { contenido: 'Nota test' }, { sub: 'u1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith(notaFake)
})
it('rechaza contenido vacío', async () => {
const { res } = resFake()
await expect(
crearNotaEndpoint(reqFake({ id: 'apt-1' }, { contenido: '' }, { sub: 'u1' }), res),
).rejects.toThrow()
})
})
describe('listarNotasEndpoint (GET /api/apartados/:id/notas)', () => {
it('retorna las notas del apartado', async () => {
const notasFake = [
{ id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'A', createdAt: new Date() },
]
prismaMock.notaApartado.findMany.mockResolvedValue(notasFake)
const { res, json } = resFake()
await listarNotasEndpoint(reqFake({ id: 'apt-1' }), res)
expect(json).toHaveBeenCalledWith(notasFake)
})
it('retorna array vacío si no hay notas', async () => {
prismaMock.notaApartado.findMany.mockResolvedValue([])
const { res, json } = resFake()
await listarNotasEndpoint(reqFake({ id: 'apt-inexistente' }), res)
expect(json).toHaveBeenCalledWith([])
})
})

View File

@@ -0,0 +1,23 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { crearNota, listarNotas } from '../services/nota-apartado.service.js'
const crearNotaSchema = z.object({
contenido: z.string().trim().min(1, 'El contenido es obligatorio'),
})
/** POST /api/apartados/:id/notas — crear nota del apartado */
export async function crearNotaEndpoint(req: Request, res: Response): Promise<void> {
const body = crearNotaSchema.parse(req.body)
const apartadoId = String(req.params['id'])
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
const nota = await crearNota(apartadoId, usuarioId, body.contenido)
res.status(201).json(nota)
}
/** GET /api/apartados/:id/notas — listar notas del apartado */
export async function listarNotasEndpoint(req: Request, res: Response): Promise<void> {
const apartadoId = String(req.params['id'])
const notas = await listarNotas(apartadoId)
res.json(notas)
}

View File

@@ -26,6 +26,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(),
}))
@@ -170,6 +173,7 @@ describe('ruteo modo local (endpoints de oportunidades)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))

View File

@@ -7,6 +7,7 @@ import { automatizacionesService } from '../services/automatizaciones.service.js
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import * as auditoria from '../services/auditoria.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
@@ -96,6 +97,7 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
}
}
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo)
}
// ---------- Cambio de etapa desde Mi día ----------
@@ -200,4 +202,5 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(usuarioSub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
}

View File

@@ -0,0 +1,41 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import {
registrarSuscripcion,
eliminarSuscripcion,
obtenerVapidPublicKey,
} from '../services/push.service.js'
const suscripcionSchema = z.object({
endpoint: z.string().url(),
p256dh: z.string().min(1),
auth: z.string().min(1),
})
/** POST /api/push/suscribir — registrar suscripción push del navegador */
export async function suscribirEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) {
res.status(401).json({ error: 'Token requerido' })
return
}
const body = suscripcionSchema.parse(req.body)
await registrarSuscripcion(usuarioId, body)
res.json({ ok: true })
}
/** POST /api/push/desuscribir — eliminar suscripción push */
export async function desuscribirEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) {
res.status(401).json({ error: 'Token requerido' })
return
}
await eliminarSuscripcion(usuarioId)
res.json({ ok: true })
}
/** GET /api/push/vapid-key — devuelve la VAPID public key (pública) */
export function vapidKeyEndpoint(_req: Request, res: Response): void {
res.json({ publicKey: obtenerVapidPublicKey() })
}

View File

@@ -0,0 +1,139 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { Request, Response } from 'express'
import { AppError } from '../utils/errors.js'
// ---------- Mocks ----------
const prismaMock = vi.hoisted(() => ({
usuario: {
findUnique: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
// Mock del servicio: permitimos que construirMiTablero funcione real; solo
// mockeamos obtenerMiTablero y obtenerMetricasUso para no tocar la BD
const servicioMock = vi.hoisted(() => ({
obtenerMiTablero: vi.fn(),
obtenerMetricasUso: vi.fn(),
}))
vi.mock('../services/reporte-mi-tablero.service.js', () => servicioMock)
import { miTableroEndpoint, metricasUsoEndpoint, requireAdminOrCoordinador } from './reporte-mi-tablero.controller.js'
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const usuarioFake = {
id: 'u-1',
nombre: 'Vendedor Test',
rol: 'vendedor',
activo: true,
mondayUserId: 100,
}
const tableroFake = {
generadoEn: '2026-09-07T12:00:00.000Z',
leadsPorDia: [],
conversion: { leads: 5, oportunidades: 3, apartados: 1, vendidos: 1 },
ventasMes: { monto: 850000, unidades: 1 },
porOrigen: [],
porDesarrollo: [],
actividadesHoy: 2,
actividadesPendientes: 5,
}
const metricasFake = {
generadoEn: '2026-09-07T12:00:00.000Z',
usuarios: [],
}
// ---------- miTableroEndpoint ----------
describe('miTableroEndpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
servicioMock.obtenerMiTablero.mockResolvedValue(tableroFake)
})
it('devuelve el tablero del vendedor logueado', async () => {
const { res, json } = resFake()
const req = { usuarioToken: { sub: 'u-1' } } as unknown as Request
await miTableroEndpoint(req, res)
expect(json).toHaveBeenCalledWith(tableroFake)
expect(servicioMock.obtenerMiTablero).toHaveBeenCalledWith('u-1')
})
it('lanza 401 si el usuario no existe', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(null)
const req = { usuarioToken: { sub: 'u-unknown' } } as unknown as Request
const { res } = resFake()
await expect(miTableroEndpoint(req, res)).rejects.toThrow(AppError)
})
it('lanza 401 si el usuario está inactivo', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, activo: false })
const req = { usuarioToken: { sub: 'u-1' } } as unknown as Request
const { res } = resFake()
await expect(miTableroEndpoint(req, res)).rejects.toThrow(AppError)
})
})
// ---------- metricasUsoEndpoint ----------
describe('metricasUsoEndpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
servicioMock.obtenerMetricasUso.mockResolvedValue(metricasFake)
})
it('devuelve métricas generales para admin', async () => {
const { res, json } = resFake()
const req = { usuarioToken: { sub: 'u-admin', rol: 'admin' } } as unknown as Request
await metricasUsoEndpoint(req, res)
expect(json).toHaveBeenCalledWith(metricasFake)
// Admin no filtra por usuario
expect(servicioMock.obtenerMetricasUso).toHaveBeenCalledWith(undefined)
})
it('filtra por usuarioId para coordinador', async () => {
const { res, json } = resFake()
const req = { usuarioToken: { sub: 'u-coord', rol: 'coordinador' } } as unknown as Request
await metricasUsoEndpoint(req, res)
expect(servicioMock.obtenerMetricasUso).toHaveBeenCalledWith('u-coord')
})
})
// ---------- requireAdminOrCoordinador ----------
describe('requireAdminOrCoordinador', () => {
it('llama next() para admin', () => {
const next = vi.fn()
const req = { usuarioToken: { rol: 'admin' } } as unknown as Request
requireAdminOrCoordinador(req, {} as Response, next)
expect(next).toHaveBeenCalled()
})
it('llama next() para coordinador', () => {
const next = vi.fn()
const req = { usuarioToken: { rol: 'coordinador' } } as unknown as Request
requireAdminOrCoordinador(req, {} as Response, next)
expect(next).toHaveBeenCalled()
})
it('lanza 403 para vendedor', () => {
const req = { usuarioToken: { rol: 'vendedor' } } as unknown as Request
expect(() => requireAdminOrCoordinador(req, {} as Response, vi.fn())).toThrow(AppError)
})
it('lanza 403 para corporativo', () => {
const req = { usuarioToken: { rol: 'corporativo' } } as unknown as Request
expect(() => requireAdminOrCoordinador(req, {} as Response, vi.fn())).toThrow(AppError)
})
})

View File

@@ -0,0 +1,52 @@
import type { NextFunction, Request, Response } from 'express'
import { obtenerMiTablero, obtenerMetricasUso } from '../services/reporte-mi-tablero.service.js'
import { prisma } from '../services/prisma.js'
import { AppError } from '../utils/errors.js'
// ---------- GET /api/reportes/mi-tablero ----------
/**
* Tablero personal del vendedor logueado: leads por día (30 días), conversión,
* ventas del mes, rendimiento por origen, ranking de desarrollos y actividades.
*/
export async function miTableroEndpoint(req: Request, res: Response): Promise<void> {
const sub = req.usuarioToken?.sub
const usuario = sub
? await prisma.usuario.findUnique({ where: { id: sub } })
: await prisma.usuario.findUnique({ where: { username: 'admin' } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
const tablero = await obtenerMiTablero(usuario.id)
res.json(tablero)
}
// ---------- GET /api/reportes/metricas-uso ----------
/** Middleware: admin o coordinador */
export function requireAdminOrCoordinador(
req: Request,
_res: Response,
next: NextFunction,
): void {
const rol = req.usuarioToken?.rol
if (rol !== 'admin' && rol !== 'coordinador') {
throw new AppError(403, 'Se requiere rol admin o coordinador')
}
next()
}
/**
* Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de
* descarte, actividades completadas vs pendientes.
* Admin ve todos; coordinador ve solo su equipo.
*/
export async function metricasUsoEndpoint(req: Request, res: Response): Promise<void> {
const rol = req.usuarioToken?.rol
const sub = req.usuarioToken?.sub
// Admin ve todos; coordinador podría filtrarse por equipo futuro, pero por
// ahora admin y coordinador ven lo mismo (todos los usuarios activos)
const usuarioId = rol === 'coordinador' && sub ? sub : undefined
const metricas = await obtenerMetricasUso(usuarioId)
res.json(metricas)
}

View File

@@ -10,6 +10,7 @@ const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [
{ metodo: 'GET', ruta: '/health' },
{ metodo: 'POST', ruta: '/auth/login' },
{ metodo: 'POST', ruta: '/webhooks/monday' },
{ metodo: 'GET', ruta: '/push/vapid-key' },
]
// Rutas públicas con parámetros (se comparan contra req.path relativo a /api).

View File

@@ -93,10 +93,24 @@ import {
resetRolesEndpoint,
obtenerAccionesEndpoint,
} from '../controllers/config.controller.js'
import { crearNotaEndpoint, listarNotasEndpoint } from '../controllers/notas.controller.js'
import { listarAuditoriaEndpoint } from '../controllers/auditoria.controller.js'
import { buscarEndpoint } from '../controllers/buscar.controller.js'
import {
miTableroEndpoint,
metricasUsoEndpoint,
requireAdminOrCoordinador,
} from '../controllers/reporte-mi-tablero.controller.js'
import {
suscribirEndpoint,
desuscribirEndpoint,
vapidKeyEndpoint,
} from '../controllers/push.controller.js'
export const apiRouter = Router()
apiRouter.get('/health', health)
apiRouter.get('/buscar', buscarEndpoint)
apiRouter.post('/auth/login', loginEndpoint)
apiRouter.get('/auth/me', meEndpoint)
@@ -163,6 +177,9 @@ apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array('archivos', 10), subirDocumentosApartado)
// Notas del apartado
apiRouter.post('/apartados/:id/notas', crearNotaEndpoint)
apiRouter.get('/apartados/:id/notas', listarNotasEndpoint)
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
@@ -185,6 +202,10 @@ apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)
// Tablero personal del vendedor: métricas, conversión, ventas, origen, desarrollos
apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades
apiRouter.get('/reportes/metricas-uso', requireAdminOrCoordinador, metricasUsoEndpoint)
// Estado del modo local (banner del front): salud de Monday + cola de sync
apiRouter.get('/sistema/estado', estadoSistema)
@@ -202,6 +223,9 @@ apiRouter.post('/ai/documents', subirDocumento)
apiRouter.get('/ai/documents', listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
// ── Auditoría (admin) ────────────────────────────────────────────────
apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)
// ── Configuración (admin) ──────────────────────────────────────────────
apiRouter.get('/config/sistema', requireAdmin, obtenerConfigsEndpoint)
apiRouter.patch('/config/sistema', requireAdmin, actualizarConfigsEndpoint)
@@ -211,3 +235,8 @@ apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint)
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
// ── Push notifications ──────────────────────────────────────────────
apiRouter.get('/push/vapid-key', vapidKeyEndpoint)
apiRouter.post('/push/suscribir', suscribirEndpoint)
apiRouter.post('/push/desuscribir', desuscribirEndpoint)

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
auditoriaLog: {
create: vi.fn(),
findMany: vi.fn(),
},
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import { registrar, buscar } from './auditoria.service.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('registrar', () => {
it('fire-and-forget: llama a prisma.create sin await', () => {
prismaMock.auditoriaLog.create.mockResolvedValue({})
// registrar no devuelve promesa (fire-and-forget)
registrar('u1', 'CREAR_LEAD', 'lead', 'lead-1', 'detalle opcional')
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: { usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'lead-1', detalle: 'detalle opcional' },
})
})
it('maneja detalle undefined como null', () => {
prismaMock.auditoriaLog.create.mockResolvedValue({})
registrar('u1', 'ACCION', 'ent', 'ent-1')
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: { usuarioId: 'u1', accion: 'ACCION', entidad: 'ent', entidadId: 'ent-1', detalle: null },
})
})
it('no lanza error si la inserción falla (fire-and-forget)', async () => {
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
prismaMock.auditoriaLog.create.mockRejectedValue(new Error('DB error'))
// registrar no lanza
registrar('u1', 'ACCION', 'ent', 'ent-1')
// Esperar microtask para que el .catch se ejecute
await new Promise((r) => setTimeout(r, 10))
expect(spyError).toHaveBeenCalled()
spyError.mockRestore()
})
})
describe('buscar', () => {
it('sin filtros: retorna todos los logs ordenados por createdAt desc', async () => {
const logsFake = [{ id: 'log-1' }]
prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake)
const resultado = await buscar({})
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: {},
orderBy: { createdAt: 'desc' },
take: 100,
})
expect(resultado).toEqual(logsFake)
})
it('con filtros de entidad y usuarioId', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
await buscar({ entidad: 'lead', usuarioId: 'u1' })
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { entidad: 'lead', usuarioId: 'u1' },
orderBy: { createdAt: 'desc' },
take: 100,
})
})
it('con filtros de fecha', async () => {
const desde = new Date('2026-09-01')
const hasta = new Date('2026-09-07')
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
await buscar({ desde, hasta })
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { createdAt: { gte: desde, lte: hasta } },
orderBy: { createdAt: 'desc' },
take: 100,
})
})
it('respeta el límite personalizado', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
await buscar({ entidad: 'lead' }, 10)
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { entidad: 'lead' },
orderBy: { createdAt: 'desc' },
take: 10,
})
})
})

View File

@@ -0,0 +1,49 @@
import { prisma } from './prisma.js'
export interface FiltrosAuditoria {
entidad?: string
entidadId?: string
usuarioId?: string
desde?: Date
hasta?: Date
}
/**
* Registra un evento de auditoría. Fire-and-forget: no devuelve promesa
*awaitable para que el caller no se bloquee.
*/
export function registrar(
usuarioId: string,
accion: string,
entidad: string,
entidadId: string,
detalle?: string,
): void {
// Fire-and-forget: errors se loguean pero no se propagan
prisma.auditoriaLog
.create({
data: { usuarioId, accion, entidad, entidadId, detalle: detalle ?? null },
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('[auditoria] Error registrando log:', err)
})
}
export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
const where: Record<string, unknown> = {}
if (filtros.entidad !== undefined) where['entidad'] = filtros.entidad
if (filtros.entidadId !== undefined) where['entidadId'] = filtros.entidadId
if (filtros.usuarioId !== undefined) where['usuarioId'] = filtros.usuarioId
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
where['createdAt'] = {
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}),
}
}
return prisma.auditoriaLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limite,
})
}

View File

@@ -10,6 +10,7 @@ import {
} from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
import { enviarPush, usuarioIdPorMondayUserId } from './push.service.js'
/**
* Automatizaciones sobre Monday:
@@ -382,6 +383,11 @@ export class AutomatizacionesService {
leadItemId: c.id,
})
}
// Push notification al vendedor asignado
const usuarioId = await usuarioIdPorMondayUserId(vendedor.id)
if (usuarioId) {
void enviarPush(usuarioId, 'Nuevo lead asignado', `${c.nombre} — ${c.tablero}`, `/hoy/`)
}
detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero })
indice++
}
@@ -700,6 +706,8 @@ export class AutomatizacionesService {
}
if (usuarioLocalId) {
// Push notification al vendedor asignado
void enviarPush(usuarioLocalId, 'Nuevo lead asignado', lead.nombreLead, '/hoy/')
await prisma.asignacionLocal.create({
data: {
leadCapturaId: lead.id,
@@ -859,6 +867,41 @@ export class AutomatizacionesService {
return { horasUmbral, candidatos, escalados }
}
/**
* Escaneo de apartados por vencer: detecta apartados con fechaLimite en los
* próximos 3 días y envía push al vendedor. Dedup diario vía
* `apartado-push:<id>:<fecha>`.
*/
async escaneoApartadosPorVencer(): Promise<{ revisados: number; enviados: number }> {
const ahora = new Date()
const limite = new Date(ahora)
limite.setDate(limite.getDate() + 3)
const apartados = await prisma.leadCaptura.findMany({
where: {
board: 'APARTADO',
etapa: { notIn: ['Cancelacion Apartado', 'Firma Cliente (Venta)'] },
fechaLimite: { not: null, lte: limite, gte: ahora },
},
})
let enviados = 0
for (const a of apartados) {
const clave = `apartado-push:${a.id}:${ahora.toISOString().slice(0, 10)}`
if ((await this.state.get(clave)) !== null) continue
void enviarPush(
a.usuarioId,
'Apartado por vencer',
`${a.nombreLead} — vence ${a.fechaLimite?.toISOString().slice(0, 10) ?? 'pronto'}`,
'/hoy/',
)
await this.state.set(clave, ahora.toISOString())
enviados++
}
return { revisados: apartados.length, enviados }
}
/** Estado actual para el endpoint admin */
async estado(): Promise<Record<string, unknown>> {
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: { findMany: vi.fn() },
cliente: { findMany: vi.fn() },
cotizacion: { findMany: vi.fn() },
unidad: { findMany: vi.fn() },
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import { buscar } from './buscar.service.js'
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.unidad.findMany.mockResolvedValue([])
})
describe('buscar (GET /api/buscar)', () => {
it('término vacío retorna [] sin consultar la base', async () => {
const r = await buscar('')
expect(r).toEqual([])
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
})
it('término con solo espacios también retorna []', async () => {
const r = await buscar(' ')
expect(r).toEqual([])
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
})
it('consulta los 4 modelos con el patrón contains', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'María López', telefono: '4771112233', email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'María López', curp: '', rfc: '', email: 'maria@x.com', celular: '4771112233', proyecto: 'DIAGONAL', createdAt: new Date() },
])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.unidad.findMany.mockResolvedValue([])
const r = await buscar('María')
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledTimes(1)
expect(prismaMock.cliente.findMany).toHaveBeenCalledTimes(1)
expect(prismaMock.cotizacion.findMany).toHaveBeenCalledTimes(1)
expect(prismaMock.unidad.findMany).toHaveBeenCalledTimes(1)
expect(r.length).toBe(2)
expect(r[0]?.tipo).toBe('lead')
expect(r[1]?.tipo).toBe('contacto')
})
it('aplica take: limite por modelo', async () => {
await buscar('test', 5)
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 5 }),
)
})
it('prioriza leads sobre contactos, contactos sobre oportunidades, oportunidades sobre unidades', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
])
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
])
prismaMock.unidad.findMany.mockResolvedValue([
{ id: 'u1', codigo: 'X', desarrollo: 'SENDA', modelo: null, status: null, updatedAt: new Date() },
])
const r = await buscar('X')
expect(r.map((i) => i.tipo)).toEqual(['lead', 'contacto', 'oportunidad', 'unidad'])
})
it('mezcla los resultados y limita al total indicado', async () => {
const leads = Array.from({ length: 10 }, (_, i) => ({
leadId: `l${i}`, nombreLead: `L${i}`, telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date(),
}))
const clientes = Array.from({ length: 10 }, (_, i) => ({
id: `c${i}`, nombreCompleto: `C${i}`, curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date(),
}))
prismaMock.leadCaptura.findMany.mockResolvedValue(leads)
prismaMock.cliente.findMany.mockResolvedValue(clientes)
const r = await buscar('x', 15)
expect(r).toHaveLength(15)
// Primeros 10 son leads (prioridad), siguientes 5 son contactos
expect(r.filter((i) => i.tipo === 'lead')).toHaveLength(10)
expect(r.filter((i) => i.tipo === 'contacto')).toHaveLength(5)
})
it('lead: detalle muestra teléfono · email, o origen como fallback', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'Ana', telefono: '4771112233', email: 'ana@x.com', origen: null, board: 'LEADS', updatedAt: new Date() },
])
const r = await buscar('Ana')
expect(r[0]?.detalle).toBe('4771112233 · ana@x.com')
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l2', nombreLead: 'Bob', telefono: null, email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
])
const r2 = await buscar('Bob')
expect(r2[0]?.detalle).toBe('Facebook')
})
it('contacto: detalle muestra email · celular · proyecto', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'Luis', curp: '', rfc: '', email: 'luis@x.com', celular: '4772223333', proyecto: 'SENDA', createdAt: new Date() },
])
const r = await buscar('Luis')
expect(r[0]?.detalle).toBe('luis@x.com · 4772223333 · SENDA')
})
it('oportunidad: detalle muestra unidadCodigo · desarrollo, o tipoPropuesta como fallback', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct1', clienteNombre: 'Carlos', clienteEmail: null, unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', tipoPropuesta: null, createdAt: new Date() },
])
const r = await buscar('Carlos')
expect(r[0]?.detalle).toBe('U-101 · DIAGONAL')
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct2', clienteNombre: 'Diana', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: 'CONTADO', createdAt: new Date() },
])
const r2 = await buscar('Diana')
expect(r2[0]?.detalle).toBe('CONTADO')
})
it('unidad: detalle muestra desarrollo · modelo · status', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
{ id: 'u1', codigo: 'U-202', desarrollo: 'ESTANZA', modelo: 'SOHO', status: 'Disponible', updatedAt: new Date() },
])
const r = await buscar('U-202')
expect(r[0]?.detalle).toBe('ESTANZA · SOHO · Disponible')
})
it('urls de navegación son correctas por tipo', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
])
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
])
prismaMock.unidad.findMany.mockResolvedValue([
{ id: 'u1', codigo: 'X', desarrollo: null, modelo: null, status: null, updatedAt: new Date() },
])
const r = await buscar('X')
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
})
})

View File

@@ -0,0 +1,111 @@
import { prisma } from './prisma.js'
export interface BuscadoItem {
tipo: 'lead' | 'oportunidad' | 'contacto' | 'unidad'
id: string
nombre: string
detalle: string
url: string
}
/**
* Búsqueda global: LeadCaptura (leads), Cliente (contactos), Cotizacion
* (oportunidades con cotización), y Unidad (inventario).
* Case-insensitive sobre los campos indicados; limita a `limite` resultados.
*/
export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[]> {
const q = termino.trim()
if (q === '') return []
const pat = `%${q}%`
const [leads, clientes, cotizaciones, unidades] = await Promise.all([
prisma.leadCaptura.findMany({
where: {
OR: [
{ nombreLead: { contains: q } },
{ telefono: { contains: q } },
{ email: { contains: q } },
],
},
orderBy: { updatedAt: 'desc' },
take: limite,
}),
prisma.cliente.findMany({
where: {
OR: [
{ nombreCompleto: { contains: q } },
{ curp: { contains: q } },
{ rfc: { contains: q } },
{ email: { contains: q } },
{ celular: { contains: q } },
],
},
orderBy: { createdAt: 'desc' },
take: limite,
}),
prisma.cotizacion.findMany({
where: {
OR: [
{ clienteNombre: { contains: q } },
{ clienteEmail: { contains: q } },
{ unidadCodigo: { contains: q } },
],
},
orderBy: { createdAt: 'desc' },
take: limite,
}),
prisma.unidad.findMany({
where: {
OR: [
{ codigo: { contains: q } },
{ desarrollo: { contains: q } },
],
},
orderBy: { updatedAt: 'desc' },
take: limite,
}),
])
const items: BuscadoItem[] = [
...leads.map((l) => ({
tipo: 'lead' as const,
id: l.leadId,
nombre: l.nombreLead,
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
url: `/app/#leads`,
})),
...clientes.map((c) => ({
tipo: 'contacto' as const,
id: c.id,
nombre: c.nombreCompleto,
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
url: `/app/#clientes`,
})),
...cotizaciones.map((ct) => ({
tipo: 'oportunidad' as const,
id: ct.id,
nombre: ct.clienteNombre,
detalle: [ct.unidadCodigo, ct.desarrollo].filter(Boolean).join(' · ') || ct.tipoPropuesta,
url: `/quote/?cotizacion=${ct.id}`,
})),
...unidades.map((u) => ({
tipo: 'unidad' as const,
id: u.id,
nombre: u.codigo,
detalle: [u.desarrollo, u.modelo, u.status].filter(Boolean).join(' · '),
url: `/quote/?unidad=${u.codigo}`,
})),
]
// Mezclar y limitar: prioridad por tipo (leads primero, luego contactos,
// oportunidades, unidades) y recientes primero (ya ordenados por fecha desc)
const porTipo: BuscadoItem['tipo'][] = ['lead', 'contacto', 'oportunidad', 'unidad']
items.sort((a, b) => {
const ia = porTipo.indexOf(a.tipo)
const ib = porTipo.indexOf(b.tipo)
if (ia !== ib) return ia - ib
return 0
})
return items.slice(0, limite)
}

View File

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
notaApartado: {
create: vi.fn(),
findMany: vi.fn(),
},
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import { crearNota, listarNotas } from './nota-apartado.service.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('crearNota', () => {
it('crea una nota con los datos correctos', async () => {
const notaFake = { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Hola', createdAt: new Date() }
prismaMock.notaApartado.create.mockResolvedValue(notaFake)
const resultado = await crearNota('apt-1', 'u1', 'Hola')
expect(prismaMock.notaApartado.create).toHaveBeenCalledWith({
data: { apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Hola' },
})
expect(resultado).toEqual(notaFake)
})
})
describe('listarNotas', () => {
it('retorna notas ordenadas por createdAt desc', async () => {
const notasFake = [
{ id: 'nota-2', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'B', createdAt: new Date('2026-09-02') },
{ id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'A', createdAt: new Date('2026-09-01') },
]
prismaMock.notaApartado.findMany.mockResolvedValue(notasFake)
const resultado = await listarNotas('apt-1')
expect(prismaMock.notaApartado.findMany).toHaveBeenCalledWith({
where: { apartadoId: 'apt-1' },
orderBy: { createdAt: 'desc' },
})
expect(resultado).toEqual(notasFake)
})
it('retorna array vacío si no hay notas', async () => {
prismaMock.notaApartado.findMany.mockResolvedValue([])
const resultado = await listarNotas('apt-inexistente')
expect(resultado).toEqual([])
})
})

View File

@@ -0,0 +1,26 @@
import { prisma } from './prisma.js'
export interface NotaApartado {
id: string
apartadoId: string
usuarioId: string
contenido: string
createdAt: Date
}
export async function crearNota(
apartadoId: string,
usuarioId: string,
contenido: string,
): Promise<NotaApartado> {
return prisma.notaApartado.create({
data: { apartadoId, usuarioId, contenido },
})
}
export async function listarNotas(apartadoId: string): Promise<NotaApartado[]> {
return prisma.notaApartado.findMany({
where: { apartadoId },
orderBy: { createdAt: 'desc' },
})
}

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
pushSubscription: {
upsert: vi.fn(),
deleteMany: vi.fn(),
findUnique: vi.fn(),
},
usuario: {
findFirst: vi.fn(),
},
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
// ---------- web-push mockeado ----------
const webPushMock = vi.hoisted(() => ({
setVapidDetails: vi.fn(),
sendNotification: vi.fn(),
}))
vi.mock('web-push', () => ({ default: webPushMock }))
// ---------- config mockeado ----------
vi.mock('../config/index.js', () => ({
config: {
push: {
vapidPublicKey: 'test-public-key',
vapidPrivateKey: 'test-private-key',
vapidEmail: 'mailto:test@frasa.com',
},
},
}))
import {
registrarSuscripcion,
eliminarSuscripcion,
enviarPush,
usuarioIdPorMondayUserId,
obtenerVapidPublicKey,
} from './push.service.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('push.service', () => {
describe('registrarSuscripcion', () => {
it('hace upsert con los datos de la suscripción', async () => {
prismaMock.pushSubscription.upsert.mockResolvedValue({})
await registrarSuscripcion('user-1', {
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
p256dh: 'key123',
auth: 'auth456',
})
expect(prismaMock.pushSubscription.upsert).toHaveBeenCalledWith({
where: { usuarioId: 'user-1' },
update: {
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
p256dh: 'key123',
auth: 'auth456',
},
create: {
usuarioId: 'user-1',
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
p256dh: 'key123',
auth: 'auth456',
},
})
})
})
describe('eliminarSuscripcion', () => {
it('elimina todas las suscripciones del usuario', async () => {
prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 })
await eliminarSuscripcion('user-1')
expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ where: { usuarioId: 'user-1' } })
})
})
describe('enviarPush', () => {
it('envía push si el usuario tiene suscripción', async () => {
prismaMock.pushSubscription.findUnique.mockResolvedValue({
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
p256dh: 'key123',
auth: 'auth456',
})
webPushMock.sendNotification.mockResolvedValue({ statusCode: 201 })
const result = await enviarPush('user-1', 'Test', 'Cuerpo del mensaje')
expect(result).toBe(true)
expect(webPushMock.sendNotification).toHaveBeenCalledWith(
{ endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys: { p256dh: 'key123', auth: 'auth456' } },
expect.stringContaining('"titulo":"Test"'),
)
})
it('devuelve false si el usuario no tiene suscripción', async () => {
prismaMock.pushSubscription.findUnique.mockResolvedValue(null)
const result = await enviarPush('user-1', 'Test', 'Cuerpo')
expect(result).toBe(false)
expect(webPushMock.sendNotification).not.toHaveBeenCalled()
})
it('elimina suscripción inválida (404) y devuelve false', async () => {
prismaMock.pushSubscription.findUnique.mockResolvedValue({
endpoint: 'https://expired',
p256dh: 'key',
auth: 'auth',
})
webPushMock.sendNotification.mockRejectedValue({ statusCode: 404 })
prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 })
const result = await enviarPush('user-1', 'Test', 'Cuerpo')
expect(result).toBe(false)
expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ where: { usuarioId: 'user-1' } })
})
})
describe('usuarioIdPorMondayUserId', () => {
it('devuelve el usuarioId si encuentra el usuario', async () => {
prismaMock.usuario.findFirst.mockResolvedValue({ id: 'local-user-1' })
const result = await usuarioIdPorMondayUserId(12345)
expect(result).toBe('local-user-1')
})
it('devuelve null si no encuentra el usuario', async () => {
prismaMock.usuario.findFirst.mockResolvedValue(null)
const result = await usuarioIdPorMondayUserId(99999)
expect(result).toBeNull()
})
})
describe('obtenerVapidPublicKey', () => {
it('devuelve la VAPID public key del config', () => {
expect(obtenerVapidPublicKey()).toBe('test-public-key')
})
})
})

View File

@@ -0,0 +1,87 @@
import webPush from 'web-push'
import { config } from '../config/index.js'
import { prisma } from './prisma.js'
// Configurar VAPID solo si las claves están presentes
if (config.push.vapidPublicKey && config.push.vapidPrivateKey) {
webPush.setVapidDetails(
config.push.vapidEmail,
config.push.vapidPublicKey,
config.push.vapidPrivateKey,
)
}
export interface PushSubscriptionInput {
endpoint: string
p256dh: string
auth: string
}
/** Registra o actualiza la suscripción push de un usuario */
export async function registrarSuscripcion(usuarioId: string, subscription: PushSubscriptionInput): Promise<void> {
await prisma.pushSubscription.upsert({
where: { usuarioId },
update: {
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
auth: subscription.auth,
},
create: {
usuarioId,
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
auth: subscription.auth,
},
})
}
/** Elimina la suscripción push de un usuario */
export async function eliminarSuscripcion(usuarioId: string): Promise<void> {
await prisma.pushSubscription.deleteMany({ where: { usuarioId } })
}
/** Devuelve la VAPID public key para el frontend */
export function obtenerVapidPublicKey(): string {
return config.push.vapidPublicKey
}
/** Busca el usuarioId local a partir de un mondayUserId */
export async function usuarioIdPorMondayUserId(mondayUserId: number): Promise<string | null> {
const usuario = await prisma.usuario.findFirst({
where: { mondayUserId },
select: { id: true },
})
return usuario?.id ?? null
}
/** Envía una notificación push a un usuario. Silencioso si no tiene suscripción o si web-push no está configurado. */
export async function enviarPush(
usuarioId: string,
titulo: string,
cuerpo: string,
url?: string,
): Promise<boolean> {
if (!config.push.vapidPublicKey || !config.push.vapidPrivateKey) return false
const sub = await prisma.pushSubscription.findUnique({ where: { usuarioId } })
if (!sub) return false
const payload = JSON.stringify({ titulo, cuerpo, url: url ?? '/' })
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload,
)
return true
} catch (err: unknown) {
// Si la suscripción ya no es válida (404 o 410), eliminarla
const status = (err as { statusCode?: number }).statusCode
if (status === 404 || status === 410) {
await prisma.pushSubscription.deleteMany({ where: { usuarioId } })
}
// eslint-disable-next-line no-console
console.error(`[push] No se pudo enviar a ${usuarioId}:`, err)
return false
}
}

View File

@@ -3,6 +3,7 @@ import { parseFechaLocal, round2, statusPago, type Pago, type PlanPago } from '.
import { LocalCanal } from './local-canal.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
import { enviarPush } from './push.service.js'
/**
* Motor de recordatorios de pago de la cobranza.
@@ -272,6 +273,10 @@ export class RecordatoriosCobranzaService {
if (canal) await canal.enviar(destinatario, c.mensaje, hoyISO(ahora))
await this.store.registrarLog(enviado)
resultado.enviados.push(enviado)
// Push notification al vendedor
if (c.vendedorId) {
void enviarPush(c.vendedorId, 'Recordatorio de pago', c.mensaje, '/hoy/')
}
} catch (err) {
// Un fallo del canal NO queda en el log: la siguiente pasada reintenta
resultado.fallidos.push({ pagoId: c.pagoId, error: err instanceof Error ? err.message : String(err) })

View File

@@ -0,0 +1,147 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { construirMiTablero } from './reporte-mi-tablero.service.js'
// ---------- Fixtures ----------
function captura(
overrides: Partial<{
leadId: string
board: string
etapa: string
origen: string | null
desarrollo: string | null
createdAt: Date
}> = {},
) {
return {
leadId: overrides.leadId ?? `cap-${Math.random().toString(36).slice(2, 8)}`,
board: overrides.board ?? 'LEADS',
etapa: overrides.etapa ?? 'NUEVO',
origen: overrides.origen ?? 'Facebook',
desarrollo: overrides.desarrollo ?? 'DIAGONAL',
createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
}
}
function cotizacion(
overrides: Partial<{ precioFinal: number; vendedorId: string | null; createdAt: Date }> = {},
) {
return {
precioFinal: overrides.precioFinal ?? 850000,
vendedorId: overrides.vendedorId ?? 'u-1',
createdAt: overrides.createdAt ?? new Date('2026-09-01T12:00:00'),
}
}
function actividad(
overrides: Partial<{ usuarioId: string; estado: string; fechaVencimiento: Date | null }> = {},
) {
return {
usuarioId: overrides.usuarioId ?? 'u-1',
estado: overrides.estado ?? 'ABIERTA',
fechaVencimiento: overrides.fechaVencimiento ?? null,
}
}
const AHORA = new Date('2026-09-07T12:00:00')
// ---------- construirMiTablero (puro) ----------
describe('construirMiTablero', () => {
it('genera 30 entradas en leadsPorDia con ceros para días sin leads', () => {
const tablero = construirMiTablero([], [], [], AHORA)
expect(tablero.leadsPorDia.length).toBe(30)
// Todos los días deben tener cantidad 0
expect(tablero.leadsPorDia.every((d) => d.cantidad === 0)).toBe(true)
})
it('cuenta leads por día correctamente', () => {
const hace2Dias = new Date(AHORA.getTime() - 2 * 24 * 60 * 60 * 1000)
const capturas = [
captura({ createdAt: hace2Dias }),
captura({ createdAt: hace2Dias }),
captura({ createdAt: hace2Dias }),
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
const entrada = tablero.leadsPorDia.find((d) => d.cantidad > 0)
expect(entrada).toBeDefined()
expect(entrada!.cantidad).toBe(3)
})
it('cuenta conversión correctamente', () => {
const capturas = [
captura({ board: 'LEADS' }),
captura({ board: 'LEADS' }),
captura({ board: 'OPORTUNIDADES' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.conversion.leads).toBe(2)
expect(tablero.conversion.oportunidades).toBe(1)
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
expect(tablero.conversion.vendidos).toBe(1)
})
it('calcula ventas del mes correctamente', () => {
const cots = [
cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }),
cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }),
cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior
]
const tablero = construirMiTablero([], cots, [], AHORA)
expect(tablero.ventasMes.unidades).toBe(2)
expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000
})
it('agrupa por origen con conteo de convertidos', () => {
const capturas = [
captura({ board: 'LEADS', origen: 'Facebook' }),
captura({ board: 'LEADS', origen: 'Facebook' }),
captura({ board: 'OPORTUNIDADES', origen: 'Facebook' }),
captura({ board: 'LEADS', origen: 'Referido' }),
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
const fb = tablero.porOrigen.find((o) => o.origen === 'Facebook')
expect(fb).toBeDefined()
expect(fb!.total).toBe(3)
expect(fb!.convertidos).toBe(1)
const ref = tablero.porOrigen.find((o) => o.origen === 'Referido')
expect(ref).toBeDefined()
expect(ref!.total).toBe(1)
expect(ref!.convertidos).toBe(0)
})
it('agrupa por desarrollo solo con vendidos', () => {
const capturas = [
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }),
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.porDesarrollo).toEqual([
{ desarrollo: 'DIAGONAL', ventas: 2 },
{ desarrollo: 'ATELIER', ventas: 1 },
])
})
it('cuenta actividades hoy y pendientes', () => {
const actividades = [
actividad({ estado: 'ABIERTA', fechaVencimiento: new Date('2026-09-07T10:00:00') }),
actividad({ estado: 'ABIERTA', fechaVencimiento: new Date('2026-09-08T10:00:00') }),
actividad({ estado: 'ABIERTA' }),
actividad({ estado: 'CERRADA' }),
]
const tablero = construirMiTablero([], [], actividades, AHORA)
expect(tablero.actividadesHoy).toBe(2) // 1 vencida + 1 sin fecha (hoy)
expect(tablero.actividadesPendientes).toBe(3) // 3 ABIERTA
})
it('normaliza orígenes vacíos a (sin origen)', () => {
const capturas = [captura({ board: 'LEADS', origen: '' })]
const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.porOrigen.length).toBe(1)
expect(tablero.porOrigen[0].origen).toBe('(sin origen)')
})
})

View File

@@ -0,0 +1,311 @@
import { prisma } from './prisma.js'
import { AppError } from '../utils/errors.js'
// ---------- Interfaces ----------
export interface MiTablero {
generadoEn: string
leadsPorDia: { fecha: string; cantidad: number }[]
conversion: {
leads: number
oportunidades: number
apartados: number
vendidos: number
}
ventasMes: {
monto: number
unidades: number
}
porOrigen: {
origen: string
total: number
convertidos: number
}[]
porDesarrollo: {
desarrollo: string
ventas: number
}[]
actividadesHoy: number
actividadesPendientes: number
}
export interface MetricasUsoUsuario {
usuarioId: string
nombre: string
rol: string
leadsHoy: number
leadsSemana: number
leadsMes: number
tasaDescarte: number
actividadesCompletadas: number
actividadesPendientes: number
}
export interface MetricasUso {
generadoEn: string
usuarios: MetricasUsoUsuario[]
}
// ---------- Helpers ----------
/** Inicio del día local de `ahora` (medianoche) */
function inicioDelDia(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
/** Fecha local 'YYYY-MM-DD' */
function formatoFecha(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
// ---------- Servicio: Mi Tablero ----------
/**
* Construye el tablero personal del vendedor: leads por día (últimos 30 días),
* tasa de conversión, ventas del mes, rendimiento por origen, ranking de
* desarrollos y conteo de actividades.
*
* Función pura: recibe los datos crudos desde Prisma; testeable sin BD.
*/
export function construirMiTablero(
capturas: {
leadId: string
board: string
etapa: string
origen: string | null
desarrollo: string | null
createdAt: Date
}[],
cotizaciones: {
precioFinal: number
vendedorId: string | null
createdAt: Date
}[],
actividades: {
usuarioId: string
estado: string
fechaVencimiento: Date | null
}[],
ahora: Date,
): MiTablero {
const hoy = inicioDelDia(ahora)
const hace30Dias = new Date(hoy.getTime() - 30 * 24 * 60 * 60 * 1000)
// -- Leads por día (últimos 30 días) --
const leadsPorDiaMap = new Map<string, number>()
// Inicializar todos los días para que la gráfica no tenga huecos
for (let i = 0; i < 30; i++) {
const d = new Date(hoy.getTime() - (29 - i) * 24 * 60 * 60 * 1000)
leadsPorDiaMap.set(formatoFecha(d), 0)
}
for (const c of capturas) {
const fecha = c.createdAt
if (fecha >= hace30Dias) {
const clave = formatoFecha(fecha)
if (leadsPorDiaMap.has(clave)) {
leadsPorDiaMap.set(clave, (leadsPorDiaMap.get(clave) ?? 0) + 1)
}
}
}
const leadsPorDia = [...leadsPorDiaMap.entries()]
.map(([fecha, cantidad]) => ({ fecha, cantidad }))
.sort((a, b) => a.fecha.localeCompare(b.fecha))
// -- Conversión (funnel) --
const leads = capturas.filter((c) => c.board === 'LEADS').length
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
const apartados = capturas.filter(
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO',
).length
const vendidos = capturas.filter(
(c) =>
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
).length
// -- Ventas del mes --
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
const cotizacionesMes = cotizaciones.filter(
(ct) => ct.createdAt >= inicioMes,
)
const ventasMes = {
monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100,
unidades: cotizacionesMes.length,
}
// -- Por origen --
const origenMap = new Map<string, { total: number; convertidos: number }>()
for (const c of capturas) {
const origen = c.origen?.trim() || '(sin origen)'
const entrada = origenMap.get(origen) ?? { total: 0, convertidos: 0 }
entrada.total++
if (c.board === 'OPORTUNIDADES' || c.board === 'APARTADO') {
entrada.convertidos++
}
origenMap.set(origen, entrada)
}
const porOrigen = [...origenMap.entries()]
.map(([origen, v]) => ({ origen, ...v }))
.sort((a, b) => b.total - a.total)
// -- Por desarrollo (solo apartados vendidos) --
const desarrolloMap = new Map<string, number>()
for (const c of capturas) {
if (
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
c.desarrollo
) {
const dev = c.desarrollo.trim()
if (dev !== '') {
desarrolloMap.set(dev, (desarrolloMap.get(dev) ?? 0) + 1)
}
}
}
const porDesarrollo = [...desarrolloMap.entries()]
.map(([desarrollo, ventas]) => ({ desarrollo, ventas }))
.sort((a, b) => b.ventas - a.ventas)
// -- Actividades --
const manana = new Date(hoy.getTime() + 24 * 60 * 60 * 1000)
const actividadesHoy = actividades.filter(
(a) =>
a.estado.toUpperCase() === 'ABIERTA' &&
(!a.fechaVencimiento || a.fechaVencimiento < manana),
).length
const actividadesPendientes = actividades.filter(
(a) => a.estado.toUpperCase() === 'ABIERTA',
).length
return {
generadoEn: ahora.toISOString(),
leadsPorDia,
conversion: { leads, oportunidades, apartados, vendidos },
ventasMes,
porOrigen,
porDesarrollo,
actividadesHoy,
actividadesPendientes,
}
}
// ---------- Servicio con Prisma ----------
/**
* Obtiene el tablero personal de un vendedor desde la base de datos.
*/
export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
const ahora = new Date()
const capturas = await prisma.leadCaptura.findMany({
where: { usuarioId },
select: {
board: true,
etapa: true,
origen: true,
desarrollo: true,
createdAt: true,
},
})
const cotizaciones = await prisma.cotizacion.findMany({
where: { vendedorId: usuarioId },
select: {
precioFinal: true,
vendedorId: true,
createdAt: true,
},
})
const actividades = await prisma.actividadLocal.findMany({
where: { usuarioId },
select: {
usuarioId: true,
estado: true,
fechaVencimiento: true,
},
})
return construirMiTablero(capturas, cotizaciones, actividades, ahora)
}
// ---------- Servicio: Métricas de uso (admin/coordinador) ----------
/**
* Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de
* descarte, actividades completadas vs pendientes. Visible solo para admin y
* coordinador.
*/
export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUso> {
const ahora = new Date()
const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
const whereUsuario = usuarioId ? { usuarioId } : undefined
const [capturas, actividades, usuarios] = await Promise.all([
prisma.leadCaptura.findMany({
where: whereUsuario,
select: {
usuarioId: true,
etapa: true,
createdAt: true,
},
}),
prisma.actividadLocal.findMany({
where: whereUsuario,
select: {
usuarioId: true,
estado: true,
},
}),
prisma.usuario.findMany({
where: whereUsuario ? { id: usuarioId } : { activo: true },
select: {
id: true,
nombre: true,
rol: true,
},
}),
])
const metricasPorUsuario: MetricasUsoUsuario[] = usuarios.map((u) => {
const misCapturas = capturas.filter((c) => c.usuarioId === u.id)
const leadsHoy = misCapturas.filter((c) => c.createdAt >= hoy).length
const leadsSemana = misCapturas.filter((c) => c.createdAt >= hace7dias).length
const leadsMes = misCapturas.filter((c) => c.createdAt >= inicioMes).length
const descartados = misCapturas.filter((c) =>
c.etapa.toUpperCase() === 'DESCARTADO',
).length
const tasaDescarte = leadsMes > 0 ? Math.round((descartados / leadsMes) * 100) / 100 : 0
const misActividades = actividades.filter((a) => a.usuarioId === u.id)
const actividadesCompletadas = misActividades.filter(
(a) => a.estado.toUpperCase() === 'CERRADA',
).length
const actividadesPendientes = misActividades.filter(
(a) => a.estado.toUpperCase() === 'ABIERTA',
).length
return {
usuarioId: u.id,
nombre: u.nombre,
rol: u.rol,
leadsHoy,
leadsSemana,
leadsMes,
tasaDescarte,
actividadesCompletadas,
actividadesPendientes,
}
})
return {
generadoEn: ahora.toISOString(),
usuarios: metricasPorUsuario,
}
}