""" Message API Routes =================== Send, edit, delete, forward messages within conversations. """ import re import logging from datetime import datetime, timedelta from collections import defaultdict from flask import jsonify, request, url_for from flask_login import login_required, current_user from sqlalchemy import and_ from . import bp from database import ( SessionLocal, User, Conversation, ConversationMember, ConvMessage, MessageAttachment, MessageReaction, UserBlock, ) from utils.decorators import member_required from utils.helpers import sanitize_html from redis_service import publish_event logger = logging.getLogger(__name__) # ============================================================ # HELPERS # ============================================================ def strip_html(text): """Strip HTML tags for plain-text previews.""" return re.sub(r'<[^>]+>', '', text or '').strip() def _verify_membership(db, conversation_id, user_id): """Return ConversationMember or None.""" return db.query(ConversationMember).filter_by( conversation_id=conversation_id, user_id=user_id, ).first() 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 _message_to_json(msg, db): """Serialize a ConvMessage to JSON dict.""" # Sender info sender = None if msg.sender: sender = { 'id': msg.sender_id, 'name': msg.sender.name or msg.sender.email.split('@')[0], } # Reply-to info reply_to = None if msg.reply_to_id and msg.reply_to: reply_sender_name = None if msg.reply_to.sender: reply_sender_name = msg.reply_to.sender.name or msg.reply_to.sender.email.split('@')[0] reply_to = { 'id': msg.reply_to_id, 'sender_name': reply_sender_name, 'content_preview': strip_html(msg.reply_to.content)[:100] if not msg.reply_to.is_deleted else '', } # If deleted, clear sensitive fields if msg.is_deleted: return { 'id': msg.id, 'conversation_id': msg.conversation_id, 'sender': sender, 'content': '', 'reply_to': None, 'reactions': [], 'attachments': [], 'link_preview': None, 'edited_at': None, 'is_deleted': True, 'created_at': msg.created_at.isoformat() if msg.created_at else None, } # Reactions — group by emoji reactions_by_emoji = defaultdict(list) for r in msg.reactions: user_info = {'id': r.user_id} if r.user: user_info['name'] = r.user.name or r.user.email.split('@')[0] reactions_by_emoji[r.emoji].append(user_info) reactions = [ {'emoji': emoji, 'count': len(users), 'users': users} for emoji, users in reactions_by_emoji.items() ] # Attachments attachments = [ { 'id': a.id, 'filename': a.filename, 'mime_type': a.mime_type, 'file_size': a.file_size, 'stored_filename': a.stored_filename, } for a in msg.attachments ] return { 'id': msg.id, 'conversation_id': msg.conversation_id, 'sender': sender, 'content': msg.content, 'reply_to': reply_to, 'reactions': reactions, 'attachments': attachments, 'link_preview': msg.link_preview, 'edited_at': msg.edited_at.isoformat() if msg.edited_at else None, 'is_deleted': False, 'created_at': msg.created_at.isoformat() if msg.created_at else None, } # ============================================================ # 1. GET MESSAGES # ============================================================ @bp.route('/api/conversations//messages', methods=['GET']) @login_required @member_required def get_messages(conv_id): """Get messages for a conversation with cursor-based pagination.""" db = SessionLocal() try: # Verify membership membership = _verify_membership(db, conv_id, current_user.id) if not membership: return jsonify({'error': 'Brak dostępu do konwersacji'}), 403 # Cursor-based pagination before_id = request.args.get('before_id', type=int) limit = 50 query = db.query(ConvMessage).filter_by(conversation_id=conv_id) if before_id: query = query.filter(ConvMessage.id < before_id) query = query.order_by(ConvMessage.created_at.desc()).limit(limit + 1) messages = query.all() has_more = len(messages) > limit if has_more: messages = messages[:limit] result = [_message_to_json(msg, db) for msg in messages] return jsonify({'messages': result, 'has_more': has_more}) except Exception as e: logger.error(f"get_messages error: {e}") return jsonify({'error': 'Błąd serwera'}), 500 finally: db.close() # ============================================================ # 2. SEND MESSAGE # ============================================================ @bp.route('/api/conversations//messages', methods=['POST']) @login_required @member_required def send_message(conv_id): """Send a message to a conversation.""" db = SessionLocal() try: # Verify membership membership = _verify_membership(db, conv_id, current_user.id) if not membership: return jsonify({'error': 'Brak dostępu do konwersacji'}), 403 # Parse content from JSON or form data if request.is_json: data = request.get_json() content = data.get('content', '').strip() reply_to_id = data.get('reply_to_id') else: content = request.form.get('content', '').strip() reply_to_id = request.form.get('reply_to_id', type=int) # Check for files files = request.files.getlist('files') if not content and not files: return jsonify({'error': 'Treść wiadomości jest wymagana'}), 400 # Sanitize content content = sanitize_html(content) if content else '' # Create message message = ConvMessage( conversation_id=conv_id, sender_id=current_user.id, content=content, reply_to_id=reply_to_id, ) db.add(message) db.flush() # get message.id # Handle file attachments if files: try: from message_upload_service import MessageUploadService from flask import current_app upload_service = MessageUploadService(current_app.root_path) for f in files: result = upload_service.save_file(f) if result['success']: attachment = MessageAttachment( conv_message_id=message.id, filename=result['filename'], stored_filename=result['stored_filename'], file_size=result['file_size'], mime_type=result['mime_type'], ) db.add(attachment) except ImportError: logger.warning("MessageUploadService not available, skipping file attachments") # Fetch link preview try: from blueprints.messages.link_preview import fetch_link_preview preview = fetch_link_preview(content) if preview: message.link_preview = preview except ImportError: pass # link_preview module not yet created # Update conversation conversation = db.query(Conversation).get(conv_id) conversation.updated_at = datetime.now() conversation.last_message_id = message.id db.commit() db.refresh(message) # Build response JSON msg_json = _message_to_json(message, db) # Publish SSE event to all members except sender _publish_to_conv(db, conv_id, 'new_message', msg_json, exclude_user_id=current_user.id) # Send email notifications _send_message_email_notifications(db, conv_id, conversation, message, content) # Send Web Push notifications _send_message_push_notifications(db, conv_id, conversation, message, content) return jsonify(msg_json), 201 except Exception as e: db.rollback() logger.error(f"send_message error: {e}") return jsonify({'error': 'Błąd wysyłania wiadomości'}), 500 finally: db.close() def _send_message_email_notifications(db, conv_id, conversation, message, content): """Send email notifications to eligible conversation members.""" try: from email_service import send_email, build_message_notification_email members = db.query(ConversationMember).filter( ConversationMember.conversation_id == conv_id, ConversationMember.user_id != current_user.id, ConversationMember.is_muted == False, # noqa: E712 ).all() sender_name = current_user.name or current_user.email.split('@')[0] preview = strip_html(content)[:200] conv_name = conversation.name or sender_name message_url = url_for('messages.conversations_page', _external=True) + f'?conv={conv_id}' settings_url = url_for('auth.konto_prywatnosc', _external=True) subject_line = f'Nowa wiadomość od {sender_name} — Norda Biznes' for m in members: user = db.query(User).get(m.user_id) if not user or not user.email: continue if user.notify_email_messages is False: continue email_html, email_text = build_message_notification_email( sender_name, conv_name, preview, message_url, settings_url, ) send_email( to=[user.email], subject=subject_line, body_text=email_text, body_html=email_html, email_type='message_notification', user_id=user.id, recipient_name=user.name, ) except ImportError: logger.warning("email_service not available, skipping email notifications") except Exception as e: logger.error(f"Email notification error: {e}") def _send_message_push_notifications(db, conv_id, conversation, message, content): """Send Web Push notifications to eligible conversation members. Gate whitelist (PUSH_USER_WHITELIST) siedzi w send_push() — tu filtrujemy tylko członków konwersacji + muted + notify_push_messages. """ try: from blueprints.push.push_service import send_push members = db.query(ConversationMember).filter( ConversationMember.conversation_id == conv_id, ConversationMember.user_id != current_user.id, ConversationMember.is_muted == False, # noqa: E712 ).all() sender_name = current_user.name or current_user.email.split('@')[0] title = f'Nowa wiadomość od {sender_name}' plain = strip_html(content or '') preview = (plain[:80] + '…') if len(plain) > 80 else plain for m in members: user = db.query(User).get(m.user_id) if not user or user.notify_push_messages is False: continue send_push( user_id=user.id, title=title, body=preview or '(załącznik)', url=f'/wiadomosci?conv={conv_id}', tag=f'conv-{conv_id}', icon='/static/img/favicon-192.png', ) except ImportError: logger.warning("push_service not available, skipping push notifications") except Exception as e: logger.error(f"Push notification error: {e}") # ============================================================ # 3. EDIT MESSAGE # ============================================================ @bp.route('/api/messages/', methods=['PATCH']) @login_required @member_required def edit_message(message_id): """Edit a message (within 24h, sender only).""" db = SessionLocal() try: message = db.query(ConvMessage).get(message_id) if not message: return jsonify({'error': 'Wiadomość nie znaleziona'}), 404 if message.sender_id != current_user.id: return jsonify({'error': 'Nie możesz edytować cudzej wiadomości'}), 403 # Check 24h limit if message.created_at < datetime.now() - timedelta(hours=24): return jsonify({'error': 'Wiadomość jest zbyt stara do edycji (limit 24h)'}), 403 data = request.get_json() new_content = data.get('content', '').strip() if not new_content: return jsonify({'error': 'Treść wiadomości jest wymagana'}), 400 message.content = sanitize_html(new_content) message.edited_at = datetime.now() db.commit() db.refresh(message) msg_json = _message_to_json(message, db) # Publish SSE event _publish_to_conv(db, message.conversation_id, 'message_edited', msg_json) return jsonify(msg_json) except Exception as e: db.rollback() logger.error(f"edit_message error: {e}") return jsonify({'error': 'Błąd edycji wiadomości'}), 500 finally: db.close() # ============================================================ # 4. DELETE MESSAGE (soft) # ============================================================ @bp.route('/api/messages/', methods=['DELETE']) @login_required @member_required def delete_message(message_id): """Soft-delete a message (sender only).""" db = SessionLocal() try: message = db.query(ConvMessage).get(message_id) if not message: return jsonify({'error': 'Wiadomość nie znaleziona'}), 404 if message.sender_id != current_user.id: return jsonify({'error': 'Nie możesz usunąć cudzej wiadomości'}), 403 message.is_deleted = True db.commit() # Publish SSE event _publish_to_conv(db, message.conversation_id, 'message_deleted', { 'id': message.id, 'conversation_id': message.conversation_id, }) return jsonify({'ok': True}) except Exception as e: db.rollback() logger.error(f"delete_message error: {e}") return jsonify({'error': 'Błąd usuwania wiadomości'}), 500 finally: db.close() # ============================================================ # 5. FORWARD MESSAGE # ============================================================ @bp.route('/api/messages//forward', methods=['POST']) @login_required @member_required def forward_message(message_id): """Forward a message to another conversation.""" db = SessionLocal() try: # Find source message message = db.query(ConvMessage).get(message_id) if not message: return jsonify({'error': 'Wiadomość nie znaleziona'}), 404 # Verify user is member of source conversation source_membership = _verify_membership(db, message.conversation_id, current_user.id) if not source_membership: return jsonify({'error': 'Brak dostępu do źródłowej konwersacji'}), 403 data = request.get_json() target_conv_id = data.get('conversation_id') if not target_conv_id: return jsonify({'error': 'Wymagany identyfikator konwersacji docelowej'}), 400 # Verify user is member of target conversation target_membership = _verify_membership(db, target_conv_id, current_user.id) if not target_membership: return jsonify({'error': 'Brak dostępu do konwersacji docelowej'}), 403 # Create new message in target conversation new_message = ConvMessage( conversation_id=target_conv_id, sender_id=current_user.id, content=message.content, ) db.add(new_message) db.flush() # Update target conversation target_conv = db.query(Conversation).get(target_conv_id) target_conv.updated_at = datetime.now() target_conv.last_message_id = new_message.id db.commit() db.refresh(new_message) msg_json = _message_to_json(new_message, db) # Publish SSE to target conversation members _publish_to_conv(db, target_conv_id, 'new_message', msg_json, exclude_user_id=current_user.id) return jsonify(msg_json), 201 except Exception as e: db.rollback() logger.error(f"forward_message error: {e}") return jsonify({'error': 'Błąd przekazywania wiadomości'}), 500 finally: db.close() # ============================================================ # 6. GET /api/messages/search — Search message content # ============================================================ @bp.route('/api/messages/search', methods=['GET']) @login_required @member_required def search_messages(): """Search messages across all user's conversations.""" query = request.args.get('q', '').strip() if len(query) < 3: return jsonify({'results': []}) db = SessionLocal() try: from sqlalchemy import func # Get user's conversation IDs user_conv_ids = [m.conversation_id for m in db.query(ConversationMember.conversation_id).filter_by( user_id=current_user.id ).all()] if not user_conv_ids: return jsonify({'results': []}) # Search in message content (strip HTML via regexp_replace) search_term = f'%{query}%' messages = db.query(ConvMessage).filter( ConvMessage.conversation_id.in_(user_conv_ids), ConvMessage.is_deleted == False, # noqa: E712 func.regexp_replace(ConvMessage.content, '<[^>]+>', '', 'g').ilike(search_term), ).order_by(ConvMessage.created_at.desc()).limit(20).all() results = [] for msg in messages: # Get conversation display name conv = msg.conversation conv_name = conv.name if not conv.is_group: other = [m for m in conv.members if m.user_id != current_user.id] if other and other[0].user: conv_name = other[0].user.name or other[0].user.email.split('@')[0] # Build preview with context around match plain = strip_html(msg.content) lower_plain = plain.lower() idx = lower_plain.find(query.lower()) if idx >= 0: start = max(0, idx - 30) end = min(len(plain), idx + len(query) + 50) preview = ('...' if start > 0 else '') + plain[start:end] + ('...' if end < len(plain) else '') else: preview = plain[:80] results.append({ 'message_id': msg.id, 'conversation_id': msg.conversation_id, 'conversation_name': conv_name, 'sender_name': msg.sender.name if msg.sender else '', 'preview': preview, 'created_at': msg.created_at.isoformat() if msg.created_at else None, }) return jsonify({'results': results}) except Exception as e: logger.error(f"search_messages error: {e}") return jsonify({'results': []}) finally: db.close()