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:
2026-03-23 23:02:30 +03:00
parent b631a281d2
commit b9116c427a
9 changed files with 495 additions and 77 deletions

148
backend/src/jobs.ts Normal file
View 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)
}