# Chat Module

Built-in messaging that lets a buyer and seller discuss a specific listing
inside Student Bazaar. This document describes the **MVP implementation** as
built; the approved data model is defined in
`docs/02_Architecture/Database/04_Chat.md` and is the source of truth for the
schema.

---

## Purpose

All communication between students happens through the platform chat — personal
contact details must not be exchanged before an offer is accepted. A
conversation is always anchored to one listing and the two participants who care
about it.

---

## Scope (MVP)

Implemented:

* One conversation per **buyer + seller + listing** (enforced by a composite
  unique constraint).
* Inbox of the current user's conversations, most-recent activity first.
* Thread view with message bubbles aligned by sender, a listing-context header,
  and a safety banner.
* Sending messages (server-validated, immutable once stored).
* Lazy conversation creation — a conversation is created only when the first
  message passes validation, so no empty threads are left behind.
* Contact-information blocking (best-effort heuristic) and a max-length limit.
* Self-messaging blocked (a seller cannot message themselves about their own
  listing).
* Unread indicators on the inbox, driven by message `read_at` timestamps.
* Light auto-refresh of the open thread via short polling (no page reload).
* Read-only rendering for closed conversations.
* Per-participant free message quota (10 each, counted from the first message),
  with rewarded-ad unlocks (+5) credited independently to each participant.
  Backed by the `{buyer,seller}_messages_used` / `_unlocked_messages` /
  `_ads_watched` columns on `conversations`.

Deferred (kept schema-ready, intentionally **not** surfaced in MVP):

* Premium unlimited messaging (the `is_premium_conversation` flag exists and is
  honoured, but no upgrade flow surfaces it yet).
* Offers / meet-up integration. Until offers exist, the contact-info safety
  warning is always shown.
* WebSockets / SSE, push or browser notifications, typing indicators, presence,
  attachments/images, reactions, message editing or deletion, archiving, and
  moderation tools.

---

## Architecture

Standard module layout — routes stay thin, all business rules live in the
service layer.

```
app/chat/
    __init__.py        # chat_bp blueprint, registered at /app/chat
    constants.py       # MAX_MESSAGE_LENGTH (single source of truth)
    validators.py      # detect_contact_info() heuristic
    services.py        # all conversation/message business logic + DB access
    forms.py           # MessageForm (Flask-WTF, CSRF)
    routes.py          # request handlers only

app/models/chat.py     # Conversation, Message + ConversationStatus, MessageType
app/templates/chat/
    inbox.html          # conversation list + unread indicators
    thread.html         # thread page (header, banner, messages, composer)
    _thread_header.html # reusable listing-context header partial
    _messages.html      # reusable message-list partial (page + poll fragment)
```

The message list is rendered from a single partial (`_messages.html`) used by
both the full thread page and the polling fragment, so there is no duplicate
rendering logic.

---

## Routes

All under the `chat` blueprint, prefix `/app/chat`.

| Method | Path                            | Endpoint              | Purpose                                             |
| ------ | ------------------------------- | --------------------- | --------------------------------------------------- |
| GET    | `/`                             | `chat.inbox`          | Paginated conversation list with unread indicators. |
| GET    | `/<public_id>`                  | `chat.thread`         | Full thread page; marks the conversation read.      |
| GET    | `/<public_id>/messages`         | `chat.thread_messages`| Message-list fragment for auto-refresh; marks read. |
| POST   | `/<public_id>/send`             | `chat.send`           | Send a message in an existing conversation (PRG).   |
| GET    | `/start/<listing_public_id>`    | `chat.start`          | Compose view / redirect to an existing thread.      |
| POST   | `/start/<listing_public_id>`    | `chat.start_send`     | Lazily create the conversation with its first message. |

All routes require authentication. The thread and fragment routes are
participant-only and return **404** for non-participants (existence is not
revealed).

**Entry points:** the listing detail page ("Message Seller") and the inbox.

---

## Service layer (`app/chat/services.py`)

* `get_or_create_conversation(buyer, listing)` — unique per buyer+seller+listing;
  `IntegrityError`-safe under concurrency; blocks self-conversations.
* `start_conversation_with_message(buyer, listing, text)` — validates the text
  **before** creating anything (lazy creation), then creates + sends.
* `get_user_conversations(user, page)` — paginated; eager-loads listing/images
  and both participants' profiles to avoid N+1 queries.
* `get_unread_conversation_ids(user, ids)` — one query returning which
  conversations have unread messages from the other party.
* `get_conversation_for_user_or_404(public_id, user)` — participant-only fetch.
* `send_message(conversation, sender, text)` — enforces participant + active +
  non-empty + length + contact-info rules; updates `last_message_at` and sets
  `first_reply_at` on the second party's first reply.
* `mark_conversation_read(conversation, user)` — bulk-marks the other party's
  unread messages read.
* `close_conversation(conversation, user)` — participant-only; not exposed in the
  MVP UI.
* `find_conversation(buyer, listing)` — read-only lookup.

Validation helpers: `_validate_message_text` (trim/empty/length/contact) and
`detect_contact_info` (emails, URLs, social handles/keywords, phone-number-like
digit runs — best-effort, defence-in-depth only).

---

## Business rules enforced

* Communication occurs only through the built-in chat.
* Personal contact information is blocked before an offer is accepted (always-on
  in MVP, since offers are not yet implemented).
* Offer/message history is never deleted — messages are immutable (soft-delete
  column only).
* A user can be both buyer and seller, but cannot open a conversation on their
  own listing.

---

## Auto-refresh (client)

`initChatPolling()` in `app/static/js/main.js`:

* Polls `chat.thread_messages` every `CHAT_POLL_INTERVAL_MS` (5s).
* Only while `document.visibilityState === "visible"`; stops on `pagehide`.
* Replaces **only** the message list, and only when the fragment actually
  changed (skips redundant DOM work).
* Keeps the view pinned to the newest message only if the user is already within
  `CHAT_SCROLL_BOTTOM_THRESHOLD` px of the bottom; otherwise preserves scroll.
* The message region is an ARIA live log (`role="log"`, `aria-live="polite"`).

No framework is used; the script is unobtrusive and degrades gracefully (the
page still works without JavaScript — it simply does not auto-refresh).
