diff --git a/docs/architecture.md b/docs/architecture.md index d719766..59e9617 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -207,6 +207,10 @@ mps_page_session (raw rows) | Discovery ring | `shop.discovery_ring` | Automatic | Auto-computed | | Subscriptions | `shop.subscription_*` | `ribbon-settings` | Off | | Karaoke (vocal isolation) | `shop.unsandbox_*_key` | `unsandbox-settings` | Off | +| Make-an-offer | `shop.offer_enabled` + `product.allow_offers` | `offer-settings` | Off | +| Offer expiration (hours) | `shop.offer_expiration_hours` | `offer-settings` | 48 | +| Offer pay window post-accept (hours) | `shop.offer_acceptance_payment_hours` | `offer-settings` | 24 | +| In-app notifications | (always on, no toggle — every txn email also drops a row) | n/a | On | ## Ticket Index @@ -242,6 +246,9 @@ mps_page_session (raw rows) | Doc | Purpose | |-----|---------| | [Design System](design-system.md) | Design tokens, CSS architecture, component library | +| [Notifications](notifications.md) | In-app notification table, kinds, and per-transition wiring | +| [Auction House](auction-house.md) | MPS-20 — bidding, soft-close, payment deadline | +| [Make Offer](make-offer.md) | MPS-21 — offer state machine, counter rounds, expiry | | [JavaScript](JAVASCRIPT.md) | Client-side JS architecture | | [Karaoke Pipeline](karaoke-pipeline.md) | Vocal isolation: pipeline, on-demand, streaming architecture | | [Sandbox Mode](sandbox-mode.md) | Creative filter system | diff --git a/docs/design-system.md b/docs/design-system.md index 92387a0..0459642 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -245,6 +245,27 @@ All components are documented with live examples at `/styleguide`. The styleguid | Theme System | `#theme` | Theme toggle and dark mode | | Footer | `#footer` | Footer component | +### MPS-20 / MPS-21 components + +| Class | Where | Description | +|---|---|---| +| `.cart-negotiation-card` | `cart.j2` | Soft-green "Offer accepted / Auction won" well at the top of a negotiated cart | +| `.cart-negotiation-deadline` | `cart.j2` | Pay-by countdown line inside the negotiation card | +| `.cart-negotiation-pill` | `cart.j2` | "Offer accepted · quantity locked" pill on line items | +| `.offer-pay-cta-actions` | `offer.j2` | Two-col grid: Cancel left, Pay right (stacks on narrow viewports) | +| `.offer-pay-deadline-note` | `offer.j2` | Buyer's "Payment due in 23 hours, 14 minutes" copy | +| `.offer-pay-link-row` | `offer.j2` | Seller's shareable offer-link with Copy button | +| `.offer-respond-deadline-note` | `offer.j2` | "Respond in 5 days, or this offer auto-expires" | +| `.auction-winner-pay` | `auction.j2` | "You won this auction!" well with countdown + pay CTA | +| `.product-add-disabled-note` | `product.j2` | Caption under disabled Add To Cart when active cart is locked | +| `.shop-offers-page` | `user_offers.j2` + `user_bids.j2` + `shop_offers.j2` | Wider page (max-width 1100px) overriding `.one-column` for the offers/bids inbox tables | +| `.shop-offers-table` | inbox templates | Responsive table; collapses to per-row block list below 720px | +| `.notification-badge` | `base.j2` + `/u/settings` | Danger-color pill showing unread notification count | +| `.notification-row` + `.notification-row-unread` | `user_notifications.j2` | Notification list rows; read rows fade to 0.65 opacity, unread rows get an alert-info background + navy left border | +| `.notification-row-breadcrumbs` | `user_notifications.j2` | Shop › Product › Offer/Auction/Invoice chain under each row | +| `.billing-page` / `.billing-paypal-card` / `.billing-actions` | `billing.j2` | `/billing` redesigned with Grid + tokens (no inline styles) | +| `[data-pay-deadline]` | offer + auction + cart | Convention attribute the shared `static/js/pay-countdown.js` (and inline tickers in `offer.js` / `auction.js`) rewrite once per second with a prose human delta ("in 23 hours, 14 minutes, 8 seconds") matching the server-rendered `ago.human()` fallback | + ## CSS Conventions ### Layout diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..f756c71 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,129 @@ +# 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).