From 3c4c40da4495a28afc363c07c9c2e69e864e3711 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 14 Apr 2026 11:25:40 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20search,=20notificati?= =?UTF-8?q?ons=20room,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 071: nullable sender_id, is_system flag, notifications room type - Backend: notifications room auto-created per hotel, search messages endpoint, notify endpoint (manager/admin posts system messages), read-only enforcement - API: ChatSearchResult type, chat.search(), chat.notify(), updated ChatMessage type - Frontend ChatWidget: search view (people + messages tabs), settings panel (notifications visibility toggle, sound toggle), notifications room (bell icon, read-only banner, amber styling for system messages), new-chat button Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/071_chat_notifications.sql | 15 + backend/src/routes/chat.ts | 111 +++- src/components/chat/ChatWidget.tsx | 533 +++++++++++++++--- src/lib/api.ts | 23 +- 4 files changed, 594 insertions(+), 88 deletions(-) create mode 100644 backend/migrations/071_chat_notifications.sql diff --git a/backend/migrations/071_chat_notifications.sql b/backend/migrations/071_chat_notifications.sql new file mode 100644 index 0000000..f33d528 --- /dev/null +++ b/backend/migrations/071_chat_notifications.sql @@ -0,0 +1,15 @@ +-- Make sender_id nullable to support system messages +ALTER TABLE chat_messages ALTER COLUMN sender_id DROP NOT NULL; + +-- Add system message fields +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS system_name TEXT; + +-- Expand room type to include notifications +ALTER TABLE chat_rooms DROP CONSTRAINT IF EXISTS chat_rooms_type_check; +ALTER TABLE chat_rooms ADD CONSTRAINT chat_rooms_type_check + CHECK (type IN ('general', 'direct', 'notifications')); + +-- One notifications room per hotel +CREATE UNIQUE INDEX IF NOT EXISTS uniq_chat_rooms_notifications_hotel + ON chat_rooms(hotel_id) WHERE type = 'notifications'; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 4f4ed6c..5652bcc 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -30,7 +30,24 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { return existing[0]?.id as string } - // GET /api/hotels/:slug/chat/rooms — list rooms (general + directs for current user) + // Ensure notifications room exists for hotel + const ensureNotificationsRoom = async (hotelId: string) => { + const { rows } = await db.query( + `INSERT INTO chat_rooms (hotel_id, type, name) + VALUES ($1, 'notifications', 'Уведомления') + ON CONFLICT DO NOTHING + RETURNING id`, + [hotelId], + ) + if (rows[0]) return rows[0].id as string + const { rows: existing } = await db.query( + `SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = 'notifications'`, + [hotelId], + ) + return existing[0]?.id as string + } + + // GET /api/hotels/:slug/chat/rooms — list rooms (general + notifications + directs for current user) fastify.get( '/api/hotels/:slug/chat/rooms', { onRequest: [fastify.authenticate] }, @@ -41,10 +58,10 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const generalRoomId = await ensureGeneralRoom(hotelId) + await ensureGeneralRoom(hotelId) + await ensureNotificationsRoom(hotelId) const userId = request.user.sub - // Get all rooms this user belongs to (general + directs) const { rows } = await db.query( `SELECT r.id, r.type, r.name, (SELECT COUNT(*) FROM chat_messages m @@ -53,23 +70,22 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { (SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id = $2), '1970-01-01' ) - AND m.sender_id != $2 + AND (m.sender_id != $2 OR m.sender_id IS NULL) ) AS unread_count, (SELECT m.text FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message, (SELECT m.created_at FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at, - (SELECT u.name FROM chat_messages m JOIN users u ON u.id = m.sender_id WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_sender, + (SELECT COALESCE(m.system_name, u.name) FROM chat_messages m LEFT JOIN users u ON u.id = m.sender_id WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_sender, -- for direct rooms: get the other user's name (SELECT u.name FROM chat_room_members crm JOIN users u ON u.id = crm.user_id WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_name, (SELECT crm.user_id FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_id FROM chat_rooms r WHERE r.hotel_id = $1 - AND (r.type = 'general' OR EXISTS ( + AND (r.type IN ('general', 'notifications') OR EXISTS ( SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2 )) ORDER BY last_message_at DESC NULLS LAST, r.type = 'general' DESC`, [hotelId, userId], ) - void generalRoomId return rows }, ) @@ -88,9 +104,11 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { const limit = Math.min(Number(request.query.limit ?? 50), 100) const { rows } = await db.query( `SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at, - u.name AS sender_name, u.role AS sender_role + m.is_system, m.system_name, + COALESCE(m.system_name, u.name) AS sender_name, + COALESCE(u.role, 'system') AS sender_role FROM chat_messages m - JOIN users u ON u.id = m.sender_id + LEFT JOIN users u ON u.id = m.sender_id WHERE m.room_id = $1 AND m.hotel_id = $2 ORDER BY m.created_at DESC LIMIT $3`, @@ -118,6 +136,11 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + // Disallow posting to notifications room + const { rows: roomRows } = await db.query('SELECT type FROM chat_rooms WHERE id = $1', [roomId]) + if (roomRows[0]?.type === 'notifications') + return reply.code(403).send({ error: 'Cannot post to notifications room' }) + const { text } = request.body if (!text?.trim()) return reply.code(400).send({ error: 'Text required' }) @@ -128,9 +151,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { [roomId, hotelId, request.user.sub, text.trim()], ) const msg = rows[0] - // Get sender name const { rows: uRows } = await db.query('SELECT name, role FROM users WHERE id = $1', [request.user.sub]) - const result = { ...msg, sender_name: uRows[0]?.name, sender_role: uRows[0]?.role } + const result = { ...msg, sender_name: uRows[0]?.name, sender_role: uRows[0]?.role, is_system: false, system_name: null } return reply.code(201).send(result) }, ) @@ -147,7 +169,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const userId = request.user.sub - // Check if direct room already exists between these two users const { rows: existing } = await db.query( `SELECT r.id FROM chat_rooms r JOIN chat_room_members m1 ON m1.room_id = r.id AND m1.user_id = $1 @@ -158,7 +179,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { ) if (existing[0]) return { room_id: existing[0].id } - // Create new direct room const { rows: [room] } = await db.query( `INSERT INTO chat_rooms (hotel_id, type) VALUES ($1, 'direct') RETURNING id`, [hotelId], @@ -185,6 +205,71 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { return { ok: true } }, ) + + // GET /api/hotels/:slug/chat/search?q= — search messages across accessible rooms + fastify.get( + '/api/hotels/:slug/chat/search', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const q = (request.query.q ?? '').trim() + if (!q) return [] + + const userId = request.user.sub + const { rows } = await db.query( + `SELECT m.id, m.room_id, m.text, m.created_at, + COALESCE(m.system_name, u.name) AS sender_name, + r.type AS room_type, r.name AS room_name, + (SELECT u2.name FROM chat_room_members crm2 JOIN users u2 ON u2.id = crm2.user_id + WHERE crm2.room_id = r.id AND crm2.user_id != $2 LIMIT 1) AS other_user_name + FROM chat_messages m + JOIN chat_rooms r ON r.id = m.room_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.hotel_id = $1 + AND m.text ILIKE $3 + AND (r.type IN ('general', 'notifications') OR EXISTS ( + SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2 + )) + ORDER BY m.created_at DESC + LIMIT 30`, + [hotelId, userId, `%${q}%`], + ) + return rows + }, + ) + + // POST /api/hotels/:slug/chat/notify — post a system notification (manager/admin only) + fastify.post( + '/api/hotels/:slug/chat/notify', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + if (!['super_admin', 'hotel_admin', 'manager'].includes(request.user.role)) + return reply.code(403).send({ error: 'Insufficient permissions' }) + + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { text, system_name = 'Система' } = request.body + if (!text?.trim()) return reply.code(400).send({ error: 'Text required' }) + + const notifRoomId = await ensureNotificationsRoom(hotelId) + const { rows } = await db.query( + `INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, is_system, system_name) + VALUES ($1, $2, NULL, $3, true, $4) + RETURNING id, room_id, text, created_at, is_system, system_name`, + [notifRoomId, hotelId, text.trim(), system_name], + ) + return reply.code(201).send({ ...rows[0], sender_name: system_name, sender_role: 'system' }) + }, + ) } export default chatRoutes diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index c150f70..e0703ee 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -1,10 +1,15 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { MessageSquare, X, ChevronLeft, Send, Users, Loader2 } from 'lucide-react' -import { api, type ChatRoom, type ChatMessage } from '../../lib/api' +import { + MessageSquare, X, ChevronLeft, Send, Users, Loader2, + Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, +} from 'lucide-react' +import { api, type ChatRoom, type ChatMessage, type ChatSearchResult } from '../../lib/api' +import type { User } from '../../types' import { useAuth } from '../../contexts/AuthContext' import { cn } from '../../lib/utils' -// Color for avatar based on name +// ── Helpers ──────────────────────────────────────────────────────────────── + function avatarColor(name: string) { const colors = ['#4F46E5','#059669','#2563EB','#7C3AED','#DC2626','#D97706','#DB2777','#0891B2'] let h = 0 @@ -17,7 +22,11 @@ function initials(name: string) { } function fmtTime(iso: string) { - return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + const d = new Date(iso) + const now = new Date() + const today = now.toDateString() === d.toDateString() + if (today) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } function Avatar({ name, size = 28 }: { name: string; size?: number }) { @@ -29,11 +38,46 @@ function Avatar({ name, size = 28 }: { name: string; size?: number }) { ) } +function SystemAvatar({ size = 28 }: { size?: number }) { + return ( +
+ +
+ ) +} + +// ── Settings ─────────────────────────────────────────────────────────────── + +interface ChatSettings { + notifVisible: boolean + soundEnabled: boolean +} + +const SETTINGS_KEY = 'hotelsync-chat-settings' + +function loadSettings(): ChatSettings { + try { + const s = localStorage.getItem(SETTINGS_KEY) + if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial } + } catch { /* ignore */ } + return { notifVisible: true, soundEnabled: false } +} + +function saveSettings(s: ChatSettings) { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) +} + +// ── Main component ───────────────────────────────────────────────────────── + +type View = 'rooms' | 'messages' | 'search' | 'settings' + export function ChatWidget() { const { user } = useAuth() const slug = user?.hotelSlug ?? '' + const [open, setOpen] = useState(false) - const [view, setView] = useState<'rooms' | 'messages'>('rooms') + const [view, setView] = useState('rooms') const [rooms, setRooms] = useState([]) const [activeRoom, setActiveRoom] = useState(null) const [messages, setMessages] = useState([]) @@ -41,11 +85,33 @@ export function ChatWidget() { const [loadingRooms, setLoadingRooms] = useState(false) const [loadingMsgs, setLoadingMsgs] = useState(false) const [sending, setSending] = useState(false) + + // Search state + const [searchQuery, setSearchQuery] = useState('') + const [searchUsers, setSearchUsers] = useState([]) + const [searchResults, setSearchResults] = useState([]) + const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people') + const [loadingSearch, setLoadingSearch] = useState(false) + const [allUsers, setAllUsers] = useState([]) + + // Settings + const [settings, setSettings] = useState(loadSettings) + const messagesEndRef = useRef(null) const pollRef = useRef | null>(null) + const searchInputRef = useRef(null) const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) + // ── Filtered rooms (exclude notifications if hidden, apply room filter) ─ + + const visibleRooms = rooms.filter(r => { + if (r.type === 'notifications' && !settings.notifVisible) return false + return true + }) + + // ── Data loaders ──────────────────────────────────────────────────────── + const loadRooms = useCallback(async () => { if (!slug) return try { @@ -54,7 +120,7 @@ export function ChatWidget() { } catch { /* ignore */ } }, [slug]) - // Poll for new messages every 5s when open + // Poll rooms when open useEffect(() => { if (!open || !slug) return setLoadingRooms(true) @@ -63,20 +129,7 @@ export function ChatWidget() { return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [open, slug, loadRooms]) - const openRoom = async (room: ChatRoom) => { - setActiveRoom(room) - setView('messages') - setLoadingMsgs(true) - try { - const msgs = await api.chat.getMessages(slug, room.id) - setMessages(msgs) - await api.chat.markRead(slug, room.id) - setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r)) - } catch { /* ignore */ } - finally { setLoadingMsgs(false) } - } - - // Auto-scroll to bottom on new messages + // Auto-scroll on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) @@ -93,6 +146,93 @@ export function ChatWidget() { return () => clearInterval(interval) }, [view, activeRoom, slug]) + // Load all users once when search view opens + useEffect(() => { + if (view !== 'search' || !slug) return + api.users.list(slug).then(setAllUsers).catch(() => { /* ignore */ }) + }, [view, slug]) + + // Search effect + useEffect(() => { + if (view !== 'search') return + const q = searchQuery.trim() + if (!q) { + setSearchUsers([]) + setSearchResults([]) + return + } + const ql = q.toLowerCase() + // People: filter client-side from allUsers + const matched = allUsers.filter(u => + u.id !== user?.id && + (u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql)), + ) + setSearchUsers(matched) + + // Messages: debounce + API + const timer = setTimeout(async () => { + if (searchTab !== 'messages') return + setLoadingSearch(true) + try { + const results = await api.chat.search(slug, q) + setSearchResults(results) + } catch { /* ignore */ } + finally { setLoadingSearch(false) } + }, 400) + return () => clearTimeout(timer) + }, [searchQuery, view, allUsers, user?.id, slug, searchTab]) + + // Focus search input when search view opens + useEffect(() => { + if (view === 'search') { + setTimeout(() => searchInputRef.current?.focus(), 50) + } + }, [view]) + + // ── Actions ───────────────────────────────────────────────────────────── + + const openRoom = async (room: ChatRoom) => { + setActiveRoom(room) + setView('messages') + setLoadingMsgs(true) + try { + const msgs = await api.chat.getMessages(slug, room.id) + setMessages(msgs) + await api.chat.markRead(slug, room.id) + setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r)) + } catch { /* ignore */ } + finally { setLoadingMsgs(false) } + } + + const openDirect = async (targetUser: User) => { + try { + const { roomId } = await api.chat.openDirect(slug, targetUser.id) + await loadRooms() + const room: ChatRoom = { + id: roomId, + type: 'direct', + name: null, + unreadCount: 0, + lastMessage: null, + lastMessageAt: null, + lastSender: null, + otherUserName: targetUser.name, + otherUserId: targetUser.id, + } + await openRoom(room) + setSearchQuery('') + } catch { /* ignore */ } + } + + const openSearchResult = async (result: ChatSearchResult) => { + const room = rooms.find(r => r.id === result.roomId) + if (room) { + setSearchQuery('') + setView('rooms') + await openRoom(room) + } + } + const sendMessage = async () => { if (!text.trim() || !activeRoom || sending) return const t = text.trim() @@ -115,8 +255,30 @@ export function ChatWidget() { } } + const updateSettings = (patch: Partial) => { + const next = { ...settings, ...patch } + setSettings(next) + saveSettings(next) + } + + const goBack = () => { + if (view === 'messages') { setView('rooms'); setActiveRoom(null) } + else if (view === 'search') { setView('rooms'); setSearchQuery('') } + else if (view === 'settings') setView('rooms') + } + + // ── Room name helper ──────────────────────────────────────────────────── + + const roomDisplayName = (room: ChatRoom) => { + if (room.type === 'general') return 'Общий чат' + if (room.type === 'notifications') return 'Уведомления' + return room.otherUserName ?? 'Чат' + } + if (!slug) return null + // ── Render ─────────────────────────────────────────────────────────────── + return ( <> {/* Floating button */} @@ -144,40 +306,58 @@ export function ChatWidget() { 'w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl', 'border border-slate-200 dark:border-slate-700', 'flex flex-col overflow-hidden', - 'transition-all', - )} style={{ height: 480 }}> + )} style={{ height: 520 }}> - {/* Header */} -
- {view === 'messages' && ( - )}

