nordabiz/blueprints/messages/message_routes.py
Maciej Pienczyn f8badfccac
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
feat(messages): search in message content — Enter triggers server-side search with highlighted results
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:54:36 +01:00

538 lines
18 KiB
Python

"""
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/<int:conv_id>/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/<int:conv_id>/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
upload_service = MessageUploadService()
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)
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_view', _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}")
# ============================================================
# 3. EDIT MESSAGE
# ============================================================
@bp.route('/api/messages/<int:message_id>', 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/<int:message_id>', 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/<int:message_id>/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()