416 lines
17 KiB
TypeScript
416 lines
17 KiB
TypeScript
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<string | null> => {
|
|
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<SlugParam>(
|
|
'/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<string, unknown>) => ({
|
|
...t,
|
|
items: items.filter((i: Record<string, unknown>) => i.template_id === t.id),
|
|
}))
|
|
},
|
|
)
|
|
|
|
// ── POST /api/hotels/:slug/checklist-templates ────────────────────────────
|
|
fastify.post<SlugParam & { Body: { name: string; task_type?: string } }>(
|
|
'/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<SlugTplParam & { Body: { name?: string; task_type?: string | null; is_active?: boolean; sort_order?: number } }>(
|
|
'/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<string, unknown>
|
|
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<SlugTplParam>(
|
|
'/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<SlugTplParam & { Body: { text: string; sort_order?: number } }>(
|
|
'/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<SlugTplItemParam & { Body: { text?: string; sort_order?: number } }>(
|
|
'/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<string, unknown>
|
|
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<SlugTplItemParam>(
|
|
'/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<SlugTaskParam>(
|
|
'/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<SlugTaskItemParam>(
|
|
'/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<SlugTaskItemParam>(
|
|
'/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<SlugParam & { Querystring: { from?: string; to?: string } }>(
|
|
'/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<SlugParam>(
|
|
'/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<SlugParam & { Body: { require_minibar_check?: boolean } }>(
|
|
'/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
|