Files
hotelsync/backend/src/jobs.ts
HotelSync 6d07632f01 feat: notification settings per role + review/unread-msg notifications
- 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>
2026-04-21 16:43:39 +03:00

406 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}