docs: add tickets MPS-14 (dev/stage shops), MPS-15 (free trial), MPS-16 (BYOB)
This commit is contained in:
parent
e15971be83
commit
add2e85248
4 changed files with 479 additions and 0 deletions
|
|
@ -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
128
docs/tickets/mps-14.md
Normal 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
142
docs/tickets/mps-15.md
Normal 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
206
docs/tickets/mps-16.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue