Backend: - notifications table (024 migration) — stores per-hotel notifications - GET/PATCH/POST/DELETE /api/hotels/:slug/notifications endpoints - createNotification() helper used by jobs + future hooks - jobs.ts — background tasks every 10min: auto-cancel no-shows and auto-checkout overdue stays based on hotel_settings, broadcasts WS events and creates notifications for each automated action Frontend: - CalendarPage: handle housekeeping_done WS → update room status in calendar in real-time (bug fix) - CalendarPage: when manually setting room to dirty via context menu, auto-create housekeeping task and broadcast via WS (bug fix) - NotificationsContext: replaced mock data with real API integration, polls every 60s, syncs markRead/delete to backend - SettingsPage booking section: auto-cancel no-show toggle + hours selector; auto-checkout toggle + hours selector - api.ts: notifications API methods - useHotelSocket: notification:new WS message type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
134 lines
4.4 KiB
TypeScript
134 lines
4.4 KiB
TypeScript
import { FastifyPluginAsync } from 'fastify'
|
|
import { db } from '../db'
|
|
import { broadcast } from './ws'
|
|
|
|
type SlugParam = { Params: { slug: string } }
|
|
type SlugIdParam = { Params: { slug: string; id: string } }
|
|
|
|
export interface NotificationRow {
|
|
id: string
|
|
hotel_id: string
|
|
type: string
|
|
title: string
|
|
body: string
|
|
booking_id: string | null
|
|
room_id: string | null
|
|
link: string | null
|
|
is_read: boolean
|
|
created_at: string
|
|
}
|
|
|
|
export async function createNotification(
|
|
hotelId: string,
|
|
hotelSlug: string,
|
|
data: {
|
|
type: string
|
|
title: string
|
|
body: string
|
|
bookingId?: string | null
|
|
roomId?: string | null
|
|
link?: string | null
|
|
},
|
|
): Promise<NotificationRow> {
|
|
const { rows } = await db.query<NotificationRow>(
|
|
`INSERT INTO notifications (hotel_id, type, title, body, booking_id, room_id, link)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
|
[hotelId, data.type, data.title, data.body,
|
|
data.bookingId ?? null, data.roomId ?? null, data.link ?? null],
|
|
)
|
|
const notif = rows[0]
|
|
broadcast(hotelSlug, { type: 'notification:new', notification: notif })
|
|
return notif
|
|
}
|
|
|
|
const notifications: 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
|
|
|
|
// GET /api/hotels/:slug/notifications
|
|
fastify.get<SlugParam>(
|
|
'/api/hotels/:slug/notifications',
|
|
{ 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<NotificationRow>(
|
|
`SELECT * FROM notifications WHERE hotel_id = $1
|
|
ORDER BY created_at DESC LIMIT 100`,
|
|
[hotelId],
|
|
)
|
|
return rows
|
|
},
|
|
)
|
|
|
|
// PATCH /api/hotels/:slug/notifications/:id — mark read
|
|
fastify.patch<SlugIdParam>(
|
|
'/api/hotels/:slug/notifications/:id',
|
|
{ onRequest: [fastify.authenticate] },
|
|
async (request, reply) => {
|
|
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 { rows } = await db.query<NotificationRow>(
|
|
`UPDATE notifications SET is_read = true WHERE id = $1 AND hotel_id = $2 RETURNING *`,
|
|
[id, hotelId],
|
|
)
|
|
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
|
return rows[0]
|
|
},
|
|
)
|
|
|
|
// POST /api/hotels/:slug/notifications/read-all
|
|
fastify.post<SlugParam>(
|
|
'/api/hotels/:slug/notifications/read-all',
|
|
{ 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' })
|
|
|
|
await db.query(
|
|
`UPDATE notifications SET is_read = true WHERE hotel_id = $1 AND is_read = false`,
|
|
[hotelId],
|
|
)
|
|
return { ok: true }
|
|
},
|
|
)
|
|
|
|
// DELETE /api/hotels/:slug/notifications/:id
|
|
fastify.delete<SlugIdParam>(
|
|
'/api/hotels/:slug/notifications/:id',
|
|
{ onRequest: [fastify.authenticate] },
|
|
async (request, reply) => {
|
|
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 { rowCount } = await db.query(
|
|
`DELETE FROM notifications WHERE id = $1 AND hotel_id = $2`,
|
|
[id, hotelId],
|
|
)
|
|
if (!rowCount) return reply.code(404).send({ error: 'Not found' })
|
|
return reply.code(204).send()
|
|
},
|
|
)
|
|
}
|
|
|
|
export default notifications
|