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:
2026-04-21 16:43:39 +03:00
parent 1975663cdd
commit 6d07632f01
10 changed files with 304 additions and 26 deletions

View File

@@ -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)
}

View File

@@ -40,6 +40,41 @@ export async function sendPushToUser(userId: string, payload: PushPayload): Prom
} catch { /**/ }
}
// Send push to hotel users whose role has the given notification type enabled.
// If notification_settings is empty / key missing → defaults to true (send).
export async function sendPushForNotification(
hotelId: string,
notificationType: string,
payload: PushPayload,
): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
try {
const { rows } = await db.query(
`SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON u.id = ps.user_id
LEFT JOIN role_permissions rp
ON rp.hotel_id = u.hotel_id AND rp.role_key = u.role
WHERE u.hotel_id = $1
AND COALESCE((rp.notification_settings->>$2)::boolean, true) = true`,
[hotelId, notificationType],
)
for (const sub of rows) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
)
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode
if (statusCode === 410 || statusCode === 404) {
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint])
}
}
}
} catch { /**/ }
}
export async function sendPushToHotel(hotelId: string, payload: PushPayload): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
try {

View File

@@ -3,6 +3,8 @@ import { db } from '../db'
import { getGatewayForModule } from './paymentGateways'
import { createCharge } from '../services/yookassa'
import { upsertGuestFromBooking } from '../services/guestUpsert'
import { createNotification } from './notifications'
import { sendPushForNotification } from '../push'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; bookingId: string } }
@@ -466,6 +468,22 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
guestPhone,
})
// Notify staff about new widget booking
const nights = Math.ceil((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
void createNotification(hotel.id, hotel.slug, {
type: 'new_booking',
title: `Новая бронь через сайт — ${guestName}`,
body: `${checkIn}${checkOut}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
bookingId,
link: `/${hotel.slug}/bookings`,
})
void sendPushForNotification(hotel.id, 'new_booking', {
title: `Новая бронь через сайт — ${guestName}`,
body: `${checkIn}${checkOut}`,
url: `/${hotel.slug}/bookings`,
tag: `booking-${bookingId}`,
})
if (paymentMethod === 'yookassa') {
// Create YooKassa payment
try {

View File

@@ -1,6 +1,8 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { sendReviewReplyEmail } from '../email'
import { createNotification } from './notifications'
import { sendPushForNotification } from '../push'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
@@ -253,6 +255,21 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
],
)
const hotelSlugRow = await db.query<{ slug: string }>('SELECT slug FROM hotels WHERE id = $1', [booking.hotel_id])
const hotelSlug = hotelSlugRow.rows[0]?.slug ?? ''
void createNotification(booking.hotel_id, hotelSlug, {
type: 'new_review',
title: `Новый отзыв — ${rating}`,
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
link: `/${hotelSlug}/reviews`,
})
void sendPushForNotification(booking.hotel_id, 'new_review', {
title: `Новый отзыв — ${rating}`,
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
url: `/${hotelSlug}/reviews`,
tag: `review-${booking.id}`,
})
return { ok: true }
},
)
@@ -340,6 +357,19 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
],
)
void createNotification(hotel.id, slug, {
type: 'new_review',
title: `Новый QR-отзыв — №${room}, ${rating}`,
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
link: `/${slug}/reviews`,
})
void sendPushForNotification(hotel.id, 'new_review', {
title: `Новый QR-отзыв — №${room}, ${rating}`,
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
url: `/${slug}/reviews`,
tag: `review-qr-${hotel.id}-${room}`,
})
return { ok: true }
},
)

View File

@@ -36,21 +36,22 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
[hotelId],
)
return rows.map(r => ({
id: r.id,
hotelId: r.hotel_id,
roleKey: r.role_key,
name: r.name,
color: r.color,
isSystem: r.is_system,
permissions: r.permissions,
homePage: r.home_page,
id: r.id,
hotelId: r.hotel_id,
roleKey: r.role_key,
name: r.name,
color: r.color,
isSystem: r.is_system,
permissions: r.permissions,
homePage: r.home_page,
notificationSettings: r.notification_settings ?? {},
}))
},
)
// ── PUT /api/hotels/:slug/role-permissions/:roleKey ────────────────────────
fastify.put<SlugRoleParam & {
Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean>; homePage?: string }
Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean>; homePage?: string; notificationSettings?: Record<string, boolean> }
}>(
'/api/hotels/:slug/role-permissions/:roleKey',
{ onRequest: [fastify.authenticate] },
@@ -65,30 +66,32 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { name, color, isSystem = false, permissions, homePage = null } = request.body
const { name, color, isSystem = false, permissions, homePage = null, notificationSettings = {} } = request.body
const { rows } = await db.query(
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page, notification_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (hotel_id, role_key) DO UPDATE
SET name = EXCLUDED.name,
color = EXCLUDED.color,
permissions = EXCLUDED.permissions,
home_page = EXCLUDED.home_page,
notification_settings = EXCLUDED.notification_settings,
updated_at = NOW()
RETURNING *`,
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage],
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage, JSON.stringify(notificationSettings)],
)
const r = rows[0]
return {
id: r.id,
hotelId: r.hotel_id,
roleKey: r.role_key,
name: r.name,
color: r.color,
isSystem: r.is_system,
permissions: r.permissions,
homePage: r.home_page,
id: r.id,
hotelId: r.hotel_id,
roleKey: r.role_key,
name: r.name,
color: r.color,
isSystem: r.is_system,
permissions: r.permissions,
homePage: r.home_page,
notificationSettings: r.notification_settings ?? {},
}
},
)

View File

@@ -1,7 +1,7 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { createNotification } from './notifications'
import { sendPushToHotel } from '../push'
import { sendPushForNotification } from '../push'
type SlugParam = { Params: { slug: string } }
type SlugItemParam = { Params: { slug: string; itemId: string } }
@@ -364,8 +364,8 @@ const roomServiceRoutes: FastifyPluginAsync = async (fastify) => {
link: '/room-service',
})
// Push notification to all hotel staff
void sendPushToHotel(hotel.id, {
// Push notification to staff with room_service_order enabled
void sendPushForNotification(hotel.id, 'room_service_order', {
title: `Новый заказ Room Service — №${room_number}`,
body: itemsSummary,
url: '/room-service',