06/15 Phase 3 codes
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user