import { FastifyPluginAsync } from 'fastify' import { db } from '../db' type SlugParam = { Params: { slug: string } } type SlugTplParam = { Params: { slug: string; templateId: string } } type SlugTplItemParam = { Params: { slug: string; templateId: string; itemId: string } } type SlugTaskParam = { Params: { slug: string; taskId: string } } type SlugTaskItemParam = { Params: { slug: string; taskId: string; itemId: string } } const checklists: 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 isManager = (role: string) => ['super_admin', 'hotel_admin', 'manager'].includes(role) // ── GET /api/hotels/:slug/checklist-templates ───────────────────────────── fastify.get( '/api/hotels/:slug/checklist-templates', { onRequest: [fastify.authenticate] }, async (request, reply) => { 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: templates } = await db.query( `SELECT * FROM checklist_templates WHERE hotel_id = $1 ORDER BY sort_order, created_at`, [hotelId], ) const { rows: items } = await db.query( `SELECT ci.* FROM checklist_items ci JOIN checklist_templates ct ON ct.id = ci.template_id WHERE ct.hotel_id = $1 ORDER BY ci.sort_order`, [hotelId], ) return templates.map((t: Record) => ({ ...t, items: items.filter((i: Record) => i.template_id === t.id), })) }, ) // ── POST /api/hotels/:slug/checklist-templates ──────────────────────────── fastify.post( '/api/hotels/:slug/checklist-templates', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { name, task_type } = request.body const { rows } = await db.query( `INSERT INTO checklist_templates (hotel_id, name, task_type) VALUES ($1, $2, $3) RETURNING *`, [hotelId, name, task_type ?? null], ) return reply.code(201).send({ ...rows[0], items: [] }) }, ) // ── PATCH /api/hotels/:slug/checklist-templates/:templateId ────────────── fastify.patch( '/api/hotels/:slug/checklist-templates/:templateId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, templateId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const allowed = ['name', 'task_type', 'is_active', 'sort_order', 'require_before_complete'] const updates: string[] = [] const values: unknown[] = [] let idx = 1 const body = request.body as Record for (const key of allowed) { if (body[key] !== undefined) { updates.push(`${key} = $${idx}`) values.push(body[key]) idx++ } } if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) values.push(templateId, hotelId) const { rows } = await db.query( `UPDATE checklist_templates SET ${updates.join(', ')} WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, values, ) if (!rows[0]) return reply.code(404).send({ error: 'Template not found' }) return rows[0] }, ) // ── DELETE /api/hotels/:slug/checklist-templates/:templateId ───────────── fastify.delete( '/api/hotels/:slug/checklist-templates/:templateId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, templateId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rowCount } = await db.query( 'DELETE FROM checklist_templates WHERE id = $1 AND hotel_id = $2', [templateId, hotelId], ) if (!rowCount) return reply.code(404).send({ error: 'Template not found' }) return reply.code(204).send() }, ) // ── POST /api/hotels/:slug/checklist-templates/:templateId/items ────────── fastify.post( '/api/hotels/:slug/checklist-templates/:templateId/items', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, templateId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) // Verify template belongs to hotel const { rows: tRows } = await db.query( 'SELECT id FROM checklist_templates WHERE id = $1 AND hotel_id = $2', [templateId, hotelId], ) if (!tRows[0]) return reply.code(404).send({ error: 'Template not found' }) const { text, sort_order = 0 } = request.body const { rows } = await db.query( `INSERT INTO checklist_items (template_id, text, sort_order) VALUES ($1, $2, $3) RETURNING *`, [templateId, text, sort_order], ) return reply.code(201).send(rows[0]) }, ) // ── PATCH /api/hotels/:slug/checklist-templates/:templateId/items/:itemId ─ fastify.patch( '/api/hotels/:slug/checklist-templates/:templateId/items/:itemId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, templateId, itemId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { 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 const body = request.body as Record for (const key of ['text', 'sort_order']) { if (body[key] !== undefined) { updates.push(`${key} = $${idx}`) values.push(body[key]) idx++ } } if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) values.push(itemId, templateId) // Ensure item belongs to this template (which belongs to hotel) const { rows } = await db.query( `UPDATE checklist_items SET ${updates.join(', ')} WHERE id = $${idx} AND template_id = $${idx + 1} RETURNING *`, values, ) if (!rows[0]) return reply.code(404).send({ error: 'Item not found' }) return rows[0] }, ) // ── DELETE /api/hotels/:slug/checklist-templates/:templateId/items/:itemId fastify.delete( '/api/hotels/:slug/checklist-templates/:templateId/items/:itemId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, templateId, itemId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rowCount } = await db.query( `DELETE FROM checklist_items WHERE id = $1 AND template_id IN ( SELECT id FROM checklist_templates WHERE id = $2 AND hotel_id = $3 )`, [itemId, templateId, hotelId], ) if (!rowCount) return reply.code(404).send({ error: 'Item not found' }) return reply.code(204).send() }, ) // ── GET /api/hotels/:slug/housekeeping/:taskId/checklist ────────────────── fastify.get( '/api/hotels/:slug/housekeeping/:taskId/checklist', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, taskId } = 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' }) // Get task to know its type const { rows: taskRows } = await db.query( 'SELECT id, type FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2', [taskId, hotelId], ) if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' }) const taskType = taskRows[0].type as string // Find matching template: exact task_type match first, then NULL (all types) fallback const { rows: templates } = await db.query( `SELECT * FROM checklist_templates WHERE hotel_id = $1 AND is_active = true AND (task_type = $2 OR task_type IS NULL) ORDER BY CASE WHEN task_type = $2 THEN 0 ELSE 1 END, sort_order LIMIT 1`, [hotelId, taskType], ) if (!templates[0]) { return { templateId: null, templateName: null, items: [] } } const template = templates[0] // Get items with completion status for this task const { rows: items } = await db.query( `SELECT ci.id, ci.text, ci.sort_order, cc.completed_at, u.name AS completed_by FROM checklist_items ci LEFT JOIN checklist_completions cc ON cc.item_id = ci.id AND cc.task_id = $1 LEFT JOIN users u ON u.id = cc.completed_by WHERE ci.template_id = $2 ORDER BY ci.sort_order`, [taskId, template.id], ) return { templateId: template.id, templateName: template.name, items, } }, ) // ── POST /api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete ─ fastify.post( '/api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, taskId, itemId } = 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' }) // Verify task belongs to hotel const { rows: taskRows } = await db.query( 'SELECT id FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2', [taskId, hotelId], ) if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' }) const { rows } = await db.query( `INSERT INTO checklist_completions (task_id, item_id, completed_by) VALUES ($1, $2, $3) ON CONFLICT (task_id, item_id) DO UPDATE SET completed_at = NOW(), completed_by = $3 RETURNING *`, [taskId, itemId, request.user.sub], ) return reply.code(201).send(rows[0]) }, ) // ── DELETE /api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete fastify.delete( '/api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, taskId, itemId } = 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' }) await db.query( `DELETE FROM checklist_completions WHERE task_id = $1 AND item_id = $2`, [taskId, itemId], ) return reply.code(204).send() }, ) // ── GET /api/hotels/:slug/reports/checklists ────────────────────────────── fastify.get( '/api/hotels/:slug/reports/checklists', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { from, to } = request.query const conditions: string[] = ['t.hotel_id = $1', "t.status = 'done'"] const values: unknown[] = [hotelId] let idx = 2 if (from) { conditions.push(`t.completed_at >= $${idx}`); values.push(from); idx++ } if (to) { conditions.push(`t.completed_at < $${idx}`); values.push(to); idx++ } const { rows } = await db.query( `SELECT t.id, t.type, t.completed_at, r.number AS room_number, u.name AS assignee_name, COUNT(ci.id) AS total_items, COUNT(cc.id) AS completed_items FROM housekeeping_tasks t LEFT JOIN rooms r ON r.id = t.room_id LEFT JOIN users u ON u.id = t.assignee_id LEFT JOIN checklist_templates ct ON ct.hotel_id = t.hotel_id AND is_active = true AND (ct.task_type = t.type OR ct.task_type IS NULL) LEFT JOIN checklist_items ci ON ci.template_id = ct.id LEFT JOIN checklist_completions cc ON cc.task_id = t.id AND cc.item_id = ci.id WHERE ${conditions.join(' AND ')} GROUP BY t.id, r.number, u.name ORDER BY t.completed_at DESC`, values, ) return rows }, ) // ── GET /api/hotels/:slug/housekeeping-rules ────────────────────────────── fastify.get( '/api/hotels/:slug/housekeeping-rules', { onRequest: [fastify.authenticate] }, async (request, reply) => { 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 require_minibar_check FROM hotel_housekeeping_rules WHERE hotel_id = $1', [hotelId], ) return { requireMinibarCheck: rows[0]?.require_minibar_check ?? false } }, ) // ── PATCH /api/hotels/:slug/housekeeping-rules ───────────────────────────── fastify.patch( '/api/hotels/:slug/housekeeping-rules', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { require_minibar_check } = request.body await db.query( `INSERT INTO hotel_housekeeping_rules (hotel_id, require_minibar_check) VALUES ($1, $2) ON CONFLICT (hotel_id) DO UPDATE SET require_minibar_check = $2`, [hotelId, require_minibar_check ?? false], ) return { requireMinibarCheck: require_minibar_check ?? false } }, ) } export default checklists