06/15 Phase 3 codes

This commit is contained in:
2026-06-15 14:19:14 -04:00
parent c2064b84b4
commit 60526262f0
20 changed files with 802 additions and 7 deletions
+48
View File
@@ -0,0 +1,48 @@
"""Contact masking.
Phone numbers and email addresses in listing bodies / messages are masked
for low-trust senders to push communication through the in-site channel.
Unmasked only once sender reaches TrustTier.trusted or verified_email=True.
Masking is a display-layer concern: raw body stored as-is; masked on render.
For the *sending* side we heuristic-flag high-contact-density messages so
moderators can spot scraping attempts.
"""
import re
from app.models.enums import TrustTier
# patterns
_PHONE_RE = re.compile(
r"(\+?1[\s\-.]?)?"
r"(\(?\d{3}\)?[\s\-.])"
r"\d{3}[\s\-.]\d{4}"
)
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I)
def contact_revealed(user) -> bool:
"""True when this user's contact info may be shown unmasked."""
if user is None:
return False
return (user.email_verified and
user.trust_tier in (TrustTier.trusted, TrustTier.verified))
def mask_body(text: str, reveal: bool = False) -> str:
"""Optionally mask phones, emails, and URLs in a block of text."""
if reveal or not text:
return text
text = _PHONE_RE.sub("[phone hidden]", text)
text = _EMAIL_RE.sub("[email hidden]", text)
text = _URL_RE.sub("[link hidden]", text)
return text
def contact_density(text: str) -> int:
"""Count how many contact signals appear in text (for heuristic flagging)."""
if not text:
return 0
return (len(_PHONE_RE.findall(text))
+ len(_EMAIL_RE.findall(text))
+ len(_URL_RE.findall(text)))
+33
View File
@@ -0,0 +1,33 @@
"""Favorites: toggle save/unsave, check status, list."""
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from app.models.favorite import Favorite
def is_favorited(user_id: int, listing_id: int) -> bool:
return Favorite.query.filter_by(
user_id=user_id, listing_id=listing_id).first() is not None
def toggle_favorite(user_id: int, listing_id: int) -> bool:
"""Add if not present; remove if present. Returns True if now favorited."""
existing = Favorite.query.filter_by(
user_id=user_id, listing_id=listing_id).first()
if existing:
db.session.delete(existing)
db.session.commit()
return False
fav = Favorite(user_id=user_id, listing_id=listing_id)
db.session.add(fav)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return True
def user_favorites(user_id: int, page=1, per_page=20):
return (Favorite.query
.filter_by(user_id=user_id)
.order_by(Favorite.created_at.desc())
.paginate(page=page, per_page=per_page, error_out=False))
+132
View File
@@ -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)