- Add notificationSettings JSONB to role_permissions (migration 089)
- sendPushForNotification() — pushes only to users with the notif type enabled
- reviews.ts — push + in-app on new direct and QR reviews
- room-service.ts — use sendPushForNotification('room_service_order')
- publicWidget.ts — push + in-app on new online booking
- jobs.ts — runUnreadMessagesJob() every 15 min, deduped by in-process set
- UsersPage: NOTIFICATION_GROUPS UI in roles tab with per-type toggles
- api.ts / RolePermissionsContext: notificationSettings in types and save()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
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<void> {
|
||
try {
|
||
await runAutoCancelNoShows()
|
||
await runAutoCheckouts()
|
||
await runReviewRequestJob()
|
||
} catch (err) {
|
||
console.error('[jobs] error running auto jobs:', err)
|
||
}
|
||
}
|
||
|
||
async function runPaymentExpiryJob(): Promise<void> {
|
||
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<void> {
|
||
// 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<void> {
|
||
// 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<void> {
|
||
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<string>()
|
||
|
||
async function runUnreadMessagesJob(): Promise<void> {
|
||
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)
|
||
}
|