feat: notifications API, auto-jobs, calendar housekeeping fixes
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>
This commit is contained in:
@@ -29,6 +29,8 @@ import ratePeriodsRoutes from './routes/rate-periods'
|
||||
import rateOverridesRoutes from './routes/rate-overrides'
|
||||
import uploadRoutes from './routes/upload'
|
||||
import housekeepingSettingsRoutes from './routes/housekeeping-settings'
|
||||
import notificationsRoutes from './routes/notifications'
|
||||
import { startJobs } from './jobs'
|
||||
|
||||
export async function buildApp() {
|
||||
const fastify = Fastify({
|
||||
@@ -102,6 +104,9 @@ export async function buildApp() {
|
||||
await fastify.register(rateOverridesRoutes)
|
||||
await fastify.register(uploadRoutes)
|
||||
await fastify.register(housekeepingSettingsRoutes)
|
||||
await fastify.register(notificationsRoutes)
|
||||
|
||||
startJobs()
|
||||
|
||||
return fastify
|
||||
}
|
||||
|
||||
148
backend/src/jobs.ts
Normal file
148
backend/src/jobs.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { db } from './db'
|
||||
import { broadcast } from './routes/ws'
|
||||
import { createNotification } from './routes/notifications'
|
||||
import { getHkSettings } from './routes/housekeeping-settings'
|
||||
|
||||
const JOB_INTERVAL_MS = 10 * 60 * 1000 // every 10 minutes
|
||||
|
||||
async function runAutoJobs(): Promise<void> {
|
||||
try {
|
||||
await runAutoCancelNoShows()
|
||||
await runAutoCheckouts()
|
||||
} catch (err) {
|
||||
console.error('[jobs] error running auto jobs:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function runAutoCancelNoShows(): Promise<void> {
|
||||
// Find hotels where auto_cancel_noshow_enabled = true
|
||||
const { rows: hotels } = await db.query<{ id: string; slug: string; hours: number }>(`
|
||||
SELECT h.id, h.slug,
|
||||
COALESCE(
|
||||
(SELECT (value::text)::int FROM hotel_settings
|
||||
WHERE hotel_id = h.id AND key = 'auto_cancel_noshow_hours'),
|
||||
24
|
||||
) AS hours
|
||||
FROM hotels h
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM hotel_settings
|
||||
WHERE hotel_id = h.id
|
||||
AND key = 'auto_cancel_noshow_enabled'
|
||||
AND value = 'true'::jsonb
|
||||
)
|
||||
`)
|
||||
|
||||
for (const hotel of hotels) {
|
||||
const { rows: bookings } = await db.query<{
|
||||
id: string; guest_name: string | null; room_id: string
|
||||
}>(`
|
||||
SELECT b.id, b.guest_name, b.room_id
|
||||
FROM bookings b
|
||||
WHERE b.hotel_id = $1
|
||||
AND b.status = 'confirmed'
|
||||
AND (b.check_in::timestamp + ($2 || ' hours')::interval) < NOW()
|
||||
`, [hotel.id, hotel.hours])
|
||||
|
||||
for (const b of bookings) {
|
||||
const { rows } = await db.query(
|
||||
`UPDATE bookings SET status = 'no_show', updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[b.id],
|
||||
)
|
||||
if (rows[0]) {
|
||||
broadcast(hotel.slug, { type: 'booking:updated', booking: rows[0] })
|
||||
}
|
||||
await createNotification(hotel.id, hotel.slug, {
|
||||
type: 'booking_cancelled',
|
||||
title: 'Гость не заехал — бронь отменена',
|
||||
body: `${b.guest_name ?? 'Гость'} не заехал вовремя. Бронирование автоматически отмечено как неявка.`,
|
||||
bookingId: b.id,
|
||||
link: `/${hotel.slug}/bookings`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAutoCheckouts(): Promise<void> {
|
||||
// Find hotels where auto_checkout_enabled = true
|
||||
const { rows: hotels } = await db.query<{ id: string; slug: string; hours: number }>(`
|
||||
SELECT h.id, h.slug,
|
||||
COALESCE(
|
||||
(SELECT (value::text)::int FROM hotel_settings
|
||||
WHERE hotel_id = h.id AND key = 'auto_checkout_hours'),
|
||||
12
|
||||
) AS hours
|
||||
FROM hotels h
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM hotel_settings
|
||||
WHERE hotel_id = h.id
|
||||
AND key = 'auto_checkout_enabled'
|
||||
AND value = 'true'::jsonb
|
||||
)
|
||||
`)
|
||||
|
||||
for (const hotel of hotels) {
|
||||
const { rows: bookings } = await db.query<{
|
||||
id: string; guest_name: string | null; room_id: string
|
||||
}>(`
|
||||
SELECT b.id, b.guest_name, b.room_id
|
||||
FROM bookings b
|
||||
WHERE b.hotel_id = $1
|
||||
AND b.status = 'checked_in'
|
||||
AND (b.check_out::timestamp + ($2 || ' hours')::interval) < NOW()
|
||||
`, [hotel.id, hotel.hours])
|
||||
|
||||
for (const b of bookings) {
|
||||
// Update booking to checked_out
|
||||
const { rows } = await db.query(
|
||||
`UPDATE bookings SET status = 'checked_out', updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[b.id],
|
||||
)
|
||||
if (rows[0]) {
|
||||
broadcast(hotel.slug, { type: 'booking:updated', booking: rows[0] })
|
||||
}
|
||||
|
||||
// Auto-create housekeeping task if enabled
|
||||
const hkSettings = await getHkSettings(hotel.id).catch(() => null)
|
||||
if (hkSettings?.checkout_auto && b.room_id) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const { rows: taskRows } = await db.query(
|
||||
`INSERT INTO housekeeping_tasks
|
||||
(hotel_id, room_id, type, priority, notes, due_date)
|
||||
VALUES ($1,$2,'turnover',$3,$4,$5)
|
||||
RETURNING *`,
|
||||
[hotel.id, b.room_id, hkSettings.checkout_priority,
|
||||
`Автоматическая уборка после выезда${b.guest_name ? ': ' + b.guest_name : ''}`,
|
||||
today],
|
||||
)
|
||||
await db.query(
|
||||
`UPDATE rooms SET housekeeping_status = 'dirty' WHERE id = $1`,
|
||||
[b.room_id],
|
||||
)
|
||||
if (taskRows[0]) {
|
||||
broadcast(hotel.slug, { type: 'housekeeping_task_created', task: taskRows[0] })
|
||||
}
|
||||
}
|
||||
|
||||
await createNotification(hotel.id, hotel.slug, {
|
||||
type: 'booking_checkout',
|
||||
title: 'Автоматическое выселение',
|
||||
body: `${b.guest_name ?? 'Гость'} выселен автоматически по истечении времени проживания.`,
|
||||
bookingId: b.id,
|
||||
link: `/${hotel.slug}/calendar`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startJobs(): void {
|
||||
// Small delay to let DB migrations finish on startup
|
||||
setTimeout(() => {
|
||||
runAutoJobs().catch(console.error)
|
||||
}, 15_000)
|
||||
|
||||
setInterval(() => {
|
||||
runAutoJobs().catch(console.error)
|
||||
}, JOB_INTERVAL_MS)
|
||||
}
|
||||
133
backend/src/routes/notifications.ts
Normal file
133
backend/src/routes/notifications.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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
|
||||
Reference in New Issue
Block a user