feat: integrate design tokens from www.makepostsell.com styleguide #94

Merged
russellballestrini merged 31 commits from explore/design-tokens into master 2026-03-07 23:28:24 -05:00
russellballestrini commented 2026-03-07 23:28:02 -05:00 (Migrated from git2.unturf.com)
  • Add tokens.css as foundation layer (loaded before common.css)
    • Color system: brand, surface, text, border, state, alert tokens
    • Typography: major third scale with 14 semantic classes
    • Spacing: 4px base, 12-step scale with utility classes
    • Shape: 7 radius levels from none to pill
    • Elevation: 5 shadow levels with dual-shadow technique
    • Motion: 4 easing curves, 7 durations, entrance animations
    • Loading: skeleton shimmer + spinner (3 sizes)
    • Scroll reveal, state layers, ripple effect, focus rings
    • Accessibility: prefers-reduced-motion, :focus-visible
  • Rewrite styleguide.j2 to comprehensive design system reference
    • New sections: tokens, elevation, motion, spacing, shape, states,
      loading, status notices, cart buttons, checkout layout, toggle
    • All app components documented with live demos
    • TOC navigation for all sections
  • Add STYLEGUIDE rule to CLAUDE.md
  • Wire tokens.css into base.j2 before common.css

Exploration branch — tokens.css provides new variables alongside
existing common.css variables. No visual regressions expected as
common.css values take precedence for shared property names.

Summary by CodeRabbit

  • New Features

    • Gift cards: purchase, apply discounts at checkout, check balance, and manage inventory
    • Bring Your Own Bucket (BYOB): shops can configure custom S3 storage for media uploads
    • Shop environments: production, staging, and development designation options
    • 21-day free trial for newly created shops
    • Enhanced design system with tokens, components, and typography utilities
  • Improvements

    • Updated media delivery to use shop-specific CDN endpoints
    • Enhanced cart with gift card discount support alongside coupons
    • Discovery search respects shop environment settings
  • Documentation

    • Added comprehensive design system documentation
    • Updated architecture reference with new features
    • Enhanced development guidelines and test coverage requirements
- Add tokens.css as foundation layer (loaded before common.css) - Color system: brand, surface, text, border, state, alert tokens - Typography: major third scale with 14 semantic classes - Spacing: 4px base, 12-step scale with utility classes - Shape: 7 radius levels from none to pill - Elevation: 5 shadow levels with dual-shadow technique - Motion: 4 easing curves, 7 durations, entrance animations - Loading: skeleton shimmer + spinner (3 sizes) - Scroll reveal, state layers, ripple effect, focus rings - Accessibility: prefers-reduced-motion, :focus-visible - Rewrite styleguide.j2 to comprehensive design system reference - New sections: tokens, elevation, motion, spacing, shape, states, loading, status notices, cart buttons, checkout layout, toggle - All app components documented with live demos - TOC navigation for all sections - Add STYLEGUIDE rule to CLAUDE.md - Wire tokens.css into base.j2 before common.css Exploration branch — tokens.css provides new variables alongside existing common.css variables. No visual regressions expected as common.css values take precedence for shared property names. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Gift cards: purchase, apply discounts at checkout, check balance, and manage inventory * Bring Your Own Bucket (BYOB): shops can configure custom S3 storage for media uploads * Shop environments: production, staging, and development designation options * 21-day free trial for newly created shops * Enhanced design system with tokens, components, and typography utilities * **Improvements** * Updated media delivery to use shop-specific CDN endpoints * Enhanced cart with gift card discount support alongside coupons * Discovery search respects shop environment settings * **Documentation** * Added comprehensive design system documentation * Updated architecture reference with new features * Enhanced development guidelines and test coverage requirements <!-- end of auto-generated comment: release notes by coderabbit.ai -->
russellballestrini (Migrated from git2.unturf.com) merged commit df8f4993c6 into master 2026-03-07 23:28:24 -05:00
russellballestrini commented 2026-03-07 23:28:27 -05:00 (Migrated from git2.unturf.com)

mentioned in commit df8f4993c6

mentioned in commit df8f4993c675258465b9610a3df513a4ac1b5efc
coderabbitai commented 2026-03-07 23:28:43 -05:00 (Migrated from git2.unturf.com)
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive gift card system (purchase, redemption, management), shop environment classification (production/staging/development), a 21-day trial feature, and Bring Your Own Bucket (BYOB) S3 support. Includes new models, migrations, request helpers, templates, routes, and extensive documentation updates across design system, architecture, and ticket specifications.

Changes

Cohort / File(s) Summary
Gift Card Core Models
make_post_sell/models/gift_card.py, gift_card_transaction.py, cart_gift_card.py
New ORM models for gift cards, transactions, and cart-to-gift-card associations. Includes code generation, validation, deduction logic, and retrieval helpers.
Gift Card Cart Integration
make_post_sell/models/cart.py
Extends Cart with json_gift_cards column, gift card association proxy, purchase tracking, discount deductions per gift card, and validation methods.
Gift Card Purchase & Routes
make_post_sell/routes.py, templates/gift_card.j2, static/js/gift_card.js
New routes for gift card page, add-to-cart, apply, and removal. Template with slider/input controls for amount selection and recipient details. Route handling not shown in diff.
Gift Card Management UI
templates/gift_card_manage.j2, gift_card_detail.j2
Admin templates for listing gift cards with totals, status badges, toggle controls, and transaction history display.
Shop Model Extensions
make_post_sell/models/shop.py
Adds gift card settings, environment classification (production/staging/development), trial tracking (started, ended, active), and BYOB primary S3 configuration with CDN endpoint.
BYOB S3 Implementation
make_post_sell/request_methods.py, lib/karaoke.py, lib/s3_mirror.py
Shop-aware S3 client selection via shop_uploads_client and shop_bucket_name request methods. Defers S3 client creation to post-shop-load in backfill operations. Adds domain check for 127.0.0.1.
Design System & Guidelines
docs/design-system.md, CLAUDE.md
Comprehensive design system documentation covering tokens, typography, spacing, components, and CSS conventions. CLAUDE.md reinforces design/test/BYOB guidelines with post-work checklists.
Architecture & Ticket Documentation
docs/architecture.md, docs/tickets/mps-10.md through mps-16.md
Updates architecture diagram for gift cards and BYOB. Detailed specifications for gift card models, purchase flow, redemption, admin settings, shop environment, trial system, and BYOB feature.
Database Migrations
make_post_sell/scripts/alembic/versions/f8201a9ba045_..., 9884324a48e3_...
Gift card tables (mps_gift_card, mps_gift_card_transaction, mps_cart_gift_card), cart json_gift_cards column, shop settings, and environment/trial/primary S3 columns with idempotent guards.
Template CDN Endpoint Updates
templates/base.j2, cart.j2, content.j2, home.j2, product_edit.j2, shop.j2, shop_about.j2
Replaces request.app["bucket.secure_uploads.get_endpoint"] with request.shop_cdn_endpoint for all media/thumbnail URLs. Adds environment and trial banners to base template.
Email & Service Updates
make_post_sell/lib/mail.py, lib/digest_sender.py
New send_gift_card_email() function with amount, code, message, and redemption instructions. Digest sender filters to production shops only (Shop.environment == 0).
Frontend State Management
make_post_sell/static/js/watch.js
Adds ring state staleness detection and server-seeding on startup; fetches current product from server if localStorage ring data is empty; fallback to existing preloadNext on fetch failure.
Model & Meta Updates
make_post_sell/models/__init__.py, meta.py, product.py
Exports new gift card model classes. Adds CLASS_TO_TABLE mappings for GiftCard, GiftCardTransaction, CartGiftCard. Updates product search to exclude non-production shops via environment filter.
Admin UI
templates/actions_view.j2
Conditional "Gift Cards" management link when request.shop.gift_card_enabled.

Sequence Diagram

sequenceDiagram
    participant Client as Client (Browser)
    participant Server as Server (Routes/Cart)
    participant DB as Database
    participant Checkout as Checkout View
    participant Email as Email Service

    Client->>Server: POST /gift-card/apply<br/>(gift_card_code, cart_id)
    Server->>DB: Fetch GiftCard by code
    DB-->>Server: GiftCard (balance, validity check)
    Server->>Server: Validate (enabled, balance > 0, shop match)
    Server->>DB: Add CartGiftCard entry
    DB-->>Server: Success
    Server->>Server: Recalculate cart.discounted_totals<br/>(apply after coupons)
    Server-->>Client: Updated cart with gift card deduction

    Client->>Server: POST /checkout (finalize)
    Server->>Checkout: Process payment
    Checkout->>DB: Create GiftCardTransaction
    Checkout->>DB: Deduct balance from GiftCard
    DB-->>Checkout: Transaction & balance updated
    Checkout->>Email: Send redemption confirmation
    Email-->>Client: Email to purchaser
    Server-->>Client: Order confirmation

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes


Poem

🎁 A gift card hops with joy today,
Through shop and cart in every way,
With buckets bright and trials new,
The MPS burrows made all true!
Five features strong, the rabbit sings—
What wonderful commerce spring brings! 🐰

🚥 Pre-merge checks | 2 | 1

Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed checks (2 passed)
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The PR title accurately describes the primary change: integrating design tokens from the styleguide into the codebase. It is specific, concise, and directly relates to the main objective of establishing tokens.css as a foundation layer.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Finishing Touches

📝 Generate docstrings: use the @coderabbitai generate docstrings command.

💡 TIP
Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

<!-- This is an auto-generated comment: summarize by coderabbit.ai --> <!-- walkthrough_start --> <details> <summary>📝 Walkthrough</summary> ## Walkthrough This PR introduces a comprehensive gift card system (purchase, redemption, management), shop environment classification (production/staging/development), a 21-day trial feature, and Bring Your Own Bucket (BYOB) S3 support. Includes new models, migrations, request helpers, templates, routes, and extensive documentation updates across design system, architecture, and ticket specifications. ## Changes |Cohort / File(s)|Summary| |---|---| |**Gift Card Core Models** <br> `make_post_sell/models/gift_card.py`, `gift_card_transaction.py`, `cart_gift_card.py`|New ORM models for gift cards, transactions, and cart-to-gift-card associations. Includes code generation, validation, deduction logic, and retrieval helpers.| |**Gift Card Cart Integration** <br> `make_post_sell/models/cart.py`|Extends Cart with `json_gift_cards` column, gift card association proxy, purchase tracking, discount deductions per gift card, and validation methods.| |**Gift Card Purchase & Routes** <br> `make_post_sell/routes.py`, `templates/gift_card.j2`, `static/js/gift_card.js`|New routes for gift card page, add-to-cart, apply, and removal. Template with slider/input controls for amount selection and recipient details. Route handling not shown in diff.| |**Gift Card Management UI** <br> `templates/gift_card_manage.j2`, `gift_card_detail.j2`|Admin templates for listing gift cards with totals, status badges, toggle controls, and transaction history display.| |**Shop Model Extensions** <br> `make_post_sell/models/shop.py`|Adds gift card settings, environment classification (production/staging/development), trial tracking (started, ended, active), and BYOB primary S3 configuration with CDN endpoint.| |**BYOB S3 Implementation** <br> `make_post_sell/request_methods.py`, `lib/karaoke.py`, `lib/s3_mirror.py`|Shop-aware S3 client selection via `shop_uploads_client` and `shop_bucket_name` request methods. Defers S3 client creation to post-shop-load in backfill operations. Adds domain check for 127.0.0.1.| |**Design System & Guidelines** <br> `docs/design-system.md`, `CLAUDE.md`|Comprehensive design system documentation covering tokens, typography, spacing, components, and CSS conventions. CLAUDE.md reinforces design/test/BYOB guidelines with post-work checklists.| |**Architecture & Ticket Documentation** <br> `docs/architecture.md`, `docs/tickets/mps-10.md` through `mps-16.md`|Updates architecture diagram for gift cards and BYOB. Detailed specifications for gift card models, purchase flow, redemption, admin settings, shop environment, trial system, and BYOB feature.| |**Database Migrations** <br> `make_post_sell/scripts/alembic/versions/f8201a9ba045_...`, `9884324a48e3_...`|Gift card tables (`mps_gift_card`, `mps_gift_card_transaction`, `mps_cart_gift_card`), cart `json_gift_cards` column, shop settings, and environment/trial/primary S3 columns with idempotent guards.| |**Template CDN Endpoint Updates** <br> `templates/base.j2`, `cart.j2`, `content.j2`, `home.j2`, `product_edit.j2`, `shop.j2`, `shop_about.j2`|Replaces `request.app["bucket.secure_uploads.get_endpoint"]` with `request.shop_cdn_endpoint` for all media/thumbnail URLs. Adds environment and trial banners to base template.| |**Email & Service Updates** <br> `make_post_sell/lib/mail.py`, `lib/digest_sender.py`|New `send_gift_card_email()` function with amount, code, message, and redemption instructions. Digest sender filters to production shops only (`Shop.environment == 0`).| |**Frontend State Management** <br> `make_post_sell/static/js/watch.js`|Adds ring state staleness detection and server-seeding on startup; fetches current product from server if localStorage ring data is empty; fallback to existing preloadNext on fetch failure.| |**Model & Meta Updates** <br> `make_post_sell/models/__init__.py`, `meta.py`, `product.py`|Exports new gift card model classes. Adds CLASS_TO_TABLE mappings for GiftCard, GiftCardTransaction, CartGiftCard. Updates product search to exclude non-production shops via environment filter.| |**Admin UI** <br> `templates/actions_view.j2`|Conditional "Gift Cards" management link when `request.shop.gift_card_enabled`.| ## Sequence Diagram ```mermaid sequenceDiagram participant Client as Client (Browser) participant Server as Server (Routes/Cart) participant DB as Database participant Checkout as Checkout View participant Email as Email Service Client->>Server: POST /gift-card/apply<br/>(gift_card_code, cart_id) Server->>DB: Fetch GiftCard by code DB-->>Server: GiftCard (balance, validity check) Server->>Server: Validate (enabled, balance > 0, shop match) Server->>DB: Add CartGiftCard entry DB-->>Server: Success Server->>Server: Recalculate cart.discounted_totals<br/>(apply after coupons) Server-->>Client: Updated cart with gift card deduction Client->>Server: POST /checkout (finalize) Server->>Checkout: Process payment Checkout->>DB: Create GiftCardTransaction Checkout->>DB: Deduct balance from GiftCard DB-->>Checkout: Transaction & balance updated Checkout->>Email: Send redemption confirmation Email-->>Client: Email to purchaser Server-->>Client: Order confirmation ``` ## Estimated code review effort 🎯 4 (Complex) | ⏱️ ~50 minutes --- ## Poem > 🎁 A gift card hops with joy today, > Through shop and cart in every way, > With buckets bright and trials new, > The MPS burrows made all true! > Five features strong, the rabbit sings— > What wonderful commerce spring brings! 🐰 </details> <!-- walkthrough_end --> <!-- pre_merge_checks_walkthrough_start --> <details> <summary>🚥 Pre-merge checks | ✅ 2 | ❌ 1</summary> ### ❌ Failed checks (1 warning) | Check name | Status | Explanation | Resolution | | :----------------: | :--------- | :------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------- | | Docstring Coverage | ⚠️ Warning | Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. | <details> <summary>✅ Passed checks (2 passed)</summary> | Check name | Status | Explanation | | :---------------: | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. | | Title check | ✅ Passed | The PR title accurately describes the primary change: integrating design tokens from the styleguide into the codebase. It is specific, concise, and directly relates to the main objective of establishing tokens.css as a foundation layer. | </details> <sub>✏️ Tip: You can configure your own custom pre-merge checks in the settings.</sub> </details> <!-- pre_merge_checks_walkthrough_end --> <!-- finishing_touch_checkbox_start --> <details> <summary>✨ Finishing Touches</summary> 📝 Generate docstrings: use the `@coderabbitai generate docstrings` command. </details> <!-- finishing_touch_checkbox_end --> <!-- announcements_start --> > 💡 **TIP** > Try [Coding Plans](https://www.coderabbit.ai/issue-planner). Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). > Share your feedback on [Discord](https://discord.com/invite/coderabbit). <!-- announcements_end --> <!-- tips_start --> --- <sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub> <!-- tips_end --> <!-- internal state start --> <!-- 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 --> <!-- internal state end -->
coderabbitai commented 2026-03-07 23:30:13 -05:00 (Migrated from git2.unturf.com)

changed the description

changed the description
coderabbitai commented 2026-03-07 23:38:50 -05:00 (Migrated from git2.unturf.com)

Actionable comments posted: 14

📝 NOTE
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🔥 CAUTION
Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
make_post_sell/models/cart.py (1)

51-67: ⚠️ Potential issue | 🟠 Major

Wire the new gift-card state into cart reset and merge flows.

After this change a cart carries both json_gift_cards and attached gift_cards, but empty() still only clears json_cart and merge_in_cart() still only merges products/coupons. That leaves stale gift cards in an “empty” cart and drops them during cart merges.

Also applies to: 79-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/cart.py` around lines 51 - 67, The cart now has both
json_gift_cards and association_proxy gift_cards but empty() and merge_in_cart()
ignore them; update empty() to clear json_gift_cards and remove all associated
CartGiftCard entries (via the cart_gift_cards relationship or by clearing the
gift_cards proxy) so an "empty" cart has no gift cards, and update
merge_in_cart() (and the equivalent logic referenced at lines ~79-80) to also
transfer/merge gift cards: merge json_gift_cards arrays and attach/deduplicate
CartGiftCard objects to the destination cart (using the CartGiftCard/ gift_cards
proxy) just like you do for coupons/products so gift cards aren’t left behind or
dropped during merges.
🟡 Minor comments (9)
docs/design-system.md-9-52 (1)

9-52: ⚠️ Potential issue | 🟡 Minor

Add languages to the fenced examples.

markdownlint is already flagging these blocks with MD040. Using text for diagrams/tables and the concrete language for actual snippets will keep docs checks clean and improve syntax highlighting.

Also applies to: 67-77, 81-94, 98-114, 118-126, 130-137, 141-151, 155-163, 167-173, 179-187, 304-309

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design-system.md` around lines 9 - 52, The fenced code blocks in the
design-system.md examples (e.g., the ASCII diagram showing tokens.css →
common.css → templates/ and other snippet blocks) lack language identifiers and
trigger markdownlint MD040; update each triple-backtick fence to include an
appropriate language tag (use text for diagrams/tables and the concrete language
like css, jinja, or html for actual snippets) across the affected example blocks
so syntax highlighting and linting pass (apply this to the diagram block
containing tokens.css/common.css/templates and the other listed example blocks).
make_post_sell/static/js/watch.js-221-228 (1)

