feat: asignaciones obligaciones/tareas + fixes backend

- Migracion 046: tablas obligacion_asignaciones y tarea_asignaciones
- Servicio y controller de asignaciones (CRUD + listados)
- Fix: enviar correo welcome al invitar usuario nuevo
- Fix: quitar JOIN users de queries tenant (usar Prisma en BD central)
- Fix: req.params.obligacionId correcto en asignaciones controller
- Fix: orden rutas estaticas antes de dinamicas en cartera.routes
- Fix: owner/cfo ven todas las asignaciones en getAsignacionesPorSupervisor
- Fix: validar que entidad pertenezca a cartera padre en subcartera
- Nuevo endpoint GET /carteras/asignaciones/sin-asignar
- Nuevo endpoint GET /tareas/mis-tareas
This commit is contained in:
Horux Dev
2026-05-23 23:40:12 +00:00
parent 0c7580aa44
commit f43cb165c6
11 changed files with 596 additions and 14 deletions

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<Map<string, string>> {
const map = new Map<string, string>();
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<AsignacionObligacion>(
`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<AsignacionTarea>(
`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<AsignacionObligacion>(
`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<AsignacionTarea>(
`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<Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
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<Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
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 };
}