133 lines
4.2 KiB
Python
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)
|