feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API
- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor, column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes, webhook con handshake challenge, Prisma SQLite dev, 55 tests - cotizador: inventario desde /api/unidades con fallback a seed - alta-clientes: POST /api/clientes + upload multipart de documentos - cobranza: persistencia via API con respaldo offline en localStorage - .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
191
api-gateway/src/controllers/clientes.controller.ts
Normal file
191
api-gateway/src/controllers/clientes.controller.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import multer from 'multer'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { NotFoundError, BadRequestError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Alta de clientes ----------
|
||||
|
||||
const crearClienteSchema = z
|
||||
.object({
|
||||
datosGenerales: z
|
||||
.object({
|
||||
nombreCompleto: z.string().min(1),
|
||||
curp: z.string().min(1),
|
||||
rfc: z.string().min(1),
|
||||
email: z.string().optional(),
|
||||
celular: z.string().optional(),
|
||||
direccion: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
viviendaCredito: z
|
||||
.object({
|
||||
proyecto: z.string().min(1),
|
||||
ubicacion: z.string().min(1),
|
||||
valorVenta: z.union([z.string(), z.number()]).optional(),
|
||||
enganche: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(),
|
||||
})
|
||||
.passthrough() // el resto del payload pasa tal cual a payloadJson
|
||||
|
||||
const aNumero = (v: string | number | undefined): number => {
|
||||
if (v === undefined) return 0
|
||||
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const hoyMas = (dias: number): string => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + dias)
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dia = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
|
||||
export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
const body = crearClienteSchema.parse(req.body)
|
||||
const dg = body.datosGenerales
|
||||
const vc = body.viviendaCredito
|
||||
|
||||
const cliente = await prisma.cliente.create({
|
||||
data: {
|
||||
nombreCompleto: dg.nombreCompleto,
|
||||
curp: dg.curp,
|
||||
rfc: dg.rfc,
|
||||
email: dg.email ?? null,
|
||||
celular: dg.celular ?? null,
|
||||
proyecto: vc.proyecto,
|
||||
unidadCodigo: vc.ubicacion, // la "ubicación" del formato es el código de unidad (U-101, etc.)
|
||||
payloadJson: JSON.stringify(body),
|
||||
firmaPng: body.firma?.firmaDataUrl ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
|
||||
let mondayContactoId: string | null = null
|
||||
let mondayApartadoId: string | null = null
|
||||
try {
|
||||
mondayContactoId = await mondayService.crearContacto({
|
||||
nombre: dg.nombreCompleto,
|
||||
estadoLabel: 'CLIENTE',
|
||||
...(dg.celular !== undefined ? { telefono: dg.celular } : {}),
|
||||
...(dg.email !== undefined ? { email: dg.email } : {}),
|
||||
curp: dg.curp,
|
||||
rfc: dg.rfc,
|
||||
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
|
||||
})
|
||||
|
||||
const valorVenta = aNumero(vc.valorVenta)
|
||||
if (valorVenta > 0) {
|
||||
const enganche = aNumero(vc.enganche)
|
||||
mondayApartadoId = await mondayService.crearApartado({
|
||||
clienteNombre: dg.nombreCompleto,
|
||||
unidadCodigo: vc.ubicacion,
|
||||
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
||||
fechaApartado: hoyMas(0),
|
||||
fechaLimite: hoyMas(15),
|
||||
estadoLabel: 'VIGENTE',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.cliente.update({
|
||||
where: { id: cliente.id },
|
||||
data: { mondayContactoId, mondayApartadoId },
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudo replicar el cliente en Monday:', err)
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
|
||||
}
|
||||
|
||||
/** GET /api/clientes/:id */
|
||||
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
|
||||
const cliente = await prisma.cliente.findUnique({
|
||||
where: { id: String(req.params['id']) },
|
||||
include: { documentos: true },
|
||||
})
|
||||
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||
res.json(cliente)
|
||||
}
|
||||
|
||||
// ---------- Documentos ----------
|
||||
|
||||
const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
||||
const EXT_POR_MIME: Record<string, string> = {
|
||||
'application/pdf': 'pdf',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const dir = path.join(config.uploadsDir, String(req.params['id']))
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
cb(null, dir)
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||
const ext = EXT_POR_MIME[file.mimetype] ?? 'bin'
|
||||
cb(null, `${tipo}_${randomUUID()}.${ext}`)
|
||||
},
|
||||
})
|
||||
|
||||
export const uploadDocumentos = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!MIME_PERMITIDOS.has(file.mimetype)) {
|
||||
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`))
|
||||
return
|
||||
}
|
||||
cb(null, true)
|
||||
},
|
||||
})
|
||||
|
||||
/** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */
|
||||
export async function subirDocumentos(req: Request, res: Response): Promise<void> {
|
||||
const clienteId = String(req.params['id'])
|
||||
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
|
||||
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||
|
||||
const archivos = (req.files ?? []) as Express.Multer.File[]
|
||||
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||
const documentos = await Promise.all(
|
||||
archivos.map((f) =>
|
||||
prisma.documento.create({
|
||||
data: {
|
||||
clienteId,
|
||||
tipo,
|
||||
nombreArchivo: f.originalname,
|
||||
ruta: path.relative(config.uploadsDir, f.path),
|
||||
mime: f.mimetype,
|
||||
tamano: f.size,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
res.status(201).json(documentos)
|
||||
}
|
||||
|
||||
/** GET /api/clientes/:id/documentos */
|
||||
export async function listarDocumentos(req: Request, res: Response): Promise<void> {
|
||||
const clienteId = String(req.params['id'])
|
||||
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
|
||||
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||
const documentos = await prisma.documento.findMany({
|
||||
where: { clienteId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json(documentos)
|
||||
}
|
||||
91
api-gateway/src/controllers/cotizaciones.controller.ts
Normal file
91
api-gateway/src/controllers/cotizaciones.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
const crearCotizacionSchema = z.object({
|
||||
unidadCodigo: z.string().min(1),
|
||||
acabados: z.boolean(),
|
||||
apartado: z.number().positive().optional(),
|
||||
clienteNombre: z.string().min(1),
|
||||
clienteEmail: z.string().email().optional(),
|
||||
clienteTelefono: z.string().min(7).optional(),
|
||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
||||
})
|
||||
|
||||
const hoyISO = (): string => {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dia = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
|
||||
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
|
||||
const body = crearCotizacionSchema.parse(req.body)
|
||||
|
||||
const unidad = await mondayService.getUnidad(body.unidadCodigo)
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
||||
|
||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
||||
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento)
|
||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
||||
|
||||
const cotizacion = await prisma.cotizacion.create({
|
||||
data: {
|
||||
unidadCodigo: unidad.codigo,
|
||||
clienteNombre: body.clienteNombre,
|
||||
clienteEmail: body.clienteEmail ?? null,
|
||||
clienteTelefono: body.clienteTelefono ?? null,
|
||||
tipoPropuesta: body.tipoPropuesta,
|
||||
acabados: body.acabados,
|
||||
apartado,
|
||||
descuento: propuesta.descuento,
|
||||
precioLista,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
enganche: propuesta.enganche,
|
||||
numMensualidades: propuesta.numMensualidades,
|
||||
mensualidad: propuesta.mensualidad,
|
||||
pagoFinal: propuesta.pagoFinal,
|
||||
},
|
||||
})
|
||||
|
||||
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
|
||||
let mondayItemId: string | null = null
|
||||
try {
|
||||
mondayItemId = await mondayService.crearOportunidad({
|
||||
clienteNombre: body.clienteNombre,
|
||||
unidadCodigo: unidad.codigo,
|
||||
tipoPropuestaLabel: body.tipoPropuesta,
|
||||
precioLista,
|
||||
descuentoPct: propuesta.descuento * 100,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
fechaCotizacion: hoyISO(),
|
||||
estadoLabel: 'COTIZADA',
|
||||
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
|
||||
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
||||
})
|
||||
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudo crear la oportunidad en Monday:', err)
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, mondayItemId, propuesta })
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones */
|
||||
export async function listarCotizaciones(_req: Request, res: Response): Promise<void> {
|
||||
const cotizaciones = await prisma.cotizacion.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
res.json(cotizaciones)
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones/:id */
|
||||
export async function obtenerCotizacion(req: Request, res: Response): Promise<void> {
|
||||
const cotizacion = await prisma.cotizacion.findUnique({ where: { id: String(req.params['id']) } })
|
||||
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
|
||||
res.json(cotizacion)
|
||||
}
|
||||
8
api-gateway/src/controllers/health.controller.ts
Normal file
8
api-gateway/src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
|
||||
/** GET /api/health — ping barato a Monday */
|
||||
export async function health(_req: Request, res: Response): Promise<void> {
|
||||
const mondayUp = await mondayService.ping()
|
||||
res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' })
|
||||
}
|
||||
236
api-gateway/src/controllers/planes.controller.ts
Normal file
236
api-gateway/src/controllers/planes.controller.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import {
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
cobranzaDelDia,
|
||||
construirNota,
|
||||
diasAtraso,
|
||||
generarCalendario,
|
||||
pagadoTotal,
|
||||
parseFechaLocal,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
totalMora,
|
||||
validarAbono,
|
||||
type PlanPago as PlanPagoDominio,
|
||||
} from '../services/cobranza.service.js'
|
||||
import { BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
|
||||
|
||||
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
|
||||
|
||||
const aFechaISO = (d: Date): string => {
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dia = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
type PlanCompleto = PlanPago & {
|
||||
pagos: (Pago & { aplicaciones: AplicacionPago[] })[]
|
||||
notas: NotaPlan[]
|
||||
}
|
||||
|
||||
function aDominio(plan: PlanCompleto): PlanPagoDominio {
|
||||
return {
|
||||
id: plan.id,
|
||||
ventaRef: plan.ventaRef,
|
||||
clienteNombre: plan.clienteNombre,
|
||||
clienteTelefono: plan.clienteTelefono,
|
||||
unidadCodigo: plan.unidadCodigo,
|
||||
proyecto: plan.proyecto,
|
||||
fechaInicio: aFechaISO(plan.fechaInicio),
|
||||
engancheTotal: plan.engancheTotal,
|
||||
numPagos: plan.numPagos,
|
||||
mensualidad: plan.mensualidad,
|
||||
tasaMoraMensual: plan.tasaMoraMensual,
|
||||
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
pagos: plan.pagos
|
||||
.slice()
|
||||
.sort((a, b) => a.numPago - b.numPago)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
numPago: p.numPago,
|
||||
fechaVencimiento: aFechaISO(p.fechaVencimiento),
|
||||
monto: p.monto,
|
||||
montoPagado: p.montoPagado,
|
||||
aplicaciones: p.aplicaciones.map((a) => ({
|
||||
id: a.id,
|
||||
fechaPago: a.fechaPago.toISOString(),
|
||||
monto: a.monto,
|
||||
referencia: a.referencia,
|
||||
})),
|
||||
})),
|
||||
notas: plan.notas.map((n) => ({ id: n.id, fecha: n.fecha.toISOString(), texto: n.texto })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Plan con los campos calculados que la app de cobranza muestra */
|
||||
function conCalculados(plan: PlanPagoDominio) {
|
||||
const hoy = new Date()
|
||||
return {
|
||||
...plan,
|
||||
status: statusPlan(plan),
|
||||
saldo: saldoPlan(plan),
|
||||
pagadoTotal: pagadoTotal(plan),
|
||||
avance: avancePlan(plan),
|
||||
alCorriente: alCorriente(plan, hoy),
|
||||
diasAtraso: diasAtraso(plan, hoy),
|
||||
totalMora: totalMora(plan, hoy),
|
||||
pagos: plan.pagos.map((p) => ({ ...p, status: statusPago(p, hoy) })),
|
||||
}
|
||||
}
|
||||
|
||||
const includeCompleto = { pagos: { include: { aplicaciones: true } }, notas: true } as const
|
||||
|
||||
async function cargarPlan(id: string): Promise<PlanCompleto> {
|
||||
const plan = await prisma.planPago.findUnique({ where: { id }, include: includeCompleto })
|
||||
if (!plan) throw new NotFoundError('Plan de pago no encontrado')
|
||||
return plan
|
||||
}
|
||||
|
||||
// ---------- Endpoints ----------
|
||||
|
||||
const crearPlanSchema = z.object({
|
||||
ventaRef: z.string().optional(),
|
||||
clienteNombre: z.string().min(1),
|
||||
clienteTelefono: z.string().optional(),
|
||||
unidadCodigo: z.string().min(1),
|
||||
proyecto: z.string().min(1),
|
||||
fechaInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'fechaInicio debe ser YYYY-MM-DD'),
|
||||
engancheTotal: z.number().positive(),
|
||||
numPagos: z.number().int().min(1),
|
||||
tasaMoraMensual: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
/** POST /api/planes — genera el calendario de pagos y lo persiste */
|
||||
export async function crearPlan(req: Request, res: Response): Promise<void> {
|
||||
const body = crearPlanSchema.parse(req.body)
|
||||
let calendario
|
||||
try {
|
||||
calendario = generarCalendario(body.fechaInicio, body.engancheTotal, body.numPagos)
|
||||
} catch (err) {
|
||||
throw new BadRequestError(err instanceof Error ? err.message : 'Parámetros inválidos')
|
||||
}
|
||||
|
||||
const plan = await prisma.planPago.create({
|
||||
data: {
|
||||
ventaRef: body.ventaRef ?? null,
|
||||
clienteNombre: body.clienteNombre,
|
||||
clienteTelefono: body.clienteTelefono ?? null,
|
||||
unidadCodigo: body.unidadCodigo,
|
||||
proyecto: body.proyecto,
|
||||
fechaInicio: parseFechaLocal(body.fechaInicio),
|
||||
engancheTotal: body.engancheTotal,
|
||||
numPagos: body.numPagos,
|
||||
mensualidad: calendario.mensualidad,
|
||||
tasaMoraMensual: body.tasaMoraMensual ?? 0,
|
||||
pagos: {
|
||||
create: calendario.pagos.map((p) => ({
|
||||
numPago: p.numPago,
|
||||
fechaVencimiento: parseFechaLocal(p.fechaVencimiento),
|
||||
monto: p.monto,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: includeCompleto,
|
||||
})
|
||||
res.status(201).json(conCalculados(aDominio(plan)))
|
||||
}
|
||||
|
||||
/** GET /api/planes — con saldo/status calculados */
|
||||
export async function listarPlanes(_req: Request, res: Response): Promise<void> {
|
||||
const planes = await prisma.planPago.findMany({
|
||||
include: includeCompleto,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json(planes.map((p) => conCalculados(aDominio(p))))
|
||||
}
|
||||
|
||||
/** GET /api/planes/cobranza-del-dia — lista plana de urgencias (antes de /:id) */
|
||||
export async function cobranzaHoy(req: Request, res: Response): Promise<void> {
|
||||
const dias = Number(req.query['diasAnticipacion'] ?? 7)
|
||||
const planes = await prisma.planPago.findMany({ include: includeCompleto })
|
||||
const items = cobranzaDelDia(
|
||||
planes.map(aDominio),
|
||||
new Date(),
|
||||
Number.isFinite(dias) ? dias : 7,
|
||||
)
|
||||
res.json(items)
|
||||
}
|
||||
|
||||
/** GET /api/planes/:id */
|
||||
export async function obtenerPlan(req: Request, res: Response): Promise<void> {
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
res.json(conCalculados(aDominio(plan)))
|
||||
}
|
||||
|
||||
const abonoSchema = z.object({
|
||||
pagoId: z.string().min(1),
|
||||
fechaPago: z.string().min(1),
|
||||
monto: z.number(),
|
||||
referencia: z.string().min(1),
|
||||
})
|
||||
|
||||
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
|
||||
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = abonoSchema.parse(req.body)
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
const dominio = aDominio(plan)
|
||||
|
||||
let resultado
|
||||
try {
|
||||
resultado = validarAbono(dominio, body)
|
||||
} catch (err) {
|
||||
throw new BadRequestError(err instanceof Error ? err.message : 'Abono inválido')
|
||||
}
|
||||
const { pago, aplicacion } = resultado
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.aplicacionPago.create({
|
||||
data: {
|
||||
pagoId: pago.id,
|
||||
fechaPago: new Date(body.fechaPago),
|
||||
monto: aplicacion.monto,
|
||||
referencia: aplicacion.referencia,
|
||||
},
|
||||
}),
|
||||
prisma.pago.update({
|
||||
where: { id: pago.id },
|
||||
data: { montoPagado: round2(pago.montoPagado + aplicacion.monto) },
|
||||
}),
|
||||
])
|
||||
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.status(201).json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
|
||||
const notaSchema = z.object({ texto: z.string() })
|
||||
|
||||
/** POST /api/planes/:id/notas */
|
||||
export async function agregarNota(req: Request, res: Response): Promise<void> {
|
||||
const { texto } = notaSchema.parse(req.body)
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
let nota
|
||||
try {
|
||||
nota = construirNota(texto)
|
||||
} catch (err) {
|
||||
throw new BadRequestError(err instanceof Error ? err.message : 'Nota inválida')
|
||||
}
|
||||
const creada = await prisma.notaPlan.create({
|
||||
data: { planId: plan.id, texto: nota.texto },
|
||||
})
|
||||
res.status(201).json(creada)
|
||||
}
|
||||
|
||||
/** POST /api/planes/:id/cancelar */
|
||||
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
await prisma.planPago.update({ where: { id: plan.id }, data: { status: 'CANCELADO' } })
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
17
api-gateway/src/controllers/unidades.controller.ts
Normal file
17
api-gateway/src/controllers/unidades.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE */
|
||||
export async function listarUnidades(req: Request, res: Response): Promise<void> {
|
||||
const unidades = await mondayService.getUnidades()
|
||||
const disponibles = req.query['disponibles'] === 'true'
|
||||
res.json(disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades)
|
||||
}
|
||||
|
||||
/** GET /api/unidades/:codigo — 404 si no existe */
|
||||
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
|
||||
const unidad = await mondayService.getUnidad(String(req.params['codigo']))
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
|
||||
res.json(unidad)
|
||||
}
|
||||
36
api-gateway/src/controllers/webhooks.controller.ts
Normal file
36
api-gateway/src/controllers/webhooks.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
|
||||
const eventoSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
boardId: z.union([z.string(), z.number()]).optional(),
|
||||
itemId: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/monday
|
||||
* Handshake: Monday envía { challenge } al registrar el webhook y exige que
|
||||
* se responda tal cual. Para eventos reales solo se loggea y se responde 200.
|
||||
*
|
||||
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Antes de
|
||||
* actuar sobre un evento hay que re-consultar el estado vía API (por ahora
|
||||
* solo se registra el evento).
|
||||
*/
|
||||
export function webhookMonday(req: Request, res: Response): void {
|
||||
const body = req.body as Record<string, unknown>
|
||||
|
||||
if (typeof body?.['challenge'] === 'string') {
|
||||
res.json({ challenge: body['challenge'] })
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = eventoSchema.safeParse(body)
|
||||
if (parsed.success) {
|
||||
const { type, boardId, itemId } = parsed.data
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[webhook monday] tipo=${type ?? 'desconocido'} board=${boardId ?? '?'} item=${itemId ?? '?'}`)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
Reference in New Issue
Block a user