- 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.
129 lines
5.5 KiB
Markdown
129 lines
5.5 KiB
Markdown
# 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).
|