Files
classifieds/app/services/messaging.py
T
2026-06-15 17:43:47 -04:00

133 lines
4.2 KiB
Python

"""Messaging business logic.
Rules:
- Buyer initiates; one conversation per (listing, buyer) pair.
- Seller cannot open a conversation with themselves.
- High contact-density messages are auto-flagged for moderation.
- Notification email sent to recipient on each new message (async in Phase 4+;
for now sent inline — fast enough at low volume).
"""
from datetime import datetime
from sqlalchemy.exc import IntegrityError
from flask import current_app
from app.extensions import db
from app.models.messaging import Conversation, Message
from app.services.contact import contact_density
from app.services.email import send_email
# Bodies with ≥ 3 contact signals get auto-flagged
_FLAG_THRESHOLD = 3
class MessagingError(ValueError):
pass
def get_or_create_conversation(listing, buyer):
"""Return existing conversation or create one. Raises if buyer == seller."""
if listing.user_id == buyer.id:
raise MessagingError("cannot message your own listing")
conv = Conversation.query.filter_by(
listing_id=listing.id, buyer_id=buyer.id).first()
if conv:
return conv, False
conv = Conversation(
listing_id=listing.id,
buyer_id=buyer.id,
seller_id=listing.user_id,
)
db.session.add(conv)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
conv = Conversation.query.filter_by(
listing_id=listing.id, buyer_id=buyer.id).first()
return conv, True
def send_message(conversation, sender, body: str) -> Message:
"""Append a message. Notify recipient. Returns the saved Message."""
body = body.strip()
if not body:
raise MessagingError("message cannot be empty")
if len(body) > 4000:
raise MessagingError("message too long (max 4000 chars)")
# verify sender is a participant
if sender.id not in (conversation.buyer_id, conversation.seller_id):
raise MessagingError("not a participant")
flagged = contact_density(body) >= _FLAG_THRESHOLD
msg = Message(
conversation_id=conversation.id,
sender_id=sender.id,
body=body,
)
db.session.add(msg)
conversation.last_message_at = datetime.utcnow()
db.session.commit()
if flagged:
current_app.logger.warning(
"High contact-density message id=%s conv=%s sender=%s",
msg.id, conversation.id, sender.id)
_notify_recipient(conversation, sender, msg)
return msg
def mark_conversation_read(conversation, reader):
"""Mark all messages NOT sent by reader as read."""
changed = False
for msg in conversation.messages:
if msg.sender_id != reader.id and msg.read_at is None:
msg.mark_read()
changed = True
if changed:
db.session.commit()
def inbox(user, page=1, per_page=20):
"""Conversations where user is buyer or seller, newest first."""
return (Conversation.query
.filter(db.or_(
Conversation.buyer_id == user.id,
Conversation.seller_id == user.id,
))
.order_by(Conversation.last_message_at.desc().nullslast(),
Conversation.created_at.desc())
.paginate(page=page, per_page=per_page, error_out=False))
def total_unread(user) -> int:
"""Total unread message count across all conversations (for nav badge)."""
convs = Conversation.query.filter(
db.or_(
Conversation.buyer_id == user.id,
Conversation.seller_id == user.id,
)
).all()
return sum(c.unread_count(user) for c in convs)
def _notify_recipient(conversation, sender, message):
recipient = (conversation.seller
if sender.id == conversation.buyer_id
else conversation.buyer)
try:
subject = f"New message about: {conversation.listing.title[:60]}"
body = (
f"Hi {recipient.display_name},\n\n"
f"{sender.display_name} sent you a message about "
f'"{conversation.listing.title}":\n\n'
f"{message.body[:500]}\n\n"
f"Reply at: /messages/{conversation.id}\n"
)
send_email(recipient.email, subject, body)
except Exception as exc:
current_app.logger.error("Message notification failed: %s", exc)