import type { Pool } from 'pg'; export const ALLOWED_MIMES = new Set([ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx ]); export const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB export type EstadoPapeleria = 'pendiente' | 'aprobado' | 'rechazado'; export interface PapeleriaItem { id: number; contribuyenteId: string; nombre: string; descripcion: string | null; archivoFilename: string; archivoMime: string; archivoSize: number; anio: number; mes: number; requiereAprobacion: boolean; estado: EstadoPapeleria | null; aprobadoPor: string | null; aprobadoAt: Date | null; comentarioRechazo: string | null; subidoPor: string; createdAt: Date; } const SELECT = ` id, contribuyente_id, nombre, descripcion, archivo_filename, archivo_mime, archivo_size, anio, mes, requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, subido_por, created_at `; const ROW = (r: any): PapeleriaItem => ({ id: r.id, contribuyenteId: r.contribuyente_id, nombre: r.nombre, descripcion: r.descripcion, archivoFilename: r.archivo_filename, archivoMime: r.archivo_mime, archivoSize: r.archivo_size, anio: r.anio, mes: r.mes, requiereAprobacion: r.requiere_aprobacion, estado: r.estado, aprobadoPor: r.aprobado_por, aprobadoAt: r.aprobado_at, comentarioRechazo: r.comentario_rechazo, subidoPor: r.subido_por, createdAt: r.created_at, }); function sanitizeUuid(id: string): string { return id.replace(/[^a-f0-9-]/gi, ''); } export interface UploadInput { contribuyenteId: string; nombre: string; descripcion: string | null; anio: number; mes: number; requiereAprobacion: boolean; archivo: Buffer; archivoFilename: string; archivoMime: string; subidoPor: string; } export async function uploadPapeleria( pool: Pool, input: UploadInput, ): Promise { if (!ALLOWED_MIMES.has(input.archivoMime)) { throw new Error(`Formato no permitido: ${input.archivoMime}. Solo PDF, Word y Excel.`); } if (input.archivo.length > MAX_SIZE_BYTES) { throw new Error(`Archivo excede el máximo de 5 MB (recibido ${(input.archivo.length / 1024 / 1024).toFixed(1)} MB).`); } const estadoInicial = input.requiereAprobacion ? 'pendiente' : null; const { rows: [r] } = await pool.query( `INSERT INTO papeleria_trabajo (contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size, anio, mes, requiere_aprobacion, estado, subido_por) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING ${SELECT}`, [ sanitizeUuid(input.contribuyenteId), input.nombre, input.descripcion, input.archivo, input.archivoFilename, input.archivoMime, input.archivo.length, input.anio, input.mes, input.requiereAprobacion, estadoInicial, input.subidoPor, ], ); return ROW(r); } export interface ListFilters { contribuyenteId: string; anio?: number; mes?: number; estado?: EstadoPapeleria | 'sin_aprobacion'; } export async function listPapeleria(pool: Pool, f: ListFilters): Promise { const conds: string[] = ['contribuyente_id = $1']; const vals: unknown[] = [sanitizeUuid(f.contribuyenteId)]; let i = 2; if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); } if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); } if (f.estado === 'sin_aprobacion') { conds.push('requiere_aprobacion = false'); } else if (f.estado) { conds.push(`estado = $${i++}`); vals.push(f.estado); } const { rows } = await pool.query( `SELECT ${SELECT} FROM papeleria_trabajo WHERE ${conds.join(' AND ')} ORDER BY anio DESC, mes DESC, created_at DESC`, vals, ); return rows.map(ROW); } export async function getById(pool: Pool, id: number): Promise { const { rows: [r] } = await pool.query( `SELECT ${SELECT} FROM papeleria_trabajo WHERE id = $1`, [id], ); return r ? ROW(r) : null; } export async function downloadArchivo( pool: Pool, id: number, ): Promise<{ archivo: Buffer; filename: string; mime: string } | null> { const { rows: [r] } = await pool.query( `SELECT archivo, archivo_filename, archivo_mime FROM papeleria_trabajo WHERE id = $1`, [id], ); if (!r) return null; return { archivo: r.archivo, filename: r.archivo_filename, mime: r.archivo_mime }; } const ROLES_APROBADOR = new Set(['owner', 'cfo', 'supervisor']); export async function aprobar( pool: Pool, id: number, userId: string, userRole: string, ): Promise { if (!ROLES_APROBADOR.has(userRole)) { throw new Error('Solo owner o supervisor pueden aprobar papelería'); } const { rows: [r] } = await pool.query( `UPDATE papeleria_trabajo SET estado = 'aprobado', aprobado_por = $2, aprobado_at = NOW(), comentario_rechazo = NULL WHERE id = $1 AND requiere_aprobacion = true RETURNING ${SELECT}`, [id, userId], ); return r ? ROW(r) : null; } export async function rechazar( pool: Pool, id: number, userId: string, userRole: string, comentario: string | null, ): Promise { if (!ROLES_APROBADOR.has(userRole)) { throw new Error('Solo owner o supervisor pueden rechazar papelería'); } const { rows: [r] } = await pool.query( `UPDATE papeleria_trabajo SET estado = 'rechazado', aprobado_por = $2, aprobado_at = NOW(), comentario_rechazo = $3 WHERE id = $1 AND requiere_aprobacion = true RETURNING ${SELECT}`, [id, userId, comentario], ); return r ? ROW(r) : null; } export async function eliminar(pool: Pool, id: number): Promise { const { rowCount } = await pool.query( `DELETE FROM papeleria_trabajo WHERE id = $1`, [id], ); return (rowCount ?? 0) > 0; }