221-228: ⚠️ Potential issue | 🟡 Minor

Preserve the current item when resetting a stale ring.

This clears ringHistory immediately after markWatched(initialId), so a stale-ring load forgets that the current product was already watched. The visible effect is an incorrect 0 / N progress state until the user loops back to this item.

Suggested fix
         if (ringProductIds.length && ringProductIds.indexOf(initialId) === -1) {
             ringProductIds = [];
             ringPosition = 0;
             ringHistory = {};
+            ringHistory[initialId] = true;
             ringLoops = 0;
             saveRingState();
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/static/js/watch.js` around lines 221 - 228, When you detect a
stale ring, don't wipe out the watched state for the currently viewed product
(initialId). Modify the reset so that ringHistory preserves the entry for
initialId (e.g. set ringHistory = ringHistory[initialId] ? { [initialId]:
ringHistory[initialId] } : {}), or alternatively clear ringHistory then
immediately call markWatched(initialId) before saveRingState(); keep resetting
ringProductIds, ringPosition and ringLoops as before and then call
saveRingState().
make_post_sell/templates/gift_card_detail.j2-59-59 (1)

59-59: ⚠️ Potential issue | 🟡 Minor

Render a human-readable transaction date here.

txn.created_timestamp is the raw millisecond DB value, so this will show a 13-digit epoch in the UI instead of a usable date/time. Pass a formatted string from the view or expose a display property on the model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/templates/gift_card_detail.j2` at line 59, The template is
rendering txn.created_timestamp (a raw millisecond epoch) which will show a
13-digit number; change the rendering to use a human-readable string by either
formatting the timestamp in the view and passing a new property (e.g.,
txn.created_at_display) or add a display property on the model that returns a
formatted datetime, then update the template to render that property instead of
txn.created_timestamp; ensure the formatter uses your app's timezone/locale
conventions so the UI shows a readable date/time.
docs/tickets/mps-14.md-10-10 (1)

10-10: ⚠️ Potential issue | 🟡 Minor

Clarify whether the two extra shops require a paid production seat.

Line 10 and Line 105 say the allowance is tied to a paid production shop, but the counting example grants it for any production shop. MPS-15 also says trial shops can create two dev/stage shops, so this needs one consistent rule before implementation/tests are written.

Also applies to: 103-115

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tickets/mps-14.md` at line 10, The statement "Every paid production shop
seat includes 2 free dev/stage shops." is inconsistent with the counting example
and MPS-15; update the policy text and all examples to state a single rule:
either (A) the two extra dev/stage shops are granted only for paid production
seats, or (B) they are granted for any production shop including trials — choose
one and apply it consistently. Specifically, edit the sentence "Every paid
production shop seat includes 2 free dev/stage shops." and the related "counting
example" and the MPS-15 reference so they all use the same rule and adjust any
example calculations that assume the opposite behavior; ensure the document
explicitly calls out whether trial production shops qualify and remove
conflicting phrasing in the passages currently using "paid" vs "any".
docs/tickets/mps-13.md-25-26 (1)

25-26: ⚠️ Potential issue | 🟡 Minor

These route examples don't match the URLs used by the new templates.

make_post_sell/templates/gift_card_detail.j2 already links to /s/{{ request.shop.uuid_str }}/gift-cards/..., so keeping /shop/{slug}/... here will send implementation and tests toward the wrong route shape.

💡 Proposed fix
-New route: `/shop/{slug}/gift-cards/manage` (shop owner only)
+New route: `/s/{shop_id}/gift-cards/manage` (shop owner only)
@@
-`/shop/{slug}/gift-cards/{card_id}` (shop owner only)
+`/s/{shop_id}/gift-cards/{card_id}` (shop owner only)
@@
-On the gift card purchase page (`/shop/{slug}/gift-card`), add a "Check Balance"
+On the gift card purchase page (`/s/{shop_id}/gift-card`), add a "Check Balance"

Also applies to: 40-40, 55-56

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tickets/mps-13.md` around lines 25 - 26, The documented route examples
use /shop/{slug}/gift-cards/manage but the templates
(make_post_sell/templates/gift_card_detail.j2) and links use /s/{{
request.shop.uuid_str }}/gift-cards/..., so update the docs to the template URL
shape (e.g., /s/{shop_uuid}/gift-cards/manage) and adjust the example
routes/tests/implementation guidance to that shape; also fix the other two
occurrences of the incorrect /shop/{slug}/... examples mentioned in the comment
so all examples match the template URL format.
docs/tickets/mps-15.md-116-129 (1)

116-129: ⚠️ Potential issue | 🟡 Minor

Use uuid_str in the banner links.

The rest of this PR builds shop URLs with request.shop.uuid_str, so documenting request.shop.id here will propagate the wrong pattern into base.j2 and tests.

💡 Proposed fix
 {% 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>
+  <a href="/s/{{ request.shop.uuid_str }}/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>
+  <a href="/s/{{ request.shop.uuid_str }}/settings#plan">Choose a plan to keep selling</a>
 </div>
 {% endif %}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tickets/mps-15.md` around lines 116 - 129, The banner link URLs use
request.shop.id but should use request.shop.uuid_str to match the rest of the
codebase; update both anchor hrefs in the trial banner blocks to build URLs with
request.shop.uuid_str (the conditional checks request.shop,
request.shop.is_trial_active, request.shop.owners,
request.shop.is_trial_expired, and request.shop.plan_active remain unchanged) so
templates and tests use the UUID string pattern consistently.
docs/tickets/mps-16.md-31-49 (1)

31-49: ⚠️ Potential issue | 🟡 Minor

Document the final BYOB schema, not both alternatives.

This ticket still presents mutually exclusive designs (mirror_s3_* reuse vs dedicated primary_s3_* columns), but the implementation chose the dedicated columns. Leaving both here makes the reference ambiguous.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tickets/mps-16.md` around lines 31 - 49, The docs currently show two
mutually exclusive BYOB schema designs (reusing mirror_s3_* vs new primary_s3_*
columns); update the document to remove the alternative and document only the
chosen schema: list and describe the final primary_s3_* columns
(primary_s3_endpoint, primary_s3_region, primary_s3_bucket,
primary_s3_access_key, primary_s3_secret_key, primary_s3_cdn_endpoint) and the
primary_s3_enabled flag, clarifying semantics (when primary_s3_enabled is true
these primary_* fields are used as the primary bucket) and remove any mention of
reusing mirror_s3_* to avoid ambiguity.
docs/tickets/mps-11.md-31-32 (1)

31-32: ⚠️ Potential issue | 🟡 Minor

Drop the “Option A” wording now that the schema is fixed.

This ticket already names json_gift_cards in the changed files, so keeping the storage format as an option makes the reference doc look undecided.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tickets/mps-11.md` around lines 31 - 32, Remove the "Option A" hedging
and make the storage format definitive: replace the "Option A:" prefix and any
language suggesting alternatives with a declarative statement that Cart now
stores gift cards in the json_gift_cards column, and keep the format example
`[{"shop_id": "...", "amount_in_cents": 2500, "gift_email": "...",
"gift_message": "..."}]` as the canonical schema; ensure references to Cart and
json_gift_cards are updated to reflect the fixed schema and not presented as
optional.
docs/tickets/mps-10.md-36-37 (1)

36-37: ⚠️ Potential issue | 🟡 Minor

Align the sample code format with the actual generator.

secrets.token_hex(8).upper() only yields hex characters, so GC-A1B2C3D4E5F6G7H8 can never be produced. Either switch the generator or make the example hex-only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tickets/mps-10.md` around lines 36 - 37, The example ticket code format
in the docs doesn't match the generator: secrets.token_hex(8).upper() produces
16 hex characters (0-9A-F) so remove the impossible alphanumeric letters from
the example or change the generator; either update the sample to "GC-" followed
by 16 hex chars (e.g., GC-<16_HEX_CHARS>) to match secrets.token_hex(8).upper(),
or replace secrets.token_hex(8).upper() with a generator that picks 16 chars
from string.ascii_uppercase + string.digits (e.g., using secrets.choice) so it
can produce values like GC-A1B2C3D4E5F6G7H8.
🧹 Nitpick comments (7)
make_post_sell/models/product.py (1)

680-685: Move the import outside the loop for efficiency.

The from .shop import Shop import (line 683) is inside the for keyword in keywords: loop, so it executes for every keyword when shop is None. While Python caches imports, the lookup still adds unnecessary overhead.

♻️ Proposed fix to move import outside loop
 def get_products_by_keywords(dbsession, keywords, shop=None):
     scores = {}
     hits = {}
+    
+    # Import Shop here to avoid circular import at module level
+    if not shop:
+        from .shop import Shop

     if shop:
         product_query = shop.products
     else:
         product_query = dbsession.query(Product)

     for keyword in keywords:
         keyword_filter = Product.title.ilike(f"%{keyword}%")
         # the product _must_ be public (1).
         query = product_query.filter(keyword_filter).filter(Product.visibility == 1)
         # Exclude non-production shops from search results (MPS-14)
         if not shop:
-            from .shop import Shop
             query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment == 0)
         products = query.all()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/product.py` around lines 680 - 685, The import of Shop
is inside the keywords loop which causes repeated lookups; move the "from .shop
import Shop" import out of the loop (place it above where product_query is used
or before the loop) and keep the existing logic that uses Shop only when shop is
falsy (the block that sets query = query.join(Shop, Product.shop_id ==
Shop.id).filter(Shop.environment == 0)); ensure references to
Product.visibility, product_query, shop, and keywords continue to work
unchanged.
make_post_sell/lib/s3_mirror.py (1)

232-248: Consider adding region fallback for consistency.

The _make_src helper uses shop.primary_s3_region directly (line 237), but this field may be None since has_primary_s3 doesn't check for it. The fallback path (lines 242-248) uses app_settings["bucket.secure_uploads.region"] which is guaranteed to exist, and _make_mirror_client uses region or "us-east-1" as a fallback (line 23).

For consistency with the mirror client pattern:

♻️ Proposed fix to add region fallback
             if shop and shop.has_primary_s3:
                 return boto3.session.Session().client(
                     "s3",
-                    region_name=shop.primary_s3_region,
+                    region_name=shop.primary_s3_region or "us-east-1",
                     endpoint_url=shop.primary_s3_endpoint,
                     aws_access_key_id=shop.primary_s3_access_key,
                     aws_secret_access_key=shop.primary_s3_secret_key,
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/lib/s3_mirror.py` around lines 232 - 248, The _make_src helper
uses shop.primary_s3_region directly even though has_primary_s3 may be true
while primary_s3_region is None; update _make_src to compute region =
shop.primary_s3_region or app_settings["bucket.secure_uploads.region"] and pass
that region into boto3.session.Session().client(...) so the source client falls
back consistently (matching the region fallback pattern used in
_make_mirror_client); reference _make_src, shop.primary_s3_region,
app_settings["bucket.secure_uploads.region"], and _make_mirror_client when
making the change.
make_post_sell/templates/gift_card.j2 (1)

16-34: Step value mismatch between slider and input may cause minor UX friction.

The range slider uses step="1" (line 20) while the numeric input uses step="0.01" (line 30). When the user types a decimal value like 25.50, the slider can only snap to 25 or 26, potentially confusing users expecting precise synchronization.

Consider aligning the step values or documenting this as intentional (slider for quick selection, input for precise amounts).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/templates/gift_card.j2` around lines 16 - 34, The slider (id
"gift-card-slider") uses step="1" while the numeric input (id
"gift-card-amount") uses step="0.01", causing desynchronization when users enter
decimal amounts; update the slider's step to "0.01" to match the input (or
change the input to "1" if intentional), and ensure any JS that syncs values
between gift-card-slider and gift-card-amount handles decimals consistently so
both controls reflect the same precision.
make_post_sell/lib/karaoke.py (1)

364-380: Consider restructuring _make_s3 to receive shop as a parameter.

The nested _make_s3 function references the outer shop variable, which creates an implicit dependency on execution order. The function is called both at line 388 (after shop is loaded) and at line 433 within worker threads. While this works because shop is loaded before any calls, passing shop as an explicit parameter would make the dependency clear and prevent accidental misuse.

♻️ Proposed refactor
-        def _make_s3():
-            # BYOB: use shop's own bucket if configured (MPS-16)
-            if shop and shop.has_primary_s3:
+        def _make_s3(shop_obj):
+            # BYOB: use shop's own bucket if configured (MPS-16)
+            if shop_obj and shop_obj.has_primary_s3:
                 return boto3.session.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,
+                    region_name=shop_obj.primary_s3_region,
+                    endpoint_url=shop_obj.primary_s3_endpoint,
+                    aws_access_key_id=shop_obj.primary_s3_access_key,
+                    aws_secret_access_key=shop_obj.primary_s3_secret_key,
                 )
             return boto3.session.Session().client(

Then update call sites:

-            s3 = _make_s3()
+            s3 = _make_s3(shop)
-                thread_s3 = _make_s3()
+                thread_s3 = _make_s3(shop)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/lib/karaoke.py` around lines 364 - 380, The nested helper
_make_s3 currently captures the outer shop variable; change its signature to
accept shop as an explicit parameter (e.g., def _make_s3(shop):) and update its
body to use that parameter instead of the outer variable while still falling
back to app_settings when shop is falsy; then update every call site that
invokes _make_s3 (including the post-shop-load call and the worker/thread
invocation) to pass the appropriate shop object (or None if using app_settings),
ensuring no code relies on the outer lexical capture of shop.
make_post_sell/lib/mail.py (2)

549-565: Remove extraneous f prefixes from strings without placeholders.

Several strings have f prefixes but contain no interpolated values. This triggers Ruff F541 errors and adds unnecessary overhead.

🔧 Proposed fix
     message_parts = [
         f"You received a {amount} gift card for {shop_name}!",
-        f"",
+        "",
         f"Your gift card code: {code}",
-        f"",
+        "",
     ]
     if gift_message:
         message_parts.append(f"Message: {gift_message}")
         message_parts.append("")
     message_parts.extend([
         f"To redeem, enter the code at checkout when shopping at {shop_name}.",
-        f"",
+        "",
         f"Visit: {shop_url}",
-        f"",
-        f"This gift card never expires.",
+        "",
+        "This gift card never expires.",
     ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/lib/mail.py` around lines 549 - 565, The message_parts
construction uses unnecessary f-string prefixes on literals without
interpolation (e.g., f"", f"This gift card never expires.", f"To redeem..."
etc.); update the list building around the message_parts variable so only
strings that actually include placeholders remain f-strings (keep f on lines
with {amount}, {shop_name}, {code}, {shop_url}, {gift_message}), and remove the
f prefix from empty strings and static sentences before joining into
message_text to eliminate Ruff F541 warnings.

567-577: Remove extraneous f prefixes in HTML parts.

Same issue in the HTML generation section.

🔧 Proposed fix
     html_parts = [
         f"<h2>You received a {amount} gift card for {shop_name}!</h2>",
-        f"<p><strong>Your gift card code:</strong></p>",
+        "<p><strong>Your gift card code:</strong></p>",
         f"<p style='font-size: 24px; font-family: monospace; background: `#f0f0f0`; padding: 12px; display: inline-block;'>{code}</p>",
     ]
     if gift_message:
         html_parts.append(f"<p><em>{gift_message}</em></p>")
     html_parts.extend([
         f"<p>To redeem, enter the code at checkout when shopping at <a href='{shop_url}'>{shop_name}</a>.</p>",
-        f"<p><small>This gift card never expires.</small></p>",
+        "<p><small>This gift card never expires.</small></p>",
     ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/lib/mail.py` around lines 567 - 577, The HTML parts list uses
unnecessary f-string prefixes for static strings; update html_parts so only
strings that interpolate variables use f-strings (keep f for entries that
reference amount, shop_name, code, shop_url, gift_message) and remove the
extraneous f prefix from static entries such as the "<p><small>This gift card
never expires.</small></p>" item (locate and edit the html_parts construction
and any nearby HTML generation that currently uses f"" without interpolation).
make_post_sell/models/cart_gift_card.py (1)

15-18: Consider adding a unique constraint on (cart_id, gift_card_id).

While the documentation mentions that validation happens at the view level (one gift card per shop), a database-level unique constraint would provide an additional safeguard against duplicate gift card applications to the same cart.

🔧 Proposed enhancement
+from sqlalchemy import UniqueConstraint
+
 class CartGiftCard(RBase, Base):
     """
     Many to many, Carts to GiftCards.
     A relationship signifies the application of a gift card to a cart.
     """
+    __table_args__ = (
+        UniqueConstraint('cart_id', 'gift_card_id', name='uq_cart_gift_card'),
+    )

     id = Column(UUIDType, primary_key=True, index=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/cart_gift_card.py` around lines 15 - 18, Add a DB-level
unique constraint to prevent duplicate gift-card entries per cart by updating
the CartGiftCard model: import and use SQLAlchemy's UniqueConstraint (e.g.,
UniqueConstraint("cart_id", "gift_card_id", name="uq_cart_gift_card")) in the
model's __table_args__ so the combination of cart_id and gift_card_id is
enforced as unique at the database level; ensure the import for UniqueConstraint
is added alongside Column/UUIDType and keep the existing columns (id, cart_id,
gift_card_id, created_timestamp) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/tickets/mps-15.md`:
- Around line 27-30: The is_active logic will leave legacy shops inactive
because NULL `trial_started_timestamp` is treated as false and `plan_active`
defaults to 0; update the activation rule or migration so legacy shops remain
active: either change the boolean expression (used where `is_active` is computed
around lines 66-69) to treat NULL `trial_started_timestamp` as "paid" (e.g.,
`plan_active OR is_trial_active OR trial_started_timestamp IS NULL`) or add an
idempotent backfill/migration on `mps_shop` to set `plan_active=true` for
existing rows; reference the column names `trial_started_timestamp`,
`trial_ended`, and `plan_active` and the `is_active` computation when making the
change.

In `@make_post_sell/models/cart.py`:
- Around line 468-485: The validate_attached_gift_cards method currently doesn't
reject duplicate attachments, so the same gift card can be applied multiple
times; update validate_attached_gift_cards to detect duplicate gift_card.id
(and/or gift_card.code) across self.gift_cards and add an error like "Gift card
'<code>' is attached multiple times" when duplicates are found (in addition to
the existing disabled/balance/shop checks), returning these errors; also note to
add a unique (cart_id, gift_card_id) constraint later in
make_post_sell/models/cart_gift_card.py to enforce this at the DB level.

In `@make_post_sell/models/gift_card_transaction.py`:
- Around line 3-4: Reject non-positive redemption amounts by adding a guard in
the GiftCardTransaction constructor and a schema-level constraint: in the
GiftCardTransaction class, update its __init__ to validate that amount_in_cents
> 0 and raise a ValueError if not (refer to the amount_in_cents parameter), and
add a SQLAlchemy CheckConstraint like CheckConstraint("amount_in_cents > 0") on
the model so the DB enforces it as well; ensure imports are added
(CheckConstraint) and adjust any existing code paths that construct
GiftCardTransaction to expect the ValueError on invalid input.

In `@make_post_sell/models/gift_card.py`:
- Around line 80-84: The deduct method currently allows negative amounts and
performs a non-atomic read/modify/write on balance_in_cents; change deduct to
first validate amount_in_cents > 0 and raise a ValueError for non-positive
inputs, then perform the deduction using a DB-level atomic operation instead of
in-memory math: either run the update inside a transaction with a SELECT ... FOR
UPDATE on the GiftCard row and adjust balance_in_cents, or issue an atomic
UPDATE gift_cards SET balance_in_cents = balance_in_cents - :amt WHERE id = :id
AND balance_in_cents >= :amt and check affected rows to determine the actual
deducted amount; keep the method name deduct and its contract of returning the
actual deducted cents.

In `@make_post_sell/models/shop.py`:
- Around line 842-850: The async rebuild bypasses the non-production guard:
update reforge_discovery_ring_async to mirror reforge_discovery_ring by checking
shop.environment and shop.is_non_production before calling
compute_discovery_ring(), setting shop.discovery_ring = [] and returning [] for
non-production shops (or alternatively add the same guard at the start of
compute_discovery_ring()); ensure you reference and modify
reforge_discovery_ring_async (and/or compute_discovery_ring) so background
rebuilds do not repopulate discovery for staging/development shops.
- Around line 173-175: The trial_ended boolean is stored but never used; update
the shop state helpers to respect it: modify is_trial_active, is_trial_expired,
and is_active to check trial_ended (and trial_started_timestamp) so that when
trial_ended is True the trial is treated as ended/expired and the shop is
considered not active via trial. Ensure is_trial_active returns False if
trial_ended is True, is_trial_expired returns True if trial_ended is True, and
is_active uses those updated helpers so early-ended trials no longer grant
access.
- Around line 339-348: The has_primary_s3 property currently omits
primary_s3_region, so a shop can pass readiness while add_shop_uploads_client()
later supplies shop.primary_s3_region to boto3 and fail; update the
has_primary_s3 boolean check to also require self.primary_s3_region (include
primary_s3_region in the AND list) so the readiness check matches what
add_shop_uploads_client() expects and prevents creating a misconfigured S3
client.

In `@make_post_sell/request_methods.py`:
- Around line 158-185: The helpers add_shop_uploads_client,
add_shop_bucket_name, and add_shop_cdn_endpoint currently treat any truthy
shop.has_primary_s3 as enough and silently fall back to the platform bucket when
one or more BYOB fields are missing; instead validate that all required BYOB
fields are present before returning BYOB values (for add_shop_uploads_client
check primary_s3_region, primary_s3_endpoint, primary_s3_access_key,
primary_s3_secret_key; for add_shop_bucket_name check primary_s3_bucket; for
add_shop_cdn_endpoint check primary_s3_cdn_endpoint), and if validation fails
raise an explicit error (or log and abort) so misconfiguration is surfaced
rather than quietly returning request.secure_uploads_client or
request.app["bucket.secure_uploads"].

In
`@make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py`:
- Around line 73-83: The migration currently adds plaintext credential columns
primary_s3_access_key and primary_s3_secret_key to the mps_shop table via
op.add_column (guarded by _column_exists); instead, change the migration to add
a single encrypted_secret_reference (or primary_s3_secret_id) column (e.g.,
VARCHAR/UUID) that stores a reference/ID to a secrets vault or an
envelope-encrypted payload, and remove the direct plaintext columns; update any
code that will write to/read from primary_s3_access_key/primary_s3_secret_key to
use the new reference and fetch/decrypt secrets via your secret manager
(KMS/Vault) at runtime, and include a migration note to securely rotate/migrate
existing plaintext data to the secret store before dropping old columns if
needed.
- Around line 98-109: The downgrade() currently unconditionally calls
op.drop_column for many columns (e.g., "primary_s3_enabled",
"primary_s3_cdn_endpoint", "primary_s3_secret_key", "primary_s3_access_key",
"primary_s3_bucket", "primary_s3_region", "primary_s3_endpoint", "plan_active",
"trial_ended", "trial_started_timestamp", "environment"), which can break on
partially applied schemas; update downgrade() to mirror the tolerant behavior of
upgrade() by checking for each column's existence before dropping it (use the
Alembic op.get_bind()/sqlalchemy.inspect Inspector or a helper like has_column
to query the table schema), and only call op.drop_column for columns that
actually exist to avoid dropping columns that this revision didn't create.

In
`@make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py`:
- Around line 36-100: The upgrade() migration is missing the required
pre-migration SQLite backup; add a call at the very start of upgrade() to invoke
the project's backup helper (e.g., call a function like ensure_sqlite_backup()
or run_sqlite_backup()) before any DDL runs, and make that helper a no-op on
non-SQLite backends; update or create a small function (ensure_sqlite_backup /
run_sqlite_backup) that checks the current DB URL/driver, performs the
filesystem copy/backup when driver == "sqlite", and raises/logs on failure so
the migration aborts safely if the backup cannot be made.
- Around line 102-108: The downgrade() fails to fully reverse upgrade() (it
never removes mps_cart.json_gift_cards) and naively drops tables/columns which
will error on partially-applied DBs; update downgrade() to drop the
mps_cart.json_gift_cards column and guard each drop with an existence check (use
SQLAlchemy Inspector or context.get_bind() to verify table/column existence)
before calling op.drop_table or op.drop_column for mps_cart_gift_card,
mps_gift_card_transaction, mps_gift_card, and the mps_shop columns
gift_card_enabled, gift_card_min_in_cents, gift_card_max_in_cents so the
rollback is idempotent and fully reverses upgrade().

In `@make_post_sell/static/js/watch.js`:
- Around line 1841-1856: The async fetchWatchData callback can overwrite
ringProductIds when the user navigates before the response arrives; to fix,
capture the product id at request time (e.g., const requestedId =
currentProductId) before calling fetchWatchData and, inside the .then handler,
verify the live currentProductId still equals requestedId (or that data is
tagged for requestedId) before assigning ringProductIds, calling
syncRingPosition, saveRingState, or updateProgressDisplay; if it doesn't match,
ignore the stale response and still call preloadNext as needed.

In `@make_post_sell/templates/content.j2`:
- Around line 42-45: The media URL generation in watch.py currently returns
presigned S3 URLs (via generate_presigned_url()) for media_url,
instrumentals_url, and vocals_url causing a scheme mismatch with templates that
expect persistent CDN URLs; update watch.py to construct these three URLs using
request.shop_cdn_endpoint the same way file_url and thumbnail_url are built
(i.e., combine request.shop_cdn_endpoint with product.s3_path and the
appropriate path/filename pattern used for product media) instead of calling
generate_presigned_url(), ensuring media_url, instrumentals_url, and vocals_url
are consistent CDN endpoints that won’t expire.

---

Outside diff comments:
In `@make_post_sell/models/cart.py`:
- Around line 51-67: The cart now has both json_gift_cards and association_proxy
gift_cards but empty() and merge_in_cart() ignore them; update empty() to clear
json_gift_cards and remove all associated CartGiftCard entries (via the
cart_gift_cards relationship or by clearing the gift_cards proxy) so an "empty"
cart has no gift cards, and update merge_in_cart() (and the equivalent logic
referenced at lines ~79-80) to also transfer/merge gift cards: merge
json_gift_cards arrays and attach/deduplicate CartGiftCard objects to the
destination cart (using the CartGiftCard/ gift_cards proxy) just like you do for
coupons/products so gift cards aren’t left behind or dropped during merges.

---

Minor comments:
In `@docs/design-system.md`:
- Around line 9-52: The fenced code blocks in the design-system.md examples
(e.g., the ASCII diagram showing tokens.css → common.css → templates/ and other
snippet blocks) lack language identifiers and trigger markdownlint MD040; update
each triple-backtick fence to include an appropriate language tag (use text for
diagrams/tables and the concrete language like css, jinja, or html for actual
snippets) across the affected example blocks so syntax highlighting and linting
pass (apply this to the diagram block containing tokens.css/common.css/templates
and the other listed example blocks).

In `@docs/tickets/mps-10.md`:
- Around line 36-37: The example ticket code format in the docs doesn't match
the generator: secrets.token_hex(8).upper() produces 16 hex characters (0-9A-F)
so remove the impossible alphanumeric letters from the example or change the
generator; either update the sample to "GC-" followed by 16 hex chars (e.g.,
GC-<16_HEX_CHARS>) to match secrets.token_hex(8).upper(), or replace
secrets.token_hex(8).upper() with a generator that picks 16 chars from
string.ascii_uppercase + string.digits (e.g., using secrets.choice) so it can
produce values like GC-A1B2C3D4E5F6G7H8.

In `@docs/tickets/mps-11.md`:
- Around line 31-32: Remove the "Option A" hedging and make the storage format
definitive: replace the "Option A:" prefix and any language suggesting
alternatives with a declarative statement that Cart now stores gift cards in the
json_gift_cards column, and keep the format example `[{"shop_id": "...",
"amount_in_cents": 2500, "gift_email": "...", "gift_message": "..."}]` as the
canonical schema; ensure references to Cart and json_gift_cards are updated to
reflect the fixed schema and not presented as optional.

In `@docs/tickets/mps-13.md`:
- Around line 25-26: The documented route examples use
 /shop/{slug}/gift-cards/manage but the templates
(make_post_sell/templates/gift_card_detail.j2) and links use /s/{{
request.shop.uuid_str }}/gift-cards/..., so update the docs to the template URL
shape (e.g., /s/{shop_uuid}/gift-cards/manage) and adjust the example
routes/tests/implementation guidance to that shape; also fix the other two
occurrences of the incorrect /shop/{slug}/... examples mentioned in the comment
so all examples match the template URL format.

In `@docs/tickets/mps-14.md`:
- Line 10: The statement "Every paid production shop seat includes 2 free
dev/stage shops." is inconsistent with the counting example and MPS-15; update
the policy text and all examples to state a single rule: either (A) the two
extra dev/stage shops are granted only for paid production seats, or (B) they
are granted for any production shop including trials — choose one and apply it
consistently. Specifically, edit the sentence "Every paid production shop seat
includes 2 free dev/stage shops." and the related "counting example" and the
MPS-15 reference so they all use the same rule and adjust any example
calculations that assume the opposite behavior; ensure the document explicitly
calls out whether trial production shops qualify and remove conflicting phrasing
in the passages currently using "paid" vs "any".

In `@docs/tickets/mps-15.md`:
- Around line 116-129: The banner link URLs use request.shop.id but should use
request.shop.uuid_str to match the rest of the codebase; update both anchor
hrefs in the trial banner blocks to build URLs with request.shop.uuid_str (the
conditional checks request.shop, request.shop.is_trial_active,
request.shop.owners, request.shop.is_trial_expired, and request.shop.plan_active
remain unchanged) so templates and tests use the UUID string pattern
consistently.

In `@docs/tickets/mps-16.md`:
- Around line 31-49: The docs currently show two mutually exclusive BYOB schema
designs (reusing mirror_s3_* vs new primary_s3_* columns); update the document
to remove the alternative and document only the chosen schema: list and describe
the final primary_s3_* columns (primary_s3_endpoint, primary_s3_region,
primary_s3_bucket, primary_s3_access_key, primary_s3_secret_key,
primary_s3_cdn_endpoint) and the primary_s3_enabled flag, clarifying semantics
(when primary_s3_enabled is true these primary_* fields are used as the primary
bucket) and remove any mention of reusing mirror_s3_* to avoid ambiguity.

In `@make_post_sell/static/js/watch.js`:
- Around line 221-228: When you detect a stale ring, don't wipe out the watched
state for the currently viewed product (initialId). Modify the reset so that
ringHistory preserves the entry for initialId (e.g. set ringHistory =
ringHistory[initialId] ? { [initialId]: ringHistory[initialId] } : {}), or
alternatively clear ringHistory then immediately call markWatched(initialId)
before saveRingState(); keep resetting ringProductIds, ringPosition and
ringLoops as before and then call saveRingState().

In `@make_post_sell/templates/gift_card_detail.j2`:
- Line 59: The template is rendering txn.created_timestamp (a raw millisecond
epoch) which will show a 13-digit number; change the rendering to use a
human-readable string by either formatting the timestamp in the view and passing
a new property (e.g., txn.created_at_display) or add a display property on the
model that returns a formatted datetime, then update the template to render that
property instead of txn.created_timestamp; ensure the formatter uses your app's
timezone/locale conventions so the UI shows a readable date/time.

---

Nitpick comments:
In `@make_post_sell/lib/karaoke.py`:
- Around line 364-380: The nested helper _make_s3 currently captures the outer
shop variable; change its signature to accept shop as an explicit parameter
(e.g., def _make_s3(shop):) and update its body to use that parameter instead of
the outer variable while still falling back to app_settings when shop is falsy;
then update every call site that invokes _make_s3 (including the post-shop-load
call and the worker/thread invocation) to pass the appropriate shop object (or
None if using app_settings), ensuring no code relies on the outer lexical
capture of shop.

In `@make_post_sell/lib/mail.py`:
- Around line 549-565: The message_parts construction uses unnecessary f-string
prefixes on literals without interpolation (e.g., f"", f"This gift card never
expires.", f"To redeem..." etc.); update the list building around the
message_parts variable so only strings that actually include placeholders remain
f-strings (keep f on lines with {amount}, {shop_name}, {code}, {shop_url},
{gift_message}), and remove the f prefix from empty strings and static sentences
before joining into message_text to eliminate Ruff F541 warnings.
- Around line 567-577: The HTML parts list uses unnecessary f-string prefixes
for static strings; update html_parts so only strings that interpolate variables
use f-strings (keep f for entries that reference amount, shop_name, code,
shop_url, gift_message) and remove the extraneous f prefix from static entries
such as the "<p><small>This gift card never expires.</small></p>" item (locate
and edit the html_parts construction and any nearby HTML generation that
currently uses f"" without interpolation).

In `@make_post_sell/lib/s3_mirror.py`:
- Around line 232-248: The _make_src helper uses shop.primary_s3_region directly
even though has_primary_s3 may be true while primary_s3_region is None; update
_make_src to compute region = shop.primary_s3_region or
app_settings["bucket.secure_uploads.region"] and pass that region into
boto3.session.Session().client(...) so the source client falls back consistently
(matching the region fallback pattern used in _make_mirror_client); reference
_make_src, shop.primary_s3_region, app_settings["bucket.secure_uploads.region"],
and _make_mirror_client when making the change.

In `@make_post_sell/models/cart_gift_card.py`:
- Around line 15-18: Add a DB-level unique constraint to prevent duplicate
gift-card entries per cart by updating the CartGiftCard model: import and use
SQLAlchemy's UniqueConstraint (e.g., UniqueConstraint("cart_id", "gift_card_id",
name="uq_cart_gift_card")) in the model's __table_args__ so the combination of
cart_id and gift_card_id is enforced as unique at the database level; ensure the
import for UniqueConstraint is added alongside Column/UUIDType and keep the
existing columns (id, cart_id, gift_card_id, created_timestamp) unchanged.

In `@make_post_sell/models/product.py`:
- Around line 680-685: The import of Shop is inside the keywords loop which
causes repeated lookups; move the "from .shop import Shop" import out of the
loop (place it above where product_query is used or before the loop) and keep
the existing logic that uses Shop only when shop is falsy (the block that sets
query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment ==
0)); ensure references to Product.visibility, product_query, shop, and keywords
continue to work unchanged.

In `@make_post_sell/templates/gift_card.j2`:
- Around line 16-34: The slider (id "gift-card-slider") uses step="1" while the
numeric input (id "gift-card-amount") uses step="0.01", causing
desynchronization when users enter decimal amounts; update the slider's step to
"0.01" to match the input (or change the input to "1" if intentional), and
ensure any JS that syncs values between gift-card-slider and gift-card-amount
handles decimals consistently so both controls reflect the same precision.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ffa4c162-a0ca-4566-b877-4c0c156a88e9

📥 Commits

Reviewing files that changed from the base of the PR and between 32a19a339c and cca3172c9b.

📒 Files selected for processing (62)
  • CLAUDE.md
  • GIT_HASH
  • docs/architecture.md
  • docs/design-system.md
  • docs/tickets/mps-10.md
  • docs/tickets/mps-11.md
  • docs/tickets/mps-12.md
  • docs/tickets/mps-13.md
  • docs/tickets/mps-14.md
  • docs/tickets/mps-15.md
  • docs/tickets/mps-16.md
  • make_post_sell/GIT_HASH
  • make_post_sell/lib/digest_sender.py
  • make_post_sell/lib/karaoke.py
  • make_post_sell/lib/mail.py
  • make_post_sell/lib/s3_mirror.py
  • make_post_sell/models/__init__.py
  • make_post_sell/models/cart.py
  • make_post_sell/models/cart_gift_card.py
  • make_post_sell/models/gift_card.py
  • make_post_sell/models/gift_card_transaction.py
  • make_post_sell/models/meta.py
  • make_post_sell/models/product.py
  • make_post_sell/models/shop.py
  • make_post_sell/request_methods.py
  • make_post_sell/routes.py
  • make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py
  • make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py
  • make_post_sell/static/css/common.css
  • make_post_sell/static/css/tokens.css
  • make_post_sell/static/js/watch.js
  • make_post_sell/templates/actions_view.j2
  • make_post_sell/templates/base.j2
  • make_post_sell/templates/cart.j2
  • make_post_sell/templates/content.j2
  • make_post_sell/templates/gift_card.j2
  • make_post_sell/templates/gift_card_detail.j2
  • make_post_sell/templates/gift_card_manage.j2
  • make_post_sell/templates/home.j2
  • make_post_sell/templates/join-or-log-in.j2
  • make_post_sell/templates/product.j2
  • make_post_sell/templates/product_edit.j2
  • make_post_sell/templates/shop.j2
  • make_post_sell/templates/shop_about.j2
  • make_post_sell/templates/shop_new.j2
  • make_post_sell/templates/shop_settings.j2
  • make_post_sell/templates/snippets/footer.j2
  • make_post_sell/templates/snippets/related_content.j2
  • make_post_sell/templates/styleguide.j2
  • make_post_sell/templates/user_purchases.j2
  • make_post_sell/tests/test_functional.py
  • make_post_sell/tests/test_integration.py
  • make_post_sell/tests/test_models.py
  • make_post_sell/views/__init__.py
  • make_post_sell/views/cart.py
  • make_post_sell/views/content.py
  • make_post_sell/views/feeds.py
  • make_post_sell/views/gift_card.py
  • make_post_sell/views/player.py
  • make_post_sell/views/product.py
  • make_post_sell/views/shop.py
  • make_post_sell/views/watch.py
**Actionable comments posted: 14** > 📝 **NOTE** > Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments. > 🔥 **CAUTION** > Some comments are outside the diff and can’t be posted inline due to platform limitations. > > > > <details> > <summary>⚠️ Outside diff range comments (1)</summary><blockquote> > > <details> > <summary>make_post_sell/models/cart.py (1)</summary><blockquote> > > `51-67`: _⚠️ Potential issue_ | _🟠 Major_ > > **Wire the new gift-card state into cart reset and merge flows.** > > After this change a cart carries both `json_gift_cards` and attached `gift_cards`, but `empty()` still only clears `json_cart` and `merge_in_cart()` still only merges products/coupons. That leaves stale gift cards in an “empty” cart and drops them during cart merges. > > > > Also applies to: 79-80 > > <details> > <summary>🤖 Prompt for AI Agents</summary> > > ``` > Verify each finding against the current code and only fix it if needed. > > In `@make_post_sell/models/cart.py` around lines 51 - 67, The cart now has both > json_gift_cards and association_proxy gift_cards but empty() and merge_in_cart() > ignore them; update empty() to clear json_gift_cards and remove all associated > CartGiftCard entries (via the cart_gift_cards relationship or by clearing the > gift_cards proxy) so an "empty" cart has no gift cards, and update > merge_in_cart() (and the equivalent logic referenced at lines ~79-80) to also > transfer/merge gift cards: merge json_gift_cards arrays and attach/deduplicate > CartGiftCard objects to the destination cart (using the CartGiftCard/ gift_cards > proxy) just like you do for coupons/products so gift cards aren’t left behind or > dropped during merges. > ``` > > </details> > > </blockquote></details> > > </blockquote></details> <details> <summary>🟡 Minor comments (9)</summary><blockquote> <details> <summary>docs/design-system.md-9-52 (1)</summary><blockquote> `9-52`: _⚠️ Potential issue_ | _🟡 Minor_ **Add languages to the fenced examples.** markdownlint is already flagging these blocks with MD040. Using `text` for diagrams/tables and the concrete language for actual snippets will keep docs checks clean and improve syntax highlighting. Also applies to: 67-77, 81-94, 98-114, 118-126, 130-137, 141-151, 155-163, 167-173, 179-187, 304-309 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/design-system.md` around lines 9 - 52, The fenced code blocks in the design-system.md examples (e.g., the ASCII diagram showing tokens.css → common.css → templates/ and other snippet blocks) lack language identifiers and trigger markdownlint MD040; update each triple-backtick fence to include an appropriate language tag (use text for diagrams/tables and the concrete language like css, jinja, or html for actual snippets) across the affected example blocks so syntax highlighting and linting pass (apply this to the diagram block containing tokens.css/common.css/templates and the other listed example blocks). ``` </details> </blockquote></details> <details> <summary>make_post_sell/static/js/watch.js-221-228 (1)</summary><blockquote> `221-228`: _⚠️ Potential issue_ | _🟡 Minor_ **Preserve the current item when resetting a stale ring.** This clears `ringHistory` immediately after `markWatched(initialId)`, so a stale-ring load forgets that the current product was already watched. The visible effect is an incorrect `0 / N` progress state until the user loops back to this item. <details> <summary>Suggested fix</summary> ```diff if (ringProductIds.length && ringProductIds.indexOf(initialId) === -1) { ringProductIds = []; ringPosition = 0; ringHistory = {}; + ringHistory[initialId] = true; ringLoops = 0; saveRingState(); } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/static/js/watch.js` around lines 221 - 228, When you detect a stale ring, don't wipe out the watched state for the currently viewed product (initialId). Modify the reset so that ringHistory preserves the entry for initialId (e.g. set ringHistory = ringHistory[initialId] ? { [initialId]: ringHistory[initialId] } : {}), or alternatively clear ringHistory then immediately call markWatched(initialId) before saveRingState(); keep resetting ringProductIds, ringPosition and ringLoops as before and then call saveRingState(). ``` </details> </blockquote></details> <details> <summary>make_post_sell/templates/gift_card_detail.j2-59-59 (1)</summary><blockquote> `59-59`: _⚠️ Potential issue_ | _🟡 Minor_ **Render a human-readable transaction date here.** `txn.created_timestamp` is the raw millisecond DB value, so this will show a 13-digit epoch in the UI instead of a usable date/time. Pass a formatted string from the view or expose a display property on the model. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/templates/gift_card_detail.j2` at line 59, The template is rendering txn.created_timestamp (a raw millisecond epoch) which will show a 13-digit number; change the rendering to use a human-readable string by either formatting the timestamp in the view and passing a new property (e.g., txn.created_at_display) or add a display property on the model that returns a formatted datetime, then update the template to render that property instead of txn.created_timestamp; ensure the formatter uses your app's timezone/locale conventions so the UI shows a readable date/time. ``` </details> </blockquote></details> <details> <summary>docs/tickets/mps-14.md-10-10 (1)</summary><blockquote> `10-10`: _⚠️ Potential issue_ | _🟡 Minor_ **Clarify whether the two extra shops require a paid production seat.** Line 10 and Line 105 say the allowance is tied to a **paid** production shop, but the counting example grants it for **any** production shop. MPS-15 also says trial shops can create two dev/stage shops, so this needs one consistent rule before implementation/tests are written. Also applies to: 103-115 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/tickets/mps-14.md` at line 10, The statement "Every paid production shop seat includes 2 free dev/stage shops." is inconsistent with the counting example and MPS-15; update the policy text and all examples to state a single rule: either (A) the two extra dev/stage shops are granted only for paid production seats, or (B) they are granted for any production shop including trials — choose one and apply it consistently. Specifically, edit the sentence "Every paid production shop seat includes 2 free dev/stage shops." and the related "counting example" and the MPS-15 reference so they all use the same rule and adjust any example calculations that assume the opposite behavior; ensure the document explicitly calls out whether trial production shops qualify and remove conflicting phrasing in the passages currently using "paid" vs "any". ``` </details> </blockquote></details> <details> <summary>docs/tickets/mps-13.md-25-26 (1)</summary><blockquote> `25-26`: _⚠️ Potential issue_ | _🟡 Minor_ **These route examples don't match the URLs used by the new templates.** `make_post_sell/templates/gift_card_detail.j2` already links to `/s/{{ request.shop.uuid_str }}/gift-cards/...`, so keeping `/shop/{slug}/...` here will send implementation and tests toward the wrong route shape. <details> <summary>💡 Proposed fix</summary> ```diff -New route: `/shop/{slug}/gift-cards/manage` (shop owner only) +New route: `/s/{shop_id}/gift-cards/manage` (shop owner only) @@ -`/shop/{slug}/gift-cards/{card_id}` (shop owner only) +`/s/{shop_id}/gift-cards/{card_id}` (shop owner only) @@ -On the gift card purchase page (`/shop/{slug}/gift-card`), add a "Check Balance" +On the gift card purchase page (`/s/{shop_id}/gift-card`), add a "Check Balance" ``` </details> Also applies to: 40-40, 55-56 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/tickets/mps-13.md` around lines 25 - 26, The documented route examples use /shop/{slug}/gift-cards/manage but the templates (make_post_sell/templates/gift_card_detail.j2) and links use /s/{{ request.shop.uuid_str }}/gift-cards/..., so update the docs to the template URL shape (e.g., /s/{shop_uuid}/gift-cards/manage) and adjust the example routes/tests/implementation guidance to that shape; also fix the other two occurrences of the incorrect /shop/{slug}/... examples mentioned in the comment so all examples match the template URL format. ``` </details> </blockquote></details> <details> <summary>docs/tickets/mps-15.md-116-129 (1)</summary><blockquote> `116-129`: _⚠️ Potential issue_ | _🟡 Minor_ **Use `uuid_str` in the banner links.** The rest of this PR builds shop URLs with `request.shop.uuid_str`, so documenting `request.shop.id` here will propagate the wrong pattern into `base.j2` and tests. <details> <summary>💡 Proposed fix</summary> ```diff {% 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> + <a href="/s/{{ request.shop.uuid_str }}/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> + <a href="/s/{{ request.shop.uuid_str }}/settings#plan">Choose a plan to keep selling</a> </div> {% endif %} ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/tickets/mps-15.md` around lines 116 - 129, The banner link URLs use request.shop.id but should use request.shop.uuid_str to match the rest of the codebase; update both anchor hrefs in the trial banner blocks to build URLs with request.shop.uuid_str (the conditional checks request.shop, request.shop.is_trial_active, request.shop.owners, request.shop.is_trial_expired, and request.shop.plan_active remain unchanged) so templates and tests use the UUID string pattern consistently. ``` </details> </blockquote></details> <details> <summary>docs/tickets/mps-16.md-31-49 (1)</summary><blockquote> `31-49`: _⚠️ Potential issue_ | _🟡 Minor_ **Document the final BYOB schema, not both alternatives.** This ticket still presents mutually exclusive designs (`mirror_s3_*` reuse vs dedicated `primary_s3_*` columns), but the implementation chose the dedicated columns. Leaving both here makes the reference ambiguous. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/tickets/mps-16.md` around lines 31 - 49, The docs currently show two mutually exclusive BYOB schema designs (reusing mirror_s3_* vs new primary_s3_* columns); update the document to remove the alternative and document only the chosen schema: list and describe the final primary_s3_* columns (primary_s3_endpoint, primary_s3_region, primary_s3_bucket, primary_s3_access_key, primary_s3_secret_key, primary_s3_cdn_endpoint) and the primary_s3_enabled flag, clarifying semantics (when primary_s3_enabled is true these primary_* fields are used as the primary bucket) and remove any mention of reusing mirror_s3_* to avoid ambiguity. ``` </details> </blockquote></details> <details> <summary>docs/tickets/mps-11.md-31-32 (1)</summary><blockquote> `31-32`: _⚠️ Potential issue_ | _🟡 Minor_ **Drop the “Option A” wording now that the schema is fixed.** This ticket already names `json_gift_cards` in the changed files, so keeping the storage format as an option makes the reference doc look undecided. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/tickets/mps-11.md` around lines 31 - 32, Remove the "Option A" hedging and make the storage format definitive: replace the "Option A:" prefix and any language suggesting alternatives with a declarative statement that Cart now stores gift cards in the json_gift_cards column, and keep the format example `[{"shop_id": "...", "amount_in_cents": 2500, "gift_email": "...", "gift_message": "..."}]` as the canonical schema; ensure references to Cart and json_gift_cards are updated to reflect the fixed schema and not presented as optional. ``` </details> </blockquote></details> <details> <summary>docs/tickets/mps-10.md-36-37 (1)</summary><blockquote> `36-37`: _⚠️ Potential issue_ | _🟡 Minor_ **Align the sample code format with the actual generator.** `secrets.token_hex(8).upper()` only yields hex characters, so `GC-A1B2C3D4E5F6G7H8` can never be produced. Either switch the generator or make the example hex-only. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/tickets/mps-10.md` around lines 36 - 37, The example ticket code format in the docs doesn't match the generator: secrets.token_hex(8).upper() produces 16 hex characters (0-9A-F) so remove the impossible alphanumeric letters from the example or change the generator; either update the sample to "GC-" followed by 16 hex chars (e.g., GC-<16_HEX_CHARS>) to match secrets.token_hex(8).upper(), or replace secrets.token_hex(8).upper() with a generator that picks 16 chars from string.ascii_uppercase + string.digits (e.g., using secrets.choice) so it can produce values like GC-A1B2C3D4E5F6G7H8. ``` </details> </blockquote></details> </blockquote></details> <details> <summary>🧹 Nitpick comments (7)</summary><blockquote> <details> <summary>make_post_sell/models/product.py (1)</summary><blockquote> `680-685`: **Move the import outside the loop for efficiency.** The `from .shop import Shop` import (line 683) is inside the `for keyword in keywords:` loop, so it executes for every keyword when `shop is None`. While Python caches imports, the lookup still adds unnecessary overhead. <details> <summary>♻️ Proposed fix to move import outside loop</summary> ```diff def get_products_by_keywords(dbsession, keywords, shop=None): scores = {} hits = {} + + # Import Shop here to avoid circular import at module level + if not shop: + from .shop import Shop if shop: product_query = shop.products else: product_query = dbsession.query(Product) for keyword in keywords: keyword_filter = Product.title.ilike(f"%{keyword}%") # the product _must_ be public (1). query = product_query.filter(keyword_filter).filter(Product.visibility == 1) # Exclude non-production shops from search results (MPS-14) if not shop: - from .shop import Shop query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment == 0) products = query.all() ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/product.py` around lines 680 - 685, The import of Shop is inside the keywords loop which causes repeated lookups; move the "from .shop import Shop" import out of the loop (place it above where product_query is used or before the loop) and keep the existing logic that uses Shop only when shop is falsy (the block that sets query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment == 0)); ensure references to Product.visibility, product_query, shop, and keywords continue to work unchanged. ``` </details> </blockquote></details> <details> <summary>make_post_sell/lib/s3_mirror.py (1)</summary><blockquote> `232-248`: **Consider adding region fallback for consistency.** The `_make_src` helper uses `shop.primary_s3_region` directly (line 237), but this field may be `None` since `has_primary_s3` doesn't check for it. The fallback path (lines 242-248) uses `app_settings["bucket.secure_uploads.region"]` which is guaranteed to exist, and `_make_mirror_client` uses `region or "us-east-1"` as a fallback (line 23). For consistency with the mirror client pattern: <details> <summary>♻️ Proposed fix to add region fallback</summary> ```diff if shop and shop.has_primary_s3: return boto3.session.Session().client( "s3", - region_name=shop.primary_s3_region, + region_name=shop.primary_s3_region or "us-east-1", endpoint_url=shop.primary_s3_endpoint, aws_access_key_id=shop.primary_s3_access_key, aws_secret_access_key=shop.primary_s3_secret_key, ) ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/lib/s3_mirror.py` around lines 232 - 248, The _make_src helper uses shop.primary_s3_region directly even though has_primary_s3 may be true while primary_s3_region is None; update _make_src to compute region = shop.primary_s3_region or app_settings["bucket.secure_uploads.region"] and pass that region into boto3.session.Session().client(...) so the source client falls back consistently (matching the region fallback pattern used in _make_mirror_client); reference _make_src, shop.primary_s3_region, app_settings["bucket.secure_uploads.region"], and _make_mirror_client when making the change. ``` </details> </blockquote></details> <details> <summary>make_post_sell/templates/gift_card.j2 (1)</summary><blockquote> `16-34`: **Step value mismatch between slider and input may cause minor UX friction.** The range slider uses `step="1"` (line 20) while the numeric input uses `step="0.01"` (line 30). When the user types a decimal value like `25.50`, the slider can only snap to `25` or `26`, potentially confusing users expecting precise synchronization. Consider aligning the step values or documenting this as intentional (slider for quick selection, input for precise amounts). <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/templates/gift_card.j2` around lines 16 - 34, The slider (id "gift-card-slider") uses step="1" while the numeric input (id "gift-card-amount") uses step="0.01", causing desynchronization when users enter decimal amounts; update the slider's step to "0.01" to match the input (or change the input to "1" if intentional), and ensure any JS that syncs values between gift-card-slider and gift-card-amount handles decimals consistently so both controls reflect the same precision. ``` </details> </blockquote></details> <details> <summary>make_post_sell/lib/karaoke.py (1)</summary><blockquote> `364-380`: **Consider restructuring `_make_s3` to receive `shop` as a parameter.** The nested `_make_s3` function references the outer `shop` variable, which creates an implicit dependency on execution order. The function is called both at line 388 (after shop is loaded) and at line 433 within worker threads. While this works because shop is loaded before any calls, passing `shop` as an explicit parameter would make the dependency clear and prevent accidental misuse. <details> <summary>♻️ Proposed refactor</summary> ```diff - def _make_s3(): - # BYOB: use shop's own bucket if configured (MPS-16) - if shop and shop.has_primary_s3: + def _make_s3(shop_obj): + # BYOB: use shop's own bucket if configured (MPS-16) + if shop_obj and shop_obj.has_primary_s3: return boto3.session.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, + region_name=shop_obj.primary_s3_region, + endpoint_url=shop_obj.primary_s3_endpoint, + aws_access_key_id=shop_obj.primary_s3_access_key, + aws_secret_access_key=shop_obj.primary_s3_secret_key, ) return boto3.session.Session().client( ``` Then update call sites: ```diff - s3 = _make_s3() + s3 = _make_s3(shop) ``` ```diff - thread_s3 = _make_s3() + thread_s3 = _make_s3(shop) ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/lib/karaoke.py` around lines 364 - 380, The nested helper _make_s3 currently captures the outer shop variable; change its signature to accept shop as an explicit parameter (e.g., def _make_s3(shop):) and update its body to use that parameter instead of the outer variable while still falling back to app_settings when shop is falsy; then update every call site that invokes _make_s3 (including the post-shop-load call and the worker/thread invocation) to pass the appropriate shop object (or None if using app_settings), ensuring no code relies on the outer lexical capture of shop. ``` </details> </blockquote></details> <details> <summary>make_post_sell/lib/mail.py (2)</summary><blockquote> `549-565`: **Remove extraneous `f` prefixes from strings without placeholders.** Several strings have `f` prefixes but contain no interpolated values. This triggers Ruff F541 errors and adds unnecessary overhead. <details> <summary>🔧 Proposed fix</summary> ```diff message_parts = [ f"You received a {amount} gift card for {shop_name}!", - f"", + "", f"Your gift card code: {code}", - f"", + "", ] if gift_message: message_parts.append(f"Message: {gift_message}") message_parts.append("") message_parts.extend([ f"To redeem, enter the code at checkout when shopping at {shop_name}.", - f"", + "", f"Visit: {shop_url}", - f"", - f"This gift card never expires.", + "", + "This gift card never expires.", ]) ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/lib/mail.py` around lines 549 - 565, The message_parts construction uses unnecessary f-string prefixes on literals without interpolation (e.g., f"", f"This gift card never expires.", f"To redeem..." etc.); update the list building around the message_parts variable so only strings that actually include placeholders remain f-strings (keep f on lines with {amount}, {shop_name}, {code}, {shop_url}, {gift_message}), and remove the f prefix from empty strings and static sentences before joining into message_text to eliminate Ruff F541 warnings. ``` </details> --- `567-577`: **Remove extraneous `f` prefixes in HTML parts.** Same issue in the HTML generation section. <details> <summary>🔧 Proposed fix</summary> ```diff html_parts = [ f"<h2>You received a {amount} gift card for {shop_name}!</h2>", - f"<p><strong>Your gift card code:</strong></p>", + "<p><strong>Your gift card code:</strong></p>", f"<p style='font-size: 24px; font-family: monospace; background: `#f0f0f0`; padding: 12px; display: inline-block;'>{code}</p>", ] if gift_message: html_parts.append(f"<p><em>{gift_message}</em></p>") html_parts.extend([ f"<p>To redeem, enter the code at checkout when shopping at <a href='{shop_url}'>{shop_name}</a>.</p>", - f"<p><small>This gift card never expires.</small></p>", + "<p><small>This gift card never expires.</small></p>", ]) ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/lib/mail.py` around lines 567 - 577, The HTML parts list uses unnecessary f-string prefixes for static strings; update html_parts so only strings that interpolate variables use f-strings (keep f for entries that reference amount, shop_name, code, shop_url, gift_message) and remove the extraneous f prefix from static entries such as the "<p><small>This gift card never expires.</small></p>" item (locate and edit the html_parts construction and any nearby HTML generation that currently uses f"" without interpolation). ``` </details> </blockquote></details> <details> <summary>make_post_sell/models/cart_gift_card.py (1)</summary><blockquote> `15-18`: **Consider adding a unique constraint on `(cart_id, gift_card_id)`.** While the documentation mentions that validation happens at the view level (one gift card per shop), a database-level unique constraint would provide an additional safeguard against duplicate gift card applications to the same cart. <details> <summary>🔧 Proposed enhancement</summary> ```diff +from sqlalchemy import UniqueConstraint + class CartGiftCard(RBase, Base): """ Many to many, Carts to GiftCards. A relationship signifies the application of a gift card to a cart. """ + __table_args__ = ( + UniqueConstraint('cart_id', 'gift_card_id', name='uq_cart_gift_card'), + ) id = Column(UUIDType, primary_key=True, index=True) ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/cart_gift_card.py` around lines 15 - 18, Add a DB-level unique constraint to prevent duplicate gift-card entries per cart by updating the CartGiftCard model: import and use SQLAlchemy's UniqueConstraint (e.g., UniqueConstraint("cart_id", "gift_card_id", name="uq_cart_gift_card")) in the model's __table_args__ so the combination of cart_id and gift_card_id is enforced as unique at the database level; ensure the import for UniqueConstraint is added alongside Column/UUIDType and keep the existing columns (id, cart_id, gift_card_id, created_timestamp) unchanged. ``` </details> </blockquote></details> </blockquote></details> <details> <summary>🤖 Prompt for all review comments with AI agents</summary> ``` Verify each finding against the current code and only fix it if needed. Inline comments: In `@docs/tickets/mps-15.md`: - Around line 27-30: The is_active logic will leave legacy shops inactive because NULL `trial_started_timestamp` is treated as false and `plan_active` defaults to 0; update the activation rule or migration so legacy shops remain active: either change the boolean expression (used where `is_active` is computed around lines 66-69) to treat NULL `trial_started_timestamp` as "paid" (e.g., `plan_active OR is_trial_active OR trial_started_timestamp IS NULL`) or add an idempotent backfill/migration on `mps_shop` to set `plan_active=true` for existing rows; reference the column names `trial_started_timestamp`, `trial_ended`, and `plan_active` and the `is_active` computation when making the change. In `@make_post_sell/models/cart.py`: - Around line 468-485: The validate_attached_gift_cards method currently doesn't reject duplicate attachments, so the same gift card can be applied multiple times; update validate_attached_gift_cards to detect duplicate gift_card.id (and/or gift_card.code) across self.gift_cards and add an error like "Gift card '<code>' is attached multiple times" when duplicates are found (in addition to the existing disabled/balance/shop checks), returning these errors; also note to add a unique (cart_id, gift_card_id) constraint later in make_post_sell/models/cart_gift_card.py to enforce this at the DB level. In `@make_post_sell/models/gift_card_transaction.py`: - Around line 3-4: Reject non-positive redemption amounts by adding a guard in the GiftCardTransaction constructor and a schema-level constraint: in the GiftCardTransaction class, update its __init__ to validate that amount_in_cents > 0 and raise a ValueError if not (refer to the amount_in_cents parameter), and add a SQLAlchemy CheckConstraint like CheckConstraint("amount_in_cents > 0") on the model so the DB enforces it as well; ensure imports are added (CheckConstraint) and adjust any existing code paths that construct GiftCardTransaction to expect the ValueError on invalid input. In `@make_post_sell/models/gift_card.py`: - Around line 80-84: The deduct method currently allows negative amounts and performs a non-atomic read/modify/write on balance_in_cents; change deduct to first validate amount_in_cents > 0 and raise a ValueError for non-positive inputs, then perform the deduction using a DB-level atomic operation instead of in-memory math: either run the update inside a transaction with a SELECT ... FOR UPDATE on the GiftCard row and adjust balance_in_cents, or issue an atomic UPDATE gift_cards SET balance_in_cents = balance_in_cents - :amt WHERE id = :id AND balance_in_cents >= :amt and check affected rows to determine the actual deducted amount; keep the method name deduct and its contract of returning the actual deducted cents. In `@make_post_sell/models/shop.py`: - Around line 842-850: The async rebuild bypasses the non-production guard: update reforge_discovery_ring_async to mirror reforge_discovery_ring by checking shop.environment and shop.is_non_production before calling compute_discovery_ring(), setting shop.discovery_ring = [] and returning [] for non-production shops (or alternatively add the same guard at the start of compute_discovery_ring()); ensure you reference and modify reforge_discovery_ring_async (and/or compute_discovery_ring) so background rebuilds do not repopulate discovery for staging/development shops. - Around line 173-175: The trial_ended boolean is stored but never used; update the shop state helpers to respect it: modify is_trial_active, is_trial_expired, and is_active to check trial_ended (and trial_started_timestamp) so that when trial_ended is True the trial is treated as ended/expired and the shop is considered not active via trial. Ensure is_trial_active returns False if trial_ended is True, is_trial_expired returns True if trial_ended is True, and is_active uses those updated helpers so early-ended trials no longer grant access. - Around line 339-348: The has_primary_s3 property currently omits primary_s3_region, so a shop can pass readiness while add_shop_uploads_client() later supplies shop.primary_s3_region to boto3 and fail; update the has_primary_s3 boolean check to also require self.primary_s3_region (include primary_s3_region in the AND list) so the readiness check matches what add_shop_uploads_client() expects and prevents creating a misconfigured S3 client. In `@make_post_sell/request_methods.py`: - Around line 158-185: The helpers add_shop_uploads_client, add_shop_bucket_name, and add_shop_cdn_endpoint currently treat any truthy shop.has_primary_s3 as enough and silently fall back to the platform bucket when one or more BYOB fields are missing; instead validate that all required BYOB fields are present before returning BYOB values (for add_shop_uploads_client check primary_s3_region, primary_s3_endpoint, primary_s3_access_key, primary_s3_secret_key; for add_shop_bucket_name check primary_s3_bucket; for add_shop_cdn_endpoint check primary_s3_cdn_endpoint), and if validation fails raise an explicit error (or log and abort) so misconfiguration is surfaced rather than quietly returning request.secure_uploads_client or request.app["bucket.secure_uploads"]. In `@make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py`: - Around line 73-83: The migration currently adds plaintext credential columns primary_s3_access_key and primary_s3_secret_key to the mps_shop table via op.add_column (guarded by _column_exists); instead, change the migration to add a single encrypted_secret_reference (or primary_s3_secret_id) column (e.g., VARCHAR/UUID) that stores a reference/ID to a secrets vault or an envelope-encrypted payload, and remove the direct plaintext columns; update any code that will write to/read from primary_s3_access_key/primary_s3_secret_key to use the new reference and fetch/decrypt secrets via your secret manager (KMS/Vault) at runtime, and include a migration note to securely rotate/migrate existing plaintext data to the secret store before dropping old columns if needed. - Around line 98-109: The downgrade() currently unconditionally calls op.drop_column for many columns (e.g., "primary_s3_enabled", "primary_s3_cdn_endpoint", "primary_s3_secret_key", "primary_s3_access_key", "primary_s3_bucket", "primary_s3_region", "primary_s3_endpoint", "plan_active", "trial_ended", "trial_started_timestamp", "environment"), which can break on partially applied schemas; update downgrade() to mirror the tolerant behavior of upgrade() by checking for each column's existence before dropping it (use the Alembic op.get_bind()/sqlalchemy.inspect Inspector or a helper like has_column to query the table schema), and only call op.drop_column for columns that actually exist to avoid dropping columns that this revision didn't create. In `@make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py`: - Around line 36-100: The upgrade() migration is missing the required pre-migration SQLite backup; add a call at the very start of upgrade() to invoke the project's backup helper (e.g., call a function like ensure_sqlite_backup() or run_sqlite_backup()) before any DDL runs, and make that helper a no-op on non-SQLite backends; update or create a small function (ensure_sqlite_backup / run_sqlite_backup) that checks the current DB URL/driver, performs the filesystem copy/backup when driver == "sqlite", and raises/logs on failure so the migration aborts safely if the backup cannot be made. - Around line 102-108: The downgrade() fails to fully reverse upgrade() (it never removes mps_cart.json_gift_cards) and naively drops tables/columns which will error on partially-applied DBs; update downgrade() to drop the mps_cart.json_gift_cards column and guard each drop with an existence check (use SQLAlchemy Inspector or context.get_bind() to verify table/column existence) before calling op.drop_table or op.drop_column for mps_cart_gift_card, mps_gift_card_transaction, mps_gift_card, and the mps_shop columns gift_card_enabled, gift_card_min_in_cents, gift_card_max_in_cents so the rollback is idempotent and fully reverses upgrade(). In `@make_post_sell/static/js/watch.js`: - Around line 1841-1856: The async fetchWatchData callback can overwrite ringProductIds when the user navigates before the response arrives; to fix, capture the product id at request time (e.g., const requestedId = currentProductId) before calling fetchWatchData and, inside the .then handler, verify the live currentProductId still equals requestedId (or that data is tagged for requestedId) before assigning ringProductIds, calling syncRingPosition, saveRingState, or updateProgressDisplay; if it doesn't match, ignore the stale response and still call preloadNext as needed. In `@make_post_sell/templates/content.j2`: - Around line 42-45: The media URL generation in watch.py currently returns presigned S3 URLs (via generate_presigned_url()) for media_url, instrumentals_url, and vocals_url causing a scheme mismatch with templates that expect persistent CDN URLs; update watch.py to construct these three URLs using request.shop_cdn_endpoint the same way file_url and thumbnail_url are built (i.e., combine request.shop_cdn_endpoint with product.s3_path and the appropriate path/filename pattern used for product media) instead of calling generate_presigned_url(), ensuring media_url, instrumentals_url, and vocals_url are consistent CDN endpoints that won’t expire. --- Outside diff comments: In `@make_post_sell/models/cart.py`: - Around line 51-67: The cart now has both json_gift_cards and association_proxy gift_cards but empty() and merge_in_cart() ignore them; update empty() to clear json_gift_cards and remove all associated CartGiftCard entries (via the cart_gift_cards relationship or by clearing the gift_cards proxy) so an "empty" cart has no gift cards, and update merge_in_cart() (and the equivalent logic referenced at lines ~79-80) to also transfer/merge gift cards: merge json_gift_cards arrays and attach/deduplicate CartGiftCard objects to the destination cart (using the CartGiftCard/ gift_cards proxy) just like you do for coupons/products so gift cards aren’t left behind or dropped during merges. --- Minor comments: In `@docs/design-system.md`: - Around line 9-52: The fenced code blocks in the design-system.md examples (e.g., the ASCII diagram showing tokens.css → common.css → templates/ and other snippet blocks) lack language identifiers and trigger markdownlint MD040; update each triple-backtick fence to include an appropriate language tag (use text for diagrams/tables and the concrete language like css, jinja, or html for actual snippets) across the affected example blocks so syntax highlighting and linting pass (apply this to the diagram block containing tokens.css/common.css/templates and the other listed example blocks). In `@docs/tickets/mps-10.md`: - Around line 36-37: The example ticket code format in the docs doesn't match the generator: secrets.token_hex(8).upper() produces 16 hex characters (0-9A-F) so remove the impossible alphanumeric letters from the example or change the generator; either update the sample to "GC-" followed by 16 hex chars (e.g., GC-<16_HEX_CHARS>) to match secrets.token_hex(8).upper(), or replace secrets.token_hex(8).upper() with a generator that picks 16 chars from string.ascii_uppercase + string.digits (e.g., using secrets.choice) so it can produce values like GC-A1B2C3D4E5F6G7H8. In `@docs/tickets/mps-11.md`: - Around line 31-32: Remove the "Option A" hedging and make the storage format definitive: replace the "Option A:" prefix and any language suggesting alternatives with a declarative statement that Cart now stores gift cards in the json_gift_cards column, and keep the format example `[{"shop_id": "...", "amount_in_cents": 2500, "gift_email": "...", "gift_message": "..."}]` as the canonical schema; ensure references to Cart and json_gift_cards are updated to reflect the fixed schema and not presented as optional. In `@docs/tickets/mps-13.md`: - Around line 25-26: The documented route examples use /shop/{slug}/gift-cards/manage but the templates (make_post_sell/templates/gift_card_detail.j2) and links use /s/{{ request.shop.uuid_str }}/gift-cards/..., so update the docs to the template URL shape (e.g., /s/{shop_uuid}/gift-cards/manage) and adjust the example routes/tests/implementation guidance to that shape; also fix the other two occurrences of the incorrect /shop/{slug}/... examples mentioned in the comment so all examples match the template URL format. In `@docs/tickets/mps-14.md`: - Line 10: The statement "Every paid production shop seat includes 2 free dev/stage shops." is inconsistent with the counting example and MPS-15; update the policy text and all examples to state a single rule: either (A) the two extra dev/stage shops are granted only for paid production seats, or (B) they are granted for any production shop including trials — choose one and apply it consistently. Specifically, edit the sentence "Every paid production shop seat includes 2 free dev/stage shops." and the related "counting example" and the MPS-15 reference so they all use the same rule and adjust any example calculations that assume the opposite behavior; ensure the document explicitly calls out whether trial production shops qualify and remove conflicting phrasing in the passages currently using "paid" vs "any". In `@docs/tickets/mps-15.md`: - Around line 116-129: The banner link URLs use request.shop.id but should use request.shop.uuid_str to match the rest of the codebase; update both anchor hrefs in the trial banner blocks to build URLs with request.shop.uuid_str (the conditional checks request.shop, request.shop.is_trial_active, request.shop.owners, request.shop.is_trial_expired, and request.shop.plan_active remain unchanged) so templates and tests use the UUID string pattern consistently. In `@docs/tickets/mps-16.md`: - Around line 31-49: The docs currently show two mutually exclusive BYOB schema designs (reusing mirror_s3_* vs new primary_s3_* columns); update the document to remove the alternative and document only the chosen schema: list and describe the final primary_s3_* columns (primary_s3_endpoint, primary_s3_region, primary_s3_bucket, primary_s3_access_key, primary_s3_secret_key, primary_s3_cdn_endpoint) and the primary_s3_enabled flag, clarifying semantics (when primary_s3_enabled is true these primary_* fields are used as the primary bucket) and remove any mention of reusing mirror_s3_* to avoid ambiguity. In `@make_post_sell/static/js/watch.js`: - Around line 221-228: When you detect a stale ring, don't wipe out the watched state for the currently viewed product (initialId). Modify the reset so that ringHistory preserves the entry for initialId (e.g. set ringHistory = ringHistory[initialId] ? { [initialId]: ringHistory[initialId] } : {}), or alternatively clear ringHistory then immediately call markWatched(initialId) before saveRingState(); keep resetting ringProductIds, ringPosition and ringLoops as before and then call saveRingState(). In `@make_post_sell/templates/gift_card_detail.j2`: - Line 59: The template is rendering txn.created_timestamp (a raw millisecond epoch) which will show a 13-digit number; change the rendering to use a human-readable string by either formatting the timestamp in the view and passing a new property (e.g., txn.created_at_display) or add a display property on the model that returns a formatted datetime, then update the template to render that property instead of txn.created_timestamp; ensure the formatter uses your app's timezone/locale conventions so the UI shows a readable date/time. --- Nitpick comments: In `@make_post_sell/lib/karaoke.py`: - Around line 364-380: The nested helper _make_s3 currently captures the outer shop variable; change its signature to accept shop as an explicit parameter (e.g., def _make_s3(shop):) and update its body to use that parameter instead of the outer variable while still falling back to app_settings when shop is falsy; then update every call site that invokes _make_s3 (including the post-shop-load call and the worker/thread invocation) to pass the appropriate shop object (or None if using app_settings), ensuring no code relies on the outer lexical capture of shop. In `@make_post_sell/lib/mail.py`: - Around line 549-565: The message_parts construction uses unnecessary f-string prefixes on literals without interpolation (e.g., f"", f"This gift card never expires.", f"To redeem..." etc.); update the list building around the message_parts variable so only strings that actually include placeholders remain f-strings (keep f on lines with {amount}, {shop_name}, {code}, {shop_url}, {gift_message}), and remove the f prefix from empty strings and static sentences before joining into message_text to eliminate Ruff F541 warnings. - Around line 567-577: The HTML parts list uses unnecessary f-string prefixes for static strings; update html_parts so only strings that interpolate variables use f-strings (keep f for entries that reference amount, shop_name, code, shop_url, gift_message) and remove the extraneous f prefix from static entries such as the "<p><small>This gift card never expires.</small></p>" item (locate and edit the html_parts construction and any nearby HTML generation that currently uses f"" without interpolation). In `@make_post_sell/lib/s3_mirror.py`: - Around line 232-248: The _make_src helper uses shop.primary_s3_region directly even though has_primary_s3 may be true while primary_s3_region is None; update _make_src to compute region = shop.primary_s3_region or app_settings["bucket.secure_uploads.region"] and pass that region into boto3.session.Session().client(...) so the source client falls back consistently (matching the region fallback pattern used in _make_mirror_client); reference _make_src, shop.primary_s3_region, app_settings["bucket.secure_uploads.region"], and _make_mirror_client when making the change. In `@make_post_sell/models/cart_gift_card.py`: - Around line 15-18: Add a DB-level unique constraint to prevent duplicate gift-card entries per cart by updating the CartGiftCard model: import and use SQLAlchemy's UniqueConstraint (e.g., UniqueConstraint("cart_id", "gift_card_id", name="uq_cart_gift_card")) in the model's __table_args__ so the combination of cart_id and gift_card_id is enforced as unique at the database level; ensure the import for UniqueConstraint is added alongside Column/UUIDType and keep the existing columns (id, cart_id, gift_card_id, created_timestamp) unchanged. In `@make_post_sell/models/product.py`: - Around line 680-685: The import of Shop is inside the keywords loop which causes repeated lookups; move the "from .shop import Shop" import out of the loop (place it above where product_query is used or before the loop) and keep the existing logic that uses Shop only when shop is falsy (the block that sets query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment == 0)); ensure references to Product.visibility, product_query, shop, and keywords continue to work unchanged. In `@make_post_sell/templates/gift_card.j2`: - Around line 16-34: The slider (id "gift-card-slider") uses step="1" while the numeric input (id "gift-card-amount") uses step="0.01", causing desynchronization when users enter decimal amounts; update the slider's step to "0.01" to match the input (or change the input to "1" if intentional), and ensure any JS that syncs values between gift-card-slider and gift-card-amount handles decimals consistently so both controls reflect the same precision. ``` </details> --- <details> <summary>ℹ️ Review info</summary> <details> <summary>⚙️ Run configuration</summary> **Configuration used**: defaults **Review profile**: CHILL **Plan**: Pro **Run ID**: `ffa4c162-a0ca-4566-b877-4c0c156a88e9` </details> <details> <summary>📥 Commits</summary> Reviewing files that changed from the base of the PR and between 32a19a339c8614bbda2ea122a99f8a42af93d454 and cca3172c9b7eaa88c41a661a641bf45bc6f56fd0. </details> <details> <summary>📒 Files selected for processing (62)</summary> * `CLAUDE.md` * `GIT_HASH` * `docs/architecture.md` * `docs/design-system.md` * `docs/tickets/mps-10.md` * `docs/tickets/mps-11.md` * `docs/tickets/mps-12.md` * `docs/tickets/mps-13.md` * `docs/tickets/mps-14.md` * `docs/tickets/mps-15.md` * `docs/tickets/mps-16.md` * `make_post_sell/GIT_HASH` * `make_post_sell/lib/digest_sender.py` * `make_post_sell/lib/karaoke.py` * `make_post_sell/lib/mail.py` * `make_post_sell/lib/s3_mirror.py` * `make_post_sell/models/__init__.py` * `make_post_sell/models/cart.py` * `make_post_sell/models/cart_gift_card.py` * `make_post_sell/models/gift_card.py` * `make_post_sell/models/gift_card_transaction.py` * `make_post_sell/models/meta.py` * `make_post_sell/models/product.py` * `make_post_sell/models/shop.py` * `make_post_sell/request_methods.py` * `make_post_sell/routes.py` * `make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py` * `make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py` * `make_post_sell/static/css/common.css` * `make_post_sell/static/css/tokens.css` * `make_post_sell/static/js/watch.js` * `make_post_sell/templates/actions_view.j2` * `make_post_sell/templates/base.j2` * `make_post_sell/templates/cart.j2` * `make_post_sell/templates/content.j2` * `make_post_sell/templates/gift_card.j2` * `make_post_sell/templates/gift_card_detail.j2` * `make_post_sell/templates/gift_card_manage.j2` * `make_post_sell/templates/home.j2` * `make_post_sell/templates/join-or-log-in.j2` * `make_post_sell/templates/product.j2` * `make_post_sell/templates/product_edit.j2` * `make_post_sell/templates/shop.j2` * `make_post_sell/templates/shop_about.j2` * `make_post_sell/templates/shop_new.j2` * `make_post_sell/templates/shop_settings.j2` * `make_post_sell/templates/snippets/footer.j2` * `make_post_sell/templates/snippets/related_content.j2` * `make_post_sell/templates/styleguide.j2` * `make_post_sell/templates/user_purchases.j2` * `make_post_sell/tests/test_functional.py` * `make_post_sell/tests/test_integration.py` * `make_post_sell/tests/test_models.py` * `make_post_sell/views/__init__.py` * `make_post_sell/views/cart.py` * `make_post_sell/views/content.py` * `make_post_sell/views/feeds.py` * `make_post_sell/views/gift_card.py` * `make_post_sell/views/player.py` * `make_post_sell/views/product.py` * `make_post_sell/views/shop.py` * `make_post_sell/views/watch.py` </details> </details> <!-- This is an auto-generated comment by CodeRabbit for review status -->
coderabbitai commented 2026-03-07 23:38:55 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Grandfathered shops stay inactive with this is_active example.

Lines 27-30 define NULL trial_started_timestamp as “pre-trial era, treated as paid,” but Line 69 only returns plan_active or is_trial_active. For those legacy shops both branches are false, so the example would lock out existing stores unless some separate backfill sets plan_active=True.

💡 Proposed fix
 `@property`
 def is_active(self):
     """Shop can operate: either paid plan or active trial."""
-    return self.plan_active or self.is_trial_active
+    return (
+        self.plan_active
+        or self.is_trial_active
+        or self.trial_started_timestamp is None
+    )

Also applies to: 66-69

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tickets/mps-15.md` around lines 27 - 30, The is_active logic will leave
legacy shops inactive because NULL `trial_started_timestamp` is treated as false
and `plan_active` defaults to 0; update the activation rule or migration so
legacy shops remain active: either change the boolean expression (used where
`is_active` is computed around lines 66-69) to treat NULL
`trial_started_timestamp` as "paid" (e.g., `plan_active OR is_trial_active OR
trial_started_timestamp IS NULL`) or add an idempotent backfill/migration on
`mps_shop` to set `plan_active=true` for existing rows; reference the column
names `trial_started_timestamp`, `trial_ended`, and `plan_active` and the
`is_active` computation when making the change.
_⚠️ Potential issue_ | _🟠 Major_ **Grandfathered shops stay inactive with this `is_active` example.** Lines 27-30 define `NULL` `trial_started_timestamp` as “pre-trial era, treated as paid,” but Line 69 only returns `plan_active or is_trial_active`. For those legacy shops both branches are false, so the example would lock out existing stores unless some separate backfill sets `plan_active=True`. <details> <summary>💡 Proposed fix</summary> ```diff `@property` def is_active(self): """Shop can operate: either paid plan or active trial.""" - return self.plan_active or self.is_trial_active + return ( + self.plan_active + or self.is_trial_active + or self.trial_started_timestamp is None + ) ``` </details> Also applies to: 66-69 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@docs/tickets/mps-15.md` around lines 27 - 30, The is_active logic will leave legacy shops inactive because NULL `trial_started_timestamp` is treated as false and `plan_active` defaults to 0; update the activation rule or migration so legacy shops remain active: either change the boolean expression (used where `is_active` is computed around lines 66-69) to treat NULL `trial_started_timestamp` as "paid" (e.g., `plan_active OR is_trial_active OR trial_started_timestamp IS NULL`) or add an idempotent backfill/migration on `mps_shop` to set `plan_active=true` for existing rows; reference the column names `trial_started_timestamp`, `trial_ended`, and `plan_active` and the `is_active` computation when making the change. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:55 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Reject duplicate gift cards before applying deductions.

The same card can be attached more than once (make_post_sell/models/cart_gift_card.py:8-33 has no (cart_id, gift_card_id) uniqueness guard), and discounted_shop_totals_in_cents() will apply its balance once per attachment. Add a duplicate-id/code check here, and ideally back it with a DB constraint later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/cart.py` around lines 468 - 485, The
validate_attached_gift_cards method currently doesn't reject duplicate
attachments, so the same gift card can be applied multiple times; update
validate_attached_gift_cards to detect duplicate gift_card.id (and/or
gift_card.code) across self.gift_cards and add an error like "Gift card '<code>'
is attached multiple times" when duplicates are found (in addition to the
existing disabled/balance/shop checks), returning these errors; also note to add
a unique (cart_id, gift_card_id) constraint later in
make_post_sell/models/cart_gift_card.py to enforce this at the DB level.
_⚠️ Potential issue_ | _🟠 Major_ **Reject duplicate gift cards before applying deductions.** The same card can be attached more than once (`make_post_sell/models/cart_gift_card.py:8-33` has no `(cart_id, gift_card_id)` uniqueness guard), and `discounted_shop_totals_in_cents()` will apply its balance once per attachment. Add a duplicate-id/code check here, and ideally back it with a DB constraint later. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/cart.py` around lines 468 - 485, The validate_attached_gift_cards method currently doesn't reject duplicate attachments, so the same gift card can be applied multiple times; update validate_attached_gift_cards to detect duplicate gift_card.id (and/or gift_card.code) across self.gift_cards and add an error like "Gift card '<code>' is attached multiple times" when duplicates are found (in addition to the existing disabled/balance/shop checks), returning these errors; also note to add a unique (cart_id, gift_card_id) constraint later in make_post_sell/models/cart_gift_card.py to enforce this at the DB level. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:55 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Reject non-positive redemption amounts.

This constructor accepts 0 or negative cents, which would either create meaningless rows or add balance back once downstream code subtracts the “deduction.” Enforce amount_in_cents > 0 here and ideally at the schema level too.

💡 Proposed fix
-from sqlalchemy import Column, BigInteger
+from sqlalchemy import Column, BigInteger, CheckConstraint
 from sqlalchemy.orm import relationship
@@
 class GiftCardTransaction(RBase, Base):
     """Tracks each time a gift card balance is used at checkout."""
+    __table_args__ = (
+        CheckConstraint(
+            "amount_in_cents > 0",
+            name="ck_gift_card_transaction_amount_positive",
+        ),
+    )
@@
     def __init__(self, gift_card, invoice, amount_in_cents):
+        if amount_in_cents <= 0:
+            raise ValueError("amount_in_cents must be positive")
         self.id = uuid.uuid1()
         self.gift_card = gift_card
         self.invoice = invoice

Also applies to: 18-24, 33-38

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/gift_card_transaction.py` around lines 3 - 4, Reject
non-positive redemption amounts by adding a guard in the GiftCardTransaction
constructor and a schema-level constraint: in the GiftCardTransaction class,
update its __init__ to validate that amount_in_cents > 0 and raise a ValueError
if not (refer to the amount_in_cents parameter), and add a SQLAlchemy
CheckConstraint like CheckConstraint("amount_in_cents > 0") on the model so the
DB enforces it as well; ensure imports are added (CheckConstraint) and adjust
any existing code paths that construct GiftCardTransaction to expect the
ValueError on invalid input.
_⚠️ Potential issue_ | _🟠 Major_ **Reject non-positive redemption amounts.** This constructor accepts `0` or negative cents, which would either create meaningless rows or add balance back once downstream code subtracts the “deduction.” Enforce `amount_in_cents > 0` here and ideally at the schema level too. <details> <summary>💡 Proposed fix</summary> ```diff -from sqlalchemy import Column, BigInteger +from sqlalchemy import Column, BigInteger, CheckConstraint from sqlalchemy.orm import relationship @@ class GiftCardTransaction(RBase, Base): """Tracks each time a gift card balance is used at checkout.""" + __table_args__ = ( + CheckConstraint( + "amount_in_cents > 0", + name="ck_gift_card_transaction_amount_positive", + ), + ) @@ def __init__(self, gift_card, invoice, amount_in_cents): + if amount_in_cents <= 0: + raise ValueError("amount_in_cents must be positive") self.id = uuid.uuid1() self.gift_card = gift_card self.invoice = invoice ``` </details> Also applies to: 18-24, 33-38 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/gift_card_transaction.py` around lines 3 - 4, Reject non-positive redemption amounts by adding a guard in the GiftCardTransaction constructor and a schema-level constraint: in the GiftCardTransaction class, update its __init__ to validate that amount_in_cents > 0 and raise a ValueError if not (refer to the amount_in_cents parameter), and add a SQLAlchemy CheckConstraint like CheckConstraint("amount_in_cents > 0") on the model so the DB enforces it as well; ensure imports are added (CheckConstraint) and adjust any existing code paths that construct GiftCardTransaction to expect the ValueError on invalid input. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:55 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🔴 Critical

Make redemption atomic and reject non-positive amounts.

min(amount_in_cents, self.balance_in_cents) turns negative inputs into balance increases, and this read/modify/write update can double-spend the same card under concurrent checkouts. This needs a positive-amount guard plus DB-level locking or an atomic UPDATE ... WHERE balance_in_cents >= ... in the redemption path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/gift_card.py` around lines 80 - 84, The deduct method
currently allows negative amounts and performs a non-atomic read/modify/write on
balance_in_cents; change deduct to first validate amount_in_cents > 0 and raise
a ValueError for non-positive inputs, then perform the deduction using a
DB-level atomic operation instead of in-memory math: either run the update
inside a transaction with a SELECT ... FOR UPDATE on the GiftCard row and adjust
balance_in_cents, or issue an atomic UPDATE gift_cards SET balance_in_cents =
balance_in_cents - :amt WHERE id = :id AND balance_in_cents >= :amt and check
affected rows to determine the actual deducted amount; keep the method name
deduct and its contract of returning the actual deducted cents.
_⚠️ Potential issue_ | _🔴 Critical_ **Make redemption atomic and reject non-positive amounts.** `min(amount_in_cents, self.balance_in_cents)` turns negative inputs into balance increases, and this read/modify/write update can double-spend the same card under concurrent checkouts. This needs a positive-amount guard plus DB-level locking or an atomic `UPDATE ... WHERE balance_in_cents >= ...` in the redemption path. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/gift_card.py` around lines 80 - 84, The deduct method currently allows negative amounts and performs a non-atomic read/modify/write on balance_in_cents; change deduct to first validate amount_in_cents > 0 and raise a ValueError for non-positive inputs, then perform the deduction using a DB-level atomic operation instead of in-memory math: either run the update inside a transaction with a SELECT ... FOR UPDATE on the GiftCard row and adjust balance_in_cents, or issue an atomic UPDATE gift_cards SET balance_in_cents = balance_in_cents - :amt WHERE id = :id AND balance_in_cents >= :amt and check affected rows to determine the actual deducted amount; keep the method name deduct and its contract of returning the actual deducted cents. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:56 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

trial_ended never changes the shop’s effective access state.

The new flag is stored, but is_trial_active, is_trial_expired, and is_active ignore it. An early-ended trial will still look active until the timestamp expires.

Also applies to: 303-335

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/shop.py` around lines 173 - 175, The trial_ended
boolean is stored but never used; update the shop state helpers to respect it:
modify is_trial_active, is_trial_expired, and is_active to check trial_ended
(and trial_started_timestamp) so that when trial_ended is True the trial is
treated as ended/expired and the shop is considered not active via trial. Ensure
is_trial_active returns False if trial_ended is True, is_trial_expired returns
True if trial_ended is True, and is_active uses those updated helpers so
early-ended trials no longer grant access.
_⚠️ Potential issue_ | _🟠 Major_ **`trial_ended` never changes the shop’s effective access state.** The new flag is stored, but `is_trial_active`, `is_trial_expired`, and `is_active` ignore it. An early-ended trial will still look active until the timestamp expires. Also applies to: 303-335 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/shop.py` around lines 173 - 175, The trial_ended boolean is stored but never used; update the shop state helpers to respect it: modify is_trial_active, is_trial_expired, and is_active to check trial_ended (and trial_started_timestamp) so that when trial_ended is True the trial is treated as ended/expired and the shop is considered not active via trial. Ensure is_trial_active returns False if trial_ended is True, is_trial_expired returns True if trial_ended is True, and is_active uses those updated helpers so early-ended trials no longer grant access. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:56 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /repo && find . -type f -name "*.py" | head -20

Repository: engineering/make-post-sell/make_post_sell

Length of output: 140


🏁 Script executed:

cd /repo && rg "add_shop_uploads_client" --type py -A 5 -B 2

Repository: engineering/make-post-sell/make_post_sell

Length of output: 140


🏁 Script executed:

cd /repo && grep -n "primary_s3_region" make_post_sell/models/shop.py | head -20

Repository: engineering/make-post-sell/make_post_sell

Length of output: 140


🏁 Script executed:

cd /repo && grep -n "has_primary_s3" make_post_sell/models/shop.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 140


🏁 Script executed:

find . -type f -name "*.py" | grep -E "(shop|upload)" | head -20

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1544


🏁 Script executed:

rg "add_shop_uploads_client" --type py -A 10

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1910


🏁 Script executed:

rg "primary_s3_region" make_post_sell/models/shop.py -n

Repository: engineering/make-post-sell/make_post_sell

Length of output: 147


🏁 Script executed:

rg "def has_primary_s3" make_post_sell/models/shop.py -A 15

Repository: engineering/make-post-sell/make_post_sell

Length of output: 542


🏁 Script executed:

rg "region_name" make_post_sell/request_methods.py -B 5 -A 5

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1019


🏁 Script executed:

rg "has_primary_s3" make_post_sell --type py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 2302


🏁 Script executed:

rg "test_has_primary_s3_requires_all_fields" --type py -A 20

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1861


🏁 Script executed:

rg "test_has_primary_s3_when_enabled_and_configured" --type py -A 15

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1349


🏁 Script executed:

grep -n "primary_s3_region" make_post_sell/tests/test_models.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 151


🏁 Script executed:

grep -n "primary_s3_region" make_post_sell/tests/test_integration.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 299


🏁 Script executed:

rg "test_has_primary_s3_missing_field" --type py -A 25 make_post_sell/tests/test_integration.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1281


🏁 Script executed:

cat -n make_post_sell/tests/test_models.py | sed -n '3595,3620p'

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1526


Include primary_s3_region in the has_primary_s3 validation.

add_shop_uploads_client() always passes shop.primary_s3_region to the boto3 S3 client, but has_primary_s3 doesn't require that field. This allows a shop to pass the BYOB readiness check while primary_s3_region is None or empty, resulting in a misconfigured client.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/shop.py` around lines 339 - 348, The has_primary_s3
property currently omits primary_s3_region, so a shop can pass readiness while
add_shop_uploads_client() later supplies shop.primary_s3_region to boto3 and
fail; update the has_primary_s3 boolean check to also require
self.primary_s3_region (include primary_s3_region in the AND list) so the
readiness check matches what add_shop_uploads_client() expects and prevents
creating a misconfigured S3 client.
_⚠️ Potential issue_ | _🟠 Major_ <details> <summary>🧩 Analysis chain</summary> 🏁 Script executed: ```shell cd /repo && find . -type f -name "*.py" | head -20 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 140 --- 🏁 Script executed: ```shell cd /repo && rg "add_shop_uploads_client" --type py -A 5 -B 2 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 140 --- 🏁 Script executed: ```shell cd /repo && grep -n "primary_s3_region" make_post_sell/models/shop.py | head -20 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 140 --- 🏁 Script executed: ```shell cd /repo && grep -n "has_primary_s3" make_post_sell/models/shop.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 140 --- 🏁 Script executed: ```shell find . -type f -name "*.py" | grep -E "(shop|upload)" | head -20 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1544 --- 🏁 Script executed: ```shell rg "add_shop_uploads_client" --type py -A 10 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1910 --- 🏁 Script executed: ```shell rg "primary_s3_region" make_post_sell/models/shop.py -n ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 147 --- 🏁 Script executed: ```shell rg "def has_primary_s3" make_post_sell/models/shop.py -A 15 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 542 --- 🏁 Script executed: ```shell rg "region_name" make_post_sell/request_methods.py -B 5 -A 5 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1019 --- 🏁 Script executed: ```shell rg "has_primary_s3" make_post_sell --type py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 2302 --- 🏁 Script executed: ```shell rg "test_has_primary_s3_requires_all_fields" --type py -A 20 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1861 --- 🏁 Script executed: ```shell rg "test_has_primary_s3_when_enabled_and_configured" --type py -A 15 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1349 --- 🏁 Script executed: ```shell grep -n "primary_s3_region" make_post_sell/tests/test_models.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 151 --- 🏁 Script executed: ```shell grep -n "primary_s3_region" make_post_sell/tests/test_integration.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 299 --- 🏁 Script executed: ```shell rg "test_has_primary_s3_missing_field" --type py -A 25 make_post_sell/tests/test_integration.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1281 --- 🏁 Script executed: ```shell cat -n make_post_sell/tests/test_models.py | sed -n '3595,3620p' ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1526 --- </details> **Include `primary_s3_region` in the `has_primary_s3` validation.** `add_shop_uploads_client()` always passes `shop.primary_s3_region` to the boto3 S3 client, but `has_primary_s3` doesn't require that field. This allows a shop to pass the BYOB readiness check while `primary_s3_region` is `None` or empty, resulting in a misconfigured client. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/shop.py` around lines 339 - 348, The has_primary_s3 property currently omits primary_s3_region, so a shop can pass readiness while add_shop_uploads_client() later supplies shop.primary_s3_region to boto3 and fail; update the has_primary_s3 boolean check to also require self.primary_s3_region (include primary_s3_region in the AND list) so the readiness check matches what add_shop_uploads_client() expects and prevents creating a misconfigured S3 client. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:57 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

The non-production discovery guard is bypassed by async rebuilds.

reforge_discovery_ring() now empties the ring for staging/development shops, but reforge_discovery_ring_async() still calls compute_discovery_ring() directly. Any background rebuild will repopulate discovery for shops that were supposed to stay hidden.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/models/shop.py` around lines 842 - 850, The async rebuild
bypasses the non-production guard: update reforge_discovery_ring_async to mirror
reforge_discovery_ring by checking shop.environment and shop.is_non_production
before calling compute_discovery_ring(), setting shop.discovery_ring = [] and
returning [] for non-production shops (or alternatively add the same guard at
the start of compute_discovery_ring()); ensure you reference and modify
reforge_discovery_ring_async (and/or compute_discovery_ring) so background
rebuilds do not repopulate discovery for staging/development shops.
_⚠️ Potential issue_ | _🟠 Major_ **The non-production discovery guard is bypassed by async rebuilds.** `reforge_discovery_ring()` now empties the ring for staging/development shops, but `reforge_discovery_ring_async()` still calls `compute_discovery_ring()` directly. Any background rebuild will repopulate discovery for shops that were supposed to stay hidden. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/models/shop.py` around lines 842 - 850, The async rebuild bypasses the non-production guard: update reforge_discovery_ring_async to mirror reforge_discovery_ring by checking shop.environment and shop.is_non_production before calling compute_discovery_ring(), setting shop.discovery_ring = [] and returning [] for non-production shops (or alternatively add the same guard at the start of compute_discovery_ring()); ensure you reference and modify reforge_discovery_ring_async (and/or compute_discovery_ring) so background rebuilds do not repopulate discovery for staging/development shops. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:57 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Don't silently fall back to the platform bucket when BYOB is enabled.

If primary_s3_enabled is true but one field is missing, all three helpers quietly return the MPS client/bucket/CDN instead of surfacing a misconfiguration. That breaks the BYOB contract and can send shop media back to the shared bucket.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/request_methods.py` around lines 158 - 185, The helpers
add_shop_uploads_client, add_shop_bucket_name, and add_shop_cdn_endpoint
currently treat any truthy shop.has_primary_s3 as enough and silently fall back
to the platform bucket when one or more BYOB fields are missing; instead
validate that all required BYOB fields are present before returning BYOB values
(for add_shop_uploads_client check primary_s3_region, primary_s3_endpoint,
primary_s3_access_key, primary_s3_secret_key; for add_shop_bucket_name check
primary_s3_bucket; for add_shop_cdn_endpoint check primary_s3_cdn_endpoint), and
if validation fails raise an explicit error (or log and abort) so
misconfiguration is surfaced rather than quietly returning
request.secure_uploads_client or request.app["bucket.secure_uploads"].
_⚠️ Potential issue_ | _🟠 Major_ **Don't silently fall back to the platform bucket when BYOB is enabled.** If `primary_s3_enabled` is true but one field is missing, all three helpers quietly return the MPS client/bucket/CDN instead of surfacing a misconfiguration. That breaks the BYOB contract and can send shop media back to the shared bucket. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/request_methods.py` around lines 158 - 185, The helpers add_shop_uploads_client, add_shop_bucket_name, and add_shop_cdn_endpoint currently treat any truthy shop.has_primary_s3 as enough and silently fall back to the platform bucket when one or more BYOB fields are missing; instead validate that all required BYOB fields are present before returning BYOB values (for add_shop_uploads_client check primary_s3_region, primary_s3_endpoint, primary_s3_access_key, primary_s3_secret_key; for add_shop_bucket_name check primary_s3_bucket; for add_shop_cdn_endpoint check primary_s3_cdn_endpoint), and if validation fails raise an explicit error (or log and abort) so misconfiguration is surfaced rather than quietly returning request.secure_uploads_client or request.app["bucket.secure_uploads"]. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:58 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Avoid storing raw BYOB credentials in mps_shop.

Adding primary_s3_access_key and primary_s3_secret_key as plain text columns means a DB leak exposes every connected bucket. This should be an encrypted secret reference or envelope-encrypted payload, not directly readable key material.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py`
around lines 73 - 83, The migration currently adds plaintext credential columns
primary_s3_access_key and primary_s3_secret_key to the mps_shop table via
op.add_column (guarded by _column_exists); instead, change the migration to add
a single encrypted_secret_reference (or primary_s3_secret_id) column (e.g.,
VARCHAR/UUID) that stores a reference/ID to a secrets vault or an
envelope-encrypted payload, and remove the direct plaintext columns; update any
code that will write to/read from primary_s3_access_key/primary_s3_secret_key to
use the new reference and fetch/decrypt secrets via your secret manager
(KMS/Vault) at runtime, and include a migration note to securely rotate/migrate
existing plaintext data to the secret store before dropping old columns if
needed.
_⚠️ Potential issue_ | _🟠 Major_ **Avoid storing raw BYOB credentials in `mps_shop`.** Adding `primary_s3_access_key` and `primary_s3_secret_key` as plain text columns means a DB leak exposes every connected bucket. This should be an encrypted secret reference or envelope-encrypted payload, not directly readable key material. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py` around lines 73 - 83, The migration currently adds plaintext credential columns primary_s3_access_key and primary_s3_secret_key to the mps_shop table via op.add_column (guarded by _column_exists); instead, change the migration to add a single encrypted_secret_reference (or primary_s3_secret_id) column (e.g., VARCHAR/UUID) that stores a reference/ID to a secrets vault or an envelope-encrypted payload, and remove the direct plaintext columns; update any code that will write to/read from primary_s3_access_key/primary_s3_secret_key to use the new reference and fetch/decrypt secrets via your secret manager (KMS/Vault) at runtime, and include a migration note to securely rotate/migrate existing plaintext data to the secret store before dropping old columns if needed. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:38:59 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Mirror the existence checks in downgrade().

upgrade() is intentionally tolerant of already-present columns, but downgrade() drops everything unconditionally. On a partially applied or hand-repaired schema, rollback will fail or remove columns this revision never created.

💡 Proposed fix
 def downgrade():
-    op.drop_column("mps_shop", "primary_s3_enabled")
-    op.drop_column("mps_shop", "primary_s3_cdn_endpoint")
-    op.drop_column("mps_shop", "primary_s3_secret_key")
-    op.drop_column("mps_shop", "primary_s3_access_key")
-    op.drop_column("mps_shop", "primary_s3_bucket")
-    op.drop_column("mps_shop", "primary_s3_region")
-    op.drop_column("mps_shop", "primary_s3_endpoint")
-    op.drop_column("mps_shop", "plan_active")
-    op.drop_column("mps_shop", "trial_ended")
-    op.drop_column("mps_shop", "trial_started_timestamp")
-    op.drop_column("mps_shop", "environment")
+    if _column_exists("mps_shop", "primary_s3_enabled"):
+        op.drop_column("mps_shop", "primary_s3_enabled")
+    if _column_exists("mps_shop", "primary_s3_cdn_endpoint"):
+        op.drop_column("mps_shop", "primary_s3_cdn_endpoint")
+    if _column_exists("mps_shop", "primary_s3_secret_key"):
+        op.drop_column("mps_shop", "primary_s3_secret_key")
+    if _column_exists("mps_shop", "primary_s3_access_key"):
+        op.drop_column("mps_shop", "primary_s3_access_key")
+    if _column_exists("mps_shop", "primary_s3_bucket"):
+        op.drop_column("mps_shop", "primary_s3_bucket")
+    if _column_exists("mps_shop", "primary_s3_region"):
+        op.drop_column("mps_shop", "primary_s3_region")
+    if _column_exists("mps_shop", "primary_s3_endpoint"):
+        op.drop_column("mps_shop", "primary_s3_endpoint")
+    if _column_exists("mps_shop", "plan_active"):
+        op.drop_column("mps_shop", "plan_active")
+    if _column_exists("mps_shop", "trial_ended"):
+        op.drop_column("mps_shop", "trial_ended")
+    if _column_exists("mps_shop", "trial_started_timestamp"):
+        op.drop_column("mps_shop", "trial_started_timestamp")
+    if _column_exists("mps_shop", "environment"):
+        op.drop_column("mps_shop", "environment")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

def downgrade():
    if _column_exists("mps_shop", "primary_s3_enabled"):
        op.drop_column("mps_shop", "primary_s3_enabled")
    if _column_exists("mps_shop", "primary_s3_cdn_endpoint"):
        op.drop_column("mps_shop", "primary_s3_cdn_endpoint")
    if _column_exists("mps_shop", "primary_s3_secret_key"):
        op.drop_column("mps_shop", "primary_s3_secret_key")
    if _column_exists("mps_shop", "primary_s3_access_key"):
        op.drop_column("mps_shop", "primary_s3_access_key")
    if _column_exists("mps_shop", "primary_s3_bucket"):
        op.drop_column("mps_shop", "primary_s3_bucket")
    if _column_exists("mps_shop", "primary_s3_region"):
        op.drop_column("mps_shop", "primary_s3_region")
    if _column_exists("mps_shop", "primary_s3_endpoint"):
        op.drop_column("mps_shop", "primary_s3_endpoint")
    if _column_exists("mps_shop", "plan_active"):
        op.drop_column("mps_shop", "plan_active")
    if _column_exists("mps_shop", "trial_ended"):
        op.drop_column("mps_shop", "trial_ended")
    if _column_exists("mps_shop", "trial_started_timestamp"):
        op.drop_column("mps_shop", "trial_started_timestamp")
    if _column_exists("mps_shop", "environment"):
        op.drop_column("mps_shop", "environment")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py`
around lines 98 - 109, The downgrade() currently unconditionally calls
op.drop_column for many columns (e.g., "primary_s3_enabled",
"primary_s3_cdn_endpoint", "primary_s3_secret_key", "primary_s3_access_key",
"primary_s3_bucket", "primary_s3_region", "primary_s3_endpoint", "plan_active",
"trial_ended", "trial_started_timestamp", "environment"), which can break on
partially applied schemas; update downgrade() to mirror the tolerant behavior of
upgrade() by checking for each column's existence before dropping it (use the
Alembic op.get_bind()/sqlalchemy.inspect Inspector or a helper like has_column
to query the table schema), and only call op.drop_column for columns that
actually exist to avoid dropping columns that this revision didn't create.
_⚠️ Potential issue_ | _🟠 Major_ **Mirror the existence checks in `downgrade()`.** `upgrade()` is intentionally tolerant of already-present columns, but `downgrade()` drops everything unconditionally. On a partially applied or hand-repaired schema, rollback will fail or remove columns this revision never created. <details> <summary>💡 Proposed fix</summary> ```diff def downgrade(): - op.drop_column("mps_shop", "primary_s3_enabled") - op.drop_column("mps_shop", "primary_s3_cdn_endpoint") - op.drop_column("mps_shop", "primary_s3_secret_key") - op.drop_column("mps_shop", "primary_s3_access_key") - op.drop_column("mps_shop", "primary_s3_bucket") - op.drop_column("mps_shop", "primary_s3_region") - op.drop_column("mps_shop", "primary_s3_endpoint") - op.drop_column("mps_shop", "plan_active") - op.drop_column("mps_shop", "trial_ended") - op.drop_column("mps_shop", "trial_started_timestamp") - op.drop_column("mps_shop", "environment") + if _column_exists("mps_shop", "primary_s3_enabled"): + op.drop_column("mps_shop", "primary_s3_enabled") + if _column_exists("mps_shop", "primary_s3_cdn_endpoint"): + op.drop_column("mps_shop", "primary_s3_cdn_endpoint") + if _column_exists("mps_shop", "primary_s3_secret_key"): + op.drop_column("mps_shop", "primary_s3_secret_key") + if _column_exists("mps_shop", "primary_s3_access_key"): + op.drop_column("mps_shop", "primary_s3_access_key") + if _column_exists("mps_shop", "primary_s3_bucket"): + op.drop_column("mps_shop", "primary_s3_bucket") + if _column_exists("mps_shop", "primary_s3_region"): + op.drop_column("mps_shop", "primary_s3_region") + if _column_exists("mps_shop", "primary_s3_endpoint"): + op.drop_column("mps_shop", "primary_s3_endpoint") + if _column_exists("mps_shop", "plan_active"): + op.drop_column("mps_shop", "plan_active") + if _column_exists("mps_shop", "trial_ended"): + op.drop_column("mps_shop", "trial_ended") + if _column_exists("mps_shop", "trial_started_timestamp"): + op.drop_column("mps_shop", "trial_started_timestamp") + if _column_exists("mps_shop", "environment"): + op.drop_column("mps_shop", "environment") ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion:-11+0 def downgrade(): if _column_exists("mps_shop", "primary_s3_enabled"): op.drop_column("mps_shop", "primary_s3_enabled") if _column_exists("mps_shop", "primary_s3_cdn_endpoint"): op.drop_column("mps_shop", "primary_s3_cdn_endpoint") if _column_exists("mps_shop", "primary_s3_secret_key"): op.drop_column("mps_shop", "primary_s3_secret_key") if _column_exists("mps_shop", "primary_s3_access_key"): op.drop_column("mps_shop", "primary_s3_access_key") if _column_exists("mps_shop", "primary_s3_bucket"): op.drop_column("mps_shop", "primary_s3_bucket") if _column_exists("mps_shop", "primary_s3_region"): op.drop_column("mps_shop", "primary_s3_region") if _column_exists("mps_shop", "primary_s3_endpoint"): op.drop_column("mps_shop", "primary_s3_endpoint") if _column_exists("mps_shop", "plan_active"): op.drop_column("mps_shop", "plan_active") if _column_exists("mps_shop", "trial_ended"): op.drop_column("mps_shop", "trial_ended") if _column_exists("mps_shop", "trial_started_timestamp"): op.drop_column("mps_shop", "trial_started_timestamp") if _column_exists("mps_shop", "environment"): op.drop_column("mps_shop", "environment") ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py` around lines 98 - 109, The downgrade() currently unconditionally calls op.drop_column for many columns (e.g., "primary_s3_enabled", "primary_s3_cdn_endpoint", "primary_s3_secret_key", "primary_s3_access_key", "primary_s3_bucket", "primary_s3_region", "primary_s3_endpoint", "plan_active", "trial_ended", "trial_started_timestamp", "environment"), which can break on partially applied schemas; update downgrade() to mirror the tolerant behavior of upgrade() by checking for each column's existence before dropping it (use the Alembic op.get_bind()/sqlalchemy.inspect Inspector or a helper like has_column to query the table schema), and only call op.drop_column for columns that actually exist to avoid dropping columns that this revision didn't create. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:39:01 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Missing the required pre-migration backup step.

This script starts applying DDL immediately, but the repo rule for Alembic migrations requires taking a SQLite backup first. Please wire that into the migration path before this ships.

As per coding guidelines, "Always create a backup of the database before any database operations (migrations, schema changes, etc.)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py`
around lines 36 - 100, The upgrade() migration is missing the required
pre-migration SQLite backup; add a call at the very start of upgrade() to invoke
the project's backup helper (e.g., call a function like ensure_sqlite_backup()
or run_sqlite_backup()) before any DDL runs, and make that helper a no-op on
non-SQLite backends; update or create a small function (ensure_sqlite_backup /
run_sqlite_backup) that checks the current DB URL/driver, performs the
filesystem copy/backup when driver == "sqlite", and raises/logs on failure so
the migration aborts safely if the backup cannot be made.
_⚠️ Potential issue_ | _🟠 Major_ **Missing the required pre-migration backup step.** This script starts applying DDL immediately, but the repo rule for Alembic migrations requires taking a SQLite backup first. Please wire that into the migration path before this ships. As per coding guidelines, "Always create a backup of the database before any database operations (migrations, schema changes, etc.)". <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py` around lines 36 - 100, The upgrade() migration is missing the required pre-migration SQLite backup; add a call at the very start of upgrade() to invoke the project's backup helper (e.g., call a function like ensure_sqlite_backup() or run_sqlite_backup()) before any DDL runs, and make that helper a no-op on non-SQLite backends; update or create a small function (ensure_sqlite_backup / run_sqlite_backup) that checks the current DB URL/driver, performs the filesystem copy/backup when driver == "sqlite", and raises/logs on failure so the migration aborts safely if the backup cannot be made. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:39:02 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Downgrade does not restore the pre-migration schema.

upgrade() adds mps_cart.json_gift_cards, but downgrade() never removes it. The unguarded drops will also fail on partially-applied or partially-rolled-back databases.

Suggested rollback fix
 def downgrade():
-    op.drop_table("mps_cart_gift_card")
-    op.drop_table("mps_gift_card_transaction")
-    op.drop_table("mps_gift_card")
-    op.drop_column("mps_shop", "gift_card_enabled")
-    op.drop_column("mps_shop", "gift_card_min_in_cents")
-    op.drop_column("mps_shop", "gift_card_max_in_cents")
+    if _table_exists("mps_cart_gift_card"):
+        op.drop_table("mps_cart_gift_card")
+    if _table_exists("mps_gift_card_transaction"):
+        op.drop_table("mps_gift_card_transaction")
+    if _table_exists("mps_gift_card"):
+        op.drop_table("mps_gift_card")
+    if _column_exists("mps_cart", "json_gift_cards"):
+        op.drop_column("mps_cart", "json_gift_cards")
+    if _column_exists("mps_shop", "gift_card_enabled"):
+        op.drop_column("mps_shop", "gift_card_enabled")
+    if _column_exists("mps_shop", "gift_card_min_in_cents"):
+        op.drop_column("mps_shop", "gift_card_min_in_cents")
+    if _column_exists("mps_shop", "gift_card_max_in_cents"):
+        op.drop_column("mps_shop", "gift_card_max_in_cents")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py`
around lines 102 - 108, The downgrade() fails to fully reverse upgrade() (it
never removes mps_cart.json_gift_cards) and naively drops tables/columns which
will error on partially-applied DBs; update downgrade() to drop the
mps_cart.json_gift_cards column and guard each drop with an existence check (use
SQLAlchemy Inspector or context.get_bind() to verify table/column existence)
before calling op.drop_table or op.drop_column for mps_cart_gift_card,
mps_gift_card_transaction, mps_gift_card, and the mps_shop columns
gift_card_enabled, gift_card_min_in_cents, gift_card_max_in_cents so the
rollback is idempotent and fully reverses upgrade().
_⚠️ Potential issue_ | _🟠 Major_ **Downgrade does not restore the pre-migration schema.** `upgrade()` adds `mps_cart.json_gift_cards`, but `downgrade()` never removes it. The unguarded drops will also fail on partially-applied or partially-rolled-back databases. <details> <summary>Suggested rollback fix</summary> ```diff def downgrade(): - op.drop_table("mps_cart_gift_card") - op.drop_table("mps_gift_card_transaction") - op.drop_table("mps_gift_card") - op.drop_column("mps_shop", "gift_card_enabled") - op.drop_column("mps_shop", "gift_card_min_in_cents") - op.drop_column("mps_shop", "gift_card_max_in_cents") + if _table_exists("mps_cart_gift_card"): + op.drop_table("mps_cart_gift_card") + if _table_exists("mps_gift_card_transaction"): + op.drop_table("mps_gift_card_transaction") + if _table_exists("mps_gift_card"): + op.drop_table("mps_gift_card") + if _column_exists("mps_cart", "json_gift_cards"): + op.drop_column("mps_cart", "json_gift_cards") + if _column_exists("mps_shop", "gift_card_enabled"): + op.drop_column("mps_shop", "gift_card_enabled") + if _column_exists("mps_shop", "gift_card_min_in_cents"): + op.drop_column("mps_shop", "gift_card_min_in_cents") + if _column_exists("mps_shop", "gift_card_max_in_cents"): + op.drop_column("mps_shop", "gift_card_max_in_cents") ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/scripts/alembic/versions/f8201a9ba045_add_gift_card_tables_and_shop_settings.py` around lines 102 - 108, The downgrade() fails to fully reverse upgrade() (it never removes mps_cart.json_gift_cards) and naively drops tables/columns which will error on partially-applied DBs; update downgrade() to drop the mps_cart.json_gift_cards column and guard each drop with an existence check (use SQLAlchemy Inspector or context.get_bind() to verify table/column existence) before calling op.drop_table or op.drop_column for mps_cart_gift_card, mps_gift_card_transaction, mps_gift_card, and the mps_shop columns gift_card_enabled, gift_card_min_in_cents, gift_card_max_in_cents so the rollback is idempotent and fully reverses upgrade(). ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:39:04 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🟠 Major

Guard the initial ring seed against stale async responses.

This callback writes ringProductIds using the mutable currentProductId. If the user navigates before the fetch resolves, the old response can overwrite the new ring state and persist the wrong ring in localStorage.

Suggested fix
     // Seed ring from server if localStorage is empty (first visit, cache cleared)
     if (!ringProductIds.length && currentProductId) {
-        fetchWatchData(currentProductId).then(function(data) {
+        var seedProductId = currentProductId;
+        fetchWatchData(seedProductId).then(function(data) {
+            if (currentProductId !== seedProductId) return;
             if (data.ring && data.ring.length) {
                 ringProductIds = data.ring;
-                syncRingPosition(currentProductId);
+                syncRingPosition(seedProductId);
                 saveRingState();
                 updateProgressDisplay();
             }
             preloadNext();
         }).catch(function() {
+            if (currentProductId !== seedProductId) return;
             preloadNext();
         });
     } else {
         preloadNext();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    // Seed ring from server if localStorage is empty (first visit, cache cleared)
    if (!ringProductIds.length && currentProductId) {
        var seedProductId = currentProductId;
        fetchWatchData(seedProductId).then(function(data) {
            if (currentProductId !== seedProductId) return;
            if (data.ring && data.ring.length) {
                ringProductIds = data.ring;
                syncRingPosition(seedProductId);
                saveRingState();
                updateProgressDisplay();
            }
            preloadNext();
        }).catch(function() {
            if (currentProductId !== seedProductId) return;
            preloadNext();
        });
    } else {
        preloadNext();
    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/static/js/watch.js` around lines 1841 - 1856, The async
fetchWatchData callback can overwrite ringProductIds when the user navigates
before the response arrives; to fix, capture the product id at request time
(e.g., const requestedId = currentProductId) before calling fetchWatchData and,
inside the .then handler, verify the live currentProductId still equals
requestedId (or that data is tagged for requestedId) before assigning
ringProductIds, calling syncRingPosition, saveRingState, or
updateProgressDisplay; if it doesn't match, ignore the stale response and still
call preloadNext as needed.
_⚠️ Potential issue_ | _🟠 Major_ **Guard the initial ring seed against stale async responses.** This callback writes `ringProductIds` using the mutable `currentProductId`. If the user navigates before the fetch resolves, the old response can overwrite the new ring state and persist the wrong ring in localStorage. <details> <summary>Suggested fix</summary> ```diff // Seed ring from server if localStorage is empty (first visit, cache cleared) if (!ringProductIds.length && currentProductId) { - fetchWatchData(currentProductId).then(function(data) { + var seedProductId = currentProductId; + fetchWatchData(seedProductId).then(function(data) { + if (currentProductId !== seedProductId) return; if (data.ring && data.ring.length) { ringProductIds = data.ring; - syncRingPosition(currentProductId); + syncRingPosition(seedProductId); saveRingState(); updateProgressDisplay(); } preloadNext(); }).catch(function() { + if (currentProductId !== seedProductId) return; preloadNext(); }); } else { preloadNext(); } ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion:-15+0 // Seed ring from server if localStorage is empty (first visit, cache cleared) if (!ringProductIds.length && currentProductId) { var seedProductId = currentProductId; fetchWatchData(seedProductId).then(function(data) { if (currentProductId !== seedProductId) return; if (data.ring && data.ring.length) { ringProductIds = data.ring; syncRingPosition(seedProductId); saveRingState(); updateProgressDisplay(); } preloadNext(); }).catch(function() { if (currentProductId !== seedProductId) return; preloadNext(); }); } else { preloadNext(); } ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/static/js/watch.js` around lines 1841 - 1856, The async fetchWatchData callback can overwrite ringProductIds when the user navigates before the response arrives; to fix, capture the product id at request time (e.g., const requestedId = currentProductId) before calling fetchWatchData and, inside the .then handler, verify the live currentProductId still equals requestedId (or that data is tagged for requestedId) before assigning ringProductIds, calling syncRingPosition, saveRingState, or updateProgressDisplay; if it doesn't match, ignore the stale response and still call preloadNext as needed. ``` </details> <!-- fingerprinting:phantom:poseidon:grasshopper --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai commented 2026-03-07 23:39:06 -05:00 (Migrated from git2.unturf.com)

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if watch.js constructs media URLs that need updating
rg -n "shop_cdn_endpoint|secure_uploads|endpoint.*product" make_post_sell/static/js/watch.js || echo "watch.js not found or no matches"

# Check the watch.py endpoint for how it provides URLs
rg -n "cdn_endpoint|secure_uploads" make_post_sell/views/watch.py || echo "watch.py not found or no matches"

Repository: engineering/make-post-sell/make_post_sell

Length of output: 261


🏁 Script executed:

# Find watch.js in the repository (may be in different location)
fd -t f "watch\.js" .

# Check the structure of the static directory
fd -t f "\.js$" make_post_sell/static/js/ | head -20

Repository: engineering/make-post-sell/make_post_sell

Length of output: 414


🏁 Script executed:

# Look at the specific lines in watch.py to understand what they do
sed -n '90,105p' make_post_sell/views/watch.py
sed -n '130,145p' make_post_sell/views/watch.py
sed -n '170,185p' make_post_sell/views/watch.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 1636


🏁 Script executed:

# Check if there's an updatePageContent function anywhere
rg -n "updatePageContent" make_post_sell/

Repository: engineering/make-post-sell/make_post_sell

Length of output: 505


🏁 Script executed:

# Look at the updatePageContent function in watch.js
sed -n '662,750p' make_post_sell/static/js/watch.js

Repository: engineering/make-post-sell/make_post_sell

Length of output: 3793


🏁 Script executed:

# Check the full watch.py to see what JSON it returns
# First, let's see the return statement and structure
rg -n "return.*json|jsonify|return {" make_post_sell/views/watch.py | head -20

Repository: engineering/make-post-sell/make_post_sell

Length of output: 206


🏁 Script executed:

# Get a broader view of watch.py to understand the endpoint structure
wc -l make_post_sell/views/watch.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 118


🏁 Script executed:

# Let's look at the return statements in watch.py more carefully
grep -n "return\|jsonify" make_post_sell/views/watch.py | head -20

Repository: engineering/make-post-sell/make_post_sell

Length of output: 442


🏁 Script executed:

# Let's get the full structure of the response - look near the end of watch.py where it builds the response
tail -100 make_post_sell/views/watch.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 3800


🏁 Script executed:

# Check if media_url, instrumentals_url, vocals_url are mentioned in watch.py
rg -n "media_url|instrumentals_url|vocals_url" make_post_sell/views/watch.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 425


🏁 Script executed:

# Look at where media_url is created in watch.py
sed -n '62,75p' make_post_sell/views/watch.py

Repository: engineering/make-post-sell/make_post_sell

Length of output: 630


Update watch.py to return CDN endpoint URLs for media, matching the template pattern.

The template now uses request.shop_cdn_endpoint for the video URL, but watch.py returns presigned S3 URLs via generate_presigned_url() for media_url, instrumentals_url, and vocals_url. This creates a scheme mismatch: the initial page load uses persistent CDN URLs, but navigation in watch mode switches to temporary presigned URLs (ExpiresIn=900), which will expire after 15 minutes and break playback.

watch.py already uses request.shop_cdn_endpoint for file_url and thumbnail_url. Update media_url, instrumentals_url, and vocals_url to follow the same pattern for consistency across watch mode lifecycle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@make_post_sell/templates/content.j2` around lines 42 - 45, The media URL
generation in watch.py currently returns presigned S3 URLs (via
generate_presigned_url()) for media_url, instrumentals_url, and vocals_url
causing a scheme mismatch with templates that expect persistent CDN URLs; update
watch.py to construct these three URLs using request.shop_cdn_endpoint the same
way file_url and thumbnail_url are built (i.e., combine
request.shop_cdn_endpoint with product.s3_path and the appropriate path/filename
pattern used for product media) instead of calling generate_presigned_url(),
ensuring media_url, instrumentals_url, and vocals_url are consistent CDN
endpoints that won’t expire.
_⚠️ Potential issue_ | _🔴 Critical_ <details> <summary>🧩 Analysis chain</summary> 🏁 Script executed: ```shell #!/bin/bash # Check if watch.js constructs media URLs that need updating rg -n "shop_cdn_endpoint|secure_uploads|endpoint.*product" make_post_sell/static/js/watch.js || echo "watch.js not found or no matches" # Check the watch.py endpoint for how it provides URLs rg -n "cdn_endpoint|secure_uploads" make_post_sell/views/watch.py || echo "watch.py not found or no matches" ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 261 --- 🏁 Script executed: ```shell # Find watch.js in the repository (may be in different location) fd -t f "watch\.js" . # Check the structure of the static directory fd -t f "\.js$" make_post_sell/static/js/ | head -20 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 414 --- 🏁 Script executed: ```shell # Look at the specific lines in watch.py to understand what they do sed -n '90,105p' make_post_sell/views/watch.py sed -n '130,145p' make_post_sell/views/watch.py sed -n '170,185p' make_post_sell/views/watch.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 1636 --- 🏁 Script executed: ```shell # Check if there's an updatePageContent function anywhere rg -n "updatePageContent" make_post_sell/ ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 505 --- 🏁 Script executed: ```shell # Look at the updatePageContent function in watch.js sed -n '662,750p' make_post_sell/static/js/watch.js ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 3793 --- 🏁 Script executed: ```shell # Check the full watch.py to see what JSON it returns # First, let's see the return statement and structure rg -n "return.*json|jsonify|return {" make_post_sell/views/watch.py | head -20 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 206 --- 🏁 Script executed: ```shell # Get a broader view of watch.py to understand the endpoint structure wc -l make_post_sell/views/watch.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 118 --- 🏁 Script executed: ```shell # Let's look at the return statements in watch.py more carefully grep -n "return\|jsonify" make_post_sell/views/watch.py | head -20 ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 442 --- 🏁 Script executed: ```shell # Let's get the full structure of the response - look near the end of watch.py where it builds the response tail -100 make_post_sell/views/watch.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 3800 --- 🏁 Script executed: ```shell # Check if media_url, instrumentals_url, vocals_url are mentioned in watch.py rg -n "media_url|instrumentals_url|vocals_url" make_post_sell/views/watch.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 425 --- 🏁 Script executed: ```shell # Look at where media_url is created in watch.py sed -n '62,75p' make_post_sell/views/watch.py ``` Repository: engineering/make-post-sell/make_post_sell Length of output: 630 --- </details> **Update watch.py to return CDN endpoint URLs for media, matching the template pattern.** The template now uses `request.shop_cdn_endpoint` for the video URL, but watch.py returns presigned S3 URLs via `generate_presigned_url()` for `media_url`, `instrumentals_url`, and `vocals_url`. This creates a scheme mismatch: the initial page load uses persistent CDN URLs, but navigation in watch mode switches to temporary presigned URLs (ExpiresIn=900), which will expire after 15 minutes and break playback. watch.py already uses `request.shop_cdn_endpoint` for `file_url` and `thumbnail_url`. Update `media_url`, `instrumentals_url`, and `vocals_url` to follow the same pattern for consistency across watch mode lifecycle. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@make_post_sell/templates/content.j2` around lines 42 - 45, The media URL generation in watch.py currently returns presigned S3 URLs (via generate_presigned_url()) for media_url, instrumentals_url, and vocals_url causing a scheme mismatch with templates that expect persistent CDN URLs; update watch.py to construct these three URLs using request.shop_cdn_endpoint the same way file_url and thumbnail_url are built (i.e., combine request.shop_cdn_endpoint with product.s3_path and the appropriate path/filename pattern used for product media) instead of calling generate_presigned_url(), ensuring media_url, instrumentals_url, and vocals_url are consistent CDN endpoints that won’t expire. ``` </details> <!-- fingerprinting:phantom:poseidon:ocelot --> <!-- This is an auto-generated comment by CodeRabbit -->
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: engineering/make_post_sell#94
No description provided.