diff --git a/apps/api/src/controllers/asignaciones.controller.ts b/apps/api/src/controllers/asignaciones.controller.ts new file mode 100644 index 0000000..77a4a05 --- /dev/null +++ b/apps/api/src/controllers/asignaciones.controller.ts @@ -0,0 +1,137 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as asignacionesService from '../services/asignaciones.service.js'; +import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +/** + * Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner). + * Owner puede asignar a cualquier auxiliar del tenant. + */ +async function validarAuxiliarDelSupervisor( + pool: import('pg').Pool, + supervisorUserId: string, + auxiliarUserId: string, + callerRole: string, +): Promise { + if (callerRole === 'owner') return; + + const { rows } = await pool.query( + `SELECT 1 FROM auxiliar_supervisores + WHERE auxiliar_user_id = $1 AND supervisor_user_id = $2 + LIMIT 1`, + [auxiliarUserId, supervisorUserId], + ); + + if (rows.length === 0) { + throw new AppError(403, 'El auxiliar no pertenece a tu equipo'); + } +} + +// ── Obligaciones ── + +export async function asignarObligacion(req: Request, res: Response, next: NextFunction) { + try { + const obligacionId = String(req.params.obligacionId); + const schema = z.object({ auxiliarUserId: z.string().uuid() }); + const { auxiliarUserId } = schema.parse(req.body); + + await validarAuxiliarDelSupervisor( + req.tenantPool!, + req.user!.userId, + auxiliarUserId, + req.user!.role, + ); + + await asignacionesService.asignarObligacion( + req.tenantPool!, + obligacionId, + auxiliarUserId, + req.user!.userId, + ); + + res.json({ message: 'Obligación asignada' }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + next(err); + } +} + +export async function desasignarObligacion(req: Request, res: Response, next: NextFunction) { + try { + const obligacionId = String(req.params.obligacionId); + await asignacionesService.desasignarObligacion(req.tenantPool!, obligacionId); + res.json({ message: 'Asignación de obligación eliminada' }); + } catch (err) { next(err); } +} + +// ── Tareas ── + +export async function asignarTarea(req: Request, res: Response, next: NextFunction) { + try { + const tareaId = String(req.params.id); + const schema = z.object({ auxiliarUserId: z.string().uuid() }); + const { auxiliarUserId } = schema.parse(req.body); + + await validarAuxiliarDelSupervisor( + req.tenantPool!, + req.user!.userId, + auxiliarUserId, + req.user!.role, + ); + + await asignacionesService.asignarTarea( + req.tenantPool!, + tareaId, + auxiliarUserId, + req.user!.userId, + ); + + res.json({ message: 'Tarea asignada' }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + next(err); + } +} + +export async function desasignarTarea(req: Request, res: Response, next: NextFunction) { + try { + const tareaId = String(req.params.id); + await asignacionesService.desasignarTarea(req.tenantPool!, tareaId); + res.json({ message: 'Asignación de tarea eliminada' }); + } catch (err) { next(err); } +} + +// ── Listados ── + +export async function listPorSupervisor(req: Request, res: Response, next: NextFunction) { + try { + const data = await asignacionesService.getAsignacionesPorSupervisor( + req.tenantPool!, + req.user!.userId, + req.user!.role, + ); + res.json(data); + } catch (err) { next(err); } +} + +export async function listPorAuxiliar(req: Request, res: Response, next: NextFunction) { + try { + const data = await asignacionesService.getAsignacionesPorAuxiliar( + req.tenantPool!, + req.user!.userId, + ); + res.json(data); + } catch (err) { next(err); } +} + +export async function listSinAsignar(req: Request, res: Response, next: NextFunction) { + try { + const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); + const [obligaciones, tareas] = await Promise.all([ + asignacionesService.getObligacionesSinAsignar(req.tenantPool!, entidadIds), + asignacionesService.getTareasSinAsignar(req.tenantPool!, entidadIds), + ]); + res.json({ obligaciones, tareas }); + } catch (err) { next(err); } +} diff --git a/apps/api/src/controllers/tareas.controller.ts b/apps/api/src/controllers/tareas.controller.ts index c37803b..93f20bc 100644 --- a/apps/api/src/controllers/tareas.controller.ts +++ b/apps/api/src/controllers/tareas.controller.ts @@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { AppError } from '../middlewares/error.middleware.js'; import * as tareasService from '../services/tareas.service.js'; +import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; import { emailService } from '../services/email/email.service.js'; import { getUserEmailById } from '../utils/memberships.js'; import { env } from '../config/env.js'; @@ -164,6 +165,17 @@ export async function descompletarPeriodo(req: Request, res: Response, next: Nex } } +export async function listMisTareas(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); + const tareas = await tareasService.listTareasConPeriodoPorContribuyentes(req.tenantPool!, entidadIds); + res.json(tareas); + } catch (error) { + next(error); + } +} + export async function seedDefaults(req: Request, res: Response, next: NextFunction) { try { rejectClienteRole(req); diff --git a/apps/api/src/migrations/tenant/046_asignaciones_obligaciones_tareas.sql b/apps/api/src/migrations/tenant/046_asignaciones_obligaciones_tareas.sql new file mode 100644 index 0000000..6ce5a36 --- /dev/null +++ b/apps/api/src/migrations/tenant/046_asignaciones_obligaciones_tareas.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS obligacion_asignaciones ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE, + auxiliar_user_id uuid NOT NULL, + asignado_por uuid NOT NULL, + asignado_at timestamptz DEFAULT now(), + UNIQUE (obligacion_id) +); + +CREATE TABLE IF NOT EXISTS tarea_asignaciones ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tarea_id uuid NOT NULL REFERENCES tareas_catalogo(id) ON DELETE CASCADE, + auxiliar_user_id uuid NOT NULL, + asignado_por uuid NOT NULL, + asignado_at timestamptz DEFAULT now(), + UNIQUE (tarea_id) +); + +CREATE INDEX IF NOT EXISTS idx_obligacion_asignaciones_auxiliar ON obligacion_asignaciones(auxiliar_user_id); +CREATE INDEX IF NOT EXISTS idx_tarea_asignaciones_auxiliar ON tarea_asignaciones(auxiliar_user_id); diff --git a/apps/api/src/routes/cartera.routes.ts b/apps/api/src/routes/cartera.routes.ts index 308415f..b0e076d 100644 --- a/apps/api/src/routes/cartera.routes.ts +++ b/apps/api/src/routes/cartera.routes.ts @@ -2,6 +2,7 @@ import { Router, type IRouter } from 'express'; import { authenticate, authorize } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import * as ctrl from '../controllers/cartera.controller.js'; +import * as asignacionesCtrl from '../controllers/asignaciones.controller.js'; const router: IRouter = Router(); @@ -11,6 +12,11 @@ router.use(tenantMiddleware); // Static routes first router.get('/supervisores', authorize('owner'), ctrl.getSupervisores); +// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico) +router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor); +router.get('/asignaciones/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar); +router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar); + // Read: owner + supervisor + auxiliar router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list); router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById); diff --git a/apps/api/src/routes/contribuyente.routes.ts b/apps/api/src/routes/contribuyente.routes.ts index 3d6b1d9..5454469 100644 --- a/apps/api/src/routes/contribuyente.routes.ts +++ b/apps/api/src/routes/contribuyente.routes.ts @@ -5,6 +5,7 @@ import * as ctrl from '../controllers/contribuyente.controller.js'; import * as configCtrl from '../controllers/contribuyente-config.controller.js'; import * as facturacionCtrl from '../controllers/facturacion.controller.js'; import * as obligacionesCtrl from '../controllers/obligaciones.controller.js'; +import * as asignacionesCtrl from '../controllers/asignaciones.controller.js'; const router: IRouter = Router(); @@ -50,4 +51,8 @@ router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cf router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo); router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo); +// Asignación de obligaciones a auxiliares (supervisor/owner) +router.post('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarObligacion); +router.delete('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarObligacion); + export default router; diff --git a/apps/api/src/routes/tareas.routes.ts b/apps/api/src/routes/tareas.routes.ts index 271b429..e115257 100644 --- a/apps/api/src/routes/tareas.routes.ts +++ b/apps/api/src/routes/tareas.routes.ts @@ -1,13 +1,15 @@ import { Router, type IRouter } from 'express'; -import { authenticate } from '../middlewares/auth.middleware.js'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import * as ctrl from '../controllers/tareas.controller.js'; +import * as asignacionesCtrl from '../controllers/asignaciones.controller.js'; const router: IRouter = Router(); router.use(authenticate); router.use(tenantMiddleware); +router.get('/mis-tareas', ctrl.listMisTareas); router.get('/', ctrl.listTareas); router.post('/', ctrl.createTarea); router.post('/seed', ctrl.seedDefaults); @@ -17,4 +19,8 @@ router.delete('/:id', ctrl.deleteTarea); router.post('/periodo/:id/completar', ctrl.completarPeriodo); router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo); +// Asignación de tareas a auxiliares (supervisor/owner) +router.post('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarTarea); +router.delete('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarTarea); + export { router as tareasRoutes }; diff --git a/apps/api/src/services/asignaciones.service.ts b/apps/api/src/services/asignaciones.service.ts new file mode 100644 index 0000000..2ff70bb --- /dev/null +++ b/apps/api/src/services/asignaciones.service.ts @@ -0,0 +1,303 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; + +// ── Asignación de obligaciones ── + +export async function asignarObligacion( + pool: Pool, + obligacionId: string, + auxiliarUserId: string, + asignadoPor: string, +): Promise { + await pool.query( + `INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por) + VALUES ($1, $2, $3) + ON CONFLICT (obligacion_id) + DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`, + [obligacionId, auxiliarUserId, asignadoPor], + ); +} + +export async function desasignarObligacion(pool: Pool, obligacionId: string): Promise { + await pool.query('DELETE FROM obligacion_asignaciones WHERE obligacion_id = $1', [obligacionId]); +} + +// ── Asignación de tareas ── + +export async function asignarTarea( + pool: Pool, + tareaId: string, + auxiliarUserId: string, + asignadoPor: string, +): Promise { + await pool.query( + `INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por) + VALUES ($1, $2, $3) + ON CONFLICT (tarea_id) + DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`, + [tareaId, auxiliarUserId, asignadoPor], + ); +} + +export async function desasignarTarea(pool: Pool, tareaId: string): Promise { + await pool.query('DELETE FROM tarea_asignaciones WHERE tarea_id = $1', [tareaId]); +} + +// ── Listados ── + +export interface AsignacionObligacion { + id: string; + obligacionId: string; + obligacionNombre: string; + contribuyenteId: string; + contribuyenteRfc: string; + contribuyenteRazonSocial: string; + auxiliarUserId: string; + auxiliarNombre: string | null; + asignadoPor: string; + asignadoAt: string; +} + +export interface AsignacionTarea { + id: string; + tareaId: string; + tareaNombre: string; + contribuyenteId: string; + contribuyenteRfc: string; + contribuyenteRazonSocial: string; + auxiliarUserId: string; + auxiliarNombre: string | null; + asignadoPor: string; + asignadoAt: string; +} + +async function resolveUserNames(userIds: string[]): Promise> { + const map = new Map(); + if (userIds.length === 0) return map; + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, nombre: true }, + }); + for (const u of users) { + map.set(u.id, u.nombre); + } + return map; +} + +/** + * Devuelve todas las asignaciones de obligaciones y tareas de los auxiliares + * que pertenecen al supervisor indicado (vía auxiliar_supervisores). + * Owner ve todas las asignaciones del tenant. + */ +export async function getAsignacionesPorSupervisor( + pool: Pool, + supervisorUserId: string, + role: string, +): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> { + const isOwner = role === 'owner' || role === 'cfo' || role === 'contador'; + + const whereObl = isOwner + ? 'WHERE 1=1' + : 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = oa.auxiliar_user_id AND asp.supervisor_user_id = $1)'; + const whereTarea = isOwner + ? 'WHERE 1=1' + : 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = ta.auxiliar_user_id AND asp.supervisor_user_id = $1)'; + const params = isOwner ? [] : [supervisorUserId]; + + const { rows: obligaciones } = await pool.query( + `SELECT + oa.id, + oa.obligacion_id AS "obligacionId", + oc.nombre AS "obligacionNombre", + oc.contribuyente_id AS "contribuyenteId", + c.rfc AS "contribuyenteRfc", + COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial", + oa.auxiliar_user_id AS "auxiliarUserId", + oa.asignado_por AS "asignadoPor", + oa.asignado_at AS "asignadoAt" + FROM obligacion_asignaciones oa + JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id + JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + ${whereObl} + ORDER BY oa.asignado_at DESC`, + params, + ); + + const { rows: tareas } = await pool.query( + `SELECT + ta.id, + ta.tarea_id AS "tareaId", + tc.nombre AS "tareaNombre", + tc.contribuyente_id AS "contribuyenteId", + c.rfc AS "contribuyenteRfc", + COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial", + ta.auxiliar_user_id AS "auxiliarUserId", + ta.asignado_por AS "asignadoPor", + ta.asignado_at AS "asignadoAt" + FROM tarea_asignaciones ta + JOIN tareas_catalogo tc ON tc.id = ta.tarea_id + JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + ${whereTarea} + ORDER BY ta.asignado_at DESC`, + params, + ); + + const allAuxIds = [...new Set([ + ...obligaciones.map(o => o.auxiliarUserId), + ...tareas.map(t => t.auxiliarUserId), + ])]; + const names = await resolveUserNames(allAuxIds); + + return { + obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: names.get(o.auxiliarUserId) ?? null })), + tareas: tareas.map(t => ({ ...t, auxiliarNombre: names.get(t.auxiliarUserId) ?? null })), + }; +} + +/** + * Devuelve las asignaciones del auxiliar logueado. + */ +export async function getAsignacionesPorAuxiliar( + pool: Pool, + auxiliarUserId: string, +): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> { + const { rows: obligaciones } = await pool.query( + `SELECT + oa.id, + oa.obligacion_id AS "obligacionId", + oc.nombre AS "obligacionNombre", + oc.contribuyente_id AS "contribuyenteId", + c.rfc AS "contribuyenteRfc", + COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial", + oa.auxiliar_user_id AS "auxiliarUserId", + oa.asignado_por AS "asignadoPor", + oa.asignado_at AS "asignadoAt" + FROM obligacion_asignaciones oa + JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id + JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + WHERE oa.auxiliar_user_id = $1 + ORDER BY oa.asignado_at DESC`, + [auxiliarUserId], + ); + + const { rows: tareas } = await pool.query( + `SELECT + ta.id, + ta.tarea_id AS "tareaId", + tc.nombre AS "tareaNombre", + tc.contribuyente_id AS "contribuyenteId", + c.rfc AS "contribuyenteRfc", + COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial", + ta.auxiliar_user_id AS "auxiliarUserId", + ta.asignado_por AS "asignadoPor", + ta.asignado_at AS "asignadoAt" + FROM tarea_asignaciones ta + JOIN tareas_catalogo tc ON tc.id = ta.tarea_id + JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + WHERE ta.auxiliar_user_id = $1 + ORDER BY ta.asignado_at DESC`, + [auxiliarUserId], + ); + + const names = await resolveUserNames([auxiliarUserId]); + const auxName = names.get(auxiliarUserId) ?? null; + + return { + obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: auxName })), + tareas: tareas.map(t => ({ ...t, auxiliarNombre: auxName })), + }; +} + +/** + * Devuelve obligaciones activas sin asignar para los contribuyentes indicados. + */ +export async function getObligacionesSinAsignar( + pool: Pool, + entidadIds: string[], +): Promise[]> { + if (entidadIds.length === 0) return []; + const { rows } = await pool.query( + `SELECT + oc.id AS "obligacionId", + oc.nombre AS "obligacionNombre", + oc.contribuyente_id AS "contribuyenteId", + c.rfc AS "contribuyenteRfc", + COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial" + FROM obligaciones_contribuyente oc + JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id + WHERE oc.activa = true AND oa.id IS NULL AND oc.contribuyente_id = ANY($1) + ORDER BY c.rfc, oc.nombre`, + [entidadIds], + ); + return rows; +} + +/** + * Devuelve tareas activas sin asignar para los contribuyentes indicados. + */ +export async function getTareasSinAsignar( + pool: Pool, + entidadIds: string[], +): Promise[]> { + if (entidadIds.length === 0) return []; + const { rows } = await pool.query( + `SELECT + tc.id AS "tareaId", + tc.nombre AS "tareaNombre", + tc.contribuyente_id AS "contribuyenteId", + c.rfc AS "contribuyenteRfc", + COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial" + FROM tareas_catalogo tc + JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id + WHERE tc.active = true AND ta.id IS NULL AND tc.contribuyente_id = ANY($1) + ORDER BY c.rfc, tc.nombre`, + [entidadIds], + ); + return rows; +} + +/** + * Resuelve el auxiliar asignado a una obligación (o null). + */ +export async function getAuxiliarAsignadoObligacion( + pool: Pool, + obligacionId: string, +): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> { + const { rows } = await pool.query<{ auxiliar_user_id: string }>( + `SELECT oa.auxiliar_user_id + FROM obligacion_asignaciones oa + WHERE oa.obligacion_id = $1`, + [obligacionId], + ); + if (rows.length === 0) return null; + const auxId = rows[0].auxiliar_user_id; + const names = await resolveUserNames([auxId]); + return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null }; +} + +/** + * Resuelve el auxiliar asignado a una tarea (o null). + */ +export async function getAuxiliarAsignadoTarea( + pool: Pool, + tareaId: string, +): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> { + const { rows } = await pool.query<{ auxiliar_user_id: string }>( + `SELECT ta.auxiliar_user_id + FROM tarea_asignaciones ta + WHERE ta.tarea_id = $1`, + [tareaId], + ); + if (rows.length === 0) return null; + const auxId = rows[0].auxiliar_user_id; + const names = await resolveUserNames([auxId]); + return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null }; +} diff --git a/apps/api/src/services/cartera.service.ts b/apps/api/src/services/cartera.service.ts index 6d3c299..5347b93 100644 --- a/apps/api/src/services/cartera.service.ts +++ b/apps/api/src/services/cartera.service.ts @@ -110,6 +110,17 @@ export async function deleteCartera(pool: Pool, id: string): Promise { // Entidades in cartera export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise { + // Si es subcartera, validar que la entidad pertenezca a la cartera padre + const cartera = await getCarteraById(pool, carteraId); + if (cartera?.parentId) { + const { rows } = await pool.query( + 'SELECT 1 FROM cartera_entidades WHERE cartera_id = $1 AND entidad_id = $2', + [cartera.parentId, entidadId], + ); + if (rows.length === 0) { + throw new Error('La entidad no pertenece a la cartera padre de esta subcartera'); + } + } await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]); } diff --git a/apps/api/src/services/obligaciones.service.ts b/apps/api/src/services/obligaciones.service.ts index 1f9c36b..26e3501 100644 --- a/apps/api/src/services/obligaciones.service.ts +++ b/apps/api/src/services/obligaciones.service.ts @@ -138,6 +138,8 @@ export interface ObligacionContribuyente { completadaPor: string | null; periodoCompletado: string | null; createdAt?: string; + auxiliarAsignadoId?: string | null; + auxiliarAsignadoNombre?: string | null; } export function getCatalogo(): ObligacionFiscal[] { @@ -146,15 +148,18 @@ export function getCatalogo(): ObligacionFiscal[] { export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise { const { rows } = await pool.query(` - SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId", - nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria, - activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom", - completada, completada_at AS "completadaAt", completada_por AS "completadaPor", - periodo_completado AS "periodoCompletado", - created_at AS "createdAt" - FROM obligaciones_contribuyente - WHERE contribuyente_id = $1 - ORDER BY categoria, nombre + SELECT + oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId", + oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria, + oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom", + oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor", + oc.periodo_completado AS "periodoCompletado", + oc.created_at AS "createdAt", + oa.auxiliar_user_id AS "auxiliarAsignadoId" + FROM obligaciones_contribuyente oc + LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id + WHERE oc.contribuyente_id = $1 + ORDER BY oc.categoria, oc.nombre `, [contribuyenteId]); return rows; } diff --git a/apps/api/src/services/tareas.service.ts b/apps/api/src/services/tareas.service.ts index 1be1b80..cf5933c 100644 --- a/apps/api/src/services/tareas.service.ts +++ b/apps/api/src/services/tareas.service.ts @@ -17,6 +17,8 @@ export interface TareaCatalogo { active: boolean; orden: number; createdAt: Date; + auxiliarAsignadoId?: string | null; + auxiliarAsignadoNombre?: string | null; } export interface TareaPeriodo { @@ -47,6 +49,8 @@ const ROW_TO_TAREA = (r: any): TareaCatalogo => ({ active: r.active, orden: r.orden, createdAt: r.created_at, + auxiliarAsignadoId: r.auxiliarAsignadoId ?? null, + auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null, }); const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({ @@ -68,9 +72,13 @@ function sanitizeUuid(id: string): string { export async function listTareas(pool: Pool, contribuyenteId: string): Promise { const { rows } = await pool.query( - `SELECT * FROM tareas_catalogo - WHERE contribuyente_id = $1 AND active = true - ORDER BY orden, nombre`, + `SELECT + tc.*, + ta.auxiliar_user_id AS "auxiliarAsignadoId" + FROM tareas_catalogo tc + LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id + WHERE tc.contribuyente_id = $1 AND tc.active = true + ORDER BY tc.orden, tc.nombre`, [sanitizeUuid(contribuyenteId)], ); return rows.map(ROW_TO_TAREA); @@ -272,6 +280,59 @@ export async function listTareasConPeriodoActual( return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null })); } +export interface TareaConContribuyente extends TareaConPeriodo { + contribuyenteRfc: string; + contribuyenteRazonSocial: string; +} + +/** + * Lee tareas activas con periodo actual para una lista de contribuyentes. + * Útil para la vista "Mis Tareas". + */ +export async function listTareasConPeriodoPorContribuyentes( + pool: Pool, + contribuyenteIds: string[], +): Promise { + if (contribuyenteIds.length === 0) return []; + + // Materializar periodos para cada contribuyente en paralelo + await Promise.all(contribuyenteIds.map(id => materializarPeriodos(pool, id))); + + const { rows: tareasRows } = await pool.query( + `SELECT tc.*, c.entidad_id AS "contribuyenteId", + c.rfc AS "contribuyenteRfc", + COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial" + FROM tareas_catalogo tc + JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true + ORDER BY c.rfc, tc.orden, tc.nombre`, + [contribuyenteIds], + ); + + if (tareasRows.length === 0) return []; + + const tareaIds = tareasRows.map((r: any) => r.id); + const today = new Date().toISOString().split('T')[0]; + const { rows: periodoRows } = await pool.query( + `SELECT DISTINCT ON (tarea_id) * + FROM tarea_periodos + WHERE tarea_id = ANY($1::uuid[]) + AND (completada = false OR fecha_limite >= $2::date) + ORDER BY tarea_id, fecha_limite ASC`, + [tareaIds, today], + ); + const periodos = new Map(periodoRows.map((r: any) => [r.tarea_id, ROW_TO_PERIODO(r)])); + + return tareasRows.map((r: any) => ({ + ...ROW_TO_TAREA(r), + contribuyenteId: r.contribuyenteId, + contribuyenteRfc: r.contribuyenteRfc, + contribuyenteRazonSocial: r.contribuyenteRazonSocial, + periodoActual: periodos.get(r.id) ?? null, + })); +} + // ─── Completar / descompletar periodo ─── const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']); diff --git a/apps/api/src/services/usuarios.service.ts b/apps/api/src/services/usuarios.service.ts index 637c6e9..f3fea33 100644 --- a/apps/api/src/services/usuarios.service.ts +++ b/apps/api/src/services/usuarios.service.ts @@ -2,6 +2,7 @@ import { prisma } from '../config/database.js'; import bcrypt from 'bcryptjs'; import { randomBytes } from 'crypto'; import { getDespachoPlanLimits } from './plan-catalogo.service.js'; +import { emailService } from './email/email.service.js'; import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared'; /** @@ -99,6 +100,13 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise lastTenantId: tenantId, }, }); + + // Enviar correo de bienvenida con credenciales (non-blocking) + emailService.sendWelcome(data.email, { + nombre: data.nombre, + email: data.email, + tempPassword, + }).catch(err => console.error('[EMAIL] Welcome email failed:', err)); } const rolId = await getRolId(data.role); @@ -224,8 +232,9 @@ export async function createUsuarioGlobal( // Si el email ya existe como user global, agregamos membership en este tenant let user = await prisma.user.findUnique({ where: { email: data.email } }); + let tempPassword: string | null = null; if (!user) { - const tempPassword = randomBytes(4).toString('hex'); + tempPassword = randomBytes(4).toString('hex'); const passwordHash = await bcrypt.hash(tempPassword, 12); user = await prisma.user.create({ data: { @@ -235,6 +244,13 @@ export async function createUsuarioGlobal( lastTenantId: tenantId, }, }); + + // Enviar correo de bienvenida con credenciales (non-blocking) + emailService.sendWelcome(data.email, { + nombre: data.nombre, + email: data.email, + tempPassword, + }).catch(err => console.error('[EMAIL] Welcome email failed:', err)); } const rolId = await getRolId(data.role);