diff --git a/docs/architecture.md b/docs/architecture.md index 28b0640..3020684 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -229,6 +229,11 @@ mps_page_session (raw rows) | [MPS-14](tickets/mps-14.md) | Shop Environment — Dev & Stage Shops | Complete | | [MPS-15](tickets/mps-15.md) | 21-Day Free Trial | Complete | | [MPS-16](tickets/mps-16.md) | Bring Your Own Bucket (BYOB) | Complete | +| [MPS-17](tickets/mps-17.md) | REST API v1 — HMAC-signed product/content + upload | Open | +| [MPS-18](tickets/mps-18.md) | Karaoke Mode — Diagnose & Fix Vocal Isolation | Open (Broken in prod) | +| [MPS-19](tickets/mps-19.md) | BitTorrent / Magnet Link — Diagnose & Fix Distribution | Open (Broken in prod) | +| [MPS-20](tickets/mps-20.md) | Auction House Mode (eBay-style Bidding) | Proposed | +| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Proposed | ## Related Docs diff --git a/docs/tickets/mps-18.md b/docs/tickets/mps-18.md new file mode 100644 index 0000000..3fac685 --- /dev/null +++ b/docs/tickets/mps-18.md @@ -0,0 +1,176 @@ +# MPS-18: Karaoke Mode — Diagnose & Fix Vocal Isolation Pipeline + +## Status + +**BROKEN in production.** Code paths exist (`lib/karaoke.py`, `lib/voxsplit.c`, +`scripts/backfill_karaoke.py`, on-demand route `POST /karaoke/{product_id}`, +backfill button in shop settings), but end-to-end runs do not produce playable +instrumental + vocal tracks attached to a product. Watch mode toggle never +finds populated karaoke URLs, so listeners never hear it. + +## Problem + +Karaoke depends on a long chain — every link must hold: + +1. Shop has `unsandbox_public_key` + `unsandbox_secret_key` configured +2. Source media reachable via presigned `get_object` from shop bucket +3. Source streams in 64KB chunks to unsandbox `POST /upload` (HMAC-signed + against **empty body** because API skips body parsing for `/upload`) +4. `voxsplit.c` source uploaded the same way (compiled inside container) +5. `POST /execute` references both `upload_id`s, runs voxsplit in zerotrust + container, returns instrumentals + vocals as base64 or upload_ids +6. Output streamed back, written to disk, uploaded to S3 at deterministic + paths under `karaoke/{product_id}/{instrumentals,vocals}.{ext}` +7. Product row updated with karaoke URLs +8. Watch JSON + content/watch templates surface URLs to player.js +9. Player.js toggle swaps audio source between original and instrumentals + +A failure at any link looks identical to a user: toggle does nothing. + +## Diagnosis Required + +Before the fix lands, run a one-shot reproducible test against staging: + +| Probe | Expected | Actual | +|-------|----------|--------| +| Shop has unsandbox creds set | yes | ? | +| `POST /upload` with empty-body HMAC succeeds | 200 + `upload_id` | ? | +| `POST /execute` returns instrumentals + vocals | 200 + bytes | ? | +| S3 write to karaoke path succeeds | object exists in bucket | ? | +| `product.karaoke_instrumentals_url` / `_vocals_url` columns populated | URL strings | ? | +| Watch JSON includes `karaoke_*_url` keys | populated when eligible | ? | +| Player toggle swaps audio src | swap occurs | ? | + +Log every link with structured records (`karaoke.step=upload_input`, +`step=execute`, `step=mirror_output`, `step=db_write`). A silent failure +upstream is the worst kind — fail-loud at every step. + +## Proposal + +### 1. Add karaoke health endpoint + +`GET /s/{shop_id}/karaoke-health` — owner-only, returns JSON: + +```json +{ + "credentials_present": true, + "api_reachable": true, + "last_run_at": "2026-05-09T14:00:00Z", + "last_run_status": "success|failed|never", + "last_error": null, + "products_with_karaoke": 12, + "products_eligible": 47 +} +``` + +Backed by a new `MpsKaraokeRun` model row written for every detached run. + +### 2. Persist run state + +New table `mps_karaoke_run`: + +| Column | Type | Purpose | +|--------|------|---------| +| `id` | UUID | row id | +| `shop_id` | UUID FK | shop | +| `product_id` | UUID FK nullable | product (null for backfill batch) | +| `started_timestamp` | int (ms) | run start | +| `finished_timestamp` | int (ms) nullable | run end | +| `status` | int | 0=running 1=success 2=failed 3=cancelled | +| `step` | str | last completed step name | +| `error_message` | UnicodeText nullable | failure detail | +| `bytes_in` | int nullable | source size | +| `bytes_out` | int nullable | output size | + +Replaces fire-and-forget invisibility. Owner UI lists last 10 runs per shop. + +### 3. Fix the most likely broken link first + +Grep current `lib/karaoke.py` for: + +- `_sign_body` called with empty string for `/upload` — confirm API still + signs empty body for streaming endpoints. If API changed, signing is wrong. +- `_resolve_credentials(shop, app)` — confirm shop creds picked up before + app fallback, BYOB-aware +- `process_karaoke_detached` double-fork — confirm child survives parent + request teardown (no leaked DB sessions, no closed file handles) +- Output retrieval — confirm we pull from `GET /internal/upload/{id}` not + from base64 in `/execute` response (memory blow-up on 3.6 GB inputs) + +### 4. Wire on-demand auto-refresh + +`watch.js` already polls every 10s while karaoke is running. Confirm it +flips on completion. If polling is broken, add SSE or a single targeted +refresh after `MpsKaraokeRun.status` flips to success. + +### 5. Backfill button for shop owners + +`POST /s/{shop_id}/settings` `form_section=backfill-karaoke` exists. +Validate: + +- Concurrency capped at unsandbox account's `max_concurrent_executions` +- Per-product idempotency — skip if `karaoke_instrumentals_url` populated + AND mtime newer than source +- Backfill records one `MpsKaraokeRun` per product, not one per batch + +## Go-to-Market + +| Surface | Action | +|---------|--------| +| `docs/karaoke-pipeline.md` | Update with run-state model + health endpoint | +| `docs/architecture.md` | Add karaoke run table to feature toggle matrix | +| `/styleguide` | Karaoke health card pattern (status + last run + error) | +| `~/git/www.makepostsell.com/index.html` | Feature card: "Vocal isolation, on-demand or batched. No ML deps. Spectral mid-side Wiener masking." | +| `~/git/www.makepostsell.com/pricing.html` | Mention karaoke as included on all paid plans (depends on unsandbox account) | +| Shop settings UI | Health card next to backfill button — show current state without surprise | + +## Files + +| File | Change | +|------|--------| +| `make_post_sell/models/karaoke_run.py` | New: `MpsKaraokeRun` | +| `make_post_sell/models/__init__.py` | Import `MpsKaraokeRun` | +| `make_post_sell/models/meta.py` | Register in `CLASS_TO_TABLE` | +| `make_post_sell/lib/karaoke.py` | Write `MpsKaraokeRun` rows at every step; harden empty-body HMAC; verify creds at entry | +| `make_post_sell/views/shop.py` | `karaoke_health` route handler | +| `make_post_sell/routes.py` | Add `/s/{shop_id}/karaoke-health` | +| `make_post_sell/templates/shop_settings.j2` | Health card; recent runs list | +| `make_post_sell/static/js/watch.js` | Confirm 10s poll triggers reload on completion; fall back to single refresh on `MpsKaraokeRun` flip | +| `make_post_sell/scripts/alembic/versions/XXXX_mps_karaoke_run_table.py` | Migration with `_table_exists` guard | +| `make_post_sell/tests/test_models.py` | `MpsKaraokeRun` unit tests | +| `make_post_sell/tests/test_integration.py` | End-to-end run record write/read; backfill idempotency | +| `make_post_sell/tests/test_functional.py` | Health endpoint auth, JSON shape, settings page renders card | + +## Tests + +### Unit (`test_models.py`) + +- `MpsKaraokeRun.is_running` / `is_success` / `is_failed` properties +- `MpsKaraokeRun.duration_ms` calculated from start/finish timestamps +- `Shop.karaoke_eligible` returns `True` only when both unsandbox keys set +- `Shop.karaoke_health_summary` returns dict matching health endpoint shape + +### Integration (`test_integration.py`) + +- Backfill skips products with fresh karaoke URLs (idempotency) +- Failed run leaves `status=failed` and populated `error_message` +- Concurrent runs respect shop unsandbox concurrency limit +- BYOB: outputs land in shop's bucket, not MPS default + +### Functional (`test_functional.py`) + +- `GET /s/{shop_id}/karaoke-health` — anon 403, non-owner 403, owner 200 JSON +- Health JSON includes all keys above; numbers match seeded fixtures +- Shop settings page renders karaoke health card with correct current status +- `POST /karaoke/{product_id}` — anon 403, owner 200 + run row created +- Backfill button POST creates run rows for eligible products only + +## Verification + +1. `source vars.sh && make test` — all pass +2. Local dev: configure unsandbox keys, upload an audio product, click karaoke + button, watch run row write, watch URLs populate, watch player toggle work +3. Health endpoint reflects each step (running → success) +4. Force a failure (revoke creds mid-run) — `status=failed`, error captured +5. Push → CI green → deploy → check `https://my.makepostsell.com/version` +6. Bump GIT_HASH, push, verify diff --git a/docs/tickets/mps-19.md b/docs/tickets/mps-19.md new file mode 100644 index 0000000..1b4c7dd --- /dev/null +++ b/docs/tickets/mps-19.md @@ -0,0 +1,187 @@ +# MPS-19: BitTorrent / Magnet Link Mode — Diagnose & Fix Distribution + +## Status + +**BROKEN in production.** Schema and code paths exist: + +- `mps_shop.torrent_enabled` (Boolean) — shop-level toggle +- `mps_product.torrent_opt_in` (Boolean) — per-product opt-in +- `mps_product.torrent_magnet_link` + `torrent_file_url` columns +- `make_post_sell/lib/torrent.py` — bundle + .torrent generation pipeline +- Migrations `0ee5654cfe7d_*` and `c0236e351476_torrent_distribution_support.py` +- Backfill route `/s/{shop_id}/torrent-backfill-status` + +What does **not** work end-to-end: opting a public product in does not yield +a populated magnet link or a `bundle.torrent` reachable on the CDN. Listeners +never see a magnet button. Files never seed. + +## Problem + +The torrent path has more moving parts than karaoke. Each is a candidate +break point: + +1. Opt-in only fires for `visibility=1` (public). Off for unlisted/private. +2. Bundle assembly downloads source + thumbnails + description.md from S3 + into a temp dir — a single missing fetch leaves an incomplete bundle +3. `torf.Torrent` builds multi-file `.torrent` with web seeds (BEP 19) and + trackers (BEP 12) — wrong web-seed URL = no seeders forever +4. `.torrent` upload back to S3 at `{s3_path}/bundle.torrent` — must use + shop-aware client, not MPS default +5. Magnet link derived from info hash + display name + trackers + web seeds +6. DB write of `torrent_magnet_link` + `torrent_file_url` is the user-visible + commit — if it fails silently, every step before it was wasted +7. Free content vs paid product bundles differ — paid never includes the + paid file (preview only). A copy-paste defect here = piracy via opt-in + +## Diagnosis Required + +Same fail-loud principle as MPS-18. Add an `MpsTorrentRun` row per attempt. + +| Probe | Expected | Actual | +|-------|----------|--------| +| Shop `torrent_enabled=True` | yes | ? | +| Product `torrent_opt_in=True` AND `visibility=1` | yes | ? | +| Bundle dir contains source + thumbs + description.md | full set | ? | +| `.torrent` bytes generated by `torf.Torrent` | non-zero | ? | +| `.torrent` uploaded to shop bucket at `{s3_path}/bundle.torrent` | object exists | ? | +| Web seed URL in `.torrent` matches CDN URL of source file | exact match | ? | +| `torrent_magnet_link` column populated with `magnet:?xt=urn:btih:...` | string | ? | +| `torrent_file_url` column populated with CDN URL of `.torrent` | string | ? | +| Watch JSON / content / product templates surface magnet button | rendered | ? | +| Magnet link opens in BitTorrent client and pulls bundle | swarm fetches bundle | ? | + +## Proposal + +### 1. Persist run state — `MpsTorrentRun` table + +| Column | Type | Purpose | +|--------|------|---------| +| `id` | UUID | row id | +| `shop_id` | UUID FK | shop | +| `product_id` | UUID FK | product | +| `started_timestamp` | int (ms) | run start | +| `finished_timestamp` | int (ms) nullable | run end | +| `status` | int | 0=running 1=success 2=failed 3=cancelled | +| `step` | str | last completed step | +| `error_message` | UnicodeText nullable | failure detail | +| `info_hash` | str(40) nullable | hex info hash on success | +| `bundle_bytes` | int nullable | total bundle size | + +### 2. Verify paid-product safety + +Add explicit unit test: paid product (`is_sellable=True`) bundle includes +`preview.{ext}` and never the source file. Bundle assembly must read +`product.is_sellable` and pick the right path. Single boolean defect here +ships the paid file as freely-seedable. Test guards this. + +### 3. Web seed URL correctness + +The web seed URL inside `.torrent` is the **CDN** URL (public, no +presigning). For shops using BYOB, that's `request.shop_cdn_endpoint`, +not the MPS default CDN. Confirm `lib/torrent.py` uses the shop-aware +endpoint when building the torrent metadata. + +### 4. Backfill UX + +Existing `/s/{shop_id}/torrent-backfill-status` is a status endpoint. +Add a backfill button to settings that POSTs `form_section=backfill-torrent` +into `views/shop.py`. Concurrency-bound. Idempotent (skip products with +populated `torrent_magnet_link` + fresh mtime). + +### 5. UI surface + +Magnet button on: + +- `templates/product.j2` — under download/CTA area +- `templates/content.j2` — same position +- `templates/snippets/related_content.j2` — small magnet icon next to free items +- Watch mode (`watch.js` `updatePageContent`, `watch.py` JSON) + +Button visibility rules: + +| Visibility | Sellable | torrent_opt_in | Show magnet? | +|------------|----------|----------------|--------------| +| public (1) | False (free) | True | yes | +| public (1) | True (paid) | True | yes — bundle is preview only | +| unlisted (2) | any | any | **no** | +| private (0) | any | any | **no** | + +### 6. Tracker / web-seed strategy + +Default `DEFAULT_TRACKERS` list in `lib/torrent.py` is acceptable but +hardcoded — make it shop-configurable via a new column +`mps_shop.torrent_trackers` (Text, newline-separated). NULL = use defaults. +Sites that want their own tracker in front (private tracker, CDN-friendly +tracker) can override. + +## Go-to-Market + +| Surface | Action | +|---------|--------| +| `docs/torrent-distribution.md` | New: end-to-end pipeline doc with dot diagram (mirror karaoke-pipeline.md) | +| `docs/architecture.md` | Add torrent run table + opt-in matrix to feature toggle matrix | +| `/styleguide` | Magnet button component + torrent health card | +| `~/git/www.makepostsell.com/index.html` | Feature card: "BitTorrent distribution. Web seeds. No friction. Free your bandwidth bill." | +| `~/git/www.makepostsell.com/pricing.html` | Note torrent included on plans that include uploads | +| Shop settings UI | Torrent health card + tracker config + backfill button | + +## Files + +| File | Change | +|------|--------| +| `make_post_sell/models/torrent_run.py` | New: `MpsTorrentRun` | +| `make_post_sell/models/__init__.py` | Import | +| `make_post_sell/models/meta.py` | Register | +| `make_post_sell/models/shop.py` | Add `torrent_trackers` (Text, nullable) | +| `make_post_sell/lib/torrent.py` | Persist run rows; shop-aware CDN; paid/free bundle branching with explicit tests | +| `make_post_sell/views/shop.py` | `backfill-torrent` form section handler; torrent health endpoint | +| `make_post_sell/views/product.py` | Trigger torrent generation on opt-in flip + visibility=public | +| `make_post_sell/views/watch.py` | Surface `torrent_magnet_link` + `torrent_file_url` in watch JSON | +| `make_post_sell/routes.py` | Add `/s/{shop_id}/torrent-health` | +| `make_post_sell/templates/shop_settings.j2` | Torrent health card + tracker config + backfill button | +| `make_post_sell/templates/product.j2` | Magnet button (server-rendered) | +| `make_post_sell/templates/content.j2` | Magnet button (server-rendered) | +| `make_post_sell/templates/snippets/related_content.j2` | Small magnet icon | +| `make_post_sell/static/js/watch.js` | `updatePageContent` swaps magnet href on SPA nav | +| `make_post_sell/scripts/alembic/versions/XXXX_torrent_runs_and_trackers.py` | Migration | +| `make_post_sell/tests/test_models.py` | `MpsTorrentRun` + visibility/opt-in matrix | +| `make_post_sell/tests/test_integration.py` | End-to-end bundle generation; paid vs free; BYOB | +| `make_post_sell/tests/test_functional.py` | Magnet button render rules; backfill POST; health endpoint auth | + +## Tests + +### Unit (`test_models.py`) + +- `Product.show_magnet_button` matrix — 8 combinations (visibility × sellable × opt_in) +- `Product.torrent_bundle_paths` returns preview path for paid, source for free +- `MpsTorrentRun` status helpers +- `Shop.effective_trackers` — falls back to `DEFAULT_TRACKERS` when column NULL + +### Integration (`test_integration.py`) + +- Free content opt-in: bundle includes source + thumbs + description.md +- Paid product opt-in: bundle includes **preview** + thumbs + description.md, **never** source +- Web seed URL inside `.torrent` matches shop's CDN endpoint (BYOB-aware) +- Magnet link round-trip: `torf` parses our generated magnet and returns same info hash +- Backfill skips products with populated `torrent_magnet_link` + fresh mtime +- Failed run leaves status=failed + populated error_message; does not corrupt product columns + +### Functional (`test_functional.py`) + +- Toggling `torrent_opt_in` on a public product fires generation (run row created) +- Toggling on private/unlisted product does **not** fire +- Magnet button renders on public product, not on private/unlisted +- Backfill button POST creates run rows for eligible products only +- Anon `GET /s/{shop_id}/torrent-health` → 403; owner → 200 JSON +- Watch JSON includes `torrent_magnet_link` + `torrent_file_url` when populated + +## Verification + +1. `source vars.sh && make test` — all pass +2. Local dev: enable `torrent_enabled` on shop, opt-in a public free product, + confirm `bundle.torrent` lands in bucket and magnet link populates +3. Open magnet link in a real BitTorrent client, watch it pull bundle from + web seeds (no peers needed for first download) +4. Repeat for paid product — confirm bundle has preview file, **not** source +5. Force a failure (revoke S3 creds mid-run) — run row reflects failure +6. Push → CI green → deploy → bump GIT_HASH diff --git a/docs/tickets/mps-20.md b/docs/tickets/mps-20.md new file mode 100644 index 0000000..9e7c5f2 --- /dev/null +++ b/docs/tickets/mps-20.md @@ -0,0 +1,300 @@ +# MPS-20: Auction House Mode (eBay-style Bidding) + +## Status + +**PROPOSED — does not exist.** Today every product has a fixed `price` column. +No code path supports rising-price bidding, reserve prices, soft-close +extensions, or proxy bidding. + +## Why This Belongs in MPS + +MPS already owns: + +- Product lifecycle (visibility, ownership, downloads) +- Shop settings + form-section pattern +- Cart + checkout pipeline (Stripe / PayPal / crypto / gift cards) +- Comments + watch mode + SPA navigation +- Email notifications + +An auction is a product whose price function is `max(bids)` instead of a +constant. We extend, not rebuild. Same checkout. Same payouts. Same fees. + +## Modes + +A shop owner toggles per product: + +| `pricing_mode` | Behavior | +|----------------|----------| +| 0 (fixed) | Today's behavior — buy now at `price` | +| 1 (auction) | Rising bids until end timestamp; winner pays high bid | +| 2 (auction + buy_now) | Both paths live; buy-now ends the auction immediately | +| 3 (offer) | See MPS-21 — make-an-offer mode | + +This ticket covers modes 1 and 2. MPS-21 covers mode 3. + +## State Machine + +``` + ┌──────────┐ + │ draft │ owner editing, not visible + └────┬─────┘ + │ schedule + ▼ + ┌──────────┐ + │scheduled │ visible, countdown to start + └────┬─────┘ + │ start_timestamp passes + ▼ + ┌──────────┐ bid placed + │ active │ ◄──────────┐ + └────┬─────┘ │ + │ │ + end_timestamp │ ┌────────────┴────────┐ + passes │ │ soft-close: bid in │ + no buy_now │ │ last N seconds │ + ▼ │ extends end by N │ + ┌──────────┴┐ + │ ended │ winner determined; payment window opens + └────┬──────┘ + │ winner pays via cart + ▼ + ┌──────────┐ + │ settled │ funds captured, product transferred + └──────────┘ + +Side branches: + active --buy_now-→ ended (winner = buy_now buyer; bid refunds n/a) + ended --no-pay-→ relisted or default-to-second-bidder (configurable) + any --cancel-→ cancelled (owner action; pre-active only without admin override) +``` + +## Models + +### `MpsAuction` + +| Column | Type | Purpose | +|--------|------|---------| +| `id` | UUID | row id | +| `product_id` | UUID FK unique | one auction per product | +| `shop_id` | UUID FK | shop | +| `state` | int | 0=draft 1=scheduled 2=active 3=ended 4=settled 5=cancelled | +| `start_timestamp` | int (ms) | when bidding opens | +| `end_timestamp` | int (ms) | when bidding closes (extended by soft-close) | +| `original_end_timestamp` | int (ms) | scheduled close; never updated | +| `start_price_in_cents` | int | minimum opening bid | +| `reserve_price_in_cents` | int nullable | hidden floor; below = no winner | +| `buy_now_price_in_cents` | int nullable | mode 2 only | +| `bid_increment_in_cents` | int | min step between bids; default 5% of current high | +| `soft_close_seconds` | int | default 60; bid in last N → extend end by N | +| `winner_user_id` | UUID FK nullable | populated when state=ended | +| `winning_bid_id` | UUID FK nullable | populated when state=ended | +| `payment_deadline_timestamp` | int (ms) nullable | winner-pay-by; default end + 48h | +| `currency` | str(3) | inherits shop default | + +### `MpsBid` + +| Column | Type | Purpose | +|--------|------|---------| +| `id` | UUID | row id | +| `auction_id` | UUID FK | auction | +| `bidder_user_id` | UUID FK | bidder | +| `amount_in_cents` | int | actual bid amount (proxy bidding fills up) | +| `max_proxy_in_cents` | int | bidder's secret max; proxy auto-bids up to this | +| `created_timestamp` | int (ms) | bid placement | +| `outbid_timestamp` | int (ms) nullable | when this bid was passed | +| `is_winning` | bool | true for current high bid only | + +### `MpsAuctionWatcher` + +| Column | Type | Purpose | +|--------|------|---------| +| `id` | UUID | row id | +| `auction_id` | UUID FK | auction | +| `user_id` | UUID FK | watcher | +| `created_timestamp` | int (ms) | when added | +| `notify_on_outbid` | bool | default True | +| `notify_on_ending_soon` | bool | default True (1h, 5min) | + +## Views / Routes + +``` +GET /a/{auction_id} auction page (live) +GET /a/{auction_id}.json auction state poll (1s for active, 10s for scheduled) +POST /a/{auction_id}/bid place bid (form_section=bid) +POST /a/{auction_id}/buy-now buy-now (mode 2) +POST /a/{auction_id}/watch toggle watcher +POST /a/{auction_id}/cancel owner cancel (pre-active only) +GET /s/{shop_id}/auctions owner's auction dashboard +POST /s/{shop_id}/products/{id}/auction create or update auction (form_section=auction) +GET /u/{user_id}/auctions user's bids + watches +``` + +State transitions (`scheduled → active → ended`) run via a periodic job — +add a tick to the existing background task system (or a new +`scripts/auction_tick.py` cron). On settled-by-payment, the cart's normal +post-payment hooks already do product transfer; no new path. + +## Soft-Close Algorithm + +```python +def place_bid(auction, bidder, amount, max_proxy): + now = now_ms() + if auction.state != STATE_ACTIVE: + raise BidRejected("auction not active") + if amount < (auction.current_high + auction.bid_increment_in_cents): + raise BidRejected("bid too low") + bid = MpsBid(auction_id=auction.id, bidder_user_id=bidder.id, + amount_in_cents=amount, max_proxy_in_cents=max_proxy) + DBSession.add(bid) + _resolve_proxy(auction, bid) # auto-bid against existing max_proxy bids + if (auction.end_timestamp - now) < (auction.soft_close_seconds * 1000): + auction.end_timestamp = now + (auction.soft_close_seconds * 1000) + notify_outbid(auction) # email + watcher inbox +``` + +Pure-function `_resolve_proxy` — unit-testable in isolation, no DB hit +beyond bid insert. Tarjan-style: handle the bid + proxy chain in a single +pass (no O(N²) loop over all bids). + +## Cart Integration + +When `state=ended` and `winner_user_id` matches the request user, the +auction product appears in cart at `winning_bid.amount_in_cents`. Existing +checkout (`views/cart.py`) handles payment + transfer. After cart success +hook, set `auction.state=4` (settled). + +If `payment_deadline_timestamp` passes without payment, run a settle-task: + +- Default: relist to second-highest bidder at their bid price (configurable + `auction_default_to_second_bidder` shop setting) +- Alternative: cancel + return to seller (auctioneer chooses) + +## Form Section + +Add `auction` to `form_section` routing in `views/product.py`: + +| field | purpose | +|-------|---------| +| `pricing_mode` | 0/1/2/3 | +| `auction_start_timestamp` | datetime-local input → ms | +| `auction_end_timestamp` | datetime-local input → ms | +| `start_price` | dollars → cents | +| `reserve_price` | dollars → cents (nullable) | +| `buy_now_price` | dollars → cents (mode 2 only) | +| `bid_increment` | dollars → cents | +| `soft_close_seconds` | int | + +## Notifications + +Email + on-site: + +- Bid received (to seller) +- Outbid (to previous high bidder) +- Auction ending in 1h / 5min (to watchers + bidders) +- Won — pay by `{deadline}` (to winner) +- Lost (to underbidders) +- Payment received / settled (to seller + winner) + +## Anti-Abuse + +- Rate limit `POST /a/{id}/bid` — 1 bid per bidder per second +- Min bidder account age (configurable per shop, default 0 = open) +- Min cart history (configurable, default 0) +- Block self-bid (bidder_user_id == seller_user_id) at form layer +- Reserve-not-met UX: show "reserve not met" indicator without leaking + reserve price + +## Go-to-Market + +| Surface | Action | +|---------|--------| +| `docs/auction-house.md` | New: state machine + dot diagram + APIs | +| `docs/architecture.md` | Add auction tables + pricing_mode to feature matrix | +| `/styleguide` | Bid form, countdown clock, reserve indicator, watcher toggle, soft-close pulse | +| `~/git/www.makepostsell.com/index.html` | Feature card: "Run auctions. Reserve prices, soft-close, proxy bids. Same payments, same fees." | +| `~/git/www.makepostsell.com/pricing.html` | Auction mode listed in plan includes | +| Marketing copy | Position: commission-free auctions vs. eBay's 13.25% final value fee | + +## Files + +| File | Change | +|------|--------| +| `make_post_sell/models/auction.py` | New: `MpsAuction`, `MpsBid`, `MpsAuctionWatcher` | +| `make_post_sell/models/__init__.py` | Imports | +| `make_post_sell/models/meta.py` | Register all three tables | +| `make_post_sell/models/product.py` | Add `pricing_mode` (Integer, default=0), `auction` relationship | +| `make_post_sell/models/cart.py` | Auction-product cart line item handling | +| `make_post_sell/views/auction.py` | New: all auction route handlers | +| `make_post_sell/views/product.py` | `form_section=auction` handler | +| `make_post_sell/views/cart.py` | Recognize auction-won line item; price = winning bid | +| `make_post_sell/views/shop.py` | `auctions_dashboard` view | +| `make_post_sell/lib/auction.py` | Pure-function bid resolution + proxy + soft-close logic | +| `make_post_sell/lib/auction_tick.py` | Scheduled state transitions + ending-soon notifications | +| `make_post_sell/lib/email_notifications.py` | Auction email templates | +| `make_post_sell/routes.py` | All auction routes | +| `make_post_sell/templates/auction.j2` | Live auction page | +| `make_post_sell/templates/auctions_dashboard.j2` | Owner dashboard | +| `make_post_sell/templates/product_edit.j2` | Auction config form section | +| `make_post_sell/templates/styleguide.j2` | Component examples | +| `make_post_sell/static/js/auction.js` | Live countdown, bid form, soft-close pulse, JSON poll | +| `make_post_sell/static/css/common.css` | Auction component styles (tokens-only) | +| `make_post_sell/scripts/alembic/versions/XXXX_auction_tables.py` | Migration with `_table_exists` + `_column_exists` guards | +| `make_post_sell/scripts/auction_tick.py` | CLI entry point for cron | +| `make_post_sell/tests/test_models.py` | Unit tests | +| `make_post_sell/tests/test_integration.py` | Integration tests | +| `make_post_sell/tests/test_functional.py` | Functional tests | + +## Tests + +### Unit (`test_models.py`) + +- `MpsAuction` state helpers (`is_active`, `is_ended`, `time_remaining_ms`) +- `MpsAuction.current_high` returns max bid amount +- `MpsAuction.reserve_met` boolean +- `MpsBid.is_winning` flag flips correctly +- `lib/auction._resolve_proxy` — proxy bidding outcomes: + - solo proxy bid: bid recorded at start_price + increment + - two competing proxies: high proxy wins at low_proxy + increment + - chained proxies: terminate cleanly, no infinite loop +- Soft-close: bid > N sec from end → no extension; bid < N sec → end pushed +- `Product.is_auction` / `Product.is_buy_now_only` based on `pricing_mode` + +### Integration (`test_integration.py`) + +- Place bid below increment → rejected; above → accepted; outbid email sent +- Buy-now in mode 2 ends auction, sets winner, refunds bid hold (n/a — no holds) +- Auction ends without bids meeting reserve → no winner; `state=ended`, + `winner_user_id IS NULL` +- Winner pays via cart → `state=settled`; product transfer fires +- Winner does not pay by deadline → second bidder gets the option + (configurable per shop) +- Watcher receives ending-soon email at 1h and 5min before end +- BYOB: auction cover/preview images use shop bucket +- `auction_tick` job: scheduled → active when `start_timestamp` passes; + active → ended when `end_timestamp` passes + +### Functional (`test_functional.py`) + +- `GET /a/{id}` renders for anon, signed-in non-bidder, owner, current high bidder +- `POST /a/{id}/bid` — anon → redirect to login; signed in → 200 +- `POST /a/{id}/bid` rate limit: 2 bids in 1s → second 429 +- `POST /a/{id}/bid` self-bid → 403 with flash message +- `POST /a/{id}/buy-now` — mode 1 → 404; mode 2 → 200, auction ends +- `POST /a/{id}/watch` toggle inserts/removes `MpsAuctionWatcher` row +- `POST /a/{id}/cancel` — pre-active by owner → 200; active by owner → 403 +- Owner dashboard `/s/{shop_id}/auctions` lists all states with counts +- Watch mode SPA: navigating between auction products updates auction-specific + elements (current high, time left, bid form `auction_id`) +- `auction.json` endpoint returns full state shape for poll + +## Verification + +1. `source vars.sh && make test` — all pass +2. Local dev: create draft auction, schedule, watch state → active, place bids + from two browser sessions, observe soft-close extension, end auction, + pay as winner, verify settled state + product transferred +3. Test reserve-not-met path +4. Test second-bidder fallback +5. Email log shows all 6 notification types fire +6. Push → CI green → deploy → bump GIT_HASH +7. Update marketing portal index.html + pricing.html diff --git a/docs/tickets/mps-21.md b/docs/tickets/mps-21.md new file mode 100644 index 0000000..ee73f74 --- /dev/null +++ b/docs/tickets/mps-21.md @@ -0,0 +1,318 @@ +# MPS-21: Make-an-Offer Mode + +## Status + +**PROPOSED — does not exist.** Today every product has a fixed `price`. No +path lets a buyer propose a different price, no path lets a seller counter, +no path lets either party walk away. + +## Why This Belongs in MPS + +Make-an-offer is the everyday cousin of MPS-20's auction mode. Instead of +many bidders pushing a price up over a fixed window, two parties (one +buyer, one seller) negotiate to a number both accept. Same checkout +pipeline. Same payouts. Same fee math. Different state machine. + +Use cases: + +- Digital art / commissioned work where price is conversation +- Used / one-of-a-kind physical goods (think Facebook Marketplace, OfferUp) +- B2B catalog items where listed price is starting point +- Soft-launch pricing — let buyers tell you what they'd pay + +## Mode + +Reuses `pricing_mode` column from MPS-20: + +| `pricing_mode` | Behavior | +|----------------|----------| +| 0 (fixed) | Today | +| 1 (auction) | MPS-20 | +| 2 (auction + buy_now) | MPS-20 | +| 3 (offer) | This ticket — buyer proposes, seller counters/accepts/declines | +| 4 (offer + buy_now) | Both — listed price = instant; offer = negotiate | + +Modes 3 and 4 add `make_offer_enabled=True` semantics on the product. + +## State Machine + +``` + ┌──────────┐ + buyer │ open │ product listed, accepting offers + submits ───► └────┬─────┘ + │ + ▼ + ┌──────────┐ + │ pending │ offer waiting on seller + └────┬─────┘ + ┌─────────────┼──────────────┬─────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ accepted │ │countered │ │ declined │ │ expired │ +└────┬─────┘ └────┬─────┘ └──────────┘ └──────────┘ + │ │ + │ │ buyer responds: accept / counter / decline / withdraw + │ ▼ + │ ┌──────────┐ + │ │ pending │ ◄── back to seller (capped at offer_max_rounds) + │ └──────────┘ + ▼ +┌──────────┐ +│ paid │ buyer paid via cart at agreed price +└──────────┘ + +Auto-accept lane (skips pending if offer >= auto_accept_threshold): + open → accepted + +Auto-decline lane (skips pending if offer < auto_decline_threshold): + open → declined +``` + +## Models + +### `MpsOffer` + +| Column | Type | Purpose | +|--------|------|---------| +| `id` | UUID | row id | +| `product_id` | UUID FK | product | +| `shop_id` | UUID FK | shop | +| `buyer_user_id` | UUID FK | buyer | +| `state` | int | 0=pending 1=accepted 2=countered 3=declined 4=expired 5=withdrawn 6=paid | +| `current_amount_in_cents` | int | latest amount on the table | +| `current_party` | int | 0=buyer's turn 1=seller's turn | +| `created_timestamp` | int (ms) | initial offer time | +| `last_action_timestamp` | int (ms) | last counter/accept/etc | +| `expires_timestamp` | int (ms) | offer auto-expires (default created + 7 days) | +| `paid_timestamp` | int (ms) nullable | populated on cart success | +| `round_count` | int | counter rounds used; cap at shop's `offer_max_rounds` | +| `buyer_message` | UnicodeText nullable | optional buyer note (initial offer) | +| `seller_message` | UnicodeText nullable | optional seller counter note | + +### `MpsOfferEvent` (audit log) + +| Column | Type | Purpose | +|--------|------|---------| +| `id` | UUID | row id | +| `offer_id` | UUID FK | offer | +| `actor_user_id` | UUID FK | who acted | +| `event_type` | int | 0=open 1=counter 2=accept 3=decline 4=withdraw 5=expire 6=pay | +| `amount_in_cents` | int nullable | amount at this step | +| `message` | UnicodeText nullable | actor's message | +| `created_timestamp` | int (ms) | event time | + +## Shop Settings (Form Section: `offer-settings`) + +| Field | Default | Purpose | +|-------|---------|---------| +| `offer_enabled` | False | shop master toggle | +| `offer_min_in_cents` | NULL | reject offers below this (per shop floor) | +| `offer_auto_accept_threshold_pct` | 95 | offer ≥ N% of list → auto-accept | +| `offer_auto_decline_threshold_pct` | 50 | offer < N% of list → auto-decline | +| `offer_expiration_hours` | 168 (7d) | how long an offer stays open | +| `offer_max_rounds` | 3 | counter cap before forcing accept/decline | +| `offer_min_buyer_account_age_hours` | 0 | anti-spam (default open) | + +Per-product override: `Product.allow_offers` (Boolean). NULL = inherit shop; +True/False = override. + +## Views / Routes + +``` +POST /p/{product_id}/offer buyer submits new offer +GET /o/{offer_id} offer detail (both parties + admin) +POST /o/{offer_id}/counter seller or buyer counters +POST /o/{offer_id}/accept accepts current amount → cart +POST /o/{offer_id}/decline declines, no further action +POST /o/{offer_id}/withdraw buyer pulls offer (pre-acceptance only) +GET /s/{shop_id}/offers seller dashboard +GET /u/{user_id}/offers buyer dashboard +``` + +When state flips to `accepted`, cart auto-creates a line item at +`current_amount_in_cents`. Buyer hits checkout. Existing payment paths +fire. Cart success hook sets `state=paid` + writes `MpsOfferEvent`. + +## Cart Integration + +Accepted offer becomes a line item (`mps_cart_offer` association proxy, +mirroring `CartCoupon` and `CartGiftCard`). Cart total is the **agreed +amount**, not the listed price. The line item carries: + +- `offer_id` for traceability +- `amount_in_cents` = `current_amount_in_cents` at time of accept +- Standard product transfer on payment + +If buyer abandons cart, offer stays `accepted` until `expires_timestamp` +passes (configurable seller setting: re-open or terminal). + +## Counter Algorithm + +Pure function, fully unit-testable: + +```python +def counter_offer(offer, actor, new_amount, message, now_ms): + _validate_actor_turn(offer, actor) + _validate_round_cap(offer) + _validate_floor(offer, new_amount) + offer.current_amount_in_cents = new_amount + offer.current_party = OTHER_PARTY[offer.current_party] + offer.last_action_timestamp = now_ms + offer.round_count += 1 + offer.state = STATE_COUNTERED if actor != offer.buyer else STATE_PENDING + DBSession.add(MpsOfferEvent(...)) + return offer +``` + +Round cap forces resolution — no infinite haggling. + +## Anti-Abuse + +- Min buyer account age (configurable) +- Rate limit: 5 new offers per buyer per shop per day +- Block self-offer (buyer == seller) +- Block offers below `offer_min_in_cents` (silent reject if seller wants + to keep floor secret — return generic flash message) +- Auto-decline threshold filters lowball spam without seller seeing it + +## Notifications + +Email + on-site: + +- New offer received (to seller) +- Offer countered by seller (to buyer) +- Offer countered by buyer (to seller) +- Offer accepted (to other party) +- Offer declined (to other party) +- Offer expiring in 24h (to active party) +- Offer expired (to both) +- Offer paid → standard cart purchase confirmation + +## UI Surface + +- Product page: "Make an Offer" button (when `allow_offers` resolves True) + next to "Add to Cart"; opens form modal +- Form: amount input, optional message, expires-at indicator +- Auto-accept preview: "Offers ≥ $X are accepted instantly" (only shows the + threshold when shop opts to disclose; default hidden) +- Seller dashboard: offers grouped by state (pending / countered-out / accepted-unpaid / paid / declined / expired) +- Buyer dashboard: same shape, buyer-side terminology +- Offer detail page: full event timeline (renders `MpsOfferEvent` rows) + +## Watch Mode SPA + +Per CLAUDE.md, all product-specific UI must round-trip through +`updatePageContent` + `watch.py` JSON. Make-offer button + state badge +must update on SPA navigation. + +## Go-to-Market + +| Surface | Action | +|---------|--------| +| `docs/make-offer.md` | New: state machine + dot diagram + API | +| `docs/architecture.md` | Add offer tables + pricing_mode mode 3/4 to feature matrix | +| `/styleguide` | Offer form, offer state badges, event timeline component | +| `~/git/www.makepostsell.com/index.html` | Feature card: "Make an offer. Negotiate without leaving the listing. Auto-accept good offers. Auto-decline lowballs. Standard checkout." | +| `~/git/www.makepostsell.com/pricing.html` | Make-offer mode listed in plan includes | +| Marketing copy | Position: commission-free negotiation vs. eBay/OfferUp listing fees | + +## Files + +| File | Change | +|------|--------| +| `make_post_sell/models/offer.py` | New: `MpsOffer`, `MpsOfferEvent` | +| `make_post_sell/models/__init__.py` | Imports | +| `make_post_sell/models/meta.py` | Register both tables | +| `make_post_sell/models/shop.py` | Add 7 offer-* columns | +| `make_post_sell/models/product.py` | Add `allow_offers` (Boolean nullable). If MPS-20 lands first, `pricing_mode` already exists | +| `make_post_sell/models/cart.py` | `CartOffer` association proxy | +| `make_post_sell/views/offer.py` | New: all offer route handlers | +| `make_post_sell/views/shop.py` | `form_section=offer-settings` handler; `offers_dashboard` view | +| `make_post_sell/views/cart.py` | Recognize offer-accepted line item | +| `make_post_sell/views/product.py` | `allow_offers` per-product toggle | +| `make_post_sell/lib/offer.py` | Pure-function counter / accept / decline / withdraw / expire logic | +| `make_post_sell/lib/offer_tick.py` | Scheduled expirations + ending-soon notifications | +| `make_post_sell/lib/email_notifications.py` | Offer email templates | +| `make_post_sell/routes.py` | All offer routes | +| `make_post_sell/templates/offer.j2` | Offer detail page with event timeline | +| `make_post_sell/templates/offers_dashboard.j2` | Seller + buyer dashboard (single template, dual-mode) | +| `make_post_sell/templates/product.j2` | Make-offer button (server-rendered) | +| `make_post_sell/templates/content.j2` | Same | +| `make_post_sell/templates/shop_settings.j2` | `offer-settings` form section | +| `make_post_sell/templates/product_edit.j2` | `allow_offers` toggle | +| `make_post_sell/templates/styleguide.j2` | Component examples | +| `make_post_sell/static/js/offer.js` | Modal form, AJAX submit, state-badge update | +| `make_post_sell/static/js/watch.js` | `updatePageContent` swaps make-offer button + state | +| `make_post_sell/static/css/common.css` | Offer component styles (tokens-only) | +| `make_post_sell/scripts/alembic/versions/XXXX_offer_tables_and_settings.py` | Migration with guards | +| `make_post_sell/scripts/offer_tick.py` | CLI entry point for cron | +| `make_post_sell/tests/test_models.py` | Unit tests | +| `make_post_sell/tests/test_integration.py` | Integration tests | +| `make_post_sell/tests/test_functional.py` | Functional tests | + +## Tests + +### Unit (`test_models.py`) + +- `MpsOffer` state helpers (`is_open`, `is_terminal`, `time_remaining_ms`) +- `MpsOffer.current_party_user` returns buyer or seller correctly +- `lib/offer.counter_offer`: + - wrong-turn raises + - over-cap raises + - below-floor raises + - happy path flips party, increments round, sets state +- `lib/offer.accept_offer` — flips to accepted, writes event, creates cart line +- `lib/offer.decline_offer` / `withdraw_offer` — terminal, no further action +- Auto-accept: offer ≥ threshold → state=accepted directly +- Auto-decline: offer < threshold → state=declined directly +- `Shop.offer_settings_dict` returns shape consumed by template +- `Product.offers_allowed` resolves per-product override + shop default + +### Integration (`test_integration.py`) + +- Full negotiation: buyer offers → seller counters → buyer accepts → cart + populated at agreed amount → checkout → state=paid +- Round cap forces accept/decline at round_count == max +- Expiration: offer past `expires_timestamp` flipped to expired by tick job +- Auto-accept threshold path +- Auto-decline threshold path (silent — no seller notification) +- BYOB: offer-related thumbnails use shop bucket +- Anti-abuse: 6th offer in 24h from same buyer → 429 +- Self-offer (buyer_user_id == seller_user_id) → 403 + +### Functional (`test_functional.py`) + +- `POST /p/{id}/offer` — anon redirect, signed-in 200, self-offer 403 +- `GET /o/{id}` — anon 403, buyer 200, seller 200, third party 403, admin 200 +- `POST /o/{id}/counter` — wrong turn 400; right turn 200 +- `POST /o/{id}/accept` — populates cart line item; cart total = agreed amount +- `POST /o/{id}/decline` / `withdraw` — terminal state, button disappears +- Make-offer button rendered when `allow_offers` resolves True; hidden otherwise +- Watch mode SPA: navigating updates make-offer button visibility + state badge +- Seller dashboard `/s/{shop_id}/offers` — only shop owner; 403 for others +- Buyer dashboard `/u/{user_id}/offers` — only buyer or admin +- `offer-settings` form section POST round-trips all 7 fields + +## Verification + +1. `source vars.sh && make test` — all pass +2. Local dev: enable offers on a shop, opt a product in, submit offer from + a different account, counter from owner, accept, pay via cart, verify + `state=paid` + transfer +3. Test auto-accept (high offer) and auto-decline (low offer) branches +4. Test round cap (3 default) — 4th counter forced to terminal +5. Test expiration via cron + manual tick +6. Email log shows all 8 notification types fire across happy + sad paths +7. Push → CI green → deploy → bump GIT_HASH +8. Update marketing portal index.html + pricing.html + +## Coupling Notes + +- **MPS-20** introduces `pricing_mode` on `Product`. If MPS-20 lands first, + this ticket reuses that column (modes 3 + 4). If MPS-21 lands first, + add `pricing_mode` here and MPS-20 extends it. +- Cart association proxy pattern: mirror `CartCoupon` / `CartGiftCard` + exactly — ordering rules already defined (gift cards apply after coupons). + Offer line is its own item (single-product cart with agreed price); + doesn't interact with coupon/gift-card discount stack.