From 6d07632f0166776dc0a0214306d7a3047e1457e3 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 21 Apr 2026 16:43:39 +0300 Subject: [PATCH] feat: notification settings per role + review/unread-msg notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migrations/089_notification_settings.sql | 4 + backend/src/jobs.ts | 86 +++++++++++++++ backend/src/push.ts | 35 ++++++ backend/src/routes/publicWidget.ts | 18 +++ backend/src/routes/reviews.ts | 30 +++++ backend/src/routes/role-permissions.ts | 45 ++++---- backend/src/routes/room-service.ts | 6 +- src/contexts/RolePermissionsContext.tsx | 1 + src/lib/api.ts | 2 +- src/pages/UsersPage.tsx | 103 +++++++++++++++++- 10 files changed, 304 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/089_notification_settings.sql diff --git a/backend/migrations/089_notification_settings.sql b/backend/migrations/089_notification_settings.sql new file mode 100644 index 0000000..20706a8 --- /dev/null +++ b/backend/migrations/089_notification_settings.sql @@ -0,0 +1,4 @@ +-- Migration 089 — Notification settings per role + +ALTER TABLE role_permissions + ADD COLUMN IF NOT EXISTS notification_settings JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/backend/src/jobs.ts b/backend/src/jobs.ts index 3d92f31..4dca95e 100644 --- a/backend/src/jobs.ts +++ b/backend/src/jobs.ts @@ -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 { try { @@ -302,6 +304,86 @@ async function runReviewRequestJob(): Promise { } } +// 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(() => { @@ -316,4 +398,8 @@ export function startJobs(): void { setInterval(() => { runPaymentExpiryJob().catch(console.error) }, PAYMENT_EXPIRY_INTERVAL) + + setInterval(() => { + runUnreadMessagesJob().catch(console.error) + }, UNREAD_MSG_INTERVAL) } diff --git a/backend/src/push.ts b/backend/src/push.ts index 53fed4e..998dad4 100644 --- a/backend/src/push.ts +++ b/backend/src/push.ts @@ -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 { + 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 { if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return try { diff --git a/backend/src/routes/publicWidget.ts b/backend/src/routes/publicWidget.ts index dce75cb..9f1970d 100644 --- a/backend/src/routes/publicWidget.ts +++ b/backend/src/routes/publicWidget.ts @@ -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 { diff --git a/backend/src/routes/reviews.ts b/backend/src/routes/reviews.ts index eff05d7..bfad5a7 100644 --- a/backend/src/routes/reviews.ts +++ b/backend/src/routes/reviews.ts @@ -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 } }, ) diff --git a/backend/src/routes/role-permissions.ts b/backend/src/routes/role-permissions.ts index e41f612..f896291 100644 --- a/backend/src/routes/role-permissions.ts +++ b/backend/src/routes/role-permissions.ts @@ -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; homePage?: string } + Body: { name: string; color: string; isSystem?: boolean; permissions: Record; homePage?: string; notificationSettings?: Record } }>( '/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 ?? {}, } }, ) diff --git a/backend/src/routes/room-service.ts b/backend/src/routes/room-service.ts index 6de9d84..7ac07b8 100644 --- a/backend/src/routes/room-service.ts +++ b/backend/src/routes/room-service.ts @@ -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', diff --git a/src/contexts/RolePermissionsContext.tsx b/src/contexts/RolePermissionsContext.tsx index 7f61d76..aac050d 100644 --- a/src/contexts/RolePermissionsContext.tsx +++ b/src/contexts/RolePermissionsContext.tsx @@ -41,6 +41,7 @@ export interface SavedRolePermission { isSystem: boolean permissions: Record homePage?: string | null + notificationSettings?: Record } interface RolePermissionsContextValue { diff --git a/src/lib/api.ts b/src/lib/api.ts index 50ebc51..9c3d242 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -313,7 +313,7 @@ export const api = { 'GET', `/api/hotels/${slug}/role-permissions`), save: (slug: string, roleKey: string, data: { - name: string; color: string; isSystem: boolean; permissions: Record; homePage?: string | null + name: string; color: string; isSystem: boolean; permissions: Record; homePage?: string | null; notificationSettings?: Record }) => req( 'PUT', `/api/hotels/${slug}/role-permissions/${roleKey}`, data), diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 337dae9..2d52db1 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -42,6 +42,7 @@ interface RolePermissions { isSystem: boolean permissions: Record homePage?: string | null + notificationSettings: Record } // ── Constants ────────────────────────────────────────────────────────────────── @@ -135,6 +136,28 @@ const ALL_MODULE_KEYS = MODULE_GROUPS.flatMap(g => g.modules.map(m => m.key)) const allPerms = (v: boolean) => Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v])) +const NOTIFICATION_GROUPS: { group: string; items: { key: string; label: string; description: string }[] }[] = [ + { + group: 'Операции', + items: [ + { key: 'room_service_order', label: 'Новый заказ Room Service', description: 'Уведомление о новом заказе в номер' }, + { key: 'new_booking', label: 'Новая бронь через сайт', description: 'Бронирование через онлайн-виджет' }, + ], + }, + { + group: 'Общение', + items: [ + { key: 'unread_messages', label: 'Непрочитанные сообщения', description: 'Чат без ответа более 15 минут' }, + ], + }, + { + group: 'Отзывы', + items: [ + { key: 'new_review', label: 'Новый отзыв гостя', description: 'Прямой отзыв, QR или email-ссылка' }, + ], + }, +] + // Permission key → route path (for home page selector) const PERM_TO_ROUTE: Record = { calendar: '/calendar', @@ -172,6 +195,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ color: '#7C3AED', isSystem: true, permissions: allPerms(true), + notificationSettings: {}, }, { id: 'rp_manager', @@ -179,6 +203,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ color: '#4F46E5', isSystem: true, permissions: allPerms(true), + notificationSettings: {}, }, { id: 'rp_receptionist', @@ -192,6 +217,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ pos: true, reviews: true, reports: true, documents: true, website: true, }, + notificationSettings: {}, }, { id: 'rp_housekeeper', @@ -202,6 +228,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ ...allPerms(false), calendar: true, housekeeping: true, rooms: true, maintenance: true, }, + notificationSettings: {}, }, { id: 'rp_accountant', @@ -214,6 +241,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true, documents: true, }, + notificationSettings: {}, }, { id: 'rp_security', @@ -224,6 +252,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ ...allPerms(false), calendar: true, bookings: true, }, + notificationSettings: {}, }, { id: 'rp_technician', @@ -236,6 +265,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ housekeeping: true, rooms: true, maintenance: true, floor_map: true, equipment: true, ttlock: true, }, + notificationSettings: {}, }, ] @@ -553,7 +583,7 @@ function RolesTab() { if (!s) return def // Ensure all module keys are present (new modules default to false) const fullPerms = { ...allPerms(false), ...s.permissions } - return { ...def, permissions: fullPerms, homePage: s.homePage ?? def.homePage } + return { ...def, permissions: fullPerms, homePage: s.homePage ?? def.homePage, notificationSettings: s.notificationSettings ?? {} } }) // Custom roles (not in system list) @@ -567,6 +597,7 @@ function RolesTab() { isSystem: false, permissions: { ...allPerms(false), ...r.permissions }, homePage: r.homePage ?? null, + notificationSettings: r.notificationSettings ?? {}, })) setRoles([...merged, ...custom]) @@ -602,6 +633,14 @@ function RolesTab() { )) } + const toggleNotif = (key: string) => { + setRoles(prev => prev.map(r => { + if (r.id !== selectedRoleId) return r + const current = r.notificationSettings[key] ?? true + return { ...r, notificationSettings: { ...r.notificationSettings, [key]: !current } } + })) + } + // ── Save to API ──────────────────────────────────────────────────────────── const handleSave = async () => { if (!slug || saving) return @@ -615,6 +654,7 @@ function RolesTab() { isSystem: role.isSystem, permissions: role.permissions, homePage: role.homePage ?? null, + notificationSettings: role.notificationSettings, }) })) setSaveOk(true) @@ -639,6 +679,7 @@ function RolesTab() { color: colors[roles.length % colors.length], isSystem: false, permissions: allPerms(false), + notificationSettings: {}, } setRoles(prev => [...prev, newRole]) setSelectedRoleId(newRole.id) @@ -653,6 +694,7 @@ function RolesTab() { isSystem: false, permissions: newRole.permissions, homePage: null, + notificationSettings: {}, }) await reloadContext() } catch (err) { @@ -902,6 +944,65 @@ function RolesTab() { + {/* Notification settings */} +
+
+

+ Настройки уведомлений +

+

+ Какие push и in-app уведомления получают сотрудники с этой ролью +

+
+ {NOTIFICATION_GROUPS.map(group => ( +
+

+ {group.group} +

+
+ {group.items.map(item => { + const enabled = selectedRole.notificationSettings[item.key] ?? true + return ( + + ) + })} +
+
+ ))} +
+ {selectedRole.isSystem && (