""" Reactions + Pins API Routes ============================ Add/remove emoji reactions to messages and pin/unpin messages. """ import re import logging from flask import jsonify, request from flask_login import login_required, current_user from sqlalchemy.exc import IntegrityError from . import bp from database import ( SessionLocal, ConvMessage, MessageReaction, MessagePin, ConversationMember, User, ) from utils.decorators import member_required from redis_service import publish_event logger = logging.getLogger(__name__) ALLOWED_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '✅'] # ============================================================ # 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): """Publish SSE event to all conversation members.""" members = db.query(ConversationMember).filter_by( conversation_id=conversation_id, ).all() for m in members: publish_event(m.user_id, event_type, data) # ============================================================ # 1. POST /api/messages//reactions — Add reaction # ============================================================ @bp.route('/api/messages//reactions', methods=['POST']) @login_required @member_required def add_reaction(message_id): """Add an emoji reaction to a message.""" db = SessionLocal() try: message = db.query(ConvMessage).get(message_id) if not message: return jsonify({'error': 'Wiadomość nie znaleziona'}), 404 # Verify membership membership = _verify_membership(db, message.conversation_id, current_user.id) if not membership: return jsonify({'error': 'Brak dostępu do konwersacji'}), 403 data = request.get_json(silent=True) or {} emoji = data.get('emoji', '').strip() if emoji not in ALLOWED_REACTIONS: return jsonify({'error': 'Niedozwolona reakcja'}), 400 reaction = MessageReaction( message_id=message_id, user_id=current_user.id, emoji=emoji, ) db.add(reaction) try: db.commit() except IntegrityError: db.rollback() # Duplicate — reaction already exists, that's fine # Build SSE payload user_name = current_user.name or current_user.email.split('@')[0] _publish_to_conv(db, message.conversation_id, 'reaction', { 'message_id': message_id, 'conversation_id': message.conversation_id, 'user_id': current_user.id, 'user_name': user_name, 'emoji': emoji, 'action': 'add', }) return jsonify({'ok': True, 'emoji': emoji}) except Exception as e: db.rollback() logger.error(f"add_reaction error: {e}") return jsonify({'error': 'Błąd serwera'}), 500 finally: db.close() # ============================================================ # 2. DELETE /api/messages//reactions/ — Remove reaction # ============================================================ @bp.route('/api/messages//reactions/', methods=['DELETE']) @login_required @member_required def remove_reaction(message_id, emoji): """Remove an emoji reaction from a message.""" db = SessionLocal() try: message = db.query(ConvMessage).get(message_id) if not message: return jsonify({'error': 'Wiadomość nie znaleziona'}), 404 # Verify membership membership = _verify_membership(db, message.conversation_id, current_user.id) if not membership: return jsonify({'error': 'Brak dostępu do konwersacji'}), 403 reaction = db.query(MessageReaction).filter_by( message_id=message_id, user_id=current_user.id, emoji=emoji, ).first() if not reaction: return jsonify({'error': 'Reakcja nie znaleziona'}), 404 db.delete(reaction) db.commit() # Build SSE payload user_name = current_user.name or current_user.email.split('@')[0] _publish_to_conv(db, message.conversation_id, 'reaction', { 'message_id': message_id, 'conversation_id': message.conversation_id, 'user_id': current_user.id, 'user_name': user_name, 'emoji': emoji, 'action': 'remove', }) return jsonify({'ok': True}) except Exception as e: db.rollback() logger.error(f"remove_reaction error: {e}") return jsonify({'error': 'Błąd serwera'}), 500 finally: db.close() # ============================================================ # 3. POST /api/messages//pin — Pin message # ============================================================ @bp.route('/api/messages//pin', methods=['POST']) @login_required @member_required def pin_message(message_id): """Pin a message in its conversation.""" db = SessionLocal() try: message = db.query(ConvMessage).get(message_id) if not message: return jsonify({'error': 'Wiadomość nie znaleziona'}), 404 # Verify membership membership = _verify_membership(db, message.conversation_id, current_user.id) if not membership: return jsonify({'error': 'Brak dostępu do konwersacji'}), 403 pin = MessagePin( conversation_id=message.conversation_id, message_id=message_id, pinned_by_id=current_user.id, ) db.add(pin) db.commit() _publish_to_conv(db, message.conversation_id, 'message_pinned', { 'message_id': message_id, 'conversation_id': message.conversation_id, 'pinned_by_id': current_user.id, }) return jsonify({'ok': True}) except Exception as e: db.rollback() logger.error(f"pin_message error: {e}") return jsonify({'error': 'Błąd serwera'}), 500 finally: db.close() # ============================================================ # 4. DELETE /api/messages//pin — Unpin message # ============================================================ @bp.route('/api/messages//pin', methods=['DELETE']) @login_required @member_required def unpin_message(message_id): """Unpin a message from its conversation.""" db = SessionLocal() try: message = db.query(ConvMessage).get(message_id) if not message: return jsonify({'error': 'Wiadomość nie znaleziona'}), 404 # Verify membership membership = _verify_membership(db, message.conversation_id, current_user.id) if not membership: return jsonify({'error': 'Brak dostępu do konwersacji'}), 403 pin = db.query(MessagePin).filter_by(message_id=message_id).first() if not pin: return jsonify({'error': 'Wiadomość nie jest przypięta'}), 404 conversation_id = pin.conversation_id db.delete(pin) db.commit() _publish_to_conv(db, conversation_id, 'message_unpinned', { 'message_id': message_id, 'conversation_id': conversation_id, }) return jsonify({'ok': True}) except Exception as e: db.rollback() logger.error(f"unpin_message error: {e}") return jsonify({'error': 'Błąd serwera'}), 500 finally: db.close() # ============================================================ # 5. GET /api/conversations//pins — List pins # ============================================================ @bp.route('/api/conversations//pins', methods=['GET']) @login_required @member_required def list_pins(conv_id): """List all pinned messages in 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 pins = db.query(MessagePin).filter_by( conversation_id=conv_id, ).order_by(MessagePin.created_at.desc()).all() result = [] for pin in pins: msg = pin.message # Sender name sender_name = None if msg and msg.sender: sender_name = msg.sender.name or msg.sender.email.split('@')[0] # Content preview (strip HTML, truncate) content_preview = '' if msg and not msg.is_deleted: content_preview = strip_html(msg.content)[:100] # Pinned-by name pinned_by_name = None if pin.pinned_by: pinned_by_name = pin.pinned_by.name or pin.pinned_by.email.split('@')[0] result.append({ 'id': pin.id, 'message_id': pin.message_id, 'content_preview': content_preview, 'sender_name': sender_name, 'pinned_by_name': pinned_by_name, 'pinned_at': pin.created_at.isoformat() if pin.created_at else None, 'created_at': msg.created_at.isoformat() if msg and msg.created_at else None, }) return jsonify(result) except Exception as e: logger.error(f"list_pins error: {e}") return jsonify({'error': 'Błąd serwera'}), 500 finally: db.close()