max / makenotwork
53 files changed,
+1260 insertions,
-407 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.5.0" | |
| 3 | + | version = "0.5.1" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -1,270 +1,221 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: All pre-beta phases, UX audit remediation, creator trust audit remediation, shopping cart + wishlist checkout. Active: Final pre-launch testing. Next: Soft launch (target 2026-05-09). | |
| 4 | + | v0.5.0 deployed 2026-05-06. Soft launch target 2026-05-09. Audit grade A. ~85K LOC, 1,912 tests, 0 warnings. Migration 096. | |
| 5 | 5 | ||
| 6 | - | v0.5.0 deployed 2026-05-06. Audit grade A (Run 20, 2026-05-04). ~85K LOC, 1,912 tests (1220 unit + 664 integration + 28 load), 0 cargo warnings, 2 flaky sandbox tests. CI on astra operational. Mutation kill rate 99.4%. Property-based testing active (proptest). UX audit (2026-05-06): Learnability A-, Complexity B+, Discoverability B+, Feature Completeness B. Shopping cart implemented: multi-item Stripe checkout sessions, wishlist-to-cart flow, per-seller grouping, savings nudges. Migration 096. | |
| 7 | - | ||
| 8 | - | Human tasks (manual testing, outreach, legal, infrastructure) moved to `human_todo.md`. | |
| 9 | - | Completed items moved to `todo_done.md`. | |
| 6 | + | Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |
| 10 | 7 | ||
| 11 | 8 | --- | |
| 12 | 9 | ||
| 13 | - | ## Remaining Audit Items | |
| 10 | + | ## Sprint 1: Creator Self-Service (table stakes) | |
| 14 | 11 | ||
| 15 | - | ### Run 20 (2026-05-04) — Medium priority — DONE | |
| 12 | + | Creators expect these from any platform. Without them, sellers hit walls during normal operation. | |
| 16 | 13 | ||
| 17 | - | ### Run 20 — Low priority — DONE | |
| 18 | - | Split checkout.rs (792 -> 434 + 308 helpers). yara-x bumped 1.14->1.15 (intaglio fix). aws-sdk-s3 bumped 1.119->1.131. rustls-webpki 0.101.7/0.103.10 remain upstream-blocked. | |
| 14 | + | - [x] Add refund initiation from item sales tab (already implemented — refund button + Stripe refund + webhook handler) | |
| 15 | + | - [x] Allow editing promo codes after creation (PUT endpoint, inline edit form for max_uses, starts_at, expires_at) | |
| 16 | + | - [x] Add bulk delete for expired promo codes (DELETE /api/promo-codes/expired + "Delete all expired" button) | |
| 17 | + | - [x] Add scheduled start/end dates for promo codes (migration 099: starts_at column, validation at all checkout paths) | |
| 18 | + | - [x] Add purchase receipt/invoice download for completed purchases (GET /receipt/{transaction_id}, linked from library) | |
| 19 | + | - [x] Handle mid-queue cart checkout failure gracefully (redirect to /cart?checkout=partial with banner) | |
| 19 | 20 | ||
| 20 | - | ### Deferred Code Quality | |
| 21 | - | - [ ] Remove `async-trait` in favor of Rust 2024 native async traits (chronic) | |
| 22 | - | - [ ] Add README.md to server/ | |
| 23 | - | - [x] Delete stale `database_schema.md` (50 migrations) — superseded by `schema.md` (57 migrations) | |
| 24 | - | - [x] Fix MT README prerequisites: removed Redis/Valkey (sessions are PostgreSQL-backed) | |
| 25 | - | - [ ] Split oversized route files: exports.rs (737), license_keys.rs (741), health.rs (844), tabs/user.rs (707) | |
| 26 | - | - [ ] Monitor scheduler.rs (1249), git/mod.rs (624) for growth | |
| 27 | - | - [x] [rust-fuzz] Replace `.unwrap()` with `.expect("tier passed is_none guard")` in db/creator_tiers.rs:607 | |
| 28 | - | - [x] [rust-fuzz] Change `Config.host_url` from `String` to `Arc<str>` — eliminates 30+ String clones across codebase | |
| 29 | - | ||
| 30 | - | ### Dashboard Usability — Remaining (2026-05-05) | |
| 31 | - | ||
| 32 | - | Dashboard restructure complete (Phases 1-6 in todo_done.md). Tab layout: Projects, Payments, Analytics, Profile, Account, Plan + overflow. Fan/creator progressive disclosure implemented. Labels removed. Jargon renamed. Discoverability items complete. | |
| 33 | - | ||
| 34 | - | #### Performance | |
| 35 | - | - [x] Add performance philosophy doc (`docs/performance_philosophy.md`) — Tufte/McMaster-Carr principles applied to MNW | |
| 36 | - | - [x] Add frontend performance rules to CONTRIBUTING.md — image dimensions, reload avoidance, loading states, density | |
| 37 | - | - [x] Fix layout shift: add aspect-ratio to all images missing dimensions (item page, audio player, buy page, wizards) | |
| 38 | - | - [x] Replace `window.location.reload()` with HTMX tab re-fetch on item settings (4x), item details (1x), user profile (2x) | |
| 39 | - | - [x] Remove "Loading..." placeholder text from 2FA and passkey sections (empty until revealed) | |
| 40 | - | - [x] Add static JS convention to CONTRIBUTING.md — data-attribute bridges, IIFE pattern, htmx:afterSwap re-init, CLAUDE.md updated | |
| 41 | - | - [x] Extract item-details.js (~350 lines) — bundle, section, tag management from item_details.html. Uses htmx:afterSwap for re-init on tab swap | |
| 42 | - | - [x] Extract item-upload.js (~220 lines) — audio + version upload from partials. htmx:afterSwap re-init | |
| 43 | - | - [x] Extract blog-editor.js (~100 lines) — from dashboard-blog-editor.html. Data attributes for project/post IDs | |
| 44 | - | - [ ] (Deferred) Extract audio-player.js (~450 lines) — will happen as part of unified media player below | |
| 45 | - | ||
| 46 | - | ### Unified Media Player | |
| 47 | - | ||
| 48 | - | Unify audio and video playback into one shared component. Video gets custom controls, insertions, chapters, speed, volume, progress persistence — matching the audio experience. Plan at `~/.claude/plans/eager-ancient-salmon.md`. | |
| 49 | - | ||
| 50 | - | - [x] **Phase 1:** Extract media-player.js + media-player.css from audio_player.html — CSS renamed to media-* classes, JS reads config from `<script type="application/json">` bridge, keyboard shortcuts (Space/arrows/M/F/S) included. Template: 1035 → 206 lines | |
| 51 | - | - [x] **Phase 2:** Make state machine media-type aware — video uses single element (no dual gapless), all mediaB references guarded with null checks | |
| 52 | - | - [x] **Phase 3:** VideoPlayerTemplate + video_player.html template + video item handler in item.rs. Video items get dedicated player page (like audio). build_segments_json handles Video content type | |
| 53 | - | - [x] **Phase 4:** Video items now route to dedicated player page (early return in item handler, matching audio pattern) — item.html video section is dead code | |
| 54 | - | - [x] **Phase 5:** Keyboard shortcuts included in Phase 1 (Space, arrows, M, F, S) | |
| 55 | - | - [x] **Code fuzz fixes:** null standbyEl crashes in chapter nav + position restore, play-before-seek race in single-element advance, integer cast overflow guards in build_segments_json | |
| 56 | - | ||
| 57 | - | #### Remaining (from code fuzz 2026-05-05) | |
| 58 | - | - [ ] **Video URL fallback on S3 presign failure** — audio handler falls back to `audio_url` (CDN URL stored in DB), but video handler falls back to `None` because `ContentData::Video` has no `video_url` field. Add `video_url` column to items table (migration), populate from CDN base + S3 key on upload, fall back to it in video handler when presign fails. Matches audio pattern. | |
| 59 | - | - [ ] **Arrow key seek should use virtual timeline** — arrow keys currently set `el.currentTime` directly, bypassing segment boundaries. In segment mode this can seek into content from adjacent segments or past boundaries. Fix: route arrow seek through the progress bar seek logic (find target segment from virtual time, load correct segment, seek to offset). Affects both audio and video. | |
| 60 | - | ||
| 61 | - | #### Discoverability — DONE | |
| 62 | - | - [x] Add Media Library access from content editors — "Insert Image" button in blog/item editors, section editors. Shared `media-picker.js` modal fetches `/api/media`, inserts markdown ref at cursor | |
| 63 | - | - [x] Add content search/filter to project Content tab — client-side search by title, filter by status + type dropdowns | |
| 64 | - | - [x] Add "Embed & Share" quick action on Item Overview — button navigates to Embed tab | |
| 65 | - | - [x] Add bulk operations hint on Content tab — form-hint explaining checkbox selection | |
| 66 | - | - [x] Add contextual next-step suggestions after key actions — empty state flow hint, draft/published guidance on item overview | |
| 67 | - | ||
| 68 | - | #### Feature Completeness | |
| 69 | - | - [ ] Add download count analytics per item | |
| 70 | - | - [ ] Add cross-project item view | |
| 71 | - | - [ ] Add refund initiation from dashboard | |
| 72 | - | - [ ] Add "Export as CSV" button on item sales tables | |
| 21 | + | ## Sprint 2: Catalog Power Tools | |
| 73 | 22 | ||
| 74 | - | ### Usability Audit (2026-05-06) — Easy Wins (batch) — DONE | |
| 75 | - | Completed: header nav (Docs, Fan+, Changelog links), contextual Edit links on project/user/item public pages, View Profile link on dashboard-user, Members→Team rename, project/item wizard hints, Copy ID tooltip. Pre-existing: empty states (Projects tab, Content tab), onboarding descriptions, insertion→clip rename, RSS link, View Live link, Tools checkboxes. | |
| 23 | + | Creators with 20+ items need these. One-at-a-time editing doesn't scale. | |
| 76 | 24 | ||
| 77 | - | All easy wins complete. Upload error fix: client-side JS now parses server JSON error responses instead of showing generic "Failed to get upload URL" / "Failed to confirm upload". Server already returns tier-specific messages ("File exceeds maximum size of X MB", "Basic tier is text-only", "You've used X of Y storage"). Fixed in: item-upload.js (audio + version), item/content.html (video + batch). | |
| 25 | + | - [ ] Always show bulk action bar (disabled/grayed until items selected) | |
| 26 | + | - [ ] Add bulk rename operation | |
| 27 | + | - [ ] Add bulk tag operation | |
| 28 | + | - [ ] Add bulk price change operation | |
| 29 | + | - [ ] Implement soft delete with 30-day recovery — "Recently Deleted" section | |
| 30 | + | - [ ] Show undo toast after bulk delete | |
| 31 | + | - [ ] Add global search across all projects and items from dashboard | |
| 78 | 32 | ||
| 79 | - | ### Usability Audit (2026-05-06) — Medium — DONE | |
| 80 | - | Pre-existing (audit false negatives — HTMX tab content not visible to probes): promo code management (Promotions tab), custom links editor (Profile tab), collections management (Library tab), bulk item operations (Content tab checkboxes + publish/unpublish/delete), custom domain config (Profile tab), scheduled releases (Settings tab — datetime picker + cancel). | |
| 33 | + | ## Sprint 3: Onboarding Overhaul | |
| 81 | 34 | ||
| 82 | - | Completed (2026-05-06): | |
| 83 | - | - Buyer Contacts: HTMX lazy-loaded in Payments tab, CSV export at `/api/export/contacts`. Uses existing `get_seller_contacts()` — no migration needed. Respects contact_revocations. | |
| 84 | - | - Page View Tracking: Migration 095 (`page_view_daily` table), fire-and-forget UPSERT from item/project/user page handlers, bot filtering, 2-year pruning in scheduler. | |
| 85 | - | - Cross-project analytics: Project Comparison table on user Analytics tab (revenue with inline bars, sales, views, conversion). Time-range aware. | |
| 86 | - | - Conversion analytics: Views + Conversion stat cards on both user and project Analytics tabs. Period-over-period % for views. | |
| 87 | - | - Subscription → Membership terminology: project dashboard tab, wizard, project page, 7 docs files. | |
| 35 | + | The join wizard conflates buyer and creator journeys. This sprint simplifies it so fans don't bounce through creator steps and creators don't skip things they need. | |
| 88 | 36 | ||
| 89 | - | ### UX Audit Remediation (2026-05-06) — DONE (4 rounds) | |
| 37 | + | - [ ] Branch signup by intent — ask "browse or sell?" after account creation | |
| 38 | + | - [ ] Collapse from 5 steps to 3 (account, profile, welcome) | |
| 39 | + | - [ ] Move creator pitch to dashboard (not a signup step) | |
| 40 | + | - [ ] Move Stripe setup to just-in-time (prompt at first publish) | |
| 41 | + | - [ ] Rename "Apply as Creator" to "Tell us about your work" | |
| 42 | + | - [ ] Rename "Pitch" — term is never defined for users | |
| 90 | 43 | ||
| 91 | - | **Learnability (B+ → A-):** Join preamble, TOTP/passkey labels, SyncKit jargon fix, empty state CTAs (subscriptions, collections), project onboarding checklist, tab hover hints (all dashboards), passkey login label. | |
| 44 | + | ## Sprint 4: Dashboard Polish | |
| 92 | 45 | ||
| 93 | - | **Complexity (B- → B+):** Pitch step simplified (removed trial/tier fields), item wizard sections step removed (6→5 steps), item Settings merged into Details, item Embed folded into Overview (7→5 tabs), item tab hints. | |
| 46 | + | Terminology fixes, layout improvements, and removing noise from the creator dashboard. | |
| 94 | 47 | ||
| 95 | - | **Upload flow:** Client-side file size pre-validation with tier limit from presign response, tier-aware error messages (HTTP 413), retry preserves file selection, upload speed + ETA display. | |
| 48 | + | - [ ] Rename "creator tiers" to "Creator Plans" everywhere | |
| 49 | + | - [ ] Show current tier limits inline on Creator Plan tab | |
| 50 | + | - [ ] Make onboarding checklist harder to dismiss | |
| 51 | + | - [ ] Add banner if creator tier not subscribed | |
| 52 | + | - [ ] Promote "Export" to top-level dashboard navigation | |
| 53 | + | - [ ] Add section headers to "More" dropdown | |
| 54 | + | - [ ] Don't show "Your account is in good standing" when no issues | |
| 55 | + | - [ ] Add feature description cards on first visit to project dashboard | |
| 56 | + | - [ ] Add "Project Features" discovery card in overview | |
| 96 | 57 | ||
| 97 | - | **Discoverability (B- → B+):** Wishlists tab in Library (new endpoint + template), data export link in dashboard header, changelog link de-muted, RSS/copy-link moved to profile header, library purchases search filter, quick-edit links confirmed present. | |
| 58 | + | ## Sprint 5: Account Settings Cleanup | |
| 98 | 59 | ||
| 99 | - | **Shopping cart + wishlist checkout (Feature Completeness B → B+):** Migration 096 (cart_items table), cart DB module, cart API (toggle/remove/count), multi-line-item Stripe Checkout Sessions per seller (Direct Charges), cart checkout route, cart webhook handler (batch transaction completion), cart page grouped by seller with savings nudge, wishlist-to-cart flow, "Add to Cart" on item pages + wishlists + purchase page, cart link in nav, scheduler cleanup. | |
| 60 | + | The account tab is dense and unsorted. Security setup has no guidance. | |
| 100 | 61 | ||
| 101 | - | ### UX — Deferred (post-launch) | |
| 102 | - | - [ ] Storefront customization (creator themes/colors — 24 TOML themes exist in shared/themes/) | |
| 103 | - | - [ ] Reviews/ratings system for items | |
| 104 | - | - [ ] Wishlist price-drop alerts | |
| 105 | - | - [ ] Gift purchases at checkout | |
| 106 | - | - [ ] HTML rich email for creator broadcasts (currently plain text only) | |
| 107 | - | - [ ] In-app notification center (beyond email-only notifications) | |
| 108 | - | - [ ] Cross-seller cart checkout (currently one Stripe session per seller) | |
| 109 | - | - [ ] Promo codes in cart checkout (currently single-item only) | |
| 110 | - | - [ ] PWYW custom amounts in cart (currently uses minimum price) | |
| 62 | + | - [ ] Reorder: Account Status (top), Security, Account Info, Preferences | |
| 63 | + | - [ ] Add security checklist | |
| 64 | + | - [ ] Split dense content into clearer sub-sections | |
| 65 | + | - [ ] Add "Setup Status" badges (e.g., "2FA: Not Set Up") | |
| 66 | + | - [ ] Promote Stripe Tax toggle to visible position in Payments tab | |
| 67 | + | - [ ] Differentiate pause/deactivate/delete with comparison table | |
| 68 | + | - [ ] Link SSH Keys from Project Code tab | |
| 111 | 69 | ||
| 112 | - | --- | |
| 113 | - | ||
| 114 | - | ## Pre-Beta — Remaining | |
| 70 | + | ## Sprint 6: Item Editor Refinements | |
| 115 | 71 | ||
| 116 | - | ### Creator Trust Audit Remediation (2026-05-03) — DONE | |
| 117 | - | All items complete. AI disclosure updated. Buyer notification wired into email-link deletion path. Compare page, fee examples, storage docs, backup test procedure all added. | |
| 72 | + | Small improvements to the item editing experience. | |
| 118 | 73 | ||
| 119 | - | ### Incident Notification System — DONE | |
| 120 | - | Creators can opt into email alerts when platform status changes. Migration 091 adds `notify_status` column. Monitor sends emails on status transitions to opted-in users with proper unsubscribe links. Dashboard checkbox in settings tab. | |
| 74 | + | - [ ] Move Sections management to its own collapsible area | |
| 75 | + | - [ ] Add tip/callout about Sections feature | |
| 76 | + | - [ ] Suggest default sections on first item creation based on type | |
| 77 | + | - [ ] Add section at top of pricing tab explaining strategies with examples | |
| 78 | + | - [ ] Clarify whether PWYW + license keys can combine | |
| 79 | + | - [ ] Add real-time validation to pricing fields with format examples | |
| 80 | + | - [ ] Ensure item type descriptions always render in wizard | |
| 81 | + | - [ ] Show allowed file types before upload attempt | |
| 82 | + | - [ ] Show tier-specific file size limit with upgrade link | |
| 121 | 83 | ||
| 122 | - | ### Frontend | |
| 123 | - | - [ ] Git browser integration: add discover/follow integration (post-beta) | |
| 84 | + | ## Sprint 7: Collections Everywhere | |
| 124 | 85 | ||
| 125 | - | --- | |
| 86 | + | Collections exist but are hard to find and use. This makes them a first-class feature. | |
| 126 | 87 | ||
| 127 | - | ## File Scanning — Future Improvements | |
| 88 | + | - [ ] Improve "Add to Collection" affordance on item page | |
| 89 | + | - [ ] Add "Add to Collection" action on discover result cards | |
| 90 | + | - [ ] Add "Add to Collection" from library items | |
| 128 | 91 | ||
| 129 | - | ### Background scan queue (next) | |
| 130 | - | - [ ] Add `scan_queue` table (s3_key, file_type, user_id, status, created_at) | |
| 131 | - | - [ ] Enqueue oversized files from `scan_and_classify` instead of blanket HeldForReview | |
| 132 | - | - [ ] Scheduler picks up queued scans, streams from S3 to temp file, scans from disk | |
| 133 | - | - [ ] Update entity scan status + notify creator on completion | |
| 92 | + | ## Sprint 8: Discovery Improvements | |
| 134 | 93 | ||
| 135 | - | ### Separate scanning service (later, when traffic justifies) | |
| 136 | - | - [ ] Extract scan worker into standalone binary (same crate, different bin target) | |
| 94 | + | Help fans find content and help creators understand their audience. | |
| 137 | 95 | ||
| 138 | - | --- | |
| 139 | - | ||
| 140 | - | ## Competitive Comparison Remediation (2026-04-29) | |
| 96 | + | - [ ] Expose AI Tier filter in discover UI | |
| 97 | + | - [ ] Add search suggestions/autocomplete | |
| 98 | + | - [ ] Add "Has source code" filter for projects with linked Git repos | |
| 99 | + | - [ ] Add download count analytics per item | |
| 100 | + | - [ ] Add distinction between "already downloaded" and "available for download" in library | |
| 141 | 101 | ||
| 142 | - | ### i18n — Grade C | |
| 143 | - | - [ ] Evaluate `fluent-rs` vs `rust-i18n` | |
| 144 | - | - [ ] Extract user-facing strings into message catalog | |
| 145 | - | - [ ] Add locale negotiation middleware | |
| 146 | - | - [ ] i18n error messages | |
| 102 | + | ## Sprint 9: Documentation | |
| 147 | 103 | ||
| 148 | - | ### OpenAPI Spec — Grade A- | |
| 149 | - | - [ ] Annotate remaining public endpoints | |
| 150 | - | - [ ] Auto-generate API reference docs from spec | |
| 104 | + | Fill gaps so creators can self-serve instead of contacting support. | |
| 151 | 105 | ||
| 152 | - | ### CI/CD Formalization — Grade B+ | |
| 153 | - | - [ ] Add `cargo clippy` + `cargo test` as git pre-push hook or CI gate | |
| 154 | - | - [ ] Add migration integrity check to CI | |
| 155 | - | - [ ] Add test timing report to CI output | |
| 156 | - | - [ ] Consider sourcehut builds.sr.ht manifest | |
| 106 | + | - [ ] Add docs for Collections feature | |
| 107 | + | - [ ] Add docs for Promo Codes & Discounts | |
| 108 | + | - [ ] Add docs for Git integration / source browser | |
| 109 | + | - [ ] Add docs for License Keys | |
| 110 | + | - [ ] Add docs for SyncKit integration | |
| 111 | + | - [ ] Add FAQ / Troubleshooting page by user role | |
| 157 | 112 | ||
| 158 | 113 | --- | |
| 159 | 114 | ||
| 160 | - | ## Post-Beta | |
| 161 | - | ||
| 162 | - | ### Earn-Back Credit Program | |
| 163 | - | - [ ] Schema, annual calculation job, apply credits, dashboard display, email notification | |
| 164 | - | ||
| 165 | - | ### Churn Monitoring and Creator Health | |
| 166 | - | - [ ] creator_health materialized view, churn risk scoring, admin dashboard widget | |
| 167 | - | - [ ] Trigger: implement before reaching 100 creators | |
| 168 | - | ||
| 169 | - | ### Phase 11B: Promotions | |
| 170 | - | - [ ] Affiliate/referral program | |
| 171 | - | ||
| 172 | - | ### Phase 13D: Creator Platform Import System — Remaining | |
| 173 | - | - [ ] Substack ZIP importer, Ghost JSON importer | |
| 174 | - | - [ ] Gumroad CSV/API, Bandcamp CSV preset, Ko-fi CSV preset, Lemon Squeezy, Patreon OAuth | |
| 175 | - | ||
| 176 | - | ### Phase 14E: Media Transcoding Pipeline | |
| 177 | - | - [ ] Probe + detect infrastructure | |
| 178 | - | - [ ] Audio transcoding (SmallFiles tier) | |
| 179 | - | - [ ] Fan download format choice | |
| 180 | - | - [ ] Video transcoding | |
| 181 | - | - [ ] Everything tier: adaptive bitrate streaming, quality ladders | |
| 115 | + | ## Backlog (no sprint assigned) | |
| 182 | 116 | ||
| 183 | - | ### Phase 14B: Embeddable Widgets | |
| 184 | - | - [ ] Embed endpoint, overlay widget, inline embed | |
| 117 | + | ### Promo Codes — Nice to Have | |
| 118 | + | - [ ] Add preset trial duration options ("14 days", "30 days") | |
| 119 | + | - [ ] Add share tracking — show which users redeemed a code | |
| 185 | 120 | ||
| 186 | - | ### Phase 16: Performance | |
| 187 | - | - [ ] Response caching, query optimization, CDN, metrics endpoint | |
| 121 | + | ### Uploads | |
| 122 | + | - [ ] Support resumable uploads for large files (S3 multipart API) | |
| 123 | + | - [ ] Surface content scanning failures to users | |
| 124 | + | - [ ] Add batch file download (ZIP) for item versions | |
| 188 | 125 | ||
| 189 | - | ### SEO & Bot Access | |
| 190 | - | - [ ] Evaluate Cloudflare `/crawl` endpoint, per-creator crawl preference toggle | |
| 126 | + | ### Media Player | |
| 127 | + | - [ ] Video URL fallback on S3 presign failure (migration: `video_url` column) | |
| 128 | + | - [ ] Arrow key seek should use virtual timeline (segment-aware) | |
| 191 | 129 | ||
| 192 | - | ### Phase 17B: Content Newsletters — Remaining | |
| 193 | - | - [ ] Delivery metrics, section-level email preferences | |
| 194 | - | ||
| 195 | - | ### Phase 17C: Comments — Remaining | |
| 196 | - | - [ ] Creator moderation via MT tools, restrict commenting to buyers/subscribers | |
| 197 | - | ||
| 198 | - | ### Phase 20: OSS Creator Tools | |
| 199 | - | - [ ] Git-backed wikis, compare view, tags/releases, code search | |
| 200 | - | - [ ] Platform-wide mailing lists, sponsor tiers, CI/build enhancements | |
| 201 | - | ||
| 202 | - | ### Phase 20B: Mobile Apps (Consumption) | |
| 203 | - | - [ ] iOS + Android: library, downloads, offline, audio player, reader, push notifications | |
| 204 | - | ||
| 205 | - | ### Phase 20C: Physical Product Listings | |
| 206 | - | - [ ] Physical product type, shipping address, order management | |
| 207 | - | ||
| 208 | - | ### Ko-fi Comparison Gaps | |
| 209 | - | - [ ] Fundraising goals, commissions, social integrations | |
| 210 | - | ||
| 211 | - | ### Phase 20D: Automated Revenue Split Payouts | |
| 212 | - | - [ ] Automated Stripe Transfers, multi-processor support | |
| 213 | - | ||
| 214 | - | ### Phase 21: Scheduled Content — Remaining | |
| 215 | - | - [ ] Pre-save + pre-order, countdown display, calendar view | |
| 216 | - | ||
| 217 | - | ### Phase 22: Live Streaming (Everything tier) | |
| 218 | - | - [ ] Full spec in git history. MVP: 22A + 22B + 22E + 22C + 22H | |
| 219 | - | - [ ] Trigger: first Everything tier creator subscribes | |
| 220 | - | ||
| 221 | - | ### Chargeback Protection Fund | |
| 222 | - | - [ ] Mutual pool: creators contribute ~0.75% of sales, pool reimburses chargeback losses (all types, not just fraud) | |
| 223 | - | - [ ] Requires: dispute webhooks on connected accounts, pool ledger, per-creator caps, experience rating | |
| 224 | - | - [ ] Full plan: `docs/internal/business/chargeback_protection_fund.md` | |
| 225 | - | - [ ] Prerequisite: 50+ participating creators, 3-month reserve buildup before payouts activate | |
| 226 | - | ||
| 227 | - | ### Phase 24: Payment Independence | |
| 228 | - | - [ ] Stablecoin checkout, reduce creator-side fees, Stripe dependency mitigation, international expansion | |
| 229 | - | ||
| 230 | - | ### License Key Self-Management | |
| 231 | - | - [ ] Buyer activation management, offline grace period, license transfer | |
| 130 | + | ### Code Quality | |
| 131 | + | - [ ] Remove `async-trait` in favor of Rust 2024 native async traits | |
| 132 | + | - [ ] Add README.md to server/ | |
| 133 | + | - [ ] Split oversized route files: exports.rs, license_keys.rs, health.rs, tabs/user.rs | |
| 134 | + | - [ ] Monitor scheduler.rs, git/mod.rs for growth | |
| 232 | 135 | ||
| 233 | - | ### SyncKit S5/S8/S9 | |
| 234 | - | - [ ] Swift SDK, multi-tenant, rate limiting, WebSocket realtime, CRDT, productize | |
| 136 | + | ### Feature Completeness | |
| 137 | + | - [ ] Add cross-project item view | |
| 138 | + | - [ ] Git browser: add discover/follow integration | |
| 235 | 139 | ||
| 236 | - | ### Developer Services | |
| 237 | - | - [ ] Crash reporting, feedback collection, dashboard aggregate endpoint | |
| 140 | + | ### Global UX | |
| 141 | + | - [ ] Add toast stacking for multiple notifications | |
| 142 | + | - [ ] Add "New" badge/dot indicator on recently launched features | |
| 143 | + | - [ ] Auto-show "What's New" modal on major version bumps | |
| 144 | + | - [ ] Link changelog from landing page | |
| 145 | + | - [ ] Add aria-describedby linking hint text to form inputs | |
| 146 | + | - [ ] Add aria-invalid and aria-errormessage for validation errors | |
| 238 | 147 | ||
| 239 | - | ### Fan+ Accounts | |
| 240 | - | - [ ] Stripe integration, monthly credit, badge, feature gating, dev community | |
| 148 | + | --- | |
| 241 | 149 | ||
| 242 | - | ### DocEngine — Remaining | |
| 243 | - | - [ ] Full-text search index, versioned docs | |
| 150 | + | ## Post-Launch | |
| 244 | 151 | ||
| 245 | - | ### Notification Service | |
| 246 | - | - [ ] notifications table, event triggers, API endpoints, digest preferences | |
| 152 | + | ### Competitive Comparison | |
| 153 | + | - [ ] i18n (fluent-rs vs rust-i18n, message catalog, locale middleware) | |
| 154 | + | - [ ] OpenAPI spec (annotate remaining endpoints, auto-generate docs) | |
| 155 | + | - [ ] CI/CD formalization (clippy gate, migration check, timing report) | |
| 247 | 156 | ||
| 248 | - | ### Search Infrastructure | |
| 249 | - | - [ ] Full-text search (tsvector), unified search API, cross-project results | |
| 157 | + | ### File Scanning | |
| 158 | + | - [ ] Background scan queue (scan_queue table, scheduler, S3 streaming) | |
| 159 | + | - [ ] Separate scanning service (standalone binary, when traffic justifies) | |
| 250 | 160 | ||
| 251 | - | ### Image Upload Pipeline | |
| 252 | - | - [ ] Thumbnail generation, format validation, size limits | |
| 161 | + | ### Revenue & Business | |
| 162 | + | - [ ] Earn-back credit program (schema, annual calc, dashboard, email) | |
| 163 | + | - [ ] Churn monitoring and creator health (materialized view, risk scoring) | |
| 164 | + | - [ ] Chargeback protection fund (dispute webhooks, pool ledger, caps) | |
| 165 | + | - [ ] Automated revenue split payouts (Stripe Transfers) | |
| 253 | 166 | ||
| 254 | - | ### Link Preview Extraction | |
| 255 | - | - [ ] Extract to shared crate, OG metadata, blog post URL previews | |
| 167 | + | ### Platform Growth | |
| 168 | + | - [ ] Affiliate/referral program | |
| 169 | + | - [ ] Creator platform import (Substack, Ghost, Gumroad, Bandcamp, Ko-fi, Patreon) | |
| 170 | + | - [ ] Fan+ accounts (Stripe, credits, badge, gating) | |
| 171 | + | - [ ] Comments (MT moderation, buyer/subscriber restriction) | |
| 172 | + | - [ ] Content newsletters (delivery metrics, section-level prefs) | |
| 173 | + | ||
| 174 | + | ### Infrastructure | |
| 175 | + | - [ ] Media transcoding pipeline (probe, audio, video, adaptive bitrate) | |
| 176 | + | - [ ] Embeddable widgets (endpoint, overlay, inline) | |
| 177 | + | - [ ] Performance (caching, query optimization, CDN, metrics) | |
| 178 | + | - [ ] Search infrastructure (tsvector, unified API, cross-project) | |
| 179 | + | - [ ] Notification service (table, triggers, API, digest prefs) | |
| 180 | + | - [ ] Image upload pipeline (thumbnails, format validation) | |
| 181 | + | - [ ] Link preview extraction (shared crate, OG metadata) | |
| 182 | + | - [ ] SEO & bot access (Cloudflare crawl endpoint, per-creator toggle) | |
| 183 | + | ||
| 184 | + | ### Major Features | |
| 185 | + | - [ ] Mobile apps (iOS + Android: library, downloads, offline, player, push) | |
| 186 | + | - [ ] Physical product listings (type, shipping, order management) | |
| 187 | + | - [ ] Live streaming (Everything tier, trigger: first subscriber) | |
| 188 | + | - [ ] Payment independence (stablecoin, fee reduction, international) | |
| 189 | + | - [ ] Scheduled content (pre-save, pre-order, countdown, calendar) | |
| 190 | + | - [ ] OSS creator tools (git wikis, compare, tags, code search, mailing lists) | |
| 191 | + | - [ ] License key self-management (activation, offline grace, transfer) | |
| 192 | + | - [ ] SyncKit S5/S8/S9 (Swift SDK, multi-tenant, WebSocket, CRDT) | |
| 193 | + | - [ ] Developer services (crash reporting, feedback, dashboard) | |
| 194 | + | - [ ] DocEngine (full-text search, versioned docs) | |
| 195 | + | ||
| 196 | + | ### UX Deferred | |
| 197 | + | - [ ] Storefront customization (creator themes/colors) | |
| 198 | + | - [ ] Reviews/ratings system | |
| 199 | + | - [ ] Wishlist price-drop alerts | |
| 200 | + | - [ ] Gift purchases | |
| 201 | + | - [ ] HTML rich email for broadcasts | |
| 202 | + | - [ ] In-app notification center | |
| 203 | + | - [ ] Cross-seller cart checkout | |
| 204 | + | - [ ] Promo codes in cart checkout | |
| 205 | + | - [ ] PWYW custom amounts in cart | |
| 206 | + | - [ ] Keyboard shortcuts | |
| 256 | 207 | ||
| 257 | 208 | --- | |
| 258 | 209 | ||
| 259 | 210 | ## Dependencies (blocked on upstream) | |
| 260 | - | - [ ] Monitor yara-x for wasmtime >=42.0.2 (RUSTSEC-2026-0095, -0096) | |
| 261 | - | - [ ] Monitor aws-sdk-s3 for lru fix (RUSTSEC-2026-0002) | |
| 262 | - | - [ ] Monitor async-stripe for instant fix (RUSTSEC-2024-0384) | |
| 211 | + | - [ ] yara-x: wasmtime >=42.0.2 (RUSTSEC-2026-0095, -0096) | |
| 212 | + | - [ ] aws-sdk-s3: lru fix (RUSTSEC-2026-0002) | |
| 213 | + | - [ ] async-stripe: instant fix (RUSTSEC-2024-0384) | |
| 263 | 214 | - [ ] rsa (RUSTSEC-2023-0071) via sqlx-mysql + yara-x — no fix available | |
| 264 | 215 | ||
| 265 | - | ## Deferred | |
| 216 | + | ## Deferred (no timeline) | |
| 266 | 217 | - [ ] Team/organization accounts | |
| 267 | - | - [ ] Creator @makenot.work email addresses (Migadu) | |
| 218 | + | - [ ] Creator @makenot.work email (Migadu) | |
| 268 | 219 | - [ ] Tax-year revenue summary, invoice generation, 1099 guidance | |
| 269 | 220 | - [ ] Merchant of Record (MoR) | |
| 270 | 221 | - [ ] Email drip workflows |
| @@ -0,0 +1,2 @@ | |||
| 1 | + | -- Add starts_at column for scheduling promo codes to activate in the future. | |
| 2 | + | ALTER TABLE promo_codes ADD COLUMN starts_at TIMESTAMPTZ; |
| @@ -37,6 +37,8 @@ pub struct DbPromoCode { | |||
| 37 | 37 | pub use_count: i32, | |
| 38 | 38 | /// When this code expires (NULL = never). | |
| 39 | 39 | pub expires_at: Option<DateTime<Utc>>, | |
| 40 | + | /// When this code becomes active (NULL = immediately). | |
| 41 | + | pub starts_at: Option<DateTime<Utc>>, | |
| 40 | 42 | /// When this code was created. | |
| 41 | 43 | pub created_at: DateTime<Utc>, | |
| 42 | 44 | /// Whether this code is platform-wide (Fan+ credits, usable on any creator's items). | |
| @@ -60,6 +62,7 @@ pub struct DbPromoCodeWithNames { | |||
| 60 | 62 | pub max_uses: Option<i32>, | |
| 61 | 63 | pub use_count: i32, | |
| 62 | 64 | pub expires_at: Option<DateTime<Utc>>, | |
| 65 | + | pub starts_at: Option<DateTime<Utc>>, | |
| 63 | 66 | pub created_at: DateTime<Utc>, | |
| 64 | 67 | /// Whether this code is platform-wide (Fan+ credits). | |
| 65 | 68 | pub is_platform_wide: bool, |
| @@ -104,6 +104,8 @@ pub struct DbTransactionExportRow { | |||
| 104 | 104 | /// A row from the user's purchase history (used on the "For You" page). | |
| 105 | 105 | #[derive(Debug, Clone, FromRow)] | |
| 106 | 106 | pub struct DbPurchaseRow { | |
| 107 | + | /// Transaction ID for receipt links. | |
| 108 | + | pub transaction_id: TransactionId, | |
| 107 | 109 | /// Purchased item's ID. | |
| 108 | 110 | pub item_id: ItemId, | |
| 109 | 111 | /// Item title at the time of query. |
| @@ -24,6 +24,7 @@ pub async fn create_promo_code( | |||
| 24 | 24 | trial_days: Option<i32>, | |
| 25 | 25 | max_uses: Option<i32>, | |
| 26 | 26 | expires_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 27 | + | starts_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 27 | 28 | item_id: Option<ItemId>, | |
| 28 | 29 | project_id: Option<ProjectId>, | |
| 29 | 30 | tier_id: Option<SubscriptionTierId>, | |
| @@ -31,8 +32,8 @@ pub async fn create_promo_code( | |||
| 31 | 32 | let promo_code = sqlx::query_as::<_, DbPromoCode>( | |
| 32 | 33 | r#" | |
| 33 | 34 | INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, | |
| 34 | - | min_price_cents, trial_days, max_uses, expires_at, item_id, project_id, tier_id) | |
| 35 | - | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) | |
| 35 | + | min_price_cents, trial_days, max_uses, expires_at, starts_at, item_id, project_id, tier_id) | |
| 36 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) | |
| 36 | 37 | RETURNING * | |
| 37 | 38 | "#, | |
| 38 | 39 | ) | |
| @@ -45,6 +46,7 @@ pub async fn create_promo_code( | |||
| 45 | 46 | .bind(trial_days) | |
| 46 | 47 | .bind(max_uses) | |
| 47 | 48 | .bind(expires_at) | |
| 49 | + | .bind(starts_at) | |
| 48 | 50 | .bind(item_id) | |
| 49 | 51 | .bind(project_id) | |
| 50 | 52 | .bind(tier_id) | |
| @@ -206,6 +208,69 @@ pub async fn release_use_count(pool: &PgPool, id: PromoCodeId) -> Result<()> { | |||
| 206 | 208 | Ok(()) | |
| 207 | 209 | } | |
| 208 | 210 | ||
| 211 | + | /// Update editable fields on a promo code (expires_at, starts_at, max_uses). | |
| 212 | + | #[tracing::instrument(skip_all)] | |
| 213 | + | pub async fn update_promo_code( | |
| 214 | + | pool: &PgPool, | |
| 215 | + | id: PromoCodeId, | |
| 216 | + | expires_at: Option<Option<chrono::DateTime<chrono::Utc>>>, | |
| 217 | + | starts_at: Option<Option<chrono::DateTime<chrono::Utc>>>, | |
| 218 | + | max_uses: Option<Option<i32>>, | |
| 219 | + | ) -> Result<DbPromoCode> { | |
| 220 | + | // Build SET clauses for provided fields only | |
| 221 | + | let mut sets = Vec::new(); | |
| 222 | + | let mut param_idx = 2u32; // $1 = id | |
| 223 | + | ||
| 224 | + | if expires_at.is_some() { | |
| 225 | + | sets.push(format!("expires_at = ${param_idx}")); | |
| 226 | + | param_idx += 1; | |
| 227 | + | } | |
| 228 | + | if starts_at.is_some() { | |
| 229 | + | sets.push(format!("starts_at = ${param_idx}")); | |
| 230 | + | param_idx += 1; | |
| 231 | + | } | |
| 232 | + | if max_uses.is_some() { | |
| 233 | + | sets.push(format!("max_uses = ${param_idx}")); | |
| 234 | + | // param_idx += 1; | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | if sets.is_empty() { | |
| 238 | + | // Nothing to update — just return current state | |
| 239 | + | return get_promo_code_by_id(pool, id) | |
| 240 | + | .await? | |
| 241 | + | .ok_or_else(|| crate::error::AppError::NotFound); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | let sql = format!("UPDATE promo_codes SET {} WHERE id = $1 RETURNING *", sets.join(", ")); | |
| 245 | + | let mut query = sqlx::query_as::<_, DbPromoCode>(&sql).bind(id); | |
| 246 | + | ||
| 247 | + | if let Some(val) = expires_at { | |
| 248 | + | query = query.bind(val); | |
| 249 | + | } | |
| 250 | + | if let Some(val) = starts_at { | |
| 251 | + | query = query.bind(val); | |
| 252 | + | } | |
| 253 | + | if let Some(val) = max_uses { | |
| 254 | + | query = query.bind(val); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | let code = query.fetch_one(pool).await?; | |
| 258 | + | Ok(code) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// Delete all expired promo codes for a creator. Returns number of rows deleted. | |
| 262 | + | #[tracing::instrument(skip_all)] | |
| 263 | + | pub async fn delete_expired_by_creator(pool: &PgPool, creator_id: UserId) -> Result<u64> { | |
| 264 | + | let result = sqlx::query( | |
| 265 | + | "DELETE FROM promo_codes WHERE creator_id = $1 AND expires_at IS NOT NULL AND expires_at < NOW()", | |
| 266 | + | ) | |
| 267 | + | .bind(creator_id) | |
| 268 | + | .execute(pool) | |
| 269 | + | .await?; | |
| 270 | + | ||
| 271 | + | Ok(result.rows_affected()) | |
| 272 | + | } | |
| 273 | + | ||
| 209 | 274 | /// Delete a promo code permanently. | |
| 210 | 275 | #[tracing::instrument(skip_all)] | |
| 211 | 276 | pub async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> { |
| @@ -467,6 +467,7 @@ pub async fn get_user_purchases(pool: &PgPool, user_id: UserId) -> Result<Vec<Db | |||
| 467 | 467 | r#" | |
| 468 | 468 | SELECT * FROM ( | |
| 469 | 469 | SELECT DISTINCT ON (p.item_id) | |
| 470 | + | p.transaction_id, | |
| 470 | 471 | p.item_id, | |
| 471 | 472 | i.title, | |
| 472 | 473 | u.username as creator, |
| @@ -16,10 +16,24 @@ use crate::{ | |||
| 16 | 16 | db, | |
| 17 | 17 | error::{AppError, Result}, | |
| 18 | 18 | helpers::{is_htmx_request, sanitize_csv_cell}, | |
| 19 | - | templates::{ExportContentReadyTemplate, ExportDownloadTemplate}, | |
| 19 | + | templates::{ExportContentReadyTemplate, ExportDownloadTemplate, FormStatusTemplate}, | |
| 20 | 20 | AppState, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| 23 | + | /// Return an inline error message for HTMX export requests instead of | |
| 24 | + | /// letting the error propagate to the JSON error layer (which would swap | |
| 25 | + | /// raw JSON text into the status div). | |
| 26 | + | fn export_error_html(message: &str) -> Response { | |
| 27 | + | axum::response::Html( | |
| 28 | + | FormStatusTemplate { | |
| 29 | + | success: false, | |
| 30 | + | message: message.to_string(), | |
| 31 | + | } | |
| 32 | + | .render_string(), | |
| 33 | + | ) | |
| 34 | + | .into_response() | |
| 35 | + | } | |
| 36 | + | ||
| 23 | 37 | /// Build an HTTP response for a downloadable file attachment. | |
| 24 | 38 | fn download_response(content: Vec<u8>, filename: &str, content_type: &str) -> Result<Response> { | |
| 25 | 39 | Response::builder() | |
| @@ -628,6 +642,9 @@ pub(super) async fn export_content( | |||
| 628 | 642 | } | |
| 629 | 643 | ||
| 630 | 644 | if files.is_empty() { | |
| 645 | + | if is_htmx { | |
| 646 | + | return Ok(export_error_html("No content files to export.")); | |
| 647 | + | } | |
| 631 | 648 | return Err(AppError::BadRequest("No content files to export.".to_string())); | |
| 632 | 649 | } | |
| 633 | 650 | ||
| @@ -640,19 +657,34 @@ pub(super) async fn export_content( | |||
| 640 | 657 | let mut downloaded: Vec<(String, Vec<u8>)> = Vec::new(); | |
| 641 | 658 | let mut total_size: u64 = 0; | |
| 642 | 659 | const MAX_TOTAL_SIZE: u64 = 2 * 1024 * 1024 * 1024; // 2 GB | |
| 660 | + | let mut skipped: Vec<String> = Vec::new(); | |
| 643 | 661 | ||
| 644 | 662 | for (s3_key, zip_path) in &files { | |
| 645 | - | let data = s3_clone.download_object(s3_key).await.map_err(|e| { | |
| 646 | - | tracing::warn!("Failed to download S3 key {}: {}", s3_key, e); | |
| 647 | - | e | |
| 648 | - | })?; | |
| 649 | - | total_size += data.len() as u64; | |
| 650 | - | if total_size > MAX_TOTAL_SIZE { | |
| 651 | - | return Err(AppError::BadRequest( | |
| 652 | - | "Content export exceeds 2 GB limit. Please contact support for large exports.".to_string() | |
| 653 | - | )); | |
| 663 | + | match s3_clone.download_object(s3_key).await { | |
| 664 | + | Ok(data) => { | |
| 665 | + | total_size += data.len() as u64; | |
| 666 | + | if total_size > MAX_TOTAL_SIZE { | |
| 667 | + | let msg = "Content export exceeds 2 GB limit. Try exporting a single project instead."; | |
| 668 | + | if is_htmx { | |
| 669 | + | return Ok(export_error_html(msg)); | |
| 670 | + | } | |
| 671 | + | return Err(AppError::BadRequest(msg.to_string())); | |
| 672 | + | } | |
| 673 | + | downloaded.push((zip_path.clone(), data)); | |
| 674 | + | } | |
| 675 | + | Err(e) => { | |
| 676 | + | tracing::warn!("Failed to download S3 key {}: {}", s3_key, e); | |
| 677 | + | skipped.push(zip_path.clone()); | |
| 678 | + | } | |
| 654 | 679 | } | |
| 655 | - | downloaded.push((zip_path.clone(), data)); | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | if downloaded.is_empty() { | |
| 683 | + | let msg = "Could not download any files from storage. Please try again later."; | |
| 684 | + | if is_htmx { | |
| 685 | + | return Ok(export_error_html(msg)); | |
| 686 | + | } | |
| 687 | + | return Err(AppError::Storage(msg.to_string())); | |
| 656 | 688 | } | |
| 657 | 689 | ||
| 658 | 690 | // Build README.txt | |
| @@ -670,6 +702,12 @@ pub(super) async fn export_content( | |||
| 670 | 702 | for (path, data) in &downloaded { | |
| 671 | 703 | readme.push_str(&format!(" {} ({})\n", path, crate::helpers::format_file_size(data.len() as i64))); | |
| 672 | 704 | } | |
| 705 | + | if !skipped.is_empty() { | |
| 706 | + | readme.push_str(&format!("\nSkipped ({} files could not be downloaded):\n", skipped.len())); | |
| 707 | + | for path in &skipped { | |
| 708 | + | readme.push_str(&format!(" {}\n", path)); | |
| 709 | + | } | |
| 710 | + | } | |
| 673 | 711 | readme.push_str("\nNote: Git repositories are not included in this export.\n"); | |
| 674 | 712 | readme.push_str("Clone them separately: git clone https://makenot.work/source/<username>/<repo>.git\n"); | |
| 675 | 713 | ||
| @@ -697,18 +735,49 @@ pub(super) async fn export_content( | |||
| 697 | 735 | let cursor = zip.finish() | |
| 698 | 736 | .map_err(|e| AppError::Internal(anyhow::anyhow!("ZIP finish error: {}", e)))?; | |
| 699 | 737 | Ok(cursor.into_inner()) | |
| 700 | - | }).await.map_err(|e| AppError::Internal(anyhow::anyhow!("ZIP task panicked: {}", e)))?; | |
| 701 | - | ||
| 702 | - | zip_result? | |
| 738 | + | }).await; | |
| 739 | + | ||
| 740 | + | match zip_result { | |
| 741 | + | Ok(Ok(bytes)) => bytes, | |
| 742 | + | Ok(Err(e)) => { | |
| 743 | + | tracing::error!(error = ?e, "Failed to create content export ZIP"); | |
| 744 | + | if is_htmx { | |
| 745 | + | return Ok(export_error_html("Failed to create export archive. Please try again.")); | |
| 746 | + | } | |
| 747 | + | return Err(e); | |
| 748 | + | } | |
| 749 | + | Err(e) => { | |
| 750 | + | let app_err = AppError::Internal(anyhow::anyhow!("ZIP task panicked: {}", e)); | |
| 751 | + | if is_htmx { | |
| 752 | + | return Ok(export_error_html("Failed to create export archive. Please try again.")); | |
| 753 | + | } | |
| 754 | + | return Err(app_err); | |
| 755 | + | } | |
| 756 | + | } | |
| 703 | 757 | }; | |
| 704 | 758 | ||
| 705 | 759 | // Upload ZIP to S3 as a temporary export | |
| 706 | 760 | let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S"); | |
| 707 | 761 | let export_key = format!("{}/exports/content-{}.zip", user.id, timestamp); | |
| 708 | - | s3.upload_object(&export_key, "application/zip", zip_bytes, None).await?; | |
| 762 | + | if let Err(e) = s3.upload_object(&export_key, "application/zip", zip_bytes, None).await { | |
| 763 | + | tracing::error!(error = ?e, "Failed to upload content export ZIP to S3"); | |
| 764 | + | if is_htmx { | |
| 765 | + | return Ok(export_error_html("Failed to prepare download. Please try again.")); | |
| 766 | + | } | |
| 767 | + | return Err(e); | |
| 768 | + | } | |
| 709 | 769 | ||
| 710 | 770 | // Generate presigned download URL (1 hour) | |
| 711 | - | let download_url = s3.presign_download(&export_key, Some(3600)).await?; | |
| 771 | + | let download_url = match s3.presign_download(&export_key, Some(3600)).await { | |
| 772 | + | Ok(url) => url, | |
| 773 | + | Err(e) => { | |
| 774 | + | tracing::error!(error = ?e, "Failed to generate presigned URL for content export"); | |
| 775 | + | if is_htmx { | |
| 776 | + | return Ok(export_error_html("Export created but download link failed. Please try again.")); | |
| 777 | + | } | |
| 778 | + | return Err(e); | |
| 779 | + | } | |
| 780 | + | }; | |
| 712 | 781 | ||
| 713 | 782 | if is_htmx { | |
| 714 | 783 | return Ok(ExportContentReadyTemplate { download_url }.into_response()); |
| @@ -323,6 +323,7 @@ pub(super) async fn create_promo_code( | |||
| 323 | 323 | None, // trial_days | |
| 324 | 324 | req.max_uses, | |
| 325 | 325 | None, // expires_at | |
| 326 | + | None, // starts_at | |
| 326 | 327 | req.item_id, | |
| 327 | 328 | req.project_id, | |
| 328 | 329 | None, // tier_id |
| @@ -288,6 +288,8 @@ pub fn api_routes() -> Router<AppState> { | |||
| 288 | 288 | // Promo code management (creator) | |
| 289 | 289 | .route("/api/promo-codes", post(promo_codes::create_promo_code)) | |
| 290 | 290 | .route("/api/promo-codes", get(promo_codes::list_promo_codes)) | |
| 291 | + | .route("/api/promo-codes/expired", delete(promo_codes::delete_expired_promo_codes)) | |
| 292 | + | .route("/api/promo-codes/{id}", put(promo_codes::update_promo_code)) | |
| 291 | 293 | .route("/api/promo-codes/{id}", delete(promo_codes::delete_promo_code)) | |
| 292 | 294 | // Promo code claim (buyer — free_access codes) | |
| 293 | 295 | .route("/api/promo-codes/claim", post(promo_codes::claim_promo_code)) |
| @@ -57,6 +57,8 @@ pub struct CreatePromoCodeForm { | |||
| 57 | 57 | pub max_uses: Option<i32>, | |
| 58 | 58 | /// Optional expiry date (HTML date input: YYYY-MM-DD). | |
| 59 | 59 | pub expires_at: Option<String>, | |
| 60 | + | /// Optional start date (HTML date input: YYYY-MM-DD). | |
| 61 | + | pub starts_at: Option<String>, | |
| 60 | 62 | pub item_id: Option<String>, | |
| 61 | 63 | pub project_id: Option<String>, | |
| 62 | 64 | pub tier_id: Option<String>, | |
| @@ -218,6 +220,29 @@ pub(super) async fn create_promo_code( | |||
| 218 | 220 | None | |
| 219 | 221 | }; | |
| 220 | 222 | ||
| 223 | + | // Parse optional start date (YYYY-MM-DD from HTML date input) | |
| 224 | + | let starts_at = if let Some(ref date_str) = req.starts_at { | |
| 225 | + | let date_str = date_str.trim(); | |
| 226 | + | if date_str.is_empty() { | |
| 227 | + | None | |
| 228 | + | } else { | |
| 229 | + | let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") | |
| 230 | + | .map_err(|_| AppError::BadRequest("Invalid start date".to_string()))?; | |
| 231 | + | Some(date.and_hms_opt(0, 0, 0) | |
| 232 | + | .ok_or_else(|| AppError::Internal(anyhow::anyhow!("invalid time 00:00:00")))? | |
| 233 | + | .and_utc()) | |
| 234 | + | } | |
| 235 | + | } else { | |
| 236 | + | None | |
| 237 | + | }; | |
| 238 | + | ||
| 239 | + | // Validate starts_at < expires_at if both present | |
| 240 | + | if let (Some(start), Some(end)) = (starts_at, expires_at) { | |
| 241 | + | if start >= end { | |
| 242 | + | return Err(AppError::BadRequest("Start date must be before expiry date".to_string())); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 221 | 246 | let promo_code = db::promo_codes::create_promo_code( | |
| 222 | 247 | &state.db, | |
| 223 | 248 | user.id, | |
| @@ -229,6 +254,7 @@ pub(super) async fn create_promo_code( | |||
| 229 | 254 | req.trial_days, | |
| 230 | 255 | req.max_uses, | |
| 231 | 256 | expires_at, | |
| 257 | + | starts_at, | |
| 232 | 258 | item_id, | |
| 233 | 259 | project_id, | |
| 234 | 260 | tier_id, | |
| @@ -341,6 +367,125 @@ pub(super) async fn delete_promo_code( | |||
| 341 | 367 | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 342 | 368 | } | |
| 343 | 369 | ||
| 370 | + | /// Form input for updating a promo code. | |
| 371 | + | #[derive(Debug, Deserialize)] | |
| 372 | + | pub struct UpdatePromoCodeForm { | |
| 373 | + | pub max_uses: Option<String>, | |
| 374 | + | pub expires_at: Option<String>, | |
| 375 | + | pub starts_at: Option<String>, | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /// Update an existing promo code (expires_at, starts_at, max_uses only). | |
| 379 | + | #[tracing::instrument(skip_all, name = "promo_codes::update_promo_code")] | |
| 380 | + | pub(super) async fn update_promo_code( | |
| 381 | + | State(state): State<AppState>, | |
| 382 | + | headers: HeaderMap, | |
| 383 | + | AuthUser(user): AuthUser, | |
| 384 | + | Path(code_id): Path<PromoCodeId>, | |
| 385 | + | Form(req): Form<UpdatePromoCodeForm>, | |
| 386 | + | ) -> Result<Response> { | |
| 387 | + | user.check_not_suspended()?; | |
| 388 | + | ||
| 389 | + | let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) | |
| 390 | + | .await? | |
| 391 | + | .ok_or(AppError::NotFound)?; | |
| 392 | + | ||
| 393 | + | if promo_code.creator_id != user.id { | |
| 394 | + | return Err(AppError::Forbidden); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // Parse optional fields — empty string means clear, absent means no change | |
| 398 | + | let parse_date = |s: &str| -> Result<Option<chrono::DateTime<chrono::Utc>>> { | |
| 399 | + | let s = s.trim(); | |
| 400 | + | if s.is_empty() { | |
| 401 | + | return Ok(None); | |
| 402 | + | } | |
| 403 | + | let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") | |
| 404 | + | .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?; | |
| 405 | + | Ok(Some( | |
| 406 | + | date.and_hms_opt(if s == s { 23 } else { 0 }, 59, 59) | |
| 407 | + | .ok_or_else(|| AppError::Internal(anyhow::anyhow!("invalid time")))? | |
| 408 | + | .and_utc(), | |
| 409 | + | )) | |
| 410 | + | }; | |
| 411 | + | ||
| 412 | + | let expires_at = req.expires_at.as_deref().map(|s| parse_date(s)).transpose()?; | |
| 413 | + | let starts_at = req.starts_at.as_deref().map(|s| { | |
| 414 | + | let s = s.trim(); | |
| 415 | + | if s.is_empty() { | |
| 416 | + | return Ok(None); | |
| 417 | + | } | |
| 418 | + | let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") | |
| 419 | + | .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?; | |
| 420 | + | Ok::<_, AppError>(Some( | |
| 421 | + | date.and_hms_opt(0, 0, 0) | |
| 422 | + | .ok_or_else(|| AppError::Internal(anyhow::anyhow!("invalid time")))? | |
| 423 | + | .and_utc(), | |
| 424 | + | )) | |
| 425 | + | }).transpose()?; | |
| 426 | + | ||
| 427 | + | let max_uses = req.max_uses.as_deref().map(|s| { | |
| 428 | + | let s = s.trim(); | |
| 429 | + | if s.is_empty() { | |
| 430 | + | return Ok(None); | |
| 431 | + | } | |
| 432 | + | let n: i32 = s.parse() | |
| 433 | + | .map_err(|_| AppError::BadRequest("Invalid max uses".to_string()))?; | |
| 434 | + | if n < 1 { | |
| 435 | + | return Err(AppError::BadRequest("Max uses must be at least 1".to_string())); | |
| 436 | + | } | |
| 437 | + | Ok::<_, AppError>(Some(n)) | |
| 438 | + | }).transpose()?; | |
| 439 | + | ||
| 440 | + | db::promo_codes::update_promo_code(&state.db, code_id, expires_at, starts_at, max_uses).await?; | |
| 441 | + | ||
| 442 | + | if let Some(pid) = promo_code.project_id { | |
| 443 | + | db::projects::bump_cache_generation(&state.db, pid).await?; | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | if is_htmx_request(&headers) { | |
| 447 | + | let codes = if let Some(pid) = promo_code.project_id { | |
| 448 | + | db::promo_codes::get_promo_codes_by_project(&state.db, pid).await? | |
| 449 | + | } else { | |
| 450 | + | db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await? | |
| 451 | + | }; | |
| 452 | + | return Ok(( | |
| 453 | + | [("HX-Trigger", hx_toast("Promo code updated", "success"))], | |
| 454 | + | PromoCodesListTemplate { | |
| 455 | + | promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), | |
| 456 | + | }, | |
| 457 | + | ) | |
| 458 | + | .into_response()); | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | /// Delete all expired promo codes for this creator. | |
| 465 | + | #[tracing::instrument(skip_all, name = "promo_codes::delete_expired")] | |
| 466 | + | pub(super) async fn delete_expired_promo_codes( | |
| 467 | + | State(state): State<AppState>, | |
| 468 | + | headers: HeaderMap, | |
| 469 | + | AuthUser(user): AuthUser, | |
| 470 | + | ) -> Result<Response> { | |
| 471 | + | user.check_not_suspended()?; | |
| 472 | + | ||
| 473 | + | let count = db::promo_codes::delete_expired_by_creator(&state.db, user.id).await?; | |
| 474 | + | ||
| 475 | + | if is_htmx_request(&headers) { | |
| 476 | + | let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?; | |
| 477 | + | return Ok(( | |
| 478 | + | [("HX-Trigger", hx_toast(&format!("{count} expired code(s) deleted"), "success"))], | |
| 479 | + | PromoCodesListTemplate { | |
| 480 | + | promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), | |
| 481 | + | }, | |
| 482 | + | ) | |
| 483 | + | .into_response()); | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | Ok(Json(serde_json::json!({ "deleted": count })).into_response()) | |
| 487 | + | } | |
| 488 | + | ||
| 344 | 489 | // ============================================================================= | |
| 345 | 490 | // Public claim (auth required, rate-limited) | |
| 346 | 491 | // ============================================================================= | |
| @@ -378,6 +523,11 @@ pub(super) async fn claim_promo_code( | |||
| 378 | 523 | let item_id = promo_code.item_id | |
| 379 | 524 | .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?; | |
| 380 | 525 | ||
| 526 | + | // Check start date | |
| 527 | + | if let Some(starts_at) = promo_code.starts_at && starts_at > chrono::Utc::now() { | |
| 528 | + | return Err(AppError::BadRequest("This promo code is not yet active".to_string())); | |
| 529 | + | } | |
| 530 | + | ||
| 381 | 531 | // Check expiration | |
| 382 | 532 | if let Some(expires_at) = promo_code.expires_at && expires_at < chrono::Utc::now() { | |
| 383 | 533 | return Err(AppError::BadRequest("This promo code has expired".to_string())); |
| @@ -13,6 +13,43 @@ use crate::{ | |||
| 13 | 13 | AppState, | |
| 14 | 14 | }; | |
| 15 | 15 | ||
| 16 | + | /// Generate up to 3 available slug suggestions based on a taken slug. | |
| 17 | + | async fn suggest_slugs( | |
| 18 | + | db: &sqlx::PgPool, | |
| 19 | + | user_id: db::UserId, | |
| 20 | + | base: &str, | |
| 21 | + | ) -> Vec<String> { | |
| 22 | + | // Strip any trailing -N suffix to get the root | |
| 23 | + | let root = base | |
| 24 | + | .rfind('-') | |
| 25 | + | .and_then(|pos| { | |
| 26 | + | if base[pos + 1..].chars().all(|c| c.is_ascii_digit()) { | |
| 27 | + | Some(&base[..pos]) | |
| 28 | + | } else { | |
| 29 | + | None | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | .unwrap_or(base); | |
| 33 | + | ||
| 34 | + | let mut suggestions = Vec::new(); | |
| 35 | + | for n in 2..=10 { | |
| 36 | + | let candidate = format!("{}-{}", root, n); | |
| 37 | + | if let Ok(slug) = Slug::new(&candidate) { | |
| 38 | + | let taken = db::projects::get_project_by_user_and_slug(db, user_id, &slug) | |
| 39 | + | .await | |
| 40 | + | .map(|p| p.is_some()) | |
| 41 | + | .unwrap_or(true); | |
| 42 | + | if !taken { | |
| 43 | + | suggestions.push(candidate); | |
| 44 | + | if suggestions.len() >= 3 { | |
| 45 | + | break; | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | suggestions | |
| 51 | + | } | |
| 52 | + | ||
| 16 | 53 | #[derive(Debug, Deserialize)] | |
| 17 | 54 | pub struct SlugForm { | |
| 18 | 55 | pub slug: String, | |
| @@ -53,7 +90,24 @@ pub async fn validate_project_slug( | |||
| 53 | 90 | .map(|p| p.is_some()) | |
| 54 | 91 | .unwrap_or(false); | |
| 55 | 92 | ||
| 56 | - | Html(SlugStatusTemplate { available: !is_taken }.render_string()) | |
| 93 | + | if is_taken { | |
| 94 | + | let suggestions = suggest_slugs(&state.db, auth.0.id, &form.slug).await; | |
| 95 | + | Html( | |
| 96 | + | SlugStatusTemplate { | |
| 97 | + | available: false, | |
| 98 | + | suggestions, | |
| 99 | + | } | |
| 100 | + | .render_string(), | |
| 101 | + | ) | |
| 102 | + | } else { | |
| 103 | + | Html( | |
| 104 | + | SlugStatusTemplate { | |
| 105 | + | available: true, | |
| 106 | + | suggestions: Vec::new(), | |
| 107 | + | } | |
| 108 | + | .render_string(), | |
| 109 | + | ) | |
| 110 | + | } | |
| 57 | 111 | } | |
| 58 | 112 | ||
| 59 | 113 | /// Check collection slug availability for the current user. | |
| @@ -86,7 +140,7 @@ pub async fn validate_collection_slug( | |||
| 86 | 140 | .map(|c| c.is_some()) | |
| 87 | 141 | .unwrap_or(false); | |
| 88 | 142 | ||
| 89 | - | Html(SlugStatusTemplate { available: !is_taken }.render_string()) | |
| 143 | + | Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()) | |
| 90 | 144 | } | |
| 91 | 145 | ||
| 92 | 146 | /// Check blog post slug availability within a project. | |
| @@ -128,5 +182,5 @@ pub async fn validate_blog_slug( | |||
| 128 | 182 | .await | |
| 129 | 183 | .unwrap_or(false); | |
| 130 | 184 | ||
| 131 | - | Html(SlugStatusTemplate { available: !is_taken }.render_string()) | |
| 185 | + | Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()) | |
| 132 | 186 | } |
| @@ -205,6 +205,60 @@ pub(super) async fn purchase_page( | |||
| 205 | 205 | .into_response()) | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | + | /// Render a purchase receipt page. | |
| 209 | + | #[tracing::instrument(skip_all, name = "content::receipt_page")] | |
| 210 | + | pub(super) async fn receipt_page( | |
| 211 | + | State(state): State<AppState>, | |
| 212 | + | session: Session, | |
| 213 | + | MaybeUser(maybe_user): MaybeUser, | |
| 214 | + | Path(transaction_id): Path<String>, | |
| 215 | + | ) -> Result<impl IntoResponse> { | |
| 216 | + | let csrf_token = get_csrf_token(&session).await; | |
| 217 | + | let tx_id: db::TransactionId = transaction_id.parse().map_err(|_| AppError::NotFound)?; | |
| 218 | + | ||
| 219 | + | let tx = db::transactions::get_transaction_by_id(&state.db, tx_id) | |
| 220 | + | .await? | |
| 221 | + | .ok_or(AppError::NotFound)?; | |
| 222 | + | ||
| 223 | + | // Only the buyer or the seller can view a receipt | |
| 224 | + | let viewer_id = maybe_user.as_ref().map(|u| u.id); | |
| 225 | + | let is_buyer = viewer_id == tx.buyer_id; | |
| 226 | + | let is_seller = viewer_id == tx.seller_id; | |
| 227 | + | if !is_buyer && !is_seller { | |
| 228 | + | return Err(AppError::Forbidden); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | let amount_cents = *tx.amount_cents; | |
| 232 | + | let is_free = amount_cents == 0; | |
| 233 | + | let amount = if is_free { | |
| 234 | + | "Free".to_string() | |
| 235 | + | } else { | |
| 236 | + | format!("${:.2}", amount_cents as f64 / 100.0) | |
| 237 | + | }; | |
| 238 | + | ||
| 239 | + | let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default(); | |
| 240 | + | let item_title = tx.item_title.unwrap_or_else(|| "[Deleted item]".to_string()); | |
| 241 | + | let seller_username = tx.seller_username.unwrap_or_else(|| "[Deleted user]".to_string()); | |
| 242 | + | let date = tx.completed_at | |
| 243 | + | .unwrap_or(tx.created_at) | |
| 244 | + | .format("%B %d, %Y at %H:%M UTC") | |
| 245 | + | .to_string(); | |
| 246 | + | ||
| 247 | + | Ok(ReceiptTemplate { | |
| 248 | + | csrf_token, | |
| 249 | + | session_user: maybe_user, | |
| 250 | + | transaction_id: tx.id.to_string(), | |
| 251 | + | item_id, | |
| 252 | + | item_title, | |
| 253 | + | seller_username, | |
| 254 | + | amount, | |
| 255 | + | is_free, | |
| 256 | + | status: tx.status.to_string(), | |
| 257 | + | date, | |
| 258 | + | } | |
| 259 | + | .into_response()) | |
| 260 | + | } | |
| 261 | + | ||
| 208 | 262 | /// Render a public collection page. | |
| 209 | 263 | #[tracing::instrument(skip_all, name = "content::collection_page")] | |
| 210 | 264 | pub(super) async fn collection_page( |
| @@ -103,8 +103,12 @@ pub async fn step_account_create( | |||
| 103 | 103 | let email_taken = db::users::get_user_by_email(&state.db, &form.email) | |
| 104 | 104 | .await? | |
| 105 | 105 | .is_some(); | |
| 106 | - | if username_taken || email_taken { | |
| 107 | - | return return_error("An account with this username or email already exists"); | |
| 106 | + | if username_taken && email_taken { | |
| 107 | + | return return_error("This username and email are already registered"); | |
| 108 | + | } else if username_taken { | |
| 109 | + | return return_error("This username is already taken"); | |
| 110 | + | } else if email_taken { | |
| 111 | + | return return_error("This email is already registered"); | |
| 108 | 112 | } | |
| 109 | 113 | ||
| 110 | 114 | // Validate password |
| @@ -69,12 +69,19 @@ pub(super) async fn library( | |||
| 69 | 69 | }) | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | /// Query parameters for the cart page. | |
| 73 | + | #[derive(Deserialize)] | |
| 74 | + | pub(super) struct CartQuery { | |
| 75 | + | pub checkout: Option<String>, | |
| 76 | + | } | |
| 77 | + | ||
| 72 | 78 | /// Render the shopping cart page with items grouped by seller. | |
| 73 | 79 | #[tracing::instrument(skip_all, name = "landing::cart_page")] | |
| 74 | 80 | pub(super) async fn cart_page( | |
| 75 | 81 | State(state): State<AppState>, | |
| 76 | 82 | session: Session, | |
| 77 | 83 | AuthUser(user): AuthUser, | |
| 84 | + | Query(query): Query<CartQuery>, | |
| 78 | 85 | ) -> Result<impl IntoResponse> { | |
| 79 | 86 | use std::collections::BTreeMap; | |
| 80 | 87 | use crate::templates::CartSellerGroup; | |
| @@ -131,6 +138,7 @@ pub(super) async fn cart_page( | |||
| 131 | 138 | seller_groups, | |
| 132 | 139 | wishlist_suggestions, | |
| 133 | 140 | total_items, | |
| 141 | + | checkout_status: query.checkout.unwrap_or_default(), | |
| 134 | 142 | }) | |
| 135 | 143 | } | |
| 136 | 144 |
| @@ -69,6 +69,7 @@ pub fn public_routes() -> Router<AppState> { | |||
| 69 | 69 | .route("/p/{slug}", get(content::project_page)) | |
| 70 | 70 | .route("/i/{item_id}", get(content::item_page)) | |
| 71 | 71 | .route("/purchase/{item_id}", get(content::purchase_page)) | |
| 72 | + | .route("/receipt/{transaction_id}", get(content::receipt_page)) | |
| 72 | 73 | .route("/buy/{item_id}", get(content::buy_page)) | |
| 73 | 74 | .route("/pricing", get(landing::pricing_page)) | |
| 74 | 75 | .route("/use-cases", get(landing::use_cases_page)) |
| @@ -143,6 +143,11 @@ pub(in crate::routes::stripe) async fn create_cart_checkout( | |||
| 143 | 143 | if pc.code_purpose == CodePurpose::FreeTrial { | |
| 144 | 144 | return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string())); | |
| 145 | 145 | } | |
| 146 | + | if let Some(starts) = pc.starts_at { | |
| 147 | + | if starts > chrono::Utc::now() { | |
| 148 | + | return Err(AppError::BadRequest("This promo code is not yet active".to_string())); | |
| 149 | + | } | |
| 150 | + | } | |
| 146 | 151 | if let Some(expires) = pc.expires_at { | |
| 147 | 152 | if expires < chrono::Utc::now() { | |
| 148 | 153 | return Err(AppError::BadRequest("This promo code has expired".to_string())); | |
| @@ -527,6 +532,11 @@ pub(super) async fn process_seller_checkout( | |||
| 527 | 532 | if pc.code_purpose == CodePurpose::FreeTrial { | |
| 528 | 533 | return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string())); | |
| 529 | 534 | } | |
| 535 | + | if let Some(starts) = pc.starts_at { | |
| 536 | + | if starts > chrono::Utc::now() { | |
| 537 | + | return Err(AppError::BadRequest("This promo code is not yet active".to_string())); | |
| 538 | + | } | |
| 539 | + | } | |
| 530 | 540 | if let Some(expires) = pc.expires_at { | |
| 531 | 541 | if expires < chrono::Utc::now() { | |
| 532 | 542 | return Err(AppError::BadRequest("This promo code has expired".to_string())); |
| @@ -124,6 +124,9 @@ pub(in crate::routes::stripe) async fn create_checkout( | |||
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | 126 | // Common validation for all item checkout codes | |
| 127 | + | if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() { | |
| 128 | + | return Err(AppError::BadRequest("This promo code is not yet active".to_string())); | |
| 129 | + | } | |
| 127 | 130 | if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { | |
| 128 | 131 | return Err(AppError::BadRequest("This promo code has expired".to_string())); | |
| 129 | 132 | } |
| @@ -80,6 +80,10 @@ pub(super) async fn checkout_success( | |||
| 80 | 80 | Err(e) => { | |
| 81 | 81 | tracing::error!(error = ?e, seller_id = %next_seller_id, "failed to process next cart seller"); | |
| 82 | 82 | session.remove::<Vec<String>>("cart_queue").await.ok(); | |
| 83 | + | session.remove::<bool>("cart_share_contact").await.ok(); | |
| 84 | + | // Previous sellers' purchases succeeded but this one failed. | |
| 85 | + | // Redirect to cart where remaining items are still present. | |
| 86 | + | return Redirect::to("/cart?checkout=partial"); | |
| 83 | 87 | } | |
| 84 | 88 | } | |
| 85 | 89 | } |
| @@ -195,6 +195,11 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout( | |||
| 195 | 195 | return Err(AppError::BadRequest("This code is not a free trial code".to_string())); | |
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | + | // Check start date | |
| 199 | + | if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() { | |
| 200 | + | return Err(AppError::BadRequest("This code is not yet active".to_string())); | |
| 201 | + | } | |
| 202 | + | ||
| 198 | 203 | // Check expiry | |
| 199 | 204 | if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { | |
| 200 | 205 | return Err(AppError::BadRequest("This code has expired".to_string())); |