# MPS Notification System Every transactional email in MPS now pairs with an in-app notification row in `mps_notification`. The user has a permanent inbox even if they never opened the email, and the navbar surfaces an unread count badge. ## Data Model `MpsNotification` (`mps_notification`, migration `5d01b163b805`): | Column | Type | Notes | |---|---|---| | `id` | UUID | primary key | | `user_id` | UUID | recipient (FK `mps_user.id`) | | `shop_id` | UUID nullable | shop this is about (FK `mps_shop.id`) | | `kind` | string(64) | discriminator — see kinds below | | `subject` | string(256) | one-line headline | | `body` | text | denormalized snippet (survives source deletion) | | `link_url` | string(512) | primary click-through | | `offer_id` | UUID nullable | breadcrumb FK | | `auction_id` | UUID nullable | breadcrumb FK | | `invoice_id` | UUID nullable | breadcrumb FK | | `created_timestamp` | bigint | ms | | `updated_timestamp` | bigint | ms | | `read` | bool, default `False` | drives the unread badge | | `read_timestamp` | bigint nullable | when dismissed | Composite index on `(user_id, read, created_timestamp)` for cheap unread-count queries. ## Kinds Defined in `make_post_sell/models/notification.py` (stable strings — templates and tests assume them). Every kind has matching email logic in `lib/mail.py`; the notification persist lives in `lib/notifications.py`. | Kind | Trigger | Recipient(s) | Source FKs | |---|---|---|---| | `offer_received` | buyer opens offer (PENDING) | shop owners | `offer_id` | | `offer_accepted` | auto-accept / seller-accept | buyer | `offer_id` | | `offer_countered` | either party counters | the other party | `offer_id` | | `offer_declined` | seller manually declines | buyer | `offer_id` | | `offer_withdrawn` | buyer withdraws pre-accept | shop owners | `offer_id` | | `offer_buyer_cancelled` | buyer cancels post-accept | shop owners | `offer_id` | | `offer_expired` | offer_tick → EXPIRED | buyer + shop owners | `offer_id` | | `purchase` | cart pays | buyer | `invoice_id` | | `sale` | cart pays | shop owners | `invoice_id` | | `auction_outbid` | new bid bumps prior leader | prior bidder | `auction_id` | | `auction_won` | auction_tick → ENDED + winner | winner | `auction_id` | | `auction_ended_no_winner` | auction_tick → ENDED, no winner | shop owners | `auction_id` | | `auction_cancelled` | reserved (no call site yet) | bidders + watchers | `auction_id` | ## Breadcrumbs `MpsNotification.breadcrumbs` returns an ordered `[(label, url)]` walk back from the notification to its source: Shop → Product → (Offer | Auction | Invoice) The template iterates this for the breadcrumb chain under each row. Each step is optional — only entities whose FK is set get rendered. For offer/auction kinds the product comes from `offer.product` / `auction.product`. For purchase/sale kinds it comes from the first invoice line item. ## Wiring **Orchestration:** `make_post_sell/lib/notifications.py` exports one helper per event class. Each helper accepts either a Pyramid request *or* a SQLAlchemy session — `_resolve_session()` extracts the right one, so the same orchestrator works in views and in tick jobs. **Offer transitions** drop their notifications inline in `views/offer.py` (alongside the existing `send_offer_*_email` sends). Each handler snapshots state pre-action, runs the action, and only emits on the actual transition. **Cart completion** drops `purchase` + `sale` rows from every checkout completion path: - `views/cart.py` ×3 (Stripe / PayPal-create / Adyen) - `views/webhooks.py` ×4 (PayPal capture, approved, Stripe, Adyen) - `lib/crypto_watcher/__init__.py` ×3 (Monero, Dogecoin, confirmed-duplicate path) — gated on `crypto_payment.sales_email_sent` so a rescan can't write duplicates. **Auction transitions** in `lib/auction_tick.py`: - ACTIVE → ENDED with winner → `notify_auction_won` - ACTIVE → ENDED without winner → `notify_auction_ended_no_winner` - bid placement bumps prior leader → `notify_auction_outbid` (in `views/auction.py`) **Offer auto-expiry** in `lib/offer_tick.py` calls `notify_offer_expired` for both pre-accept and post-accept windows. ## UI - **Badge** (`templates/base.j2` + `request.unread_notification_count`, reified): pill next to the profile name in the navbar. Same pill appears on the `/u/settings` "Notifications" button. - **List** at `/u/notifications` (`templates/user_notifications.j2`): newest first, kind label + relative time + subject + body + breadcrumb nav. Unread rows carry an `alert-info-bg` left-border accent; **read rows stay in the list** but fade to `opacity: 0.65` so the unread set visually dominates. - **Mark-read endpoints**: - `POST /u/notifications/{id}/read` — single row - `POST /u/notifications/read-all` — bulk-dismiss all unread ## Read semantics (important) Marking a notification read **does not delete it**. The row stays in the DB and stays in the list, just less prominent. The unread *count* drops because `count_unread_notifications` filters `read == False`. This was explicit feedback from fox: users want a permanent audit log of every event, not a self-emptying inbox. ## Non-fatal by design Every notification persist is wrapped in `_safe_add` — if the DB write fails the exception is logged but the HTTP response (and the corresponding email send) is not interrupted. The same pattern applies to the email-side `_safe_email` wrapper. Notification persist and email send are now **independent**: an SMTP outage cannot block notification creation (and vice versa).