Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
When composing a new message to someone you already have a conversation with, the dedup logic returned the existing conversation without adding the message. Now it creates the message and publishes SSE notification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
811 lines
28 KiB
Python
811 lines
28 KiB
Python
"""
|
|
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 and avatar for 1:1: show the other person
|
|
display_name = conv.display_name
|
|
avatar_url = None
|
|
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]
|
|
if u.avatar_path:
|
|
avatar_url = '/static/' + u.avatar_path
|
|
|
|
# Online status for 1:1 conversations
|
|
is_online = False
|
|
if not conv.is_group:
|
|
other_members = [m for m in conv.members if m.user_id != current_user.id]
|
|
if other_members:
|
|
is_online = is_user_online(other_members[0].user_id)
|
|
|
|
return {
|
|
'id': conv.id,
|
|
'name': conv.name,
|
|
'is_group': conv.is_group,
|
|
'display_name': display_name,
|
|
'avatar_url': avatar_url,
|
|
'is_online': is_online,
|
|
'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,
|
|
'avatar_url': ('/static/' + current_user.avatar_path) if current_user.avatar_path else None,
|
|
}
|
|
|
|
from flask import make_response
|
|
resp = make_response(render_template(
|
|
'messages/conversations.html',
|
|
conversations_json=json.dumps(conversations_data, ensure_ascii=False),
|
|
current_user_json=json.dumps(user_data, ensure_ascii=False),
|
|
))
|
|
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
return resp
|
|
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}:
|
|
# Existing 1:1 — add message if provided
|
|
if message_text:
|
|
now = datetime.now()
|
|
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()
|
|
db.refresh(conv)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
avatar_url = None
|
|
if m.user.avatar_path:
|
|
avatar_url = '/static/' + m.user.avatar_path
|
|
members_list.append({
|
|
'user_id': m.user_id,
|
|
'name': m.user.name or m.user.email.split('@')[0],
|
|
'email': m.user.email,
|
|
'avatar_url': avatar_url,
|
|
'company_name': company_name,
|
|
'role': m.role,
|
|
'last_read_at': m.last_read_at.isoformat() if m.last_read_at else None,
|
|
'last_active_at': m.user.last_active_at.isoformat() if m.user.last_active_at else (m.user.last_login.isoformat() if m.user.last_login 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()
|
|
|
|
|
|
# ============================================================
|
|
# 8. GET /api/users/search — Search users for new message
|
|
# ============================================================
|
|
|
|
@bp.route('/api/users/search')
|
|
@login_required
|
|
@member_required
|
|
def api_users_search():
|
|
"""Search active, verified users by name or email for recipient picker."""
|
|
query = (request.args.get('q') or '').strip()
|
|
if len(query) < 2:
|
|
return jsonify([])
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
search_pattern = f'%{query}%'
|
|
users_with_companies = db.query(
|
|
User.id,
|
|
User.name,
|
|
User.email,
|
|
User.avatar_path,
|
|
Company.name.label('company_name'),
|
|
).outerjoin(
|
|
UserCompanyPermissions,
|
|
UserCompanyPermissions.user_id == User.id
|
|
).outerjoin(
|
|
Company,
|
|
and_(
|
|
Company.id == UserCompanyPermissions.company_id,
|
|
Company.status == 'active',
|
|
)
|
|
).filter(
|
|
User.is_active == True, # noqa: E712
|
|
User.is_verified == True, # noqa: E712
|
|
User.id != current_user.id,
|
|
or_(
|
|
User.name.ilike(search_pattern),
|
|
User.email.ilike(search_pattern),
|
|
),
|
|
).order_by(User.name).limit(10).all()
|
|
|
|
# Deduplicate (user may have multiple company permissions)
|
|
seen = set()
|
|
results = []
|
|
for uid, name, email, avatar_path, company_name in users_with_companies:
|
|
if uid in seen:
|
|
continue
|
|
seen.add(uid)
|
|
results.append({
|
|
'id': uid,
|
|
'name': name or email.split('@')[0],
|
|
'email': email,
|
|
'avatar_url': ('/static/' + avatar_path) if avatar_path else None,
|
|
'company_name': company_name,
|
|
})
|
|
|
|
return jsonify(results)
|
|
finally:
|
|
db.close()
|