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

129 lines
5.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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