From 8353eb7c7ff257a3b7d4f33e57cd451474ddd69d Mon Sep 17 00:00:00 2001
From: HotelSync
Date: Tue, 14 Apr 2026 17:36:04 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20group=20chat=20creat?=
=?UTF-8?q?ion=20(create-group=20view,=20backend=20endpoint,=20group=20roo?=
=?UTF-8?q?m=20display)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/migrations/076_chat_group_rooms.sql | 4 +
backend/src/routes/chat.ts | 67 ++++++++++++
src/components/chat/ChatWidget.tsx | 110 ++++++++++++++++++--
src/lib/api.ts | 6 +-
4 files changed, 176 insertions(+), 11 deletions(-)
create mode 100644 backend/migrations/076_chat_group_rooms.sql
diff --git a/backend/migrations/076_chat_group_rooms.sql b/backend/migrations/076_chat_group_rooms.sql
new file mode 100644
index 0000000..5c6feb9
--- /dev/null
+++ b/backend/migrations/076_chat_group_rooms.sql
@@ -0,0 +1,4 @@
+-- Allow 'group' room type (drop old check constraint, add new one)
+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', 'group'));
diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts
index 118b57d..0d9d14a 100644
--- a/backend/src/routes/chat.ts
+++ b/backend/src/routes/chat.ts
@@ -97,12 +97,17 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
(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 u.role 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_role,
(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,
+ (SELECT COUNT(*) FROM chat_room_members crm WHERE crm.room_id = r.id) AS member_count,
+ (SELECT json_agg(u.name ORDER BY 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 4) AS member_names,
(SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id != $2 LIMIT 1) AS other_user_last_read
FROM chat_rooms r
WHERE r.hotel_id = $1
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
))
+ AND (r.type != 'group' 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],
)
@@ -324,6 +329,68 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
},
)
+ // ── POST group room ───────────────────────────────────────────────────────
+
+ fastify.post(
+ '/api/hotels/:slug/chat/group',
+ { 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 { name, memberIds } = request.body
+ if (!name?.trim()) return reply.code(400).send({ error: 'Name required' })
+
+ const userId = request.user.sub
+ const allMembers = [...new Set([userId, ...memberIds])]
+
+ const { rows: [room] } = await db.query(
+ `INSERT INTO chat_rooms (hotel_id, type, name) VALUES ($1, 'group', $2) RETURNING id`,
+ [hotelId, name.trim()],
+ )
+ const memberValues = allMembers.map((_, i) => `($1, $${i + 2})`).join(', ')
+ await db.query(
+ `INSERT INTO chat_room_members (room_id, user_id) VALUES ${memberValues}`,
+ [room.id, ...allMembers],
+ )
+ return reply.code(201).send({ roomId: room.id })
+ },
+ )
+
+ // ── PATCH group room (rename / add/remove members) ────────────────────────
+
+ fastify.patch(
+ '/api/hotels/:slug/chat/rooms/:roomId/group',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug, roomId } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+
+ const { name, addMemberIds = [], removeMemberIds = [] } = request.body
+ if (name) {
+ await db.query('UPDATE chat_rooms SET name = $1 WHERE id = $2', [name.trim(), roomId])
+ }
+ if (addMemberIds.length > 0) {
+ const vals = addMemberIds.map((_, i) => `($1, $${i + 2})`).join(', ')
+ await db.query(
+ `INSERT INTO chat_room_members (room_id, user_id) VALUES ${vals} ON CONFLICT DO NOTHING`,
+ [roomId, ...addMemberIds],
+ )
+ }
+ if (removeMemberIds.length > 0) {
+ await db.query(
+ `DELETE FROM chat_room_members WHERE room_id = $1 AND user_id = ANY($2::uuid[])`,
+ [roomId, removeMemberIds],
+ )
+ }
+ return { ok: true }
+ },
+ )
+
// ── PATCH mark read ───────────────────────────────────────────────────────
fastify.patch(
diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx
index e53be4e..a610781 100644
--- a/src/components/chat/ChatWidget.tsx
+++ b/src/components/chat/ChatWidget.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
- Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin,
+ Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin, UserPlus,
} from 'lucide-react'
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api'
import type { User } from '../../types'
@@ -125,7 +125,7 @@ function renderWithMentions(text: string, myName?: string) {
// ── Main ───────────────────────────────────────────────────────────────────
-type View = 'rooms' | 'messages' | 'search' | 'settings'
+type View = 'rooms' | 'messages' | 'search' | 'settings' | 'create-group'
export function ChatWidget() {
const { user } = useAuth()
@@ -180,6 +180,11 @@ export function ChatWidget() {
// @mentions
const [mentionQuery, setMentionQuery] = useState(null)
+ // Create group
+ const [groupName, setGroupName] = useState('')
+ const [groupMemberIds, setGroupMemberIds] = useState([])
+ const [creatingGroup, setCreatingGroup] = useState(false)
+
const widgetRef = useRef(null)
const textareaRef = useRef(null)
const messagesContainerRef = useRef(null)
@@ -230,6 +235,7 @@ export function ChatWidget() {
if (settings.popupEnabled !== false) {
const rName = room.type === 'general' ? 'Общий чат'
: room.type === 'notifications' ? 'Уведомления'
+ : room.type === 'group' ? (room.name ?? 'Группа')
: room.otherUserName ?? 'Чат'
setToasts(p => [...p.slice(-2), {
id: `${room.id}-${Date.now()}`,
@@ -303,10 +309,10 @@ export function ChatWidget() {
// Clear typing names when leaving room
useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view])
- // Load users list (for search view + @mentions in general chat)
+ // Load users list (for search view + @mentions in general chat + create-group)
useEffect(() => {
if (!slug) return
- if (view === 'search' || (view === 'messages' && activeRoom?.type === 'general')) {
+ if (view === 'search' || view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) {
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
}
}, [view, slug, activeRoom?.type, allUsers.length])
@@ -413,7 +419,7 @@ export function ChatWidget() {
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
const data = await api.chat.listRooms(slug)
setRooms(data)
- await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null })
+ await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null, memberCount: 0, memberNames: null })
setSearchQuery('')
} catch { /**/ }
}
@@ -512,6 +518,21 @@ export function ChatWidget() {
finally { setLoadingOlder(false) }
}
+ const handleCreateGroup = async () => {
+ if (!groupName.trim() || groupMemberIds.length === 0 || creatingGroup) return
+ setCreatingGroup(true)
+ try {
+ const { roomId } = await api.chat.createGroup(slug, groupName.trim(), groupMemberIds)
+ const data = await api.chat.listRooms(slug)
+ setRooms(data)
+ const room = data.find(r => r.id === roomId)
+ setGroupName(''); setGroupMemberIds([])
+ if (room) await openRoom(room)
+ else setView('rooms')
+ } catch { /**/ }
+ finally { setCreatingGroup(false) }
+ }
+
const goBack = () => {
if (view === 'messages') {
sendTyping(false)
@@ -521,10 +542,14 @@ export function ChatWidget() {
}
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
else if (view === 'settings') setView('rooms')
+ else if (view === 'create-group') { setView('rooms'); setGroupName(''); setGroupMemberIds([]) }
}
const roomDisplayName = (room: ChatRoom) =>
- room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат'
+ room.type === 'general' ? 'Общий чат'
+ : room.type === 'notifications' ? 'Уведомления'
+ : room.type === 'group' ? (room.name ?? 'Группа')
+ : room.otherUserName ?? 'Чат'
const isOtherOnline = (room: ChatRoom) =>
room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false
@@ -572,6 +597,7 @@ export function ChatWidget() {
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
{view === 'search' && 'Поиск'}
{view === 'settings' && 'Настройки чата'}
+ {view === 'create-group' && 'Новая группа'}
{/* Online dot in messages header */}
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
@@ -595,15 +621,18 @@ export function ChatWidget() {
)
})()}
+ {view === 'messages' && activeRoom?.type === 'group' && activeRoom.memberCount && (
+ {activeRoom.memberCount} участников
+ )}
{view === 'messages' && activeRoom?.type === 'notifications' && (
Только чтение
)}
{view === 'rooms' && (
-
-
-
+
+
+
)}
{view === 'messages' && activeRoom?.type !== 'notifications' && (
@@ -721,6 +750,63 @@ export function ChatWidget() {
)}
+ {/* ── Create Group ── */}
+ {view === 'create-group' && (
+
+
+
setGroupName(e.target.value)}
+ placeholder="Название группы..."
+ maxLength={50}
+ autoFocus
+ className="w-full px-3 py-2 rounded-xl text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none border border-transparent focus:border-brand-400"
+ />
+ {groupMemberIds.length > 0 && (
+
Выбрано: {groupMemberIds.length} участника(-ов)
+ )}
+
+
+ {allUsers.length === 0 ? (
+
+ ) : (
+ allUsers.filter(u => u.id !== user?.id).map(u => {
+ const selected = groupMemberIds.includes(u.id)
+ const roleLabels: Record
= {
+ hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
+ receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
+ }
+ return (
+
+ )
+ })
+ )}
+
+
+
+
+
+ )}
+
{/* ── Messages ── */}
{view === 'messages' && (
<>
@@ -955,6 +1041,10 @@ function RoomRow({ room, isPinned, isMuted, isOnline, onClick, onMenuClick }: {
+ ) : room.type === 'group' ? (
+
+
+
) : (
)}
@@ -971,7 +1061,7 @@ function RoomRow({ room, isPinned, isMuted, isOnline, onClick, onMenuClick }: {
{isPinned && }
{isMuted && }
0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
- {room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName}
+ {room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.type === 'group' ? (room.name ?? 'Группа') : room.otherUserName}
{!hovered && room.lastMessageAt && (
{fmtTime(room.lastMessageAt)}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 3e7fb24..f2f7910 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -356,6 +356,8 @@ export const api = {
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
openDirect: (slug: string, otherUserId: string) =>
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
+ createGroup: (slug: string, name: string, memberIds: string[]) =>
+ req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }),
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
req('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }),
deleteMessage: (slug: string, roomId: string, msgId: string) =>
@@ -1335,7 +1337,7 @@ export interface LoyaltyTransaction {
export interface ChatRoom {
id: string
- type: 'general' | 'direct' | 'notifications'
+ type: 'general' | 'direct' | 'notifications' | 'group'
name: string | null
unreadCount: number
lastMessage: string | null
@@ -1345,6 +1347,8 @@ export interface ChatRoom {
otherUserRole: string | null
otherUserId: string | null
otherUserLastRead: string | null
+ memberCount: number
+ memberNames: string[] | null
}
export interface ChatReaction {