make_post_sell/docs/notifications.md
russell@unturf.com 547cc14589
docs: notification system + design-system surface for offers/auctions
- 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.
2026-05-14 14:49:39 -04:00

5.5 KiB
Raw Blame History

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).