feat(messages): redirect old inbox to new conversation view

Move legacy inbox to /wiadomosci/stare, promote /wiadomosci-v2 to /wiadomosci,
and update nav links in base.html to point to conversations_page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-27 13:25:26 +01:00
parent 755520ee62
commit 9a4afa6907
3 changed files with 706 additions and 4 deletions

View File

@ -0,0 +1,702 @@
"""
Conversation API Routes
========================
CRUD endpoints for the unified conversation system (1:1 and group).
"""
import re
import json
import logging
from datetime import datetime
from flask import jsonify, request, render_template
from flask_login import login_required, current_user
from sqlalchemy import and_, or_, func
from sqlalchemy.orm import joinedload
from . import bp
from database import (
SessionLocal, User, Company, UserCompanyPermissions,
Conversation, ConversationMember, ConvMessage, MessagePin, UserBlock,
)
from utils.decorators import member_required
from redis_service import publish_event, is_user_online
logger = logging.getLogger(__name__)
# ============================================================
# HELPERS
# ============================================================
def strip_html(text):
"""Strip HTML tags for plain-text previews."""
return re.sub(r'<[^>]+>', '', text or '').strip()
def _publish_to_conv(db, conversation_id, event_type, data, exclude_user_id=None):
"""Publish SSE event to all conversation members (except excluded)."""
members = db.query(ConversationMember).filter_by(
conversation_id=conversation_id
).all()
for m in members:
if exclude_user_id is not None and m.user_id == exclude_user_id:
continue
publish_event(m.user_id, event_type, data)
def _conversation_list_json(db, membership):
"""Build JSON dict for a conversation list item."""
conv = membership.conversation
# Unread count
unread = 0
if membership.last_read_at:
unread = db.query(func.count(ConvMessage.id)).filter(
ConvMessage.conversation_id == conv.id,
ConvMessage.created_at > membership.last_read_at,
ConvMessage.sender_id != current_user.id,
).scalar()
else:
# Never read -> all messages are unread (except own)
unread = db.query(func.count(ConvMessage.id)).filter(
ConvMessage.conversation_id == conv.id,
ConvMessage.sender_id != current_user.id,
).scalar()
# Last message
last_msg = None
if conv.last_message:
lm = conv.last_message
sender_name = ''
if lm.sender:
sender_name = lm.sender.name or lm.sender.email.split('@')[0]
last_msg = {
'id': lm.id,
'content_preview': strip_html(lm.content)[:100],
'sender_name': sender_name,
'created_at': lm.created_at.isoformat() if lm.created_at else None,
}
# Display name for 1:1: show the other person's name
display_name = conv.display_name
if not conv.is_group and not conv.name:
other_members = [m for m in conv.members if m.user_id != current_user.id]
if other_members and other_members[0].user:
u = other_members[0].user
display_name = u.name or u.email.split('@')[0]
return {
'id': conv.id,
'name': conv.name,
'is_group': conv.is_group,
'display_name': display_name,
'member_count': conv.member_count,
'last_message': last_msg,
'unread_count': unread,
'is_muted': membership.is_muted,
'updated_at': conv.updated_at.isoformat() if conv.updated_at else None,
}
def _check_blocks(db, user_ids):
"""Check if any UserBlock exists between current_user and given user_ids.
Returns list of blocked user_ids (in either direction).
"""
if not user_ids:
return []
blocks = db.query(UserBlock).filter(
or_(
and_(UserBlock.user_id == current_user.id,
UserBlock.blocked_user_id.in_(user_ids)),
and_(UserBlock.user_id.in_(user_ids),
UserBlock.blocked_user_id == current_user.id),
)
).all()
blocked = set()
for b in blocks:
blocked.add(b.user_id if b.user_id != current_user.id else b.blocked_user_id)
return list(blocked)
# ============================================================
# 1. GET /api/conversations — List conversations
# ============================================================
@bp.route('/api/conversations', methods=['GET'])
@login_required
@member_required
def api_conversations_list():
"""List current user's non-archived conversations."""
db = SessionLocal()
try:
memberships = db.query(ConversationMember).options(
joinedload(ConversationMember.conversation)
.joinedload(Conversation.last_message)
.joinedload(ConvMessage.sender),
joinedload(ConversationMember.conversation)
.joinedload(Conversation.members)
.joinedload(ConversationMember.user),
).filter(
ConversationMember.user_id == current_user.id,
ConversationMember.is_archived == False, # noqa: E712
).all()
# Sort by conversation.updated_at DESC
memberships.sort(
key=lambda m: m.conversation.updated_at or datetime.min,
reverse=True
)
result = [_conversation_list_json(db, m) for m in memberships]
return jsonify(result)
finally:
db.close()
# ============================================================
# 2. GET /wiadomosci — HTML page (main view)
# ============================================================
@bp.route('/wiadomosci')
@login_required
@member_required
def conversations_page():
"""Render the unified conversations page."""
db = SessionLocal()
try:
memberships = db.query(ConversationMember).options(
joinedload(ConversationMember.conversation)
.joinedload(Conversation.last_message)
.joinedload(ConvMessage.sender),
joinedload(ConversationMember.conversation)
.joinedload(Conversation.members)
.joinedload(ConversationMember.user),
).filter(
ConversationMember.user_id == current_user.id,
ConversationMember.is_archived == False, # noqa: E712
).all()
memberships.sort(
key=lambda m: m.conversation.updated_at or datetime.min,
reverse=True
)
conversations_data = [_conversation_list_json(db, m) for m in memberships]
user_data = {
'id': current_user.id,
'name': current_user.name or current_user.email.split('@')[0],
'email': current_user.email,
}
return render_template(
'messages/conversations.html',
conversations_json=json.dumps(conversations_data, ensure_ascii=False),
current_user_json=json.dumps(user_data, ensure_ascii=False),
)
finally:
db.close()
# ============================================================
# 3. POST /api/conversations — Create new conversation
# ============================================================
@bp.route('/api/conversations', methods=['POST'])
@login_required
@member_required
def api_conversations_create():
"""Create a new conversation (1:1 or group)."""
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'Nieprawidłowe dane'}), 400
member_ids = data.get('member_ids', [])
name = data.get('name')
message_text = data.get('message', '').strip()
if not member_ids:
return jsonify({'error': 'Wybierz co najmniej jednego uczestnika'}), 400
# Remove duplicates, ensure current_user is not in the list
member_ids = list(set(int(uid) for uid in member_ids if int(uid) != current_user.id))
if not member_ids:
return jsonify({'error': 'Nie możesz utworzyć konwersacji sam ze sobą'}), 400
db = SessionLocal()
try:
# Check blocks
blocked = _check_blocks(db, member_ids)
if blocked:
return jsonify({'error': 'Nie można utworzyć konwersacji — użytkownik zablokowany'}), 403
# Verify all members exist and are active
users = db.query(User).filter(
User.id.in_(member_ids),
User.is_active == True, # noqa: E712
).all()
if len(users) != len(member_ids):
return jsonify({'error': 'Niektórzy użytkownicy nie istnieją lub są nieaktywni'}), 400
# 1:1 dedup
is_group = len(member_ids) > 1
if not is_group:
other_id = member_ids[0]
# Find existing 1:1 conversation between current_user and other_id
existing = db.query(Conversation).filter(
Conversation.is_group == False, # noqa: E712
).join(ConversationMember).filter(
ConversationMember.user_id == current_user.id,
).all()
for conv in existing:
conv_member_ids = {m.user_id for m in conv.members}
if conv_member_ids == {current_user.id, other_id}:
# Return existing conversation
membership = db.query(ConversationMember).filter_by(
conversation_id=conv.id,
user_id=current_user.id,
).first()
return jsonify(_conversation_list_json(db, membership)), 200
# Create conversation
now = datetime.now()
conv = Conversation(
name=name if is_group else None,
is_group=is_group,
owner_id=current_user.id,
created_at=now,
updated_at=now,
)
db.add(conv)
db.flush()
# Add members
db.add(ConversationMember(
conversation_id=conv.id,
user_id=current_user.id,
role='owner',
joined_at=now,
last_read_at=now,
))
for uid in member_ids:
db.add(ConversationMember(
conversation_id=conv.id,
user_id=uid,
role='member',
joined_at=now,
))
# Create first message if provided
if message_text:
msg = ConvMessage(
conversation_id=conv.id,
sender_id=current_user.id,
content=message_text,
created_at=now,
)
db.add(msg)
db.flush()
conv.last_message_id = msg.id
conv.updated_at = now
db.commit()
# Reload with relationships
db.refresh(conv)
membership = db.query(ConversationMember).filter_by(
conversation_id=conv.id,
user_id=current_user.id,
).first()
result = _conversation_list_json(db, membership)
# Publish SSE to other members
if message_text:
sender_name = current_user.name or current_user.email.split('@')[0]
_publish_to_conv(db, conv.id, 'new_message', {
'conversation_id': conv.id,
'message': {
'id': msg.id,
'content_preview': strip_html(message_text)[:100],
'sender_name': sender_name,
'sender_id': current_user.id,
},
}, exclude_user_id=current_user.id)
return jsonify(result), 201
except Exception as e:
db.rollback()
logger.error(f"Error creating conversation: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()
# ============================================================
# 4. GET /api/conversations/<id> — Get conversation details
# ============================================================
@bp.route('/api/conversations/<int:conv_id>', methods=['GET'])
@login_required
@member_required
def api_conversation_detail(conv_id):
"""Get conversation details with members list."""
db = SessionLocal()
try:
# Verify membership
membership = db.query(ConversationMember).filter_by(
conversation_id=conv_id,
user_id=current_user.id,
).first()
if not membership:
return jsonify({'error': 'Brak dostępu do konwersacji'}), 403
conv = db.query(Conversation).options(
joinedload(Conversation.members).joinedload(ConversationMember.user),
).filter_by(id=conv_id).first()
if not conv:
return jsonify({'error': 'Konwersacja nie istnieje'}), 404
# Build members list
members_list = []
for m in conv.members:
if not m.user:
continue
# Get company name
company_name = None
perm = db.query(UserCompanyPermissions).filter_by(
user_id=m.user_id
).first()
if perm:
company = db.query(Company).filter_by(
id=perm.company_id, status='active'
).first()
if company:
company_name = company.name
members_list.append({
'user_id': m.user_id,
'name': m.user.name or m.user.email.split('@')[0],
'email': m.user.email,
'company_name': company_name,
'role': m.role,
'last_read_at': m.last_read_at.isoformat() if m.last_read_at else None,
'is_online': is_user_online(m.user_id),
})
# Display name for 1:1
display_name = conv.display_name
if not conv.is_group and not conv.name:
other_members = [m for m in conv.members if m.user_id != current_user.id]
if other_members and other_members[0].user:
u = other_members[0].user
display_name = u.name or u.email.split('@')[0]
# Pins count
pins_count = db.query(func.count(MessagePin.id)).filter_by(
conversation_id=conv_id
).scalar()
return jsonify({
'id': conv.id,
'name': conv.name,
'is_group': conv.is_group,
'display_name': display_name,
'members': members_list,
'pins_count': pins_count,
'is_muted': membership.is_muted,
'is_archived': membership.is_archived,
})
finally:
db.close()
# ============================================================
# 5. PATCH /api/conversations/<id> — Edit conversation
# ============================================================
@bp.route('/api/conversations/<int:conv_id>', methods=['PATCH'])
@login_required
@member_required
def api_conversation_edit(conv_id):
"""Edit conversation (name, description). Owner only, groups 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 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
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'Nieprawidłowe dane'}), 400
if 'name' in data:
conv.name = data['name'].strip() if data['name'] else None
conv.updated_at = datetime.now()
db.commit()
return jsonify({
'id': conv.id,
'name': conv.name,
'is_group': conv.is_group,
'updated_at': conv.updated_at.isoformat(),
})
except Exception as e:
db.rollback()
logger.error(f"Error editing conversation {conv_id}: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()
# ============================================================
# 6. DELETE /api/conversations/<id> — Delete conversation
# ============================================================
@bp.route('/api/conversations/<int:conv_id>', methods=['DELETE'])
@login_required
@member_required
def api_conversation_delete(conv_id):
"""Delete 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 usunąć konwersację'}), 403
db.delete(conv)
db.commit()
return jsonify({'ok': True})
except Exception as e:
db.rollback()
logger.error(f"Error deleting conversation {conv_id}: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()
# ============================================================
# 7. POST /api/conversations/<id>/members — Add member
# ============================================================
@bp.route('/api/conversations/<int:conv_id>/members', methods=['POST'])
@login_required
@member_required
def api_conversation_add_member(conv_id):
"""Add a member to a 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 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
data = request.get_json(silent=True)
if not data or 'user_id' not in data:
return jsonify({'error': 'Podaj user_id'}), 400
user_id = int(data['user_id'])
# Check if already a member
existing = db.query(ConversationMember).filter_by(
conversation_id=conv_id, user_id=user_id
).first()
if existing:
return jsonify({'error': 'Użytkownik jest już członkiem konwersacji'}), 400
# Check user exists
user = db.query(User).filter_by(id=user_id, is_active=True).first()
if not user:
return jsonify({'error': 'Użytkownik nie istnieje lub jest nieaktywny'}), 404
# Check blocks
blocked = _check_blocks(db, [user_id])
if blocked:
return jsonify({'error': 'Nie można dodać zablokowanego użytkownika'}), 403
now = datetime.now()
db.add(ConversationMember(
conversation_id=conv_id,
user_id=user_id,
role='member',
joined_at=now,
added_by_id=current_user.id,
))
conv.updated_at = now
db.commit()
# Publish SSE
sender_name = current_user.name or current_user.email.split('@')[0]
added_name = user.name or user.email.split('@')[0]
_publish_to_conv(db, conv_id, 'member_added', {
'conversation_id': conv_id,
'user_id': user_id,
'name': added_name,
'added_by': sender_name,
})
# Return updated members
members = db.query(ConversationMember).options(
joinedload(ConversationMember.user)
).filter_by(conversation_id=conv_id).all()
members_list = []
for m in members:
if not m.user:
continue
members_list.append({
'user_id': m.user_id,
'name': m.user.name or m.user.email.split('@')[0],
'role': m.role,
'is_online': is_user_online(m.user_id),
})
return jsonify({'members': members_list}), 201
except Exception as e:
db.rollback()
logger.error(f"Error adding member to conversation {conv_id}: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()
# ============================================================
# 8. DELETE /api/conversations/<id>/members/<user_id> — Remove member
# ============================================================
@bp.route('/api/conversations/<int:conv_id>/members/<int:user_id>', methods=['DELETE'])
@login_required
@member_required
def api_conversation_remove_member(conv_id, user_id):
"""Remove member from conversation. Owner can remove anyone, member can leave."""
db = SessionLocal()
try:
conv = db.query(Conversation).filter_by(id=conv_id).first()
if not conv:
return jsonify({'error': 'Konwersacja nie istnieje'}), 404
# Permission check
is_owner = conv.owner_id == current_user.id
is_self = user_id == current_user.id
if not is_owner and not is_self:
return jsonify({'error': 'Brak uprawnień'}), 403
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
db.delete(member)
conv.updated_at = datetime.now()
db.commit()
# Publish SSE
_publish_to_conv(db, conv_id, 'member_removed', {
'conversation_id': conv_id,
'user_id': user_id,
})
return jsonify({'ok': True})
except Exception as e:
db.rollback()
logger.error(f"Error removing member from conversation {conv_id}: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()
# ============================================================
# 9. PATCH /api/conversations/<id>/settings — User settings
# ============================================================
@bp.route('/api/conversations/<int:conv_id>/settings', methods=['PATCH'])
@login_required
@member_required
def api_conversation_settings(conv_id):
"""Update user's conversation settings (mute, archive)."""
db = SessionLocal()
try:
membership = db.query(ConversationMember).filter_by(
conversation_id=conv_id,
user_id=current_user.id,
).first()
if not membership:
return jsonify({'error': 'Brak dostępu do konwersacji'}), 403
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'Nieprawidłowe dane'}), 400
if 'is_muted' in data:
membership.is_muted = bool(data['is_muted'])
if 'is_archived' in data:
membership.is_archived = bool(data['is_archived'])
db.commit()
return jsonify({
'is_muted': membership.is_muted,
'is_archived': membership.is_archived,
})
except Exception as e:
db.rollback()
logger.error(f"Error updating conversation settings {conv_id}: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()
# ============================================================
# 10. POST /api/conversations/<id>/read — Mark as read
# ============================================================
@bp.route('/api/conversations/<int:conv_id>/read', methods=['POST'])
@login_required
@member_required
def api_conversation_mark_read(conv_id):
"""Mark conversation as read for current user."""
db = SessionLocal()
try:
membership = db.query(ConversationMember).filter_by(
conversation_id=conv_id,
user_id=current_user.id,
).first()
if not membership:
return jsonify({'error': 'Brak dostępu do konwersacji'}), 403
now = datetime.now()
membership.last_read_at = now
db.commit()
# Publish SSE to other members
_publish_to_conv(db, conv_id, 'message_read', {
'conversation_id': conv_id,
'user_id': current_user.id,
'last_read_at': now.isoformat(),
}, exclude_user_id=current_user.id)
return jsonify({'ok': True})
except Exception as e:
db.rollback()
logger.error(f"Error marking conversation {conv_id} as read: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()

View File

@ -24,7 +24,7 @@ from message_upload_service import MessageUploadService
# PRIVATE MESSAGES ROUTES
# ============================================================
@bp.route('/wiadomosci')
@bp.route('/wiadomosci/stare')
@login_required
@member_required
def messages_inbox():
@ -128,7 +128,7 @@ def messages_inbox():
db.close()
@bp.route('/wiadomosci/wyslane')
@bp.route('/wiadomosci/stare/wyslane')
@login_required
@member_required
def messages_sent():

View File

@ -1573,7 +1573,7 @@
<!-- Wiadomości - dla zalogowanych członków -->
{% if current_user.is_authenticated %}
<li>
<a href="{{ url_for('messages_inbox') }}" class="nav-link {% if request.endpoint and 'messages' in (request.endpoint or '') %}active{% endif %}" style="position: relative;">
<a href="{{ url_for('messages.conversations_page') }}" class="nav-link {% if request.endpoint and 'messages' in (request.endpoint or '') %}active{% endif %}" style="position: relative;">
Wiadomości
<span class="nav-unread-badge" id="navMessagesBadge" style="display: none; position: absolute; top: -2px; right: -10px; background: #ef4444; color: white; font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; border-radius: 8px; display: none; align-items: center; justify-content: center; padding: 0 4px;">0</span>
</a>
@ -1664,7 +1664,7 @@
</svg>
Mój Panel
</a>
<a href="{{ url_for('messages_inbox') }}" class="user-menu-item user-menu-item-badge">
<a href="{{ url_for('messages.conversations_page') }}" class="user-menu-item user-menu-item-badge">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>