docs: add tickets MPS-14 (dev/stage shops), MPS-15 (free trial), MPS-16 (BYOB)

This commit is contained in:
russell@unturf.com 2026-03-07 17:30:05 -05:00
parent e15971be83
commit add2e85248
4 changed files with 479 additions and 0 deletions

View file

@ -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

128
docs/tickets/mps-14.md Normal file
View file

@ -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 %}
<div class="environment-banner environment-{{ request.shop.environment_label|lower }}">
{{ request.shop.environment_label }} Shop
</div>
{% 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

142
docs/tickets/mps-15.md Normal file
View file

@ -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 %}
<div class="trial-banner">
Free trial: {{ request.shop.trial_days_remaining }} days remaining.
<a href="/s/{{ request.shop.id }}/settings#plan">Choose a plan</a>
</div>
{% endif %}
{% if request.shop and request.shop.is_trial_expired and not request.shop.plan_active %}
<div class="trial-banner trial-expired">
Your 21-day trial has expired.
<a href="/s/{{ request.shop.id }}/settings#plan">Choose a plan to keep selling</a>
</div>
{% 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

206
docs/tickets/mps-16.md Normal file
View file

@ -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