188 lines
6.0 KiB
TypeScript
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);
|
|
}
|
|
}
|