- {view === 'rooms' ? 'Чат сотрудников' : (activeRoom?.type === 'general' ? 'Общий чат' : activeRoom?.otherUserName ?? 'Чат')} + {view === 'rooms' && 'Чат сотрудников'} + {view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')} + {view === 'search' && 'Поиск'} + {view === 'settings' && 'Настройки чата'}

{view === 'rooms' && ( -

{rooms.length} чатов

+

{visibleRooms.length} чатов

+ )} + {view === 'messages' && activeRoom?.type === 'notifications' && ( +

Только чтение

)}
- + {/* Header actions for rooms view */} + {view === 'rooms' && ( +
+ + + +
+ )}
- {/* Rooms list */} + {/* ── Rooms list ── */} {view === 'rooms' && (
{loadingRooms ? (
- ) : rooms.length === 0 ? ( + ) : visibleRooms.length === 0 ? (
- Нет чатов. Начните общение! + Нет чатов. Нажмите чтобы начать.
) : ( - rooms.map(room => ( + visibleRooms.map(room => (
)} - {/* Messages view */} + {/* ── Search ── */} + {view === 'search' && ( +
+ {/* Search input */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Люди или сообщения..." + className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-200 placeholder-slate-400 outline-none" + /> + {searchQuery && ( + + )} +
+
+ + {/* Tabs */} +
+ {(['people', 'messages'] as const).map(tab => ( + + ))} +
+ + {/* Results */} +
+ {searchTab === 'people' && ( + <> + {!searchQuery ? ( + // Show all users when no query + allUsers.length === 0 ? ( +
Загрузка...
+ ) : ( + allUsers.filter(u => u.id !== user?.id).map(u => ( + openDirect(u)} /> + )) + ) + ) : searchUsers.length === 0 ? ( +
Никого не найдено
+ ) : ( + searchUsers.map(u => ( + openDirect(u)} /> + )) + )} + + )} + + {searchTab === 'messages' && ( + <> + {!searchQuery ? ( +
Введите запрос для поиска
+ ) : loadingSearch ? ( +
+ +
+ ) : searchResults.length === 0 ? ( +
Ничего не найдено
+ ) : ( + searchResults.map(r => ( + + )) + )} + + )} +
+
+ )} + + {/* ── Settings ── */} + {view === 'settings' && ( +
+ + : } + label="Показывать канал уведомлений" + checked={settings.notifVisible} + onChange={v => updateSettings({ notifVisible: v })} + /> + + + + : } + label="Звуковое уведомление" + checked={settings.soundEnabled} + onChange={v => updateSettings({ soundEnabled: v })} + /> + + + +

+ Чат сотрудников доступен всем пользователям отеля. + Канал «Уведомления» содержит автоматические сообщения о событиях в системе. +

+
+
+ )} + + {/* ── Messages ── */} {view === 'messages' && ( <>
@@ -230,23 +538,32 @@ export function ChatWidget() {
) : messages.length === 0 ? (
- Нет сообщений. Напишите первым! + {activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
) : ( messages.map(msg => { - const isOwn = msg.senderId === user?.id + const isSystem = msg.isSystem + const isOwn = !isSystem && msg.senderId === user?.id return (
- {!isOwn && } -
+ {!isOwn && ( + isSystem + ? + : + )} +
{!isOwn && ( -

{msg.senderName.split(' ')[0]}

+

+ {isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]} +

)}
{msg.text}
@@ -259,32 +576,43 @@ export function ChatWidget() {
- {/* Input */} -
-
-