import { FastifyPluginAsync } from 'fastify' import bcrypt from 'bcryptjs' import { db } from '../db' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } const users: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) return rows[0]?.id ?? null } const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug const USER_FIELDS = 'id, hotel_id, email, name, role, created_at, updated_at' // ── GET /api/hotels/:slug/users ──────────────────────────────────────────── fastify.get( '/api/hotels/:slug/users', { onRequest: [fastify.authenticate] }, async (request, reply) => { if (!['manager', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( `SELECT ${USER_FIELDS} FROM users WHERE hotel_id = $1 ORDER BY name`, [hotelId], ) return rows }, ) // ── POST /api/hotels/:slug/users ─────────────────────────────────────────── fastify.post( '/api/hotels/:slug/users', { onRequest: [fastify.authenticate] }, async (request, reply) => { if (!['manager', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { email, password, name, role = 'housekeeper' } = request.body // Managers cannot create other managers or super_admins if (request.user.role === 'manager' && role !== 'housekeeper') { return reply.code(403).send({ error: 'Managers can only create housekeepers' }) } const passwordHash = await bcrypt.hash(password, 12) try { const { rows } = await db.query( `INSERT INTO users (hotel_id, email, password_hash, name, role) VALUES ($1, $2, $3, $4, $5) RETURNING ${USER_FIELDS}`, [hotelId, email.toLowerCase(), passwordHash, name, role], ) return reply.code(201).send(rows[0]) } catch (err: unknown) { if ((err as { code?: string }).code === '23505') { return reply.code(409).send({ error: 'Email already in use' }) } throw err } }, ) // ── PATCH /api/hotels/:slug/users/:id ───────────────────────────────────── fastify.patch( '/api/hotels/:slug/users/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { if (!['manager', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const updates: string[] = [] const values: unknown[] = [] let idx = 1 if (request.body.name) { updates.push(`name = $${idx}`); values.push(request.body.name); idx++ } if (request.body.email) { updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++ } if (request.body.role && request.user.role === 'super_admin') { updates.push(`role = $${idx}`); values.push(request.body.role); idx++ } if (request.body.password) { const hash = await bcrypt.hash(request.body.password, 12) updates.push(`password_hash = $${idx}`); values.push(hash); idx++ } if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) updates.push(`updated_at = NOW()`) values.push(id, hotelId) const { rows } = await db.query( `UPDATE users SET ${updates.join(', ')} WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING ${USER_FIELDS}`, values, ) if (!rows[0]) return reply.code(404).send({ error: 'User not found' }) return rows[0] }, ) // ── DELETE /api/hotels/:slug/users/:id ──────────────────────────────────── fastify.delete( '/api/hotels/:slug/users/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { if (!['manager', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } // Prevent self-deletion if (request.user.sub === id) { return reply.code(400).send({ error: 'Cannot delete yourself' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rowCount } = await db.query( 'DELETE FROM users WHERE id = $1 AND hotel_id = $2', [id, hotelId], ) if (!rowCount) return reply.code(404).send({ error: 'User not found' }) return reply.code(204).send() }, ) } export default users