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>
This commit is contained in:
@@ -3,10 +3,12 @@ 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 {
|
||||
@@ -302,6 +304,86 @@ async function runReviewRequestJob(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
@@ -316,4 +398,8 @@ export function startJobs(): void {
|
||||
setInterval(() => {
|
||||
runPaymentExpiryJob().catch(console.error)
|
||||
}, PAYMENT_EXPIRY_INTERVAL)
|
||||
|
||||
setInterval(() => {
|
||||
runUnreadMessagesJob().catch(console.error)
|
||||
}, UNREAD_MSG_INTERVAL)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user