diff --git a/blueprints/messages/conversation_routes.py b/blueprints/messages/conversation_routes.py index 0aa4baa..cb8875c 100644 --- a/blueprints/messages/conversation_routes.py +++ b/blueprints/messages/conversation_routes.py @@ -471,7 +471,7 @@ def api_conversation_detail(conv_id): @login_required @member_required def api_conversation_edit(conv_id): - """Edit conversation (name, description). Owner only, groups only.""" + """Edit conversation (name, description). Owner or admin only, groups only.""" db = SessionLocal() try: conv = db.query(Conversation).filter_by(id=conv_id).first() @@ -479,8 +479,12 @@ def api_conversation_edit(conv_id): return jsonify({'error': 'Konwersacja nie istnieje'}), 404 if not conv.is_group: return jsonify({'error': 'Nie można edytować konwersacji 1:1'}), 400 - if conv.owner_id != current_user.id: - return jsonify({'error': 'Tylko właściciel może edytować konwersację'}), 403 + + caller_membership = db.query(ConversationMember).filter_by( + conversation_id=conv_id, user_id=current_user.id + ).first() + if not caller_membership or caller_membership.role not in ('owner', 'admin'): + return jsonify({'error': 'Tylko właściciel lub administrator może edytować'}), 403 data = request.get_json(silent=True) if not data: @@ -543,7 +547,7 @@ def api_conversation_delete(conv_id): @login_required @member_required def api_conversation_add_member(conv_id): - """Add a member to a group conversation. Owner only.""" + """Add a member to a group conversation. Owner or admin only.""" db = SessionLocal() try: conv = db.query(Conversation).filter_by(id=conv_id).first() @@ -551,8 +555,12 @@ def api_conversation_add_member(conv_id): return jsonify({'error': 'Konwersacja nie istnieje'}), 404 if not conv.is_group: return jsonify({'error': 'Nie można dodać osób do konwersacji 1:1'}), 400 - if conv.owner_id != current_user.id: - return jsonify({'error': 'Tylko właściciel może dodawać członków'}), 403 + + caller_membership = db.query(ConversationMember).filter_by( + conversation_id=conv_id, user_id=current_user.id + ).first() + if not caller_membership or caller_membership.role not in ('owner', 'admin'): + return jsonify({'error': 'Tylko właściciel lub administrator może dodawać członków'}), 403 data = request.get_json(silent=True) if not data or 'user_id' not in data: @@ -631,7 +639,7 @@ def api_conversation_add_member(conv_id): @login_required @member_required def api_conversation_remove_member(conv_id, user_id): - """Remove member from conversation. Owner can remove anyone, member can leave.""" + """Remove member from conversation. Owner/admin can remove others, anyone can leave.""" db = SessionLocal() try: conv = db.query(Conversation).filter_by(id=conv_id).first() @@ -639,12 +647,22 @@ def api_conversation_remove_member(conv_id, user_id): return jsonify({'error': 'Konwersacja nie istnieje'}), 404 # Permission check - is_owner = conv.owner_id == current_user.id + caller_membership = db.query(ConversationMember).filter_by( + conversation_id=conv_id, user_id=current_user.id + ).first() + if not caller_membership: + return jsonify({'error': 'Brak dostępu do konwersacji'}), 403 + + is_admin_or_owner = caller_membership.role in ('owner', 'admin') is_self = user_id == current_user.id - if not is_owner and not is_self: + if not is_admin_or_owner and not is_self: return jsonify({'error': 'Brak uprawnień'}), 403 + # Cannot remove owner + if user_id == conv.owner_id and not is_self: + return jsonify({'error': 'Nie można usunąć właściciela grupy'}), 403 + member = db.query(ConversationMember).filter_by( conversation_id=conv_id, user_id=user_id ).first() @@ -670,6 +688,53 @@ def api_conversation_remove_member(conv_id, user_id): db.close() +# ============================================================ +# 8b. PATCH /api/conversations//members/ — Change role +# ============================================================ + +@bp.route('/api/conversations//members/', methods=['PATCH']) +@login_required +@member_required +def api_conversation_change_role(conv_id, user_id): + """Change member role in group conversation. Owner only.""" + db = SessionLocal() + try: + conv = db.query(Conversation).filter_by(id=conv_id).first() + if not conv: + return jsonify({'error': 'Konwersacja nie istnieje'}), 404 + + if conv.owner_id != current_user.id: + return jsonify({'error': 'Tylko właściciel może zmieniać role'}), 403 + + if user_id == current_user.id: + return jsonify({'error': 'Nie możesz zmienić swojej własnej roli'}), 400 + + data = request.get_json(silent=True) + if not data or 'role' not in data: + return jsonify({'error': 'Podaj role'}), 400 + + new_role = data['role'] + if new_role not in ('admin', 'member'): + return jsonify({'error': 'Rola musi być "admin" lub "member"'}), 400 + + member = db.query(ConversationMember).filter_by( + conversation_id=conv_id, user_id=user_id + ).first() + if not member: + return jsonify({'error': 'Użytkownik nie jest członkiem konwersacji'}), 404 + + member.role = new_role + db.commit() + + return jsonify({'ok': True, 'role': new_role}) + except Exception as e: + db.rollback() + logger.error(f"Error changing role in conversation {conv_id}: {e}") + return jsonify({'error': 'Błąd serwera'}), 500 + finally: + db.close() + + # ============================================================ # 9. PATCH /api/conversations//settings — User settings # ============================================================ diff --git a/static/js/conversations.js b/static/js/conversations.js index be59b48..7aa0e74 100644 --- a/static/js/conversations.js +++ b/static/js/conversations.js @@ -188,7 +188,7 @@ renderItem: function (conv) { var item = el('div', 'conversation-item'); item.dataset.id = conv.id; - if (conv.id === state.currentConversationId) item.classList.add('active'); + if (conv.id === state.currentConversationIdId) item.classList.add('active'); if (conv.unread_count > 0) item.classList.add('unread'); // Avatar @@ -262,7 +262,7 @@ }, selectConversation: function (id) { - state.currentConversationId = id; + state.currentConversationIdId = id; // Update active class document.querySelectorAll('.conversation-item').forEach(function (el) { @@ -514,7 +514,7 @@ // Sender name (groups, other people's messages) if (!isMine && msg.sender) { - var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; }); + var conv = state.conversations.find(function (c) { return c.id === state.currentConversationIdId; }); if (conv && conv.is_group) { var senderName = el('div', 'message-subject', msg.sender.name); bubble.appendChild(senderName); @@ -636,7 +636,7 @@ var check = el('span', 'read-status'); // Check if other members have read this message - var details = state.conversationDetails[state.currentConversationId]; + var details = state.conversationDetails[state.currentConversationIdId]; var isRead = false; var readAt = null; @@ -663,7 +663,7 @@ var otherMembers = details.members.filter(function (m) { return m.user_id !== window.__CURRENT_USER__.id; }); - var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; }); + var conv = state.conversations.find(function (c) { return c.id === state.currentConversationIdId; }); var isGroup = conv && conv.is_group; if (isGroup) { @@ -761,7 +761,7 @@ if (dominated) return; state.messages[convId].push(msg); - if (convId !== state.currentConversationId) return; + if (convId !== state.currentConversationIdId) return; var container = document.getElementById('chatMessages'); if (!container) return; @@ -1011,7 +1011,7 @@ list.style.overflowY = 'auto'; state.conversations.forEach(function (conv) { - if (conv.id === state.currentConversationId) return; + if (conv.id === state.currentConversationIdId) return; var item = el('div', 'conversation-item'); item.style.cursor = 'pointer'; @@ -1064,7 +1064,7 @@ } catch (_) {} } // Refresh pins - ChatView.loadConversationDetails(state.currentConversationId); + ChatView.loadConversationDetails(state.currentConversationIdId); }, startEdit: function (msg) { @@ -1122,7 +1122,7 @@ api('/api/messages/' + msg.id, 'PATCH', { content: newContent }) .then(function (updated) { // Update in state - var msgs = state.messages[state.currentConversationId] || []; + var msgs = state.messages[state.currentConversationIdId] || []; var idx = msgs.findIndex(function (m) { return m.id === msg.id; }); if (idx !== -1) msgs[idx] = updated; state.editingMessageId = null; @@ -1136,7 +1136,7 @@ cancelBtn.addEventListener('click', function () { state.editingMessageId = null; - ChatView.renderMessages(state.messages[state.currentConversationId] || []); + ChatView.renderMessages(state.messages[state.currentConversationIdId] || []); ChatView.scrollToMessage(msg.id); }); @@ -1162,7 +1162,7 @@ api('/api/messages/' + msg.id, 'DELETE') .then(function () { // Update in state - var msgs = state.messages[state.currentConversationId] || []; + var msgs = state.messages[state.currentConversationIdId] || []; var idx = msgs.findIndex(function (m) { return m.id === msg.id; }); if (idx !== -1) { msgs[idx].is_deleted = true; @@ -1207,7 +1207,7 @@ await api('/api/messages/' + messageId + '/reactions', 'POST', { emoji: emoji }); } // Refresh messages to update reactions display - ChatView.loadMessages(state.currentConversationId); + ChatView.loadMessages(state.currentConversationIdId); } catch (e) { // silently ignore } @@ -1216,7 +1216,7 @@ addFromPicker: async function (messageId, emoji) { try { await api('/api/messages/' + messageId + '/reactions', 'POST', { emoji: emoji }); - ChatView.loadMessages(state.currentConversationId); + ChatView.loadMessages(state.currentConversationIdId); } catch (e) { // silently ignore } @@ -1363,11 +1363,11 @@ }, sendContent: function (html, text) { - if (!state.currentConversationId) return; + if (!state.currentConversationIdId) return; // Add to queue with current state snapshot var tempId = 'temp-' + Date.now() + '-' + Math.random(); Composer._sendQueue.push({ - convId: state.currentConversationId, + convId: state.currentConversationIdId, html: html, text: text, replyTo: state.replyToMessage, @@ -1382,7 +1382,7 @@ // Show optimistic message immediately ChatView.appendMessage({ id: tempId, - conversation_id: state.currentConversationId, + conversation_id: state.currentConversationIdId, content: html, sender_id: window.__CURRENT_USER__ ? window.__CURRENT_USER__.id : null, sender: window.__CURRENT_USER__ || {}, @@ -1405,7 +1405,7 @@ // send: called from button click — reads from Quill send: async function () { - if (!state.currentConversationId || !state.quill) return; + if (!state.currentConversationIdId || !state.quill) return; var html = state.quill.root.innerHTML; var text = state.quill.getText().trim(); @@ -1414,7 +1414,7 @@ if (!text && !hasImage && !state.attachedFiles.length) return; if (!text && hasImage) text = '📷'; - var convId = state.currentConversationId; + var convId = state.currentConversationIdId; var savedReplyTo = state.replyToMessage; var savedFiles = state.attachedFiles.slice(); @@ -1532,17 +1532,17 @@ }, sendTyping: function () { - if (!state.currentConversationId) return; + if (!state.currentConversationIdId) return; if (state.typingTimeout) clearTimeout(state.typingTimeout); state.typingTimeout = setTimeout(function () { - api('/api/conversations/' + state.currentConversationId + '/typing', 'POST') + api('/api/conversations/' + state.currentConversationIdId + '/typing', 'POST') .catch(function () {}); }, 300); // Debounce: only send after 300ms of no typing, then don't send again for 2s clearTimeout(state.typingTimeout); if (!state._lastTypingSent || Date.now() - state._lastTypingSent > 2000) { state._lastTypingSent = Date.now(); - api('/api/conversations/' + state.currentConversationId + '/typing', 'POST') + api('/api/conversations/' + state.currentConversationIdId + '/typing', 'POST') .catch(function () {}); } }, @@ -1616,8 +1616,8 @@ state.sse.addEventListener('message_pinned', function (e) { try { var data = JSON.parse(e.data); - if (data.conversation_id === state.currentConversationId) { - ChatView.loadConversationDetails(state.currentConversationId); + if (data.conversation_id === state.currentConversationIdId) { + ChatView.loadConversationDetails(state.currentConversationIdId); } } catch (_) {} }); @@ -1625,8 +1625,8 @@ state.sse.addEventListener('message_unpinned', function (e) { try { var data = JSON.parse(e.data); - if (data.conversation_id === state.currentConversationId) { - ChatView.loadConversationDetails(state.currentConversationId); + if (data.conversation_id === state.currentConversationIdId) { + ChatView.loadConversationDetails(state.currentConversationIdId); } } catch (_) {} }); @@ -1652,7 +1652,7 @@ var convId = msg.conversation_id; // Append to current view if active - if (convId === state.currentConversationId) { + if (convId === state.currentConversationIdId) { ChatView.appendMessage(msg); // Mark read api('/api/conversations/' + convId + '/read', 'POST').catch(function () {}); @@ -1670,7 +1670,7 @@ created_at: msg.created_at, }, updated_at: msg.created_at, - unread_count: convId === state.currentConversationId + unread_count: convId === state.currentConversationIdId ? 0 : (conv.unread_count || 0) + 1, }); @@ -1685,8 +1685,8 @@ handleMessageRead: function (data) { // Update read receipts if viewing the same conversation - if (data.conversation_id === state.currentConversationId) { - var details = state.conversationDetails[state.currentConversationId]; + if (data.conversation_id === state.currentConversationIdId) { + var details = state.conversationDetails[state.currentConversationIdId]; if (details && details.members) { var member = details.members.find(function (m) { return m.user_id === data.user_id; }); if (member) { @@ -1694,13 +1694,13 @@ } } // Re-render to update check marks - ChatView.renderMessages(state.messages[state.currentConversationId] || []); + ChatView.renderMessages(state.messages[state.currentConversationIdId] || []); } }, handleTyping: function (data) { var convId = data.conversation_id; - if (convId !== state.currentConversationId) return; + if (convId !== state.currentConversationIdId) return; var typingEl = document.getElementById('typingIndicator'); var typingName = document.getElementById('typingName'); @@ -1732,14 +1732,14 @@ handleReaction: function (data) { var convId = data.conversation_id; - if (convId !== state.currentConversationId) return; + if (convId !== state.currentConversationIdId) return; // Reload messages to update reactions ChatView.loadMessages(convId); }, handleMessageEdited: function (msg) { var convId = msg.conversation_id; - if (convId !== state.currentConversationId) return; + if (convId !== state.currentConversationIdId) return; var msgs = state.messages[convId] || []; var idx = msgs.findIndex(function (m) { return m.id === msg.id; }); if (idx !== -1) { @@ -1750,7 +1750,7 @@ handleMessageDeleted: function (data) { var convId = data.conversation_id; - if (convId !== state.currentConversationId) return; + if (convId !== state.currentConversationIdId) return; var msgs = state.messages[convId] || []; var idx = msgs.findIndex(function (m) { return m.id === data.id; }); if (idx !== -1) { @@ -1762,8 +1762,8 @@ handlePresence: function (data) { // Update online dot in header if relevant - if (!state.currentConversationId) return; - var details = state.conversationDetails[state.currentConversationId]; + if (!state.currentConversationIdId) return; + var details = state.conversationDetails[state.currentConversationIdId]; if (!details || !details.members) return; var member = details.members.find(function (m) { return m.user_id === data.user_id; }); @@ -1793,8 +1793,8 @@ // Poll for new messages every 5 seconds when SSE is unavailable if (state.pollingInterval) return; state.pollingInterval = setInterval(function () { - if (!state.currentConversationId) return; - var convId = state.currentConversationId; + if (!state.currentConversationIdId) return; + var convId = state.currentConversationIdId; var msgs = state.messages[convId]; if (!msgs || !msgs.length) return; @@ -1889,8 +1889,8 @@ startPolling: function () { state.presenceInterval = setInterval(function () { - if (state.currentConversationId) { - Presence.fetchForConversation(state.currentConversationId); + if (state.currentConversationIdId) { + Presence.fetchForConversation(state.currentConversationIdId); } }, 60000); }, @@ -2513,7 +2513,7 @@ }, loadDetails: async function () { - var convId = state.currentConversation; + var convId = state.currentConversationId; if (!convId) return; var details = state.conversationDetails[convId]; @@ -2528,19 +2528,22 @@ var nameInput = document.getElementById('groupEditName'); if (nameInput) nameInput.value = details.name || ''; - // Owner check - var isOwner = details.members.some(function (m) { - return m.user_id === window.__CURRENT_USER__.id && m.role === 'owner'; + // Check caller's role + var myMembership = details.members.find(function (m) { + return m.user_id === window.__CURRENT_USER__.id; }); + var myRole = myMembership ? myMembership.role : 'member'; + var canManage = myRole === 'owner' || myRole === 'admin'; + var isOwner = myRole === 'owner'; - // Save name — show only for owner + // Save name — show for owner/admin var saveBtn = document.getElementById('saveGroupName'); - if (saveBtn) saveBtn.style.display = isOwner ? '' : 'none'; - if (nameInput) nameInput.readOnly = !isOwner; + if (saveBtn) saveBtn.style.display = canManage ? '' : 'none'; + if (nameInput) nameInput.readOnly = !canManage; - // Add member section — owner only + // Add member section — owner/admin var addSection = document.getElementById('groupAddMemberSection'); - if (addSection) addSection.style.display = isOwner ? '' : 'none'; + if (addSection) addSection.style.display = canManage ? '' : 'none'; // Members list var countEl = document.getElementById('groupMemberCount'); @@ -2550,6 +2553,8 @@ if (!listEl) return; listEl.innerHTML = ''; + var roleLabels = { owner: 'Właściciel', admin: 'Administrator', member: 'Członek' }; + details.members.forEach(function (m) { var item = el('div', 'group-member-item'); @@ -2562,11 +2567,9 @@ var info = el('div', 'group-member-info'); var nameEl = el('div', 'group-member-name', m.name); + var roleEl = el('span', 'group-member-role', ' \u2014 ' + (roleLabels[m.role] || m.role)); + nameEl.appendChild(roleEl); info.appendChild(nameEl); - if (m.role === 'owner') { - var roleEl = el('span', 'group-member-role', ' (właściciel)'); - nameEl.appendChild(roleEl); - } if (m.company_name) { var companyEl = el('div', 'group-member-role', m.company_name); info.appendChild(companyEl); @@ -2575,14 +2578,37 @@ item.appendChild(avatar); item.appendChild(info); - // Actions (owner can remove others, anyone can see) - if (isOwner && m.user_id !== window.__CURRENT_USER__.id) { + // Actions + var isMe = m.user_id === window.__CURRENT_USER__.id; + if (!isMe && m.role !== 'owner') { var actions = el('div', 'group-member-actions'); - var removeBtn = el('button', 'btn-member-action danger', 'Usuń'); - removeBtn.addEventListener('click', function () { - GroupManager.removeMember(convId, m.user_id, m.name); - }); - actions.appendChild(removeBtn); + + // Role toggle — only owner can change roles + if (isOwner) { + if (m.role === 'member') { + var promoteBtn = el('button', 'btn-member-action', 'Nadaj admina'); + promoteBtn.addEventListener('click', function () { + GroupManager.changeRole(convId, m.user_id, 'admin'); + }); + actions.appendChild(promoteBtn); + } else if (m.role === 'admin') { + var demoteBtn = el('button', 'btn-member-action', 'Odbierz admina'); + demoteBtn.addEventListener('click', function () { + GroupManager.changeRole(convId, m.user_id, 'member'); + }); + actions.appendChild(demoteBtn); + } + } + + // Remove — owner or admin can remove + if (canManage) { + var removeBtn = el('button', 'btn-member-action danger', 'Usuń'); + removeBtn.addEventListener('click', function () { + GroupManager.removeMember(convId, m.user_id, m.name); + }); + actions.appendChild(removeBtn); + } + item.appendChild(actions); } @@ -2591,7 +2617,7 @@ }, saveName: async function () { - var convId = state.currentConversation; + var convId = state.currentConversationId; var nameInput = document.getElementById('groupEditName'); if (!convId || !nameInput) return; @@ -2632,6 +2658,17 @@ } }, + changeRole: async function (convId, userId, newRole) { + try { + await api('/api/conversations/' + convId + '/members/' + userId, 'PATCH', { role: newRole }); + // Refresh + delete state.conversationDetails[convId]; + GroupManager.loadDetails(); + } catch (e) { + alert('Nie udało się zmienić roli: ' + e.message); + } + }, + searchNewMember: async function (query) { var suggestions = document.getElementById('groupAddMemberSuggestions'); if (!suggestions) return; @@ -2640,7 +2677,7 @@ query = (query || '').trim(); if (query.length < 2) return; - var convId = state.currentConversation; + var convId = state.currentConversationId; var details = state.conversationDetails[convId]; var existingIds = details ? details.members.map(function (m) { return m.user_id; }) : []; @@ -2870,9 +2907,9 @@ }, loadAndShow: async function () { - if (!state.currentConversationId) return; + if (!state.currentConversationIdId) return; try { - var data = await api('/api/conversations/' + state.currentConversationId + '/pins'); + var data = await api('/api/conversations/' + state.currentConversationIdId + '/pins'); // API returns array directly, not {pins: [...]} var pins = Array.isArray(data) ? data : (data.pins || []); @@ -2994,16 +3031,16 @@ if (!chatMessages) return; chatMessages.addEventListener('scroll', function () { - if (chatMessages.scrollTop < 100 && state.currentConversationId) { - var msgs = state.messages[state.currentConversationId] || []; - var hasMore = state.hasMore[state.currentConversationId]; + if (chatMessages.scrollTop < 100 && state.currentConversationIdId) { + var msgs = state.messages[state.currentConversationIdId] || []; + var hasMore = state.hasMore[state.currentConversationIdId]; if (hasMore && msgs.length > 0) { var oldestId = msgs[0].id; // Prevent multiple loads - state.hasMore[state.currentConversationId] = false; + state.hasMore[state.currentConversationIdId] = false; var scrollHeightBefore = chatMessages.scrollHeight; - ChatView.loadMessages(state.currentConversationId, oldestId).then(function () { + ChatView.loadMessages(state.currentConversationIdId, oldestId).then(function () { // Maintain scroll position var scrollHeightAfter = chatMessages.scrollHeight; chatMessages.scrollTop = scrollHeightAfter - scrollHeightBefore; @@ -3023,7 +3060,7 @@ backBtn.addEventListener('click', function () { var container = document.getElementById('conversationsApp'); if (container) container.classList.remove('show-chat'); - state.currentConversationId = null; + state.currentConversationIdId = null; }); } diff --git a/templates/messages/conversations.html b/templates/messages/conversations.html index 27b3cee..a8d3762 100644 --- a/templates/messages/conversations.html +++ b/templates/messages/conversations.html @@ -326,7 +326,7 @@ window.__CSRF_TOKEN__ = '{{ csrf_token() }}'; // Load conversations.js after data is set (function() { var s = document.createElement('script'); - s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=20'; + s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=21'; document.body.appendChild(s); })(); {% endblock %}