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 {