diff --git a/docs/architecture.md b/docs/architecture.md index 69f91bc..d20493f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -209,6 +209,9 @@ mps_page_session (raw rows) | [MPS-11](tickets/mps-11.md) | Gift Card — Purchase Flow | Complete | | [MPS-12](tickets/mps-12.md) | Gift Card — Redemption at Checkout | Complete | | [MPS-13](tickets/mps-13.md) | Gift Card — Shop Admin & Settings | Complete | +| [MPS-14](tickets/mps-14.md) | Shop Environment — Dev & Stage Shops | Open | +| [MPS-15](tickets/mps-15.md) | 21-Day Free Trial | Open | +| [MPS-16](tickets/mps-16.md) | Bring Your Own Bucket (BYOB) | Open | ## Related Docs diff --git a/docs/tickets/mps-14.md b/docs/tickets/mps-14.md new file mode 100644 index 0000000..25a5663 --- /dev/null +++ b/docs/tickets/mps-14.md @@ -0,0 +1,128 @@ +# MPS-14: Shop Environment — Dev & Stage Shops + +## Summary + +Add an `environment` column to Shop so owners can create development and staging +shops for practicing thumbnails, videos, product staging, and testing checkout +flows. Non-production shops are fully independent (no sync to production) and +invisible to the public. + +Every paid production shop seat includes 2 free dev/stage shops. + +## Model Changes + +### Shop model (`models/shop.py`) + +Add column: + +```python +environment = Column(BigInteger, default=0) +# 0 = production (default) +# 1 = staging +# 2 = development +``` + +Add properties: + +```python +@property +def is_production(self): + return self.environment == 0 + +@property +def is_staging(self): + return self.environment == 1 + +@property +def is_development(self): + return self.environment == 2 + +@property +def is_non_production(self): + return self.environment != 0 + +@property +def environment_label(self): + return {0: "Production", 1: "Staging", 2: "Development"}.get(self.environment, "Production") +``` + +### Migration + +- Add `environment` column to `mps_shop` (BigInteger, server_default="0", NOT NULL) +- Idempotent guard with `_column_exists` + +## Exclusion Points + +Non-production shops (environment != 0) must be excluded from: + +1. **Search results** — `views/shop.py:129` `search()` — filter query to `shop.environment == 0` +2. **Discovery ring** — `models/shop.py:656` `_build_discovery_ring()` — already scoped to shop's own products, but ring should not be reforged for non-production shops +3. **RSS/Atom/Sitemap** — `views/feeds.py:204,222,242` — skip non-production shops entirely (return empty feed or 404) +4. **Subscription digests** — `models/shop_subscription.py` query helpers — filter by `shop.environment == 0` +5. **Public shop listings** — any place shops are listed publicly + +Non-production shops still fully function for the owner: product upload, cart, +checkout, settings, analytics — all work normally. + +## Banner + +Display a persistent environment banner for non-production shops, similar to +the ribbon pattern. In `base.j2` or `snippets/ribbon.j2`: + +```html +{% if request.shop and request.shop.is_non_production %} +
+ {{ request.shop.environment_label }} Shop +
+{% endif %} +``` + +CSS in `common.css`: +- Staging banner: amber/yellow background +- Development banner: blue/purple background +- Always visible, not dismissible + +## Settings UI + +Add environment selector to shop settings. New form section `environment-settings` +or add to existing `shop-settings` section. + +Radio buttons or select: +- Production (default) +- Staging +- Development + +Changing from non-production to production should warn: "This shop will become +publicly visible." + +## Shop Creation Flow + +On `/s/new`, add an optional environment selector (default: production). +This lets users create dev/stage shops directly during onboarding. + +## Enforcement: 2 Free Dev/Stage Per Production Shop + +Each paid production shop seat entitles the user to 2 free non-production shops. + +### Counting logic + +```python +def non_production_shop_allowance(user): + production_count = sum(1 for s in user.shops if s.is_production) + allowed_non_production = production_count * 2 + current_non_production = sum(1 for s in user.shops if s.is_non_production) + return allowed_non_production - current_non_production +``` + +### Enforcement points + +- **Shop creation** (`views/shop.py` POST `/s/new`): if environment != 0 and + allowance <= 0, flash error and reject +- **Environment change** (`views/shop.py` settings POST): if changing to + non-production and allowance <= 0, reject; if changing to production, always allow + +## Tests + +- Unit: `test_models.py` — environment properties, environment_label +- Integration: `test_integration.py` — non-production shop excluded from discovery ring query, allowance counting +- Functional: `test_functional.py` — create dev shop, verify search excludes it, verify banner appears, verify allowance enforcement diff --git a/docs/tickets/mps-15.md b/docs/tickets/mps-15.md new file mode 100644 index 0000000..89382e9 --- /dev/null +++ b/docs/tickets/mps-15.md @@ -0,0 +1,142 @@ +# MPS-15: 21-Day Free Trial + +## Summary + +New shops get a 21-day free trial. Trial includes 1 shop, 1 seat, all features. +After 21 days the shop enters a grace period, then becomes read-only until a +plan is chosen. + +## Model Changes + +### Shop model (`models/shop.py`) + +Add columns: + +```python +trial_started_timestamp = Column(BigInteger, nullable=True) +trial_ended = Column(Boolean, default=False) +plan_active = Column(Boolean, default=False) +``` + +- `trial_started_timestamp`: set to current time (ms) on shop creation +- `trial_ended`: flipped to True when trial expires (background check or request-time check) +- `plan_active`: True when a paid plan is active (billing integration, future ticket) + +### Migration + +- Add 3 columns to `mps_shop` with idempotent guards +- `trial_started_timestamp` nullable (existing shops get NULL = pre-trial era, treated as paid) +- `trial_ended` server_default="0" +- `plan_active` server_default="0" + +### Properties + +```python +TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days in milliseconds + +@property +def trial_expiry_timestamp(self): + if self.trial_started_timestamp is None: + return None + return self.trial_started_timestamp + self.TRIAL_DURATION_MS + +@property +def is_trial_active(self): + if self.plan_active: + return False # paid plan supersedes trial + if self.trial_started_timestamp is None: + return False # pre-trial shop (existing shops) + now = int(time.time() * 1000) + return now < self.trial_expiry_timestamp + +@property +def is_trial_expired(self): + if self.plan_active or self.trial_started_timestamp is None: + return False + now = int(time.time() * 1000) + return now >= self.trial_expiry_timestamp + +@property +def trial_days_remaining(self): + if not self.is_trial_active: + return 0 + remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000) + return max(0, remaining_ms // (24 * 60 * 60 * 1000)) + +@property +def is_active(self): + """Shop can operate: either paid plan or active trial.""" + return self.plan_active or self.is_trial_active +``` + +## Trial Enforcement + +### What trial shops CAN do (all features) +- Create products, upload files, set prices +- Accept payments (all 5 methods) +- Use watch mode, analytics, subscriptions, gift cards +- Create 2 dev/stage shops (per MPS-14) +- Full settings access + +### What happens when trial expires +- Shop becomes **read-only**: products visible, downloads work for existing purchases +- New purchases blocked (checkout disabled) +- Product creation/editing disabled +- Settings page shows "Trial expired — choose a plan to continue" +- Flash message on every page: "Your 21-day trial has expired. Choose a plan to keep selling." + +### Enforcement points + +Request-time check (middleware or request method): + +```python +def shop_trial_check(request): + shop = request.shop + if shop and shop.is_trial_expired and not shop.plan_active: + # Allow read-only routes, block write routes + ... +``` + +Write routes to block when trial expired: +- Product create/edit/delete +- Checkout completion (all 3 paths: Stripe, PayPal, Adyen) +- Gift card purchase +- Settings changes (except choosing a plan) + +Read routes to allow: +- Product view, shop view, search +- Cart view (but not checkout) +- Settings view (read-only, plan selection enabled) +- Download (for existing purchases) + +## Trial Banner + +In `base.j2`, show trial status for shop owners: + +```html +{% if request.shop and request.shop.is_trial_active and request.user in request.shop.owners %} +
+ Free trial: {{ request.shop.trial_days_remaining }} days remaining. + Choose a plan +
+{% endif %} + +{% if request.shop and request.shop.is_trial_expired and not request.shop.plan_active %} +
+ Your 21-day trial has expired. + Choose a plan to keep selling +
+{% endif %} +``` + +## Shop Creation Changes + +In `views/shop.py` `shop_new()`: +- Set `shop.trial_started_timestamp = int(time.time() * 1000)` on creation +- Existing shops (NULL timestamp) are grandfathered as paid + +## Tests + +- Unit: trial properties (is_trial_active, is_trial_expired, trial_days_remaining, is_active) +- Integration: trial shop with real DB, verify expiry behavior +- Functional: create shop, verify trial banner, verify trial countdown diff --git a/docs/tickets/mps-16.md b/docs/tickets/mps-16.md new file mode 100644 index 0000000..b2ae625 --- /dev/null +++ b/docs/tickets/mps-16.md @@ -0,0 +1,206 @@ +# MPS-16: Bring Your Own Bucket (BYOB) — Primary S3 Per Shop + +## Summary + +Allow shops to use their own S3-compatible bucket as the **primary** storage +for all media (products, thumbnails, previews, karaoke tracks). Files are +uploaded directly to the shop's bucket — MPS never stores them on the +platform bucket. + +Free trial users must bring their own bucket during onboarding (zero storage +cost for MPS during trial). Paid plan users can use BYOB or the MPS bucket. + +## Current Architecture + +- All uploads go to MPS DigitalOcean Spaces bucket (global, configured in INI) +- `request.secure_uploads_client` is a single global boto3 client +- Presigned URLs always use `request.app["bucket.secure_uploads.get_endpoint"]` +- Mirror S3 (`shop.mirror_s3_*`) is an async secondary copy + +## Target Architecture + +- Each shop can optionally specify a **primary** S3 bucket +- If configured, all uploads, presigned URLs, and thumbnail CDN URLs use the shop's bucket +- MPS bucket is never touched for BYOB shops +- Mirror S3 continues to work as a secondary copy (shop can mirror from their primary to another bucket) + +## Model Changes + +### Shop model (`models/shop.py`) + +Reuse existing `mirror_s3_*` columns but add a new flag to indicate primary vs mirror: + +```python +primary_s3_enabled = Column(Boolean, default=False) +# When True: mirror_s3_* columns are used as the PRIMARY bucket +# When False: mirror_s3_* columns are used as mirror (current behavior) +``` + +Or add separate columns for clarity: + +```python +primary_s3_endpoint = Column(Unicode(256), nullable=True) +primary_s3_region = Column(Unicode(64), nullable=True) +primary_s3_bucket = Column(Unicode(128), nullable=True) +primary_s3_access_key = Column(Unicode(128), nullable=True) +primary_s3_secret_key = Column(Unicode(128), nullable=True) +primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True) # public CDN URL for thumbnails +primary_s3_enabled = Column(Boolean, default=False) +``` + +The CDN endpoint is critical — thumbnails and previews use public CDN URLs, +not presigned URLs. The shop owner must configure their bucket's CDN endpoint +(e.g., `https://mybucket.nyc3.cdn.digitaloceanspaces.com`). + +### Properties + +```python +@property +def has_primary_s3(self): + return bool( + self.primary_s3_enabled + and self.primary_s3_endpoint + and self.primary_s3_bucket + and self.primary_s3_access_key + and self.primary_s3_secret_key + and self.primary_s3_cdn_endpoint + ) + +@property +def media_cdn_endpoint(self): + """Return the CDN endpoint for this shop's media.""" + if self.has_primary_s3: + return self.primary_s3_cdn_endpoint + return None # caller falls back to request.app default +``` + +## Request Method Changes + +### `request_methods.py` + +Add a shop-aware S3 client factory: + +```python +def add_shop_uploads_client(request): + """Return S3 client for the current shop (BYOB or MPS default).""" + shop = request.shop + if shop and shop.has_primary_s3: + import boto3 + session = boto3.session.Session() + return session.client( + "s3", + region_name=shop.primary_s3_region, + endpoint_url=shop.primary_s3_endpoint, + aws_access_key_id=shop.primary_s3_access_key, + aws_secret_access_key=shop.primary_s3_secret_key, + ) + return request.secure_uploads_client # default MPS bucket +``` + +Add `request.shop_uploads_client` as a reified request method. + +### Bucket name resolution + +```python +def get_shop_bucket_name(request): + shop = request.shop + if shop and shop.has_primary_s3: + return shop.primary_s3_bucket + return request.app["bucket.secure_uploads"] +``` + +Add `request.shop_bucket_name` as a reified request method. + +## View Changes + +### `views/product.py` + +All S3 operations must use `request.shop_uploads_client` and +`request.shop_bucket_name` instead of `request.secure_uploads_client` and +`request.app["bucket.secure_uploads"]`: + +- **Presigned GET** (downloads, line 45-83): use shop client + shop bucket +- **Presigned POST** (uploads, line 429-456): use shop client + shop bucket +- **Copy object** (line 344): use shop client + shop bucket +- **Delete object**: use shop client + shop bucket + +### Template changes + +All thumbnail/media URLs must resolve through the shop's CDN endpoint: + +```jinja2 +{# Before #} +{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1 + +{# After #} +{{ product.shop.media_cdn_endpoint or request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1 +``` + +Affected templates: +- `product.j2` (lines 13, 43, 75, 97, 112, 131-132) +- `cart.j2` (line 124) +- `shop.j2` (thumbnail rendering) +- `content.j2` +- `snippets/related_content.j2` + +### `views/watch.py` + +Watch mode JSON endpoint (line 96-99) must use shop CDN endpoint: + +```python +cdn = shop.media_cdn_endpoint or request.app["bucket.secure_uploads.get_endpoint"] +thumbnail_url = f"{cdn}/{product.s3_path}/thumbnail1?ts={product.updated_timestamp}" +``` + +### `lib/karaoke.py` + +Karaoke downloads/uploads must use shop client + shop bucket. + +### `lib/s3_mirror.py` + +When primary_s3 is enabled, mirror source becomes the shop's bucket (not MPS). +Mirror destination is still the mirror_s3_* config. + +## Settings UI + +### New form section: `bucket-settings` + +In `shop_settings.j2`, add a "Storage" or "Media Bucket" section: + +- Endpoint URL (text input) +- Region (text input) +- Bucket name (text input) +- Access key (text input) +- Secret key (password input) +- CDN endpoint (text input, with help text: "Public URL for thumbnails") +- Enable checkbox +- Test connection button (reuse `test_mirror_connection` pattern) + +### Validation + +- Endpoint must start with `https://` +- All 6 fields required if any provided +- Connection test: list bucket, attempt a test PUT/GET/DELETE cycle +- CDN endpoint must be reachable (optional HEAD request) + +## Onboarding for Trial Users + +During shop creation (`/s/new`), after the shop is created and the user is +redirected to `/s/{shop_id}/settings`: + +- If trial user (no paid plan), show a prominent "Set Up Storage" step +- Guide them through configuring their S3 bucket +- Trial shops cannot upload files until BYOB is configured +- Provide documentation links for DigitalOcean Spaces, AWS S3, Backblaze B2, MinIO + +## Migration + +- Add `primary_s3_*` columns (6 columns) to `mps_shop` +- All nullable, `primary_s3_enabled` server_default="0" +- Idempotent guards + +## Tests + +- Unit: has_primary_s3 property, media_cdn_endpoint property +- Integration: shop with BYOB config, verify client resolution +- Functional: enable BYOB via settings, verify connection test, verify upload uses shop bucket