Implements 5 endpoints: add/remove emoji reactions (6 allowed emojis, IntegrityError-safe dedup), pin/unpin messages, and list conversation pins. All endpoints verify ConversationMember access and publish SSE events to conversation participants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
309 lines
9.8 KiB
Python
309 lines
9.8 KiB
Python
"""
|
|
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/<message_id>/reactions — Add reaction
|
|
# ============================================================
|
|
|
|
@bp.route('/api/messages/<int:message_id>/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/<message_id>/reactions/<emoji> — Remove reaction
|
|
# ============================================================
|
|
|
|
@bp.route('/api/messages/<int:message_id>/reactions/<path:emoji>', 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/<message_id>/pin — Pin message
|
|
# ============================================================
|
|
|
|
@bp.route('/api/messages/<int:message_id>/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/<message_id>/pin — Unpin message
|
|
# ============================================================
|
|
|
|
@bp.route('/api/messages/<int:message_id>/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/<conv_id>/pins — List pins
|
|
# ============================================================
|
|
|
|
@bp.route('/api/conversations/<int:conv_id>/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()
|