diff --git a/backend/migrations/079_chat_room_owner.sql b/backend/migrations/079_chat_room_owner.sql new file mode 100644 index 0000000..2aef8cf --- /dev/null +++ b/backend/migrations/079_chat_room_owner.sql @@ -0,0 +1,2 @@ +-- Group room owner (creator) +ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 1cb3fd8..2b445d8 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -80,7 +80,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { const userId = request.user.sub const { rows } = await db.query( - `SELECT r.id, r.type, r.name, r.avatar_url, + `SELECT r.id, r.type, r.name, r.avatar_url, r.created_by, (SELECT COUNT(*) FROM chat_messages m WHERE m.room_id = r.id AND m.deleted_at IS NULL AND m.created_at > COALESCE( @@ -374,8 +374,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { 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()], + `INSERT INTO chat_rooms (hotel_id, type, name, created_by) VALUES ($1, 'group', $2, $3) RETURNING id`, + [hotelId, name.trim(), userId], ) const memberValues = allMembers.map((_, i) => `($1, $${i + 2})`).join(', ') await db.query( @@ -388,7 +388,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { // ── PATCH group room (rename / add/remove members) ──────────────────────── - fastify.patch( + fastify.patch( '/api/hotels/:slug/chat/rooms/:roomId/group', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -403,6 +403,25 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { : Array.isArray(b.addMemberIds) ? b.addMemberIds as string[] : [] const removeMemberIds: string[] = Array.isArray(b.remove_member_ids) ? b.remove_member_ids as string[] : Array.isArray(b.removeMemberIds) ? b.removeMemberIds as string[] : [] + const transferOwnerTo = (b.transfer_owner_to ?? b.transferOwnerTo) as string | undefined + + // Fetch current room to get created_by + const roomRes = await db.query('SELECT created_by FROM chat_rooms WHERE id = $1', [roomId]) + const createdBy: string | null = roomRes.rows[0]?.created_by ?? null + + // Block removal of the creator + if (createdBy && removeMemberIds.includes(createdBy)) { + return reply.code(403).send({ error: 'Cannot remove the group creator' }) + } + + // Transfer ownership (only current owner or admin can do this) + if (transferOwnerTo) { + const userId = request.user.sub + if (createdBy !== userId && request.user.role !== 'admin') { + return reply.code(403).send({ error: 'Only the group creator can transfer ownership' }) + } + await db.query('UPDATE chat_rooms SET created_by = $1 WHERE id = $2', [transferOwnerTo, roomId]) + } if (name) { await db.query('UPDATE chat_rooms SET name = $1 WHERE id = $2', [name.trim(), roomId]) diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index c78c9c1..b326544 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, UserPlus, + Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin, UserPlus, LogOut, } from 'lucide-react' import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api' import type { User } from '../../types' @@ -544,11 +544,21 @@ export function ChatWidget() { } catch { /**/ } } - const handleSaveGroupInfo = async (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => { + const handleSaveGroupInfo = async (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[]; transferOwnerTo?: string; leaveAfter?: boolean }) => { if (!activeRoom || savingGroup) return setSavingGroup(true) try { - await api.chat.updateGroup(slug, activeRoom.id, patch) + const { leaveAfter, ...apiPatch } = patch + await api.chat.updateGroup(slug, activeRoom.id, apiPatch) + if (leaveAfter) { + // After transferring ownership, leave the group + await api.chat.updateGroup(slug, activeRoom.id, { removeMemberIds: [user?.id ?? ''] }) + const data = await api.chat.listRooms(slug) + setRooms(data) + setActiveRoom(null) + setView('rooms') + return + } const data = await api.chat.listRooms(slug) setRooms(data) const updated = data.find(r => r.id === activeRoom.id) @@ -1425,7 +1435,7 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd editName: string onEditName: (v: string) => void onAvatarClick: () => void - onSave: (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => void + onSave: (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[]; transferOwnerTo?: string; leaveAfter?: boolean }) => void }) { const roleLabels: Record = { hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная', @@ -1433,8 +1443,11 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd } const [addingMembers, setAddingMembers] = useState(false) const [selectedAdd, setSelectedAdd] = useState([]) + const [transferMode, setTransferMode] = useState(false) + const [transferTarget, setTransferTarget] = useState('') + + const isCreator = room.createdBy === currentUserId - // Current member ids from allMembers that are in this room (from memberNames we don't have IDs, so use a different approach) // We track removes locally until saved const [removedIds, setRemovedIds] = useState([]) @@ -1443,10 +1456,6 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd .map(u => u.id) .filter(id => !removedIds.includes(id)) - const removableMemberIds = allMembers.filter(u => - (room.memberNames?.some(n => n === u.name) || u.id === currentUserId) && u.id !== currentUserId - ).map(u => u.id) - const notMembers = allMembers.filter(u => u.id !== currentUserId && !currentMemberIds.includes(u.id) && @@ -1465,6 +1474,19 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd setAddingMembers(false) } + const handleLeave = () => { + if (isCreator) { + setTransferMode(true) + } else { + onSave({ removeMemberIds: [currentUserId] }) + } + } + + const handleTransferAndLeave = () => { + if (!transferTarget) return + onSave({ transferOwnerTo: transferTarget, leaveAfter: true }) + } + return (
{/* Avatar + name */} @@ -1538,12 +1560,14 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd
-

- {u.name} {u.id === currentUserId && (вы)} +

+ {u.name} + {room.createdBy === u.id && } + {u.id === currentUserId && (вы)}

{roleLabels[u.role] ?? u.role}

- {u.id !== currentUserId && ( + {u.id !== currentUserId && room.createdBy !== u.id && (
))} + + {/* Transfer ownership UI (for creator leaving) */} + {transferMode && ( +
+

+ Передайте управление группой перед выходом +

+ +
+ + +
+
+ )} + + {/* Leave group button */} + {!transferMode && ( + + )}
) diff --git a/src/lib/api.ts b/src/lib/api.ts index a363b30..3aa4741 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -360,7 +360,7 @@ export const api = { req<{ id: string; name: string; role: string }[]>('GET', `/api/hotels/${slug}/chat/members`), createGroup: (slug: string, name: string, memberIds: string[]) => req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }), - updateGroup: (slug: string, roomId: string, patch: { name?: string; avatarUrl?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => + updateGroup: (slug: string, roomId: string, patch: { name?: string; avatarUrl?: string; addMemberIds?: string[]; removeMemberIds?: string[]; transferOwnerTo?: string }) => req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/group`, patch), editMessage: (slug: string, roomId: string, msgId: string, text: string) => req('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }), @@ -1363,6 +1363,7 @@ export interface ChatRoom { memberCount: number memberNames: string[] | null avatarUrl: string | null + createdBy?: string | null } export interface ChatReaction {