Skip to main content

max / makenotwork

Usability audit remediation and creator self-service (v0.5.1) Error handling: field-specific validation errors, slug conflict suggestions, custom HTML5 validation messages, Stripe setup feedback, export error handling. Dashboard: item details declutter (AI/release date to Advanced), UUID->Item ID, Promotions->Promo Codes rename, empty state improvements (blog, team, promos), membership tiers tab always visible, content tab column sorting, toast dismiss button, chart tooltips, Export All button, CSV exports across all data views. Item page: promo code field moved above buy buttons with "Have a code?" label, improved bundle-only item messaging, cart removal confirmation. Library: visible Open button on purchase rows, receipt link in context menu. Promo codes: editable after creation (max_uses, expires_at, starts_at via PUT), scheduled start dates (migration 099), bulk delete expired codes, starts_at validation at all checkout paths. Purchase receipts: GET /receipt/{transaction_id} with print support, buyer and seller access, linked from library purchases. Cart checkout: partial failure now redirects to /cart?checkout=partial with warning banner instead of misleading success redirect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 19:38 UTC
Commit: a79dbba2d9266329ad21489474646c2a670a241e
Parent: cbf083b
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
M server/docs/todo.md +163 -212
@@ -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()));