import { db } from './db' import { broadcast } from './routes/ws' import { createNotification } from './routes/notifications' import { getHkSettings } from './routes/housekeeping-settings' import { sendReviewRequestEmail } from './email' import { sendPushForNotification } from './push' import { randomUUID } from 'crypto' const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour const PAYMENT_EXPIRY_INTERVAL = 2 * 60 * 1000 // every 2 minutes const UNREAD_MSG_INTERVAL = 15 * 60 * 1000 // every 15 minutes async function runAutoJobs(): Promise { try { await runAutoCancelNoShows() await runAutoCheckouts() await runReviewRequestJob() } catch (err) { console.error('[jobs] error running auto jobs:', err) } } async function runPaymentExpiryJob(): Promise { try { // Find online bookings whose payment window has expired and are still pending const { rows: expired } = await db.query<{ id: string; booking_id: string | null; slug: string }>( `SELECT ob.id, ob.booking_id, h.slug FROM online_bookings ob JOIN hotels h ON h.id = ob.hotel_id WHERE ob.payment_expires_at IS NOT NULL AND ob.payment_expires_at < NOW() AND ob.status = 'pending' AND ob.payment_method = 'yookassa'`, ) for (const ob of expired) { await db.query( `UPDATE online_bookings SET status = 'cancelled' WHERE id = $1`, [ob.id], ) if (ob.booking_id) { const { rows: bRows } = await db.query( `UPDATE bookings SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status = 'reserved' RETURNING *`, [ob.booking_id], ) if (bRows[0]) { broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] }) } } } } catch (err) { console.error('[jobs] payment expiry error:', err) } } async function runAutoCancelNoShows(): Promise { // Find hotels where auto_cancel_noshow_enabled = true // Also fetch check_in_time to calculate from the correct arrival time const { rows: hotels } = await db.query<{ id: string; slug: string; hours: number; checkInTime: string }>(` SELECT h.id, h.slug, COALESCE(h.check_in_time, '14:00') AS "checkInTime", 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) { // Threshold = check_in date + hotel check-in time + N hours // e.g. check-in date 2025-03-23, check_in_time=14:00, hours=24 → cancel after 14:00 next day 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::date + $2::time + ($3 || ' hours')::interval ) < NOW() `, [hotel.id, hotel.checkInTime, 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 { // Find hotels where auto_checkout_enabled = true // Also fetch check_out_time so we calculate from the correct departure time const { rows: hotels } = await db.query<{ id: string; slug: string; hours: number; checkOutTime: string }>(` SELECT h.id, h.slug, COALESCE(h.check_out_time, '12:00') AS "checkOutTime", 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) { // Threshold = check_out date + hotel checkout time + N hours // e.g. checkout date 2025-03-23, check_out_time=12:00, hours=2 → auto-checkout after 14:00 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::date + $2::time + ($3 || ' hours')::interval ) < NOW() `, [hotel.id, hotel.checkOutTime, 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(() => {}) } } } // Extract first name from "Фамилия Имя" or "Имя Фамилия" or single word function extractFirstName(fullName: string | null): string { if (!fullName) return '' const parts = fullName.trim().split(/\s+/) // If 2+ words, prefer the second word (typically Имя in Russian "Фамилия Имя" format) // If only 1 word, return empty so greeting is generic return parts.length >= 2 ? parts[1] : '' } async function runReviewRequestJob(): Promise { const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru' // Hotels with review_request_enabled = true const { rows: hotels } = await db.query<{ id: string; name: string; hours: number; brandColor: string }>( `SELECT h.id, h.name, COALESCE( (SELECT (value::text)::int FROM hotel_settings WHERE hotel_id = h.id AND key = 'review_request_hours'), 2 ) AS hours, COALESCE( (SELECT value #>> '{}' FROM hotel_settings WHERE hotel_id = h.id AND key = 'review_brand_color'), '#2563eb' ) AS "brandColor" FROM hotels h WHERE EXISTS ( SELECT 1 FROM hotel_settings WHERE hotel_id = h.id AND key = 'review_request_enabled' AND value = 'true'::jsonb )`, ) for (const hotel of hotels) { // Bookings checked out >= hotel.hours ago, within last 30 days, email not yet sent const { rows: bookings } = await db.query<{ id: string; guest_name: string | null; guest_email: string | null }>( `SELECT b.id, b.guest_name, b.guest_email FROM bookings b WHERE b.hotel_id = $1 AND b.status = 'checked_out' AND b.guest_email IS NOT NULL AND b.guest_email <> '' AND b.review_email_sent_at IS NULL AND b.updated_at + ($2 || ' hours')::interval < NOW() AND b.updated_at > NOW() - interval '30 days'`, [hotel.id, hotel.hours], ) for (const b of bookings) { const token = randomUUID() await db.query( `UPDATE bookings SET review_token = $1, review_email_sent_at = NOW() WHERE id = $2`, [token, b.id], ) await sendReviewRequestEmail({ to: b.guest_email!, guestFirstName: extractFirstName(b.guest_name), hotelName: hotel.name, brandColor: hotel.brandColor, reviewUrl: `${appUrl}/review/${token}`, }).catch(err => console.error('[jobs] review email error:', err)) } // ── Retry send ─────────────────────────────────────────────────────────── const { rows: retrySetting } = await db.query( `SELECT value FROM hotel_settings WHERE hotel_id = $1 AND key = 'review_request_retry' LIMIT 1`, [hotel.id], ) if (retrySetting[0]?.value !== true) continue const { rows: retryBookings } = await db.query<{ id: string; guest_name: string | null; guest_email: string | null; review_token: string }>( `SELECT b.id, b.guest_name, b.guest_email, b.review_token FROM bookings b WHERE b.hotel_id = $1 AND b.status = 'checked_out' AND b.review_email_sent_at IS NOT NULL AND b.review_retry_sent_at IS NULL AND b.review_email_sent_at + interval '24 hours' < NOW() AND b.updated_at > NOW() - interval '30 days' AND NOT EXISTS ( SELECT 1 FROM reviews r WHERE r.booking_id = b.id )`, [hotel.id], ) for (const b of retryBookings) { await db.query( `UPDATE bookings SET review_retry_sent_at = NOW() WHERE id = $1`, [b.id], ) await sendReviewRequestEmail({ to: b.guest_email!, guestFirstName: extractFirstName(b.guest_name), hotelName: hotel.name, brandColor: hotel.brandColor, reviewUrl: `${appUrl}/review/${b.review_token}`, }).catch(err => console.error('[jobs] review retry email error:', err)) } } } // Track which rooms we already notified (in-process, resets on restart — acceptable) const notifiedRooms = new Set() async function runUnreadMessagesJob(): Promise { try { // Find chat rooms that have unread messages older than 15 minutes // (messages sent by guests/external, not yet read by any hotel user) const { rows } = await db.query<{ hotel_id: string; hotel_slug: string; room_id: string; room_name: string; unread_count: number }>(` SELECT h.id AS hotel_id, h.slug AS hotel_slug, cr.id AS room_id, COALESCE(cr.name, 'Чат') AS room_name, COUNT(cm.id) AS unread_count FROM chat_messages cm JOIN chat_rooms cr ON cr.id = cm.room_id JOIN hotels h ON h.id = cr.hotel_id WHERE cm.created_at < NOW() - interval '15 minutes' AND cm.sender_type IN ('guest', 'system') AND NOT EXISTS ( SELECT 1 FROM chat_message_reads cmr WHERE cmr.message_id = cm.id AND cmr.user_id IN ( SELECT id FROM users WHERE hotel_id = h.id ) ) GROUP BY h.id, h.slug, cr.id, cr.name HAVING COUNT(cm.id) > 0 `) for (const row of rows) { const key = `${row.hotel_id}:${row.room_id}` if (notifiedRooms.has(key)) continue notifiedRooms.add(key) const count = Number(row.unread_count) const body = `${count} непрочитанн${count === 1 ? 'ое сообщение' : count < 5 ? 'ых сообщения' : 'ых сообщений'} более 15 минут` void createNotification(row.hotel_id, row.hotel_slug, { type: 'unread_messages', title: `Непрочитанные сообщения — ${row.room_name}`, body, link: `/${row.hotel_slug}/chat`, }).catch(() => {}) void sendPushForNotification(row.hotel_id, 'unread_messages', { title: `Непрочитанные сообщения — ${row.room_name}`, body, url: `/${row.hotel_slug}/chat`, tag: `unread-${row.room_id}`, }) } // Clear keys for rooms that have been read (so next cycle they can re-trigger if new unread appear) for (const key of notifiedRooms) { const [hotelId, roomId] = key.split(':') const { rows: unread } = await db.query<{ count: string }>( `SELECT COUNT(cm.id) AS count FROM chat_messages cm JOIN hotels h ON h.id = $1 WHERE cm.room_id = $2 AND cm.sender_type IN ('guest', 'system') AND NOT EXISTS ( SELECT 1 FROM chat_message_reads cmr WHERE cmr.message_id = cm.id AND cmr.user_id IN (SELECT id FROM users WHERE hotel_id = $1) )`, [hotelId, roomId], ) if (Number(unread[0]?.count ?? 0) === 0) { notifiedRooms.delete(key) } } } catch (err) { console.error('[jobs] unread messages job error:', err) } } export function startJobs(): void { // Small delay to let DB migrations finish on startup setTimeout(() => { runAutoJobs().catch(console.error) runPaymentExpiryJob().catch(console.error) }, 15_000) setInterval(() => { runAutoJobs().catch(console.error) }, JOB_INTERVAL_MS) setInterval(() => { runPaymentExpiryJob().catch(console.error) }, PAYMENT_EXPIRY_INTERVAL) setInterval(() => { runUnreadMessagesJob().catch(console.error) }, UNREAD_MSG_INTERVAL) }