From 60526262f0aaa20804116e6597cc029303580086 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 15 Jun 2026 14:19:14 -0400 Subject: [PATCH] 06/15 Phase 3 codes --- app/__init__.py | 11 ++ app/blueprints/auth/routes.py | 4 +- app/blueprints/listings/routes.py | 4 +- app/blueprints/messaging/__init__.py | 0 app/blueprints/messaging/forms.py | 11 ++ app/blueprints/messaging/routes.py | 122 +++++++++++++++++ app/models/__init__.py | 5 +- app/models/favorite.py | 26 ++++ app/models/messaging.py | 76 +++++++++++ app/services/contact.py | 48 +++++++ app/services/favorites.py | 33 +++++ app/services/messaging.py | 132 +++++++++++++++++++ app/static/style.css | 29 +++++ app/templates/base.html | 5 + app/templates/listings/detail.html | 15 ++- app/templates/messaging/conversation.html | 37 ++++++ app/templates/messaging/favorites.html | 34 +++++ app/templates/messaging/inbox.html | 46 +++++++ app/templates/messaging/start.html | 19 +++ tests/test_smoke.py | 152 +++++++++++++++++++++- 20 files changed, 802 insertions(+), 7 deletions(-) create mode 100644 app/blueprints/messaging/__init__.py create mode 100644 app/blueprints/messaging/forms.py create mode 100644 app/blueprints/messaging/routes.py create mode 100644 app/models/favorite.py create mode 100644 app/models/messaging.py create mode 100644 app/services/contact.py create mode 100644 app/services/favorites.py create mode 100644 app/services/messaging.py create mode 100644 app/templates/messaging/conversation.html create mode 100644 app/templates/messaging/favorites.html create mode 100644 app/templates/messaging/inbox.html create mode 100644 app/templates/messaging/start.html diff --git a/app/__init__.py b/app/__init__.py index 3cdfaea..3f91017 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -52,10 +52,12 @@ def _register_blueprints(app): from app.blueprints.auth.routes import auth_bp from app.blueprints.i18n.routes import i18n_bp from app.blueprints.listings.routes import listings_bp + from app.blueprints.messaging.routes import messaging_bp app.register_blueprint(main_bp) app.register_blueprint(auth_bp) app.register_blueprint(i18n_bp) app.register_blueprint(listings_bp) + app.register_blueprint(messaging_bp) def _register_errorhandlers(app): @@ -75,6 +77,7 @@ def _register_errorhandlers(app): def _register_context(app): from flask_babel import get_locale from flask import request + from flask_login import current_user def merge_query(**overrides): merged = request.args.to_dict() @@ -83,11 +86,19 @@ def _register_context(app): @app.context_processor def inject_globals(): + unread = 0 + if current_user.is_authenticated: + try: + from app.services.messaging import total_unread + unread = total_unread(current_user) + except Exception: + pass return { "get_locale": get_locale, "merge_query": merge_query, "SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"], "TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""), + "unread_count": unread, } diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py index 399b73f..ddc9bf9 100644 --- a/app/blueprints/auth/routes.py +++ b/app/blueprints/auth/routes.py @@ -101,7 +101,7 @@ def verify_email(token): if user_id is None: flash(_("Verification link is invalid or expired."), "danger") return redirect(url_for("auth.login")) - user = User.query.get(user_id) + user = db.session.get(User, user_id) if user is None: abort(404) if not user.email_verified: @@ -136,7 +136,7 @@ def reset_password(token): if user_id is None: flash(_("Reset link is invalid or expired."), "danger") return redirect(url_for("auth.reset_request")) - user = User.query.get(user_id) + user = db.session.get(User, user_id) if user is None: abort(404) form = ResetForm() diff --git a/app/blueprints/listings/routes.py b/app/blueprints/listings/routes.py index 64122b7..419d849 100644 --- a/app/blueprints/listings/routes.py +++ b/app/blueprints/listings/routes.py @@ -109,7 +109,7 @@ def create(): category = None if request.method == "POST" and form.category_id.data: - category = Category.query.get(form.category_id.data) + category = db.session.get(Category, form.category_id.data) if form.validate_on_submit() and category: raw_attrs = _parse_attributes(category) @@ -156,7 +156,7 @@ def edit(listing_id): form.price.data = (listing.price_cents / 100) if listing.price_cents else None form.zip.data = listing.zip - category = Category.query.get(form.category_id.data or listing.category_id) + category = db.session.get(Category, form.category_id.data or listing.category_id) if form.validate_on_submit() and category: raw_attrs = _parse_attributes(category) diff --git a/app/blueprints/messaging/__init__.py b/app/blueprints/messaging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blueprints/messaging/forms.py b/app/blueprints/messaging/forms.py new file mode 100644 index 0000000..3003954 --- /dev/null +++ b/app/blueprints/messaging/forms.py @@ -0,0 +1,11 @@ +"""Messaging form.""" +from flask_wtf import FlaskForm +from wtforms import TextAreaField, SubmitField +from wtforms.validators import DataRequired, Length +from flask_babel import lazy_gettext as _l + + +class MessageForm(FlaskForm): + body = TextAreaField(_l("Message"), + validators=[DataRequired(), Length(1, 4000)]) + submit = SubmitField(_l("Send")) diff --git a/app/blueprints/messaging/routes.py b/app/blueprints/messaging/routes.py new file mode 100644 index 0000000..c095047 --- /dev/null +++ b/app/blueprints/messaging/routes.py @@ -0,0 +1,122 @@ +"""Messaging + Favorites routes.""" +from flask import (Blueprint, render_template, redirect, url_for, + flash, abort, request, jsonify) +from flask_login import login_required, current_user +from flask_babel import gettext as _ + +from app.extensions import db, limiter +from app.models.listing import Listing +from app.models.messaging import Conversation +from app.services import messaging as msvc +from app.services import favorites as fsvc +from app.services.contact import contact_revealed, mask_body +from app.blueprints.messaging.forms import MessageForm + +messaging_bp = Blueprint("messaging", __name__) + + +# ── Inbox ────────────────────────────────────────────────────────────────── +@messaging_bp.route("/messages") +@login_required +def inbox(): + page = request.args.get("page", 1, type=int) + pagination = msvc.inbox(current_user, page=page) + return render_template("messaging/inbox.html", + pagination=pagination, + unread_total=msvc.total_unread(current_user)) + + +# ── Conversation thread ──────────────────────────────────────────────────── +@messaging_bp.route("/messages/", methods=["GET", "POST"]) +@login_required +def conversation(conv_id): + conv = db.session.get(Conversation, conv_id) + if conv is None: + abort(404) + if current_user.id not in (conv.buyer_id, conv.seller_id): + abort(403) + + msvc.mark_conversation_read(conv, current_user) + reveal = contact_revealed(current_user) + form = MessageForm() + + if form.validate_on_submit(): + try: + msvc.send_message(conv, current_user, form.body.data) + return redirect(url_for("messaging.conversation", conv_id=conv.id)) + except msvc.MessagingError as e: + flash(str(e), "danger") + + # mask contact info in messages for low-trust users + masked_messages = [ + (msg, mask_body(msg.body, reveal=reveal)) + for msg in conv.messages + ] + return render_template("messaging/conversation.html", + conv=conv, form=form, + masked_messages=masked_messages, + reveal=reveal, + other=conv.other_party(current_user)) + + +# ── Start conversation from listing detail ───────────────────────────────── +@messaging_bp.route("/listings//contact", methods=["GET", "POST"]) +@login_required +@limiter.limit("20 per hour", methods=["POST"]) +def start_conversation(listing_id): + listing = db.session.get(Listing, listing_id) + if listing is None: + abort(404) + if listing.user_id == current_user.id: + flash(_("You cannot message your own listing."), "warning") + return redirect(url_for("listings.detail", listing_id=listing_id)) + + try: + conv, created = msvc.get_or_create_conversation(listing, current_user) + except msvc.MessagingError as e: + flash(str(e), "danger") + return redirect(url_for("listings.detail", listing_id=listing_id)) + + form = MessageForm() + if form.validate_on_submit(): + try: + msvc.send_message(conv, current_user, form.body.data) + flash(_("Message sent."), "success") + return redirect(url_for("messaging.conversation", conv_id=conv.id)) + except msvc.MessagingError as e: + flash(str(e), "danger") + + return render_template("messaging/start.html", + listing=listing, conv=conv, form=form, + created=created) + + +# ── Favorites ────────────────────────────────────────────────────────────── +@messaging_bp.route("/listings//favorite", methods=["POST"]) +@login_required +def toggle_favorite(listing_id): + listing = db.session.get(Listing, listing_id) + if listing is None: + abort(404) + now_fav = fsvc.toggle_favorite(current_user.id, listing_id) + # AJAX or form fallback + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify(favorited=now_fav) + flash(_("Saved.") if now_fav else _("Removed from saved."), "info") + return redirect(request.referrer or url_for("listings.detail", + listing_id=listing_id)) + + +@messaging_bp.route("/my/favorites") +@login_required +def my_favorites(): + page = request.args.get("page", 1, type=int) + pagination = fsvc.user_favorites(current_user.id, page=page) + return render_template("messaging/favorites.html", pagination=pagination) + + +# ── Unread count API (for nav badge polling) ─────────────────────────────── +@messaging_bp.route("/api/unread") +@login_required +def unread_count(): + return jsonify(unread=msvc.total_unread(current_user)) diff --git a/app/models/__init__.py b/app/models/__init__.py index 1aa7ad6..9d4ea92 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,9 @@ from app.models.trust import TrustEvent from app.models.category import Category from app.models.listing import Listing, ListingImage from app.models.geo import ZipGeo, Metro +from app.models.messaging import Conversation, Message +from app.models.favorite import Favorite __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", - "ListingImage", "ZipGeo", "Metro"] + "ListingImage", "ZipGeo", "Metro", "Conversation", "Message", + "Favorite"] diff --git a/app/models/favorite.py b/app/models/favorite.py new file mode 100644 index 0000000..e8d7f0c --- /dev/null +++ b/app/models/favorite.py @@ -0,0 +1,26 @@ +"""Favorite (saved listing). One row per user+listing pair.""" +from datetime import datetime +from app.extensions import db + + +class Favorite(db.Model): + __tablename__ = "favorites" + __table_args__ = ( + db.UniqueConstraint("user_id", "listing_id", name="uq_fav_user_listing"), + ) + + id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + primary_key=True, autoincrement=True) + user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("users.id"), nullable=False, index=True) + listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("listings.id"), nullable=False, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship("User", + backref=db.backref("favorites", lazy="dynamic")) + listing = db.relationship("Listing", + backref=db.backref("favorited_by", lazy="dynamic")) + + def __repr__(self): + return f"" diff --git a/app/models/messaging.py b/app/models/messaging.py new file mode 100644 index 0000000..7ede49c --- /dev/null +++ b/app/models/messaging.py @@ -0,0 +1,76 @@ +"""Messaging models: Conversation (per listing, per buyer/seller pair) + Message. + +One conversation per (listing, buyer) pair — enforced by unique constraint. +The seller is always listing.user; buyer is any other authenticated user. +Messages are append-only; soft-delete not needed at Phase 3. +""" +from datetime import datetime +from app.extensions import db + + +class Conversation(db.Model): + __tablename__ = "conversations" + __table_args__ = ( + db.UniqueConstraint("listing_id", "buyer_id", name="uq_conv_listing_buyer"), + ) + + id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + primary_key=True, autoincrement=True) + listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("listings.id"), nullable=False, index=True) + buyer_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("users.id"), nullable=False, index=True) + seller_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("users.id"), nullable=False, index=True) + last_message_at = db.Column(db.DateTime, nullable=True, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + listing = db.relationship("Listing", + backref=db.backref("conversations", lazy="dynamic")) + buyer = db.relationship("User", foreign_keys=[buyer_id], + backref=db.backref("bought_conversations", lazy="dynamic")) + seller = db.relationship("User", foreign_keys=[seller_id], + backref=db.backref("sold_conversations", lazy="dynamic")) + messages = db.relationship("Message", back_populates="conversation", + order_by="Message.created_at", + cascade="all, delete-orphan", lazy="selectin") + + def other_party(self, user): + return self.seller if user.id == self.buyer_id else self.buyer + + def unread_count(self, user): + return sum(1 for m in self.messages + if m.sender_id != user.id and m.read_at is None) + + def __repr__(self): + return f"" + + +class Message(db.Model): + __tablename__ = "messages" + + id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + primary_key=True, autoincrement=True) + conversation_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("conversations.id"), + nullable=False, index=True) + sender_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("users.id"), nullable=False) + body = db.Column(db.Text, nullable=False) + read_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + conversation = db.relationship("Conversation", back_populates="messages") + sender = db.relationship("User", + backref=db.backref("sent_messages", lazy="dynamic")) + + @property + def is_read(self): + return self.read_at is not None + + def mark_read(self): + if self.read_at is None: + self.read_at = datetime.utcnow() + + def __repr__(self): + return f"" diff --git a/app/services/contact.py b/app/services/contact.py new file mode 100644 index 0000000..b7cc625 --- /dev/null +++ b/app/services/contact.py @@ -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))) diff --git a/app/services/favorites.py b/app/services/favorites.py new file mode 100644 index 0000000..3f41825 --- /dev/null +++ b/app/services/favorites.py @@ -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)) diff --git a/app/services/messaging.py b/app/services/messaging.py new file mode 100644 index 0000000..f14da37 --- /dev/null +++ b/app/services/messaging.py @@ -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) diff --git a/app/static/style.css b/app/static/style.css index b273397..d700fd7 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -99,3 +99,32 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)} .thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px} .thumb-wrap form{position:absolute;top:2px;right:2px;margin:0} @media(max-width:760px){.browse,.detail{grid-template-columns:1fr}} + +/* --- Phase 3: messaging + favorites --- */ +.msg-link{position:relative} +.badge-count{display:inline-flex;align-items:center;justify-content:center; + background:var(--danger);color:#fff;border-radius:10px;font-size:11px; + min-width:18px;height:18px;padding:0 5px;font-weight:700;vertical-align:middle} +.badge-count.sm{min-width:16px;height:16px;font-size:10px} +.inbox-head{display:flex;align-items:center;gap:10px;margin-bottom:16px} +.inbox-head h2{margin:0} +.conv-list{list-style:none;padding:0;margin:0} +.conv-row{border-bottom:1px solid var(--line);padding:12px 0} +.conv-row a{display:block;text-decoration:none;color:var(--fg)} +.conv-row a:hover{background:var(--bg);border-radius:8px;padding:4px;margin:-4px} +.conv-row.unread .conv-who{font-weight:700} +.conv-meta{display:flex;align-items:center;gap:8px;margin-bottom:2px} +.conv-who{flex:1} +.conv-time{margin-left:auto} +.conv-listing,.conv-preview{margin-top:2px} +.conv-wrap{display:flex;flex-direction:column;gap:16px;max-width:680px;margin:0 auto} +.conv-header h3{margin:8px 0 4px} +.thread{display:flex;flex-direction:column;gap:12px} +.bubble{max-width:80%;padding:12px 14px;border-radius:16px;line-height:1.5} +.bubble.mine{align-self:flex-end;background:var(--brand);color:#fff;border-bottom-right-radius:4px} +.bubble.theirs{align-self:flex-start;background:var(--card);border:1px solid var(--line);border-bottom-left-radius:4px} +.bubble-meta{margin-top:6px;font-size:11px;opacity:.7} +.bubble.mine .bubble-meta{text-align:right} +.reply-box{padding:16px} +.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px; + padding:8px 12px;margin-top:8px} diff --git a/app/templates/base.html b/app/templates/base.html index ff70b01..caf17d9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -16,6 +16,11 @@ {% if current_user.is_authenticated %} {{ _('Post') }} {{ _('My listings') }} + {{ _('Saved') }} + + {{ _('Messages') }} + {% if unread_count %}{{ unread_count }}{% endif %} + {{ current_user.display_name }} {{ _('Sign out') }} {% else %} diff --git a/app/templates/listings/detail.html b/app/templates/listings/detail.html index 44cf836..fe981fb 100644 --- a/app/templates/listings/detail.html +++ b/app/templates/listings/detail.html @@ -52,7 +52,20 @@ {% else %} -

{{ _('Messaging arrives in Phase 3.') }}

+ {% if current_user.is_authenticated %} + + {{ _('Contact seller') }} + +
+ + +
+ {% else %} + + {{ _('Sign in to contact') }} + + {% endif %} {% endif %} diff --git a/app/templates/messaging/conversation.html b/app/templates/messaging/conversation.html new file mode 100644 index 0000000..5508019 --- /dev/null +++ b/app/templates/messaging/conversation.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% from "auth/_macros.html" import field %} +{% block title %}{{ _('Conversation') }}{% endblock %} +{% block content %} +
+
+ ← {{ _('Inbox') }} +

{{ conv.listing.title }}

+ {{ _('with') }} {{ other.display_name }} + {% if not reveal %} +

+ {{ _('Phone numbers, emails, and links are hidden until your account is trusted. Verify your email and build trust to unlock contact info.') }} +

+ {% endif %} +
+ +
+ {% for msg, body in masked_messages %} +
+
{{ body | replace('\n','
') | safe }}
+
+ {{ msg.sender.display_name }} · {{ msg.created_at.strftime('%b %d %H:%M') }} + {% if msg.sender_id == current_user.id and msg.is_read %}· {{ _('Read') }}{% endif %} +
+
+ {% endfor %} +
+ +
+
+ {{ form.hidden_tag() }} + {{ field(form.body) }} + {{ form.submit(class="btn") }} +
+
+
+{% endblock %} diff --git a/app/templates/messaging/favorites.html b/app/templates/messaging/favorites.html new file mode 100644 index 0000000..1488e3c --- /dev/null +++ b/app/templates/messaging/favorites.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}{{ _('Saved listings') }}{% endblock %} +{% block content %} +
+

{{ _('Saved listings') }}

+ {% if not pagination.items %} +

{{ _('No saved listings yet.') }}

+ {% endif %} + +
+ {% if pagination.has_prev %} + ← {{ _('Prev') }} + {% endif %} + {% if pagination.has_next %} + {{ _('Next') }} → + {% endif %} +
+
+{% endblock %} diff --git a/app/templates/messaging/inbox.html b/app/templates/messaging/inbox.html new file mode 100644 index 0000000..dc74bb4 --- /dev/null +++ b/app/templates/messaging/inbox.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}{{ _('Messages') }}{% endblock %} +{% block content %} +
+
+

{{ _('Messages') }}

+ {% if unread_total %}{{ unread_total }}{% endif %} +
+ {% if not pagination.items %} +

{{ _('No conversations yet.') }}

+ {% endif %} + +
+ {% if pagination.has_prev %} + ← {{ _('Prev') }} + {% endif %} + {% if pagination.has_next %} + {{ _('Next') }} → + {% endif %} +
+
+{% endblock %} diff --git a/app/templates/messaging/start.html b/app/templates/messaging/start.html new file mode 100644 index 0000000..806de92 --- /dev/null +++ b/app/templates/messaging/start.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% from "auth/_macros.html" import field %} +{% block title %}{{ _('Contact seller') }}{% endblock %} +{% block content %} +
+

{{ _('Contact seller') }}

+

{{ _('Re:') }} {{ listing.title }}

+ {% if not created %} +

{{ _('You already have a conversation about this listing.') }} + {{ _('View it') }} +

+ {% endif %} +
+ {{ form.hidden_tag() }} + {{ field(form.body) }} + {{ form.submit(class="btn") }} +
+
+{% endblock %} diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 874bbe9..5efd7f9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -37,6 +37,155 @@ def _csrf(html): return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1) + +def _phase3(app): + """Phase 3: messaging, favorites, contact masking.""" + from app.models.user import User + from app.models.plan import Plan + from app.models.category import Category + from app.models.listing import Listing + from app.models.messaging import Conversation, Message + from app.models.enums import TrustTier + from app.services import messaging as msvc + from app.services import favorites as fsvc + from app.services.contact import mask_body, contact_revealed, contact_density + + with app.app_context(): + # create a second user (buyer) + buyer = User(email="buyer@example.com", display_name="Buyer", + email_verified=True) + buyer.set_password("BuyerPass123") + buyer.tier_id = Plan.query.filter_by(slug="free").first().id + buyer.trust_tier = TrustTier.trusted # can see contact info + db.session.add(buyer); db.session.commit() + + seller = User.query.filter_by(email="t@example.com").first() + fs = Category.query.filter_by(slug="for-sale").first() + listing = Listing.query.filter_by(user_id=seller.id).first() + + # --- conversation create --- + conv, created = msvc.get_or_create_conversation(listing, buyer) + assert created and conv.buyer_id == buyer.id + assert conv.seller_id == seller.id + print("conversation created: ok") + + # idempotent: get same conv again + conv2, created2 = msvc.get_or_create_conversation(listing, buyer) + assert conv2.id == conv.id and not created2 + print("conversation idempotent: ok") + + # seller can't message own listing + try: + msvc.get_or_create_conversation(listing, seller) + assert False, "should have raised" + except msvc.MessagingError: + pass + print("self-message blocked: ok") + + # --- send message --- + msg1 = msvc.send_message(conv, buyer, "Hi, is this still available?") + assert msg1.sender_id == buyer.id and msg1.read_at is None + assert conv.last_message_at is not None + print("send message: ok") + + # seller replies + msg2 = msvc.send_message(conv, seller, "Yes, come pick it up tomorrow.") + assert len(conv.messages) == 2 + print("reply: ok") + + # --- unread count --- + # buyer: 1 unread (seller's reply) + # seller: 1 unread (buyer's opening message) + unread_buyer = conv.unread_count(buyer) + unread_seller = conv.unread_count(seller) + assert unread_buyer == 1, unread_buyer + assert unread_seller == 1, unread_seller + print(f"unread count: ok (buyer={unread_buyer}, seller={unread_seller})") + + # --- mark read --- + msvc.mark_conversation_read(conv, buyer) + assert conv.unread_count(buyer) == 0 + print("mark read: ok") + + # --- total unread --- + total = msvc.total_unread(seller) + assert total >= 1 + print(f"total unread: ok (seller sees {total})") + + # --- inbox --- + page = msvc.inbox(buyer) + assert any(c.id == conv.id for c in page.items) + print("inbox: ok") + + # --- contact masking --- + spammy = "Call me at 703-555-0192 or email me@test.com to buy!" + assert contact_density(spammy) == 2 + masked = mask_body(spammy, reveal=False) + assert "703" not in masked and "me@test.com" not in masked + revealed = mask_body(spammy, reveal=True) + assert "703-555-0192" in revealed + print("contact masking: ok") + + # trusted user can reveal; new-trust cannot + assert contact_revealed(buyer) # trust_tier=trusted + email_verified + seller.trust_tier = TrustTier.new + db.session.commit() + assert not contact_revealed(seller) + print("contact reveal gating by trust tier: ok") + + # --- favorites --- + now_fav = fsvc.toggle_favorite(buyer.id, listing.id) + assert now_fav + assert fsvc.is_favorited(buyer.id, listing.id) + page = fsvc.user_favorites(buyer.id) + assert any(f.listing_id == listing.id for f in page.items) + print("favorite add: ok") + + now_fav = fsvc.toggle_favorite(buyer.id, listing.id) + assert not now_fav + assert not fsvc.is_favorited(buyer.id, listing.id) + print("favorite remove: ok") + + # --- empty body rejected --- + try: + msvc.send_message(conv, buyer, " ") + assert False + except msvc.MessagingError: + pass + print("empty message rejected: ok") + + # --- route render checks --- + import re + c = app.test_client(); B = "https://localhost" + def csrf(h): return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', h).group(1) + + # login as buyer + r = c.get("/auth/login", base_url=B) + tok = csrf(r.get_data(as_text=True)) + c.post("/auth/login", base_url=B, + data={"csrf_token": tok, "email": "buyer@example.com", + "password": "BuyerPass123"}, + headers={"Referer": B + "/auth/login"}, follow_redirects=True) + + with app.app_context(): + conv_id = Conversation.query.filter_by( + buyer_id=User.query.filter_by(email="buyer@example.com").first().id + ).first().id + listing_id = Listing.query.first().id + + for path, expect in [ + ("/messages", 200), + (f"/messages/{conv_id}", 200), + ("/my/favorites", 200), + (f"/listings/{listing_id}/contact", 200), + ]: + code = c.get(path, base_url=B).status_code + assert code == expect, f"{path} -> {code}" + print(f"{code} {path}") + + print("Phase 3 route renders: ok") + + def run(): app = create_app() with app.app_context(): @@ -133,6 +282,7 @@ def run(): print("home / healthz / 404: ok") _phase2(app) + _phase3(app) print("\nALL SMOKE CHECKS PASSED") @@ -241,7 +391,7 @@ def _phase2(app): user_id=user.id, status=ListingStatus.active).count() assert n == 1, n assert active_status_after == active_status_before - 1 - assert Listing.query.get(l2.id).status == ListingStatus.expired + assert db.session.get(Listing, l2.id).status == ListingStatus.expired print(f"expiry sweep: ok (status active {active_status_before}->{active_status_after})")