- New docs/notifications.md: schema, kind matrix, breadcrumb walk, per-call-site wiring, UI surfaces, read-but-not-deleted semantics, non-fatal design. - docs/architecture.md feature-toggle matrix extended with make-an-offer (shop + per-product gate), pre-accept expiration window, post-accept pay window, and the always-on notification surface. Related-docs section now links the new notifications doc + the existing auction-house / make-offer state-machine docs. - docs/design-system.md component library extended with every class shipped this offer/auction/notification cycle: cart-negotiation-card + deadline + pill, offer-pay-cta-actions row, auction-winner-pay well, product-add-disabled-note, shop-offers-page width override, notification-badge pill + row + breadcrumbs + read-fade behavior, billing redesign classes, and the [data-pay-deadline] tick convention.
5.5 KiB
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 oncrypto_payment.sales_email_sentso 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(inviews/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 analert-info-bgleft-border accent; read rows stay in the list but fade toopacity: 0.65so the unread set visually dominates. - Mark-read endpoints:
POST /u/notifications/{id}/read— single rowPOST /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).