Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)
This commit is contained in:
154
apps/api/src/routes/users.ts
Normal file
154
apps/api/src/routes/users.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express';
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
import type { UserRole } from '@naturcalabacera/shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const VALID_ROLES: UserRole[] = ['admin', 'internal_staff', 'external_availability_viewer'];
|
||||
|
||||
/**
|
||||
* Middleware: extrae el JWT del header Authorization, valida la sesión con
|
||||
* Supabase y comprueba que el usuario es admin. Si pasa, deja el id del
|
||||
* caller en res.locals.callerId.
|
||||
*/
|
||||
async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Falta token de autenticación' });
|
||||
return;
|
||||
}
|
||||
const token = auth.slice('Bearer '.length);
|
||||
|
||||
const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token);
|
||||
if (userError || !userData.user) {
|
||||
res.status(401).json({ error: 'Token inválido' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
res.status(403).json({ error: 'Perfil no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Solo admins pueden gestionar usuarios' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.callerId = userData.user.id;
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /api/users — listar todos los perfiles
|
||||
router.get('/', requireAdmin, async (_req, res) => {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.select('id, email, role, display_name, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
return res.json({ users: data ?? [] });
|
||||
});
|
||||
|
||||
// POST /api/users/invite — invitar un usuario nuevo
|
||||
router.post('/invite', requireAdmin, async (req, res) => {
|
||||
const { email, role, display_name } = req.body as {
|
||||
email?: string;
|
||||
role?: UserRole;
|
||||
display_name?: string;
|
||||
};
|
||||
|
||||
if (!email || !role) {
|
||||
return res.status(400).json({ error: 'email y role son requeridos' });
|
||||
}
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: 'Rol inválido' });
|
||||
}
|
||||
|
||||
// 1. Invitar al usuario por email (envía correo con magic link)
|
||||
const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(email);
|
||||
if (inviteError || !invited.user) {
|
||||
return res.status(400).json({ error: inviteError?.message ?? 'Error al invitar usuario' });
|
||||
}
|
||||
|
||||
// 2. Crear el perfil con el rol seleccionado
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.insert({
|
||||
id: invited.user.id,
|
||||
email,
|
||||
role,
|
||||
display_name: display_name ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
// Best-effort cleanup: eliminar el usuario auth si el perfil falló
|
||||
await supabaseAdmin.auth.admin.deleteUser(invited.user.id).catch(() => {});
|
||||
return res.status(500).json({ error: profileError.message });
|
||||
}
|
||||
|
||||
return res.status(201).json({ user: profile });
|
||||
});
|
||||
|
||||
// PATCH /api/users/:id — actualizar rol o display_name
|
||||
router.patch('/:id', requireAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { role, display_name } = req.body as {
|
||||
role?: UserRole;
|
||||
display_name?: string;
|
||||
};
|
||||
|
||||
if (role !== undefined && !VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: 'Rol inválido' });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (role !== undefined) updates.role = role;
|
||||
if (display_name !== undefined) updates.display_name = display_name;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: 'Nada que actualizar' });
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
return res.json({ user: data });
|
||||
});
|
||||
|
||||
// DELETE /api/users/:id — eliminar usuario auth + perfil
|
||||
router.delete('/:id', requireAdmin, async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const callerId = res.locals.callerId as string;
|
||||
|
||||
if (id === callerId) {
|
||||
return res.status(400).json({ error: 'No puedes eliminar tu propia cuenta' });
|
||||
}
|
||||
|
||||
// ON DELETE CASCADE en user_profiles.id elimina el perfil al eliminar el usuario auth.
|
||||
const { error } = await supabaseAdmin.auth.admin.deleteUser(id);
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
export { router as usersRouter };
|
||||
Reference in New Issue
Block a user