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

@@ -42,6 +42,7 @@ interface RolePermissions {
isSystem: boolean
permissions: Record<string, boolean>
homePage?: string | null
notificationSettings: Record<string, boolean>
}
// ── 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<string, string> = {
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() {
</select>
</div>
{/* Notification settings */}
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">
Настройки уведомлений
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-3">
Какие push и in-app уведомления получают сотрудники с этой ролью
</p>
</div>
{NOTIFICATION_GROUPS.map(group => (
<div key={group.group}>
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-2">
{group.group}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{group.items.map(item => {
const enabled = selectedRole.notificationSettings[item.key] ?? true
return (
<button
key={item.key}
type="button"
onClick={() => toggleNotif(item.key)}
className={cn(
'flex items-start gap-3 p-3 rounded-xl border text-left transition-all',
enabled
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
)}
>
<div className={cn(
'w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 mt-0.5 transition-colors',
enabled
? 'bg-emerald-600 border-emerald-600'
: 'border-slate-300 dark:border-slate-600',
)}>
{enabled && <Check size={11} className="text-white" />}
</div>
<div>
<p className={cn(
'text-sm font-medium',
enabled
? 'text-emerald-700 dark:text-emerald-300'
: 'text-slate-700 dark:text-slate-300',
)}>
{item.label}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed">
{item.description}
</p>
</div>
</button>
)
})}
</div>
</div>
))}
</div>
{selectedRole.isSystem && (
<div className="px-4 pb-4">
<div className="flex items-center gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">