Files
HoruxDespachosNuevo/apps/api/src/controllers/platform-staff.controller.ts

188 lines
6.0 KiB
TypeScript

import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { hasPlatformRole, invalidatePlatformRolesCache, type PlatformRole } from '../utils/platform-admin.js';
import { auditFromReq } from '../utils/audit.js';
const VALID_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
const SUPERSET_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti'];
async function requirePlatformAdmin(req: Request, res: Response): Promise<boolean> {
const ok = await hasPlatformRole(req.user?.userId, 'platform_admin');
if (!ok) {
res.status(403).json({ message: 'Solo platform_admin puede gestionar staff' });
}
return ok;
}
/**
* Lista users que tienen al menos un platform role + users candidatos a serlo.
* Admin global (platform_admin) only.
*/
export async function listStaff(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
// Todos los users con al menos un platform role
const roles = await prisma.userPlatformRole.findMany({
include: {
user: {
select: {
id: true, email: true, nombre: true, active: true,
// Tenant principal del staff: el primer membership owner por joinedAt
// ASC. Se incluye solo para mostrar contexto en la UI admin.
memberships: {
where: { active: true, isOwner: true },
orderBy: { joinedAt: 'asc' },
take: 1,
include: { tenant: { select: { id: true, nombre: true, rfc: true } } },
},
},
},
},
orderBy: { createdAt: 'desc' },
});
// Agrupa por user
const byUser = new Map<string, any>();
for (const r of roles) {
const existing = byUser.get(r.userId);
if (existing) {
existing.roles.push(r.role);
} else {
const { memberships, ...userBase } = r.user;
byUser.set(r.userId, {
...userBase,
tenant: memberships[0]?.tenant ?? null,
roles: [r.role],
});
}
}
res.json(Array.from(byUser.values()));
} catch (error) {
next(error);
}
}
/**
* Busca users por email (para agregar nuevos staff). Admin global only.
*/
export async function searchUsers(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
const q = String(req.query.q || '').trim();
if (q.length < 2) return res.json([]);
const users = await prisma.user.findMany({
where: {
OR: [
{ email: { contains: q, mode: 'insensitive' } },
{ nombre: { contains: q, mode: 'insensitive' } },
],
},
select: {
id: true, email: true, nombre: true, active: true,
memberships: {
where: { active: true, isOwner: true },
orderBy: { joinedAt: 'asc' },
take: 1,
include: { tenant: { select: { id: true, nombre: true, rfc: true } } },
},
},
take: 10,
});
res.json(users.map(u => {
const { memberships, ...rest } = u;
return { ...rest, tenant: memberships[0]?.tenant ?? null };
}));
} catch (error) {
next(error);
}
}
/**
* Asigna un rol a un user. Idempotente (si ya existe, no duplica).
*/
export async function grantRole(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
const { userId, role } = req.body;
if (!userId || typeof userId !== 'string') {
return res.status(400).json({ message: 'userId requerido' });
}
if (!VALID_ROLES.includes(role)) {
return res.status(400).json({ message: `role inválido. Valores: ${VALID_ROLES.join(', ')}` });
}
const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true } });
if (!user) return res.status(404).json({ message: 'Usuario no encontrado' });
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId, role } },
create: { userId, role, createdBy: req.user!.userId },
update: {},
});
invalidatePlatformRolesCache(userId);
auditFromReq(req, 'platform_role.granted', {
entityType: 'User',
entityId: userId,
metadata: { role, targetEmail: user.email },
});
res.json({ ok: true });
} catch (error) {
next(error);
}
}
/**
* Quita un rol a un user. Protección: no puedes quitarte tu propio `platform_admin`
* si eres el último admin (evita bootstrap problem — nadie queda con acceso).
*/
export async function revokeRole(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
const { userId, role } = req.body;
if (!userId || typeof userId !== 'string') {
return res.status(400).json({ message: 'userId requerido' });
}
if (!VALID_ROLES.includes(role)) {
return res.status(400).json({ message: 'role inválido' });
}
// Protección: no quitar tu último rol superset (admin o TI) — evita bootstrap problem
if (SUPERSET_ROLES.includes(role) && userId === req.user!.userId) {
const supersetCount = await prisma.userPlatformRole.count({
where: { role: { in: SUPERSET_ROLES } },
});
if (supersetCount <= 1) {
return res.status(400).json({
message: 'No puedes quitar tu propio rol superset — serías el último con acceso transversal. Asigna platform_admin o platform_ti a otro usuario primero.',
});
}
}
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
await prisma.userPlatformRole.deleteMany({
where: { userId, role },
});
invalidatePlatformRolesCache(userId);
auditFromReq(req, 'platform_role.revoked', {
entityType: 'User',
entityId: userId,
metadata: { role, targetEmail: user?.email },
});
res.json({ ok: true });
} catch (error) {
next(error);
}
}