max / makenotwork
37 files changed,
+1303 insertions,
-305 deletions
| @@ -3445,7 +3445,7 @@ dependencies = [ | |||
| 3445 | 3445 | ||
| 3446 | 3446 | [[package]] | |
| 3447 | 3447 | name = "makenotwork" | |
| 3448 | - | version = "0.5.0" | |
| 3448 | + | version = "0.5.6" | |
| 3449 | 3449 | dependencies = [ | |
| 3450 | 3450 | "anyhow", | |
| 3451 | 3451 | "argon2", |
| @@ -1,92 +1,29 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 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. | |
| 4 | + | v0.5.0 deployed 2026-05-06. Soft launch target 2026-05-09. Audit grade A. ~85K LOC, 1,912 tests, 0 warnings (verified). Migration 100. Sprints 1-9 complete (see `todo_done.md`). | |
| 5 | 5 | ||
| 6 | 6 | Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |
| 7 | 7 | ||
| 8 | 8 | --- | |
| 9 | 9 | ||
| 10 | - | ## Sprint 1: Creator Self-Service (table stakes) | |
| 10 | + | ## Deferred from Sprints | |
| 11 | 11 | ||
| 12 | - | Creators expect these from any platform. Without them, sellers hit walls during normal operation. | |
| 12 | + | - [ ] Add bulk rename operation (Sprint 2) | |
| 13 | + | - [ ] Add global search across all projects and items from dashboard (Sprint 2) | |
| 14 | + | - Deferred: onboarding checklist persistence, banner for unsubscribed creators (Sprint 4, low urgency during alpha) | |
| 15 | + | - Deferred: real-time pricing validation (Sprint 6, low value — server validates on save) | |
| 13 | 16 | ||
| 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) | |
| 20 | - | ||
| 21 | - | ## Sprint 2: Catalog Power Tools | |
| 22 | - | ||
| 23 | - | Creators with 20+ items need these. One-at-a-time editing doesn't scale. | |
| 24 | - | ||
| 25 | - | - [x] Always show bulk action bar (disabled/grayed until items selected, enables on checkbox) | |
| 26 | - | - [ ] Add bulk rename operation | |
| 27 | - | - [x] Add bulk tag operation (find-or-create tag, bulk INSERT ON CONFLICT) | |
| 28 | - | - [x] Add bulk price change operation (inline form with dollar input) | |
| 29 | - | - [x] Implement soft delete with 7-day recovery — "Recently Deleted" collapsible with restore buttons | |
| 30 | - | - [x] Bulk delete now soft-deletes (sets deleted_at + is_public=false, scheduler purges after 7 days) | |
| 31 | - | - [ ] Add global search across all projects and items from dashboard | |
| 32 | - | ||
| 33 | - | ## Sprint 3: Onboarding Overhaul — DONE | |
| 34 | - | ||
| 35 | - | Join wizard collapsed from 5 steps to 3 (Account, Profile, Welcome). Pitch and Stripe removed from wizard — pitch lives on dashboard Creator Plan tab, Stripe on Payments tab. Welcome page branches by intent: "Browse and buy" vs "I want to sell" with contextual CTAs for invited users and existing creators. | |
| 36 | - | ||
| 37 | - | ## Sprint 4: Dashboard Polish — DONE | |
| 38 | - | ||
| 39 | - | - [x] Rename "creator tiers" to "Creator Plans" (user_creator.html, dashboard-user.html, creators.html) | |
| 40 | - | - [x] Show tier limits inline on Creator Plan tab (storage usage + link to plan docs) | |
| 41 | - | - [x] Hide "good standing" noise — Account Status section hidden when no moderation actions | |
| 42 | - | - [x] Export link already visible in dashboard header (line 78) | |
| 43 | - | - [x] Add section headers to "More" dropdown (Content, Integration, Support) | |
| 44 | - | - [x] "Explore Your Project Tools" discovery card in project overview (6 feature cards in collapsible) | |
| 45 | - | - Deferred: onboarding checklist persistence, banner for unsubscribed creators (low urgency during alpha) | |
| 46 | - | ||
| 47 | - | ## Sprint 5: Account Settings Cleanup — DONE | |
| 48 | - | ||
| 49 | - | Reordered: Account Status (top, only shown when moderation active) → Security (checklist + password/2FA/passkeys/sessions) → Account (email/username) → Preferences → Data → Account Management. Added security checklist card. Differentiated pause vs delete with inline guidance. Linked SSH Keys from project Code tab. Stripe Tax toggle stays in Payments (natural home, not worth moving). | |
| 50 | - | ||
| 51 | - | ## Sprint 6: Item Editor Refinements — DONE | |
| 52 | - | ||
| 53 | - | - [x] Move Sections management to collapsible details (auto-open when sections exist) | |
| 54 | - | - [x] Add tip/callout about Sections (explanation + suggested section names in empty state) | |
| 55 | - | - [x] Pricing strategy guide at top of pricing tab (fixed, PWYW, free+codes, license keys) | |
| 56 | - | - [x] Clarified PWYW + license keys compatibility in license key description | |
| 57 | - | - [x] Item type descriptions already render (audit false negative — type_card_desc in wizard) | |
| 58 | - | - [x] File type hints already shown (MP3/WAV/FLAC/OGG/AAC for audio, MP4/WebM/MOV for video) | |
| 59 | - | - [x] Tier-specific file size limit already validated client-side with upgrade message | |
| 60 | - | - Deferred: real-time pricing validation (low value — server validates on save) | |
| 61 | - | ||
| 62 | - | ## Sprint 7: Collections Everywhere | |
| 63 | - | ||
| 64 | - | Collections exist but are hard to find and use. This makes them a first-class feature. | |
| 65 | - | ||
| 66 | - | - [ ] Improve "Add to Collection" affordance on item page | |
| 67 | - | - [ ] Add "Add to Collection" action on discover result cards | |
| 68 | - | - [ ] Add "Add to Collection" from library items | |
| 69 | - | ||
| 70 | - | ## Sprint 8: Discovery Improvements | |
| 71 | - | ||
| 72 | - | Help fans find content and help creators understand their audience. | |
| 73 | - | ||
| 74 | - | - [ ] Expose AI Tier filter in discover UI | |
| 75 | - | - [ ] Add search suggestions/autocomplete | |
| 76 | - | - [ ] Add "Has source code" filter for projects with linked Git repos | |
| 77 | - | - [ ] Add download count analytics per item | |
| 78 | - | - [ ] Add distinction between "already downloaded" and "available for download" in library | |
| 79 | - | ||
| 80 | - | ## Sprint 9: Documentation | |
| 17 | + | --- | |
| 81 | 18 | ||
| 82 | - | Fill gaps so creators can self-serve instead of contacting support. | |
| 19 | + | ## Audit Remediations (2026-05-08, post-Sprint 9) | |
| 83 | 20 | ||
| 84 | - | - [ ] Add docs for Collections feature | |
| 85 | - | - [ ] Add docs for Promo Codes & Discounts | |
| 86 | - | - [ ] Add docs for Git integration / source browser | |
| 87 | - | - [ ] Add docs for License Keys | |
| 88 | - | - [ ] Add docs for SyncKit integration | |
| 89 | - | - [ ] Add FAQ / Troubleshooting page by user role | |
| 21 | + | - [x] Fix XSS: escape `s.category` and `s.url` in search suggestions innerHTML (discover.html) | |
| 22 | + | - [x] Fix XSS: replace inline `onsubmit` handler with `addEventListener` in collections.js | |
| 23 | + | - [x] Fix a11y: convert `<span onclick>` save buttons to `<button>` with `aria-label` on discover cards | |
| 24 | + | - [ ] Performance: consider rewriting double-nested correlated subquery in `get_user_purchases` (has_new_version) to use LEFT JOIN or CTE | |
| 25 | + | - [x] Remove unused import `spawn_email` in `stripe/checkout/cart.rs` | |
| 26 | + | - [x] Remove dead code `update_app_sync_sub_tier` in `db/app_sync.rs` | |
| 90 | 27 | ||
| 91 | 28 | --- | |
| 92 | 29 |
| @@ -254,3 +254,80 @@ Systematic creator-perspective audit of docs, legal, code, and competitive posit | |||
| 254 | 254 | ## Integration Test Fixes (2026-05-02) | |
| 255 | 255 | ||
| 256 | 256 | All 34 previously-failing tests resolved. Key changes: auth rate limiter per-handler, advisory lock deadlock fix, unique IP per TestClient, fast-tests feature flag, sandbox test consolidation. | |
| 257 | + | ||
| 258 | + | --- | |
| 259 | + | ||
| 260 | + | ## Sprint 1: Creator Self-Service (2026-05-06) | |
| 261 | + | ||
| 262 | + | - [x] Add refund initiation from item sales tab (already implemented — refund button + Stripe refund + webhook handler) | |
| 263 | + | - [x] Allow editing promo codes after creation (PUT endpoint, inline edit form for max_uses, starts_at, expires_at) | |
| 264 | + | - [x] Add bulk delete for expired promo codes (DELETE /api/promo-codes/expired + "Delete all expired" button) | |
| 265 | + | - [x] Add scheduled start/end dates for promo codes (migration 099: starts_at column, validation at all checkout paths) | |
| 266 | + | - [x] Add purchase receipt/invoice download for completed purchases (GET /receipt/{transaction_id}, linked from library) | |
| 267 | + | - [x] Handle mid-queue cart checkout failure gracefully (redirect to /cart?checkout=partial with banner) | |
| 268 | + | ||
| 269 | + | --- | |
| 270 | + | ||
| 271 | + | ## Sprint 3: Onboarding Overhaul (2026-05-06) | |
| 272 | + | ||
| 273 | + | Join wizard collapsed from 5 steps to 3 (Account, Profile, Welcome). Pitch and Stripe removed from wizard — pitch lives on dashboard Creator Plan tab, Stripe on Payments tab. Welcome page branches by intent: "Browse and buy" vs "I want to sell" with contextual CTAs for invited users and existing creators. | |
| 274 | + | ||
| 275 | + | --- | |
| 276 | + | ||
| 277 | + | ## Sprint 4: Dashboard Polish (2026-05-06) | |
| 278 | + | ||
| 279 | + | - [x] Rename "creator tiers" to "Creator Plans" (user_creator.html, dashboard-user.html, creators.html) | |
| 280 | + | - [x] Show tier limits inline on Creator Plan tab (storage usage + link to plan docs) | |
| 281 | + | - [x] Hide "good standing" noise — Account Status section hidden when no moderation actions | |
| 282 | + | - [x] Export link already visible in dashboard header (line 78) | |
| 283 | + | - [x] Add section headers to "More" dropdown (Content, Integration, Support) | |
| 284 | + | - [x] "Explore Your Project Tools" discovery card in project overview (6 feature cards in collapsible) | |
| 285 | + | ||
| 286 | + | --- | |
| 287 | + | ||
| 288 | + | ## Sprint 5: Account Settings Cleanup (2026-05-06) | |
| 289 | + | ||
| 290 | + | Reordered: Account Status (top, only shown when moderation active) → Security (checklist + password/2FA/passkeys/sessions) → Account (email/username) → Preferences → Data → Account Management. Added security checklist card. Differentiated pause vs delete with inline guidance. Linked SSH Keys from project Code tab. | |
| 291 | + | ||
| 292 | + | --- | |
| 293 | + | ||
| 294 | + | ## Sprint 6: Item Editor Refinements (2026-05-06) | |
| 295 | + | ||
| 296 | + | - [x] Move Sections management to collapsible details (auto-open when sections exist) | |
| 297 | + | - [x] Add tip/callout about Sections (explanation + suggested section names in empty state) | |
| 298 | + | - [x] Pricing strategy guide at top of pricing tab (fixed, PWYW, free+codes, license keys) | |
| 299 | + | - [x] Clarified PWYW + license keys compatibility in license key description | |
| 300 | + | - [x] Item type descriptions already render (audit false negative — type_card_desc in wizard) | |
| 301 | + | - [x] File type hints already shown (MP3/WAV/FLAC/OGG/AAC for audio, MP4/WebM/MOV for video) | |
| 302 | + | - [x] Tier-specific file size limit already validated client-side with upgrade message | |
| 303 | + | ||
| 304 | + | --- | |
| 305 | + | ||
| 306 | + | ## Sprint 7: Collections Everywhere (2026-05-07) | |
| 307 | + | ||
| 308 | + | - [x] Improve "Add to Collection" affordance on item page (saved state button, toast feedback, shared collections.js) | |
| 309 | + | - [x] Add "Add to Collection" action on discover result cards (list + grid views, auth-gated) | |
| 310 | + | - [x] Add "Add to Collection" from library items (shared picker with inline creation) | |
| 311 | + | ||
| 312 | + | --- | |
| 313 | + | ||
| 314 | + | ## Sprint 8: Discovery Improvements (2026-05-07) | |
| 315 | + | ||
| 316 | + | - [x] Expose AI Tier filter in discover UI (sidebar facet with counts, hidden input sync) | |
| 317 | + | - [x] Add search suggestions/autocomplete (combined tags/projects/creators endpoint, keyboard nav dropdown) | |
| 318 | + | - [x] Add "Has source code" filter for projects with linked Git repos (checkbox, EXISTS subquery) | |
| 319 | + | - [x] Add download count analytics per item (already implemented — per-version in Files tab, total in analytics) | |
| 320 | + | - [x] Add distinction between "already downloaded" and "available for download" in library (migration 100, user_downloads table, "New" badge) | |
| 321 | + | ||
| 322 | + | --- | |
| 323 | + | ||
| 324 | + | ## Sprint 9: Documentation (2026-05-08) | |
| 325 | + | ||
| 326 | + | All 6 docs updated with feature map cross-reference after /map-features audit. | |
| 327 | + | ||
| 328 | + | - [x] Collections docs — fixed limits 50/200, added discover/library save points, saved state | |
| 329 | + | - [x] Promo Codes docs — added editing, starts_at, bulk delete expired | |
| 330 | + | - [x] Git docs — added raw file access, ref selector, README rendering | |
| 331 | + | - [x] License Keys docs — added offline verify endpoint, license.txt, revocation, PWYW, /api/v1/ prefix | |
| 332 | + | - [x] SyncKit docs — added SSE, key rotation, subscription gating, batch_id, sync status | |
| 333 | + | - [x] FAQ — added For Developers section, tier features, custom domains, data retention, content moderation |
| @@ -0,0 +1,11 @@ | |||
| 1 | + | -- Track which versions a user has downloaded, so the library can show | |
| 2 | + | -- "already downloaded" vs "new version available". | |
| 3 | + | CREATE TABLE IF NOT EXISTS user_downloads ( | |
| 4 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 5 | + | item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, | |
| 6 | + | version_id UUID NOT NULL REFERENCES versions(id) ON DELETE CASCADE, | |
| 7 | + | downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 8 | + | PRIMARY KEY (user_id, item_id, version_id) | |
| 9 | + | ); | |
| 10 | + | ||
| 11 | + | CREATE INDEX IF NOT EXISTS idx_user_downloads_user_item ON user_downloads(user_id, item_id); |
| @@ -0,0 +1,16 @@ | |||
| 1 | + | <div style="max-width: 280px;"> | |
| 2 | + | <button class="secondary" style="width: 100%; font-size: 0.9rem;">Save to collection</button> | |
| 3 | + | <div class="collection-picker" style="position: static; display: block;"> | |
| 4 | + | <div class="collection-picker-list"> | |
| 5 | + | <label class="collection-picker-item"><input type="checkbox" checked disabled> Best of 2026</label> | |
| 6 | + | <label class="collection-picker-item"><input type="checkbox" disabled> Study Music</label> | |
| 7 | + | <label class="collection-picker-item"><input type="checkbox" disabled> Gift Ideas</label> | |
| 8 | + | </div> | |
| 9 | + | <div class="collection-picker-create"> | |
| 10 | + | <form style="display: flex; gap: 0.5rem;"> | |
| 11 | + | <input type="text" name="title" placeholder="New collection" disabled style="flex: 1; padding: 0.3rem 0.5rem; font-size: 0.85rem;"> | |
| 12 | + | <button class="secondary" type="button" disabled style="font-size: 0.85rem;">Create</button> | |
| 13 | + | </form> | |
| 14 | + | </div> | |
| 15 | + | </div> | |
| 16 | + | </div> |
| @@ -0,0 +1,29 @@ | |||
| 1 | + | <div class="discover-sidebar" style="position: static; display: block; max-width: 240px;"> | |
| 2 | + | <div class="filter-section"> | |
| 3 | + | <div class="filter-title">Type</div> | |
| 4 | + | <ul class="filter-list"> | |
| 5 | + | <li class="filter-item active"><span>All</span> <span class="count">42</span></li> | |
| 6 | + | <li class="filter-item"><span>Audio</span> <span class="count">18</span></li> | |
| 7 | + | <li class="filter-item"><span>Text</span> <span class="count">12</span></li> | |
| 8 | + | <li class="filter-item"><span>Video</span> <span class="count">7</span></li> | |
| 9 | + | <li class="filter-item"><span>Download</span> <span class="count">5</span></li> | |
| 10 | + | </ul> | |
| 11 | + | </div> | |
| 12 | + | <div class="filter-section"> | |
| 13 | + | <div class="filter-title">Tags</div> | |
| 14 | + | <ul class="filter-list"> | |
| 15 | + | <li class="filter-item"><span>ambient</span> <span class="count">9</span></li> | |
| 16 | + | <li class="filter-item"><span>field-recording</span> <span class="count">6</span></li> | |
| 17 | + | <li class="filter-item"><span>tutorial</span> <span class="count">4</span></li> | |
| 18 | + | </ul> | |
| 19 | + | </div> | |
| 20 | + | <div class="filter-section"> | |
| 21 | + | <div class="filter-title">AI Disclosure</div> | |
| 22 | + | <ul class="filter-list"> | |
| 23 | + | <li class="filter-item active"><span>All</span> <span class="count">42</span></li> | |
| 24 | + | <li class="filter-item"><span>Handmade</span> <span class="count">35</span></li> | |
| 25 | + | <li class="filter-item"><span>Assisted</span> <span class="count">5</span></li> | |
| 26 | + | <li class="filter-item"><span>Generated</span> <span class="count">2</span></li> | |
| 27 | + | </ul> | |
| 28 | + | </div> | |
| 29 | + | </div> |
| @@ -2,12 +2,14 @@ | |||
| 2 | 2 | ||
| 3 | 3 | The License Key API lets software applications validate and manage license keys issued through Makenot.work. All endpoints are public (no authentication required) and rate-limited. | |
| 4 | 4 | ||
| 5 | + | Endpoints are available at both `/api/keys/` and `/api/v1/keys/` paths. New integrations should use the `/api/v1/` prefix. | |
| 6 | + | ||
| 5 | 7 | ## Validate and Activate a Key | |
| 6 | 8 | ||
| 7 | 9 | Validates a license key and optionally activates it on a machine. | |
| 8 | 10 | ||
| 9 | 11 | ``` | |
| 10 | - | POST https://makenot.work/api/keys/validate | |
| 12 | + | POST https://makenot.work/api/v1/keys/validate | |
| 11 | 13 | Content-Type: application/json | |
| 12 | 14 | ||
| 13 | 15 | { | |
| @@ -64,7 +66,7 @@ If the same `key` + `machine_id` combination is submitted again, the existing ac | |||
| 64 | 66 | Check whether a key is valid without activating it. | |
| 65 | 67 | ||
| 66 | 68 | ``` | |
| 67 | - | GET https://makenot.work/api/keys/{key_code}/status | |
| 69 | + | GET https://makenot.work/api/v1/keys/{key_code}/status | |
| 68 | 70 | ``` | |
| 69 | 71 | ||
| 70 | 72 | Response: | |
| @@ -89,7 +91,7 @@ Use this for periodic license checks without affecting activation state. | |||
| 89 | 91 | Release an activation slot (e.g., when the user uninstalls your software). | |
| 90 | 92 | ||
| 91 | 93 | ``` | |
| 92 | - | POST https://makenot.work/api/keys/deactivate | |
| 94 | + | POST https://makenot.work/api/v1/keys/deactivate | |
| 93 | 95 | Content-Type: application/json | |
| 94 | 96 | ||
| 95 | 97 | { | |
| @@ -107,9 +109,97 @@ Response: | |||
| 107 | 109 | } | |
| 108 | 110 | ``` | |
| 109 | 111 | ||
| 112 | + | ## License Verification (Offline Grace Period) | |
| 113 | + | ||
| 114 | + | For apps that need to work offline, use the verification endpoint. It validates and activates the key, then returns a signed JWT token valid for 7 days. Your app can verify this token locally without contacting the server. | |
| 115 | + | ||
| 116 | + | This endpoint requires the project to have license verification enabled (configured by the creator in project settings). | |
| 117 | + | ||
| 118 | + | ``` | |
| 119 | + | POST https://makenot.work/api/v1/license/verify | |
| 120 | + | Content-Type: application/json | |
| 121 | + | ||
| 122 | + | { | |
| 123 | + | "key": "XXXX-XXXX-XXXX-XXXX", | |
| 124 | + | "machine_fingerprint": "unique-machine-identifier" | |
| 125 | + | } | |
| 126 | + | ``` | |
| 127 | + | ||
| 128 | + | ### Response | |
| 129 | + | ||
| 130 | + | ```json | |
| 131 | + | { | |
| 132 | + | "valid": true, | |
| 133 | + | "token": "eyJhbGciOiJIUzI1NiIs...", | |
| 134 | + | "expires_in": 604800 | |
| 135 | + | } | |
| 136 | + | ``` | |
| 137 | + | ||
| 138 | + | | Field | Description | | |
| 139 | + | |-------|-------------| | |
| 140 | + | | `valid` | Whether the key is valid and activated | | |
| 141 | + | | `token` | Signed JWT for offline verification (7-day validity) | | |
| 142 | + | | `expires_in` | Token lifetime in seconds (604800 = 7 days) | | |
| 143 | + | ||
| 144 | + | ### JWT Claims | |
| 145 | + | ||
| 146 | + | The token contains: | |
| 147 | + | ||
| 148 | + | | Claim | Description | | |
| 149 | + | |-------|-------------| | |
| 150 | + | | `sub` | License key ID | | |
| 151 | + | | `machine` | Machine fingerprint | | |
| 152 | + | | `item` | Item ID | | |
| 153 | + | | `iat` | Issued at (Unix timestamp) | | |
| 154 | + | | `exp` | Expires at (Unix timestamp) | | |
| 155 | + | ||
| 156 | + | ### Offline Flow | |
| 157 | + | ||
| 158 | + | 1. Call `/api/v1/license/verify` on app launch (when online) | |
| 159 | + | 2. Cache the returned JWT locally | |
| 160 | + | 3. On subsequent launches, verify the JWT signature and `exp` claim locally | |
| 161 | + | 4. Re-verify with the server when the token nears expiry or when connectivity returns | |
| 162 | + | ||
| 163 | + | ### Deactivation via Verify API | |
| 164 | + | ||
| 165 | + | ``` | |
| 166 | + | POST https://makenot.work/api/v1/license/deactivate | |
| 167 | + | Content-Type: application/json | |
| 168 | + | ||
| 169 | + | { | |
| 170 | + | "key": "XXXX-XXXX-XXXX-XXXX", | |
| 171 | + | "machine_fingerprint": "unique-machine-identifier" | |
| 172 | + | } | |
| 173 | + | ``` | |
| 174 | + | ||
| 175 | + | | Error | Meaning | | |
| 176 | + | |-------|---------| | |
| 177 | + | | `invalid_key` | Key does not exist | | |
| 178 | + | | `key_revoked` | Key has been revoked by the creator | | |
| 179 | + | | `activation_limit_reached` | All activation slots are in use | | |
| 180 | + | | `verification_not_enabled` | Project does not have license verification enabled | | |
| 181 | + | ||
| 182 | + | ## License Text | |
| 183 | + | ||
| 184 | + | Retrieve the license agreement for an item in plain text: | |
| 185 | + | ||
| 186 | + | ``` | |
| 187 | + | GET https://makenot.work/api/v1/items/{item_id}/license.txt | |
| 188 | + | ``` | |
| 189 | + | ||
| 190 | + | Returns the license text configured by the creator (MIT, Apache 2.0, custom, etc.) as `text/plain`. | |
| 191 | + | ||
| 192 | + | ## Revocation | |
| 193 | + | ||
| 194 | + | Creators can revoke license keys from their dashboard at any time. A revoked key returns `key_revoked` on all validation, status, and verify calls. Revoked keys cannot be reactivated. | |
| 195 | + | ||
| 196 | + | ## PWYW Compatibility | |
| 197 | + | ||
| 198 | + | License keys work with Pay What You Want (PWYW) items. Any customer who completes a purchase — regardless of the amount paid — receives a license key if the creator has keys enabled for that item. | |
| 199 | + | ||
| 110 | 200 | ## Machine ID Guidelines | |
| 111 | 201 | ||
| 112 | - | The `machine_id` should be: | |
| 202 | + | The `machine_id` (or `machine_fingerprint` for the verify API) should be: | |
| 113 | 203 | - **Stable**: Same value across app restarts and updates | |
| 114 | 204 | - **Unique**: Different on each machine | |
| 115 | 205 | - **Not personally identifiable**: Avoid using MAC addresses or usernames | |
| @@ -129,9 +219,9 @@ Exceeding the limit returns HTTP 429. Implement exponential backoff in your clie | |||
| 129 | 219 | ||
| 130 | 220 | Recommended flow for desktop applications: | |
| 131 | 221 | ||
| 132 | - | 1. **On first launch**: Prompt for license key, call `/api/keys/validate` with a generated machine ID | |
| 133 | - | 2. **On subsequent launches**: Call `/api/keys/{key}/status` to verify the key is still valid | |
| 134 | - | 3. **On uninstall**: Call `/api/keys/deactivate` to release the activation slot | |
| 222 | + | 1. **On first launch**: Prompt for license key, call `/api/v1/keys/validate` with a generated machine ID | |
| 223 | + | 2. **On subsequent launches**: Call `/api/v1/license/verify` for offline-capable apps, or `/api/v1/keys/{key}/status` for online-only apps | |
| 224 | + | 3. **On uninstall**: Call `/api/v1/keys/deactivate` to release the activation slot | |
| 135 | 225 | 4. **On activation failure**: Show the error message and allow the user to enter a different key | |
| 136 | 226 | ||
| 137 | 227 | ## Error Response Format |
| @@ -45,7 +45,7 @@ Response: | |||
| 45 | 45 | } | |
| 46 | 46 | ``` | |
| 47 | 47 | ||
| 48 | - | Direct auth does not work for accounts with 2FA enabled — those must use [OAuth2 PKCE](./oauth.md). | |
| 48 | + | Direct auth does not work for accounts with 2FA enabled — those users must use [OAuth2 PKCE](./oauth.md). OAuth2 PKCE is recommended for all apps as it provides a better UX (browser-based login, no credential handling in the app). | |
| 49 | 49 | ||
| 50 | 50 | ### OAuth2 PKCE | |
| 51 | 51 | ||
| @@ -105,6 +105,7 @@ Content-Type: application/json | |||
| 105 | 105 | ||
| 106 | 106 | { | |
| 107 | 107 | "device_id": "770a0600-...", | |
| 108 | + | "batch_id": "a1b2c3d4-...", | |
| 108 | 109 | "changes": [ | |
| 109 | 110 | { | |
| 110 | 111 | "table": "tasks", | |
| @@ -125,6 +126,8 @@ Response: | |||
| 125 | 126 | } | |
| 126 | 127 | ``` | |
| 127 | 128 | ||
| 129 | + | The `batch_id` is a UUID for idempotent pushes. If the same `batch_id` is submitted twice, the server returns the existing cursor without re-inserting changes. Always generate a unique `batch_id` per push and retry with the same ID on network failure. | |
| 130 | + | ||
| 128 | 131 | Changes per push are capped at 500 (the server returns an error if exceeded). Table names are limited to 100 characters (alphanumeric and underscores only). Row IDs are limited to 255 characters. The server validates device ownership. | |
| 129 | 132 | ||
| 130 | 133 | ### Pulling Changes | |
| @@ -283,6 +286,80 @@ Response: | |||
| 283 | 286 | ||
| 284 | 287 | The download URL is a presigned S3 URL valid for a limited time. | |
| 285 | 288 | ||
| 289 | + | ## Real-Time Notifications (SSE) | |
| 290 | + | ||
| 291 | + | Instead of polling for changes, your app can subscribe to Server-Sent Events. The server pushes a notification whenever another device in the same app+user namespace pushes changes. | |
| 292 | + | ||
| 293 | + | ``` | |
| 294 | + | GET /api/sync/subscribe | |
| 295 | + | Authorization: Bearer <token> | |
| 296 | + | ``` | |
| 297 | + | ||
| 298 | + | This is a long-lived SSE connection. Events: | |
| 299 | + | ||
| 300 | + | | Event | Data | Meaning | | |
| 301 | + | |-------|------|---------| | |
| 302 | + | | `changed` | `{"cursor": "..."}` | Another device pushed changes. Pull from cursor to catch up. | | |
| 303 | + | ||
| 304 | + | Recommended pattern: | |
| 305 | + | ||
| 306 | + | 1. Open SSE connection on app launch | |
| 307 | + | 2. On `changed` event, call pull to fetch new changes | |
| 308 | + | 3. Reconnect on connection drop (with exponential backoff) | |
| 309 | + | 4. Fall back to periodic polling if SSE is unavailable | |
| 310 | + | ||
| 311 | + | ## Key Rotation | |
| 312 | + | ||
| 313 | + | If you need to rotate your app's encryption key (e.g., after a suspected compromise), SyncKit provides a multi-step rotation flow: | |
| 314 | + | ||
| 315 | + | 1. **Begin rotation** — Store the new encrypted key alongside the old one: | |
| 316 | + | ||
| 317 | + | ``` | |
| 318 | + | POST /api/sync/keys/rotate/begin | |
| 319 | + | Authorization: Bearer <token> | |
| 320 | + | Content-Type: application/json | |
| 321 | + | ||
| 322 | + | { "new_encrypted_key": "<base64>" } | |
| 323 | + | ``` | |
| 324 | + | ||
| 325 | + | 2. **Fetch entries to re-encrypt** — Pull changelog entries encrypted with the old key: | |
| 326 | + | ||
| 327 | + | ``` | |
| 328 | + | POST /api/sync/keys/rotate/entries | |
| 329 | + | Authorization: Bearer <token> | |
| 330 | + | Content-Type: application/json | |
| 331 | + | ||
| 332 | + | { "cursor": "...", "limit": 100 } | |
| 333 | + | ``` | |
| 334 | + | ||
| 335 | + | 3. **Submit re-encrypted batch** — Upload entries re-encrypted with the new key: | |
| 336 | + | ||
| 337 | + | ``` | |
| 338 | + | POST /api/sync/keys/rotate/batch | |
| 339 | + | Authorization: Bearer <token> | |
| 340 | + | Content-Type: application/json | |
| 341 | + | ||
| 342 | + | { "entries": [...] } | |
| 343 | + | ``` | |
| 344 | + | ||
| 345 | + | 4. **Complete rotation** — Finalize once all entries are re-encrypted: | |
| 346 | + | ||
| 347 | + | ``` | |
| 348 | + | POST /api/sync/keys/rotate/complete | |
| 349 | + | Authorization: Bearer <token> | |
| 350 | + | ``` | |
| 351 | + | ||
| 352 | + | During rotation, other devices pulling changes may receive a mix of old-key and new-key entries. Clients should be prepared to decrypt with both keys until rotation completes. | |
| 353 | + | ||
| 354 | + | ## Subscription Gating | |
| 355 | + | ||
| 356 | + | Some apps require an active subscription to use SyncKit sync features. This is configured per app: | |
| 357 | + | ||
| 358 | + | - Apps linked to a free item (or no item) have unrestricted sync access | |
| 359 | + | - Apps linked to a paid item or subscription tier require an active purchase or subscription | |
| 360 | + | ||
| 361 | + | If the user's subscription lapses, push and pull requests return `403` with an error indicating the subscription requirement. Device registration and key storage remain accessible. | |
| 362 | + | ||
| 286 | 363 | ## See Also | |
| 287 | 364 | ||
| 288 | 365 | - [OTA Updates](./ota.md) — auto-update your app through SyncKit |
| @@ -4,24 +4,27 @@ Collections let you group items across projects into curated lists. Use them for | |||
| 4 | 4 | ||
| 5 | 5 | ## Creating a Collection | |
| 6 | 6 | ||
| 7 | - | 1. Go to your dashboard | |
| 8 | - | 2. Click "New Collection" | |
| 7 | + | 1. Go to your **Library** and open the **Collections** tab | |
| 8 | + | 2. Click **New Collection** | |
| 9 | 9 | 3. Enter a title, an optional description, and a URL slug | |
| 10 | 10 | 4. Choose whether the collection is public or private | |
| 11 | 11 | ||
| 12 | - | You can create up to 100 collections. | |
| 12 | + | You can create up to 50 collections. | |
| 13 | 13 | ||
| 14 | 14 | ## Adding Items | |
| 15 | 15 | ||
| 16 | - | Add any of your published (public) items to a collection: | |
| 16 | + | Any public item on the platform can be saved to your collections. There are several ways to add items: | |
| 17 | 17 | ||
| 18 | - | 1. Open the collection from your dashboard | |
| 19 | - | 2. Click "Add Item" | |
| 20 | - | 3. Select from your published items | |
| 18 | + | **From an item page** — Click "Save to collection" below the purchase buttons. If the item is already saved, the button shows "Saved (N)" with the number of collections it belongs to. The dropdown lets you check/uncheck collections or create a new one inline. | |
| 21 | 19 | ||
| 22 | - | Each collection can hold up to 1,000 items. Only public items can be added — draft or unlisted items are not eligible. | |
| 20 | + | > [!UI] collection-picker | |
| 21 | + | > The collection picker with inline creation | |
| 23 | 22 | ||
| 24 | - | You can also add items to collections from the item edit page, which shows a checklist of your existing collections. | |
| 23 | + | **From discover results** — Each item card shows a "+" button (visible when signed in). Click it to open the collection picker without leaving the discover page. Works in both grid and list views. | |
| 24 | + | ||
| 25 | + | **From your library** — In the Purchases tab, click the "..." menu on any item and choose "Add to collection". The picker supports inline collection creation here too. | |
| 26 | + | ||
| 27 | + | Each collection can hold up to 200 items. Only public items can be added — draft or unlisted items are not eligible. | |
| 25 | 28 | ||
| 26 | 29 | ## Reordering | |
| 27 | 30 | ||
| @@ -29,7 +32,7 @@ Items within a collection can be reordered by drag-and-drop on the collection ed | |||
| 29 | 32 | ||
| 30 | 33 | ## Public URLs | |
| 31 | 34 | ||
| 32 | - | Public collections are visible at your profile. Fans can browse the collection and access any item in it. Private collections are only visible to you. | |
| 35 | + | Public collections are visible at `/c/your-username/collection-slug` and linked from your profile page. Fans can browse the collection and access any item in it. Private collections are only visible to you. | |
| 33 | 36 | ||
| 34 | 37 | ## Editing and Deleting | |
| 35 | 38 |
| @@ -25,6 +25,9 @@ Fans can narrow results by: | |||
| 25 | 25 | - **Price range** — Free, Under $25, $25-50, $50-100, $100+, or custom min/max | |
| 26 | 26 | - **AI tier** — Handmade, Assisted, or Generated | |
| 27 | 27 | ||
| 28 | + | > [!UI] discover-filters | |
| 29 | + | > Discover sidebar with type, tag, and AI tier filters | |
| 30 | + | ||
| 28 | 31 | ### Sorting | |
| 29 | 32 | ||
| 30 | 33 | When not searching, fans can sort by: |
| @@ -29,8 +29,10 @@ Your repository is available at `/git/username/repo-name`. The source browser pr | |||
| 29 | 29 | - **Commit detail** — Full diff with inline additions and deletions (capped at 100 files / 10,000 lines) | |
| 30 | 30 | - **Blame view** — Per-line commit attribution | |
| 31 | 31 | - **Per-file history** — Commit log filtered to a single file's changes | |
| 32 | + | - **Raw file access** — View or download raw file contents at `/git/username/repo-name/raw/branch/path/to/file` | |
| 33 | + | - **Ref selector** — Switch between branches and tags from any view | |
| 32 | 34 | ||
| 33 | - | The repository overview shows the tree at HEAD and renders your README if one exists. | |
| 35 | + | The repository overview shows the tree at HEAD and renders your README.md (or README.markdown) as HTML if one exists. | |
| 34 | 36 | ||
| 35 | 37 | ## Visibility | |
| 36 | 38 |
| @@ -20,23 +20,23 @@ Give temporary access to subscription content. | |||
| 20 | 20 | ||
| 21 | 21 | ### Free Access Codes | |
| 22 | 22 | ||
| 23 | - | Grant permanent free access to a specific item. These use auto-generated word-based codes (easier to share verbally or in print). | |
| 23 | + | Grant permanent free access to a specific item. These use auto-generated word-based codes (easier to share verbally or in print). You can also provide a custom code up to 100 characters. | |
| 24 | 24 | ||
| 25 | 25 | When a fan claims a free access code for an item that has license keys enabled, a license key is automatically generated for them. | |
| 26 | 26 | ||
| 27 | 27 | ## Creating a Promo Code | |
| 28 | 28 | ||
| 29 | - | 1. Go to your dashboard > Promo Codes | |
| 30 | - | 2. Click "Create Code" | |
| 29 | + | 1. Go to your project dashboard and open the **Promotions** tab | |
| 30 | + | 2. Click **Create Code** | |
| 31 | 31 | 3. Choose the type (Discount, Free Trial, or Free Access) | |
| 32 | 32 | 4. Set the parameters: | |
| 33 | 33 | - **Discount**: percentage or fixed amount | |
| 34 | 34 | - **Free Trial**: number of days | |
| 35 | 35 | - **Free Access**: select the item | |
| 36 | - | 5. Optionally set a scope, expiry date, and usage limit | |
| 37 | - | 6. Click "Create" | |
| 36 | + | 5. Optionally set a scope, start date, expiry date, and usage limit | |
| 37 | + | 6. Click **Create** | |
| 38 | 38 | ||
| 39 | - | Discount and free trial codes use custom alphanumeric codes you define. Free access codes are auto-generated with memorable word combinations. | |
| 39 | + | Discount and free trial codes use custom uppercase alphanumeric codes you define (1-50 characters). Free access codes are auto-generated with memorable word combinations unless you provide a custom code. | |
| 40 | 40 | ||
| 41 | 41 | ## Scope | |
| 42 | 42 | ||
| @@ -49,19 +49,28 @@ Codes can be scoped to different levels: | |||
| 49 | 49 | | Item | Applies to a single item only | | |
| 50 | 50 | | Tier | Applies to a specific subscription tier | | |
| 51 | 51 | ||
| 52 | - | ## Expiry and Limits | |
| 52 | + | ## Scheduling, Expiry, and Limits | |
| 53 | 53 | ||
| 54 | + | - **Start date** — Optional. Set a future date (YYYY-MM-DD) when the code becomes active. Before this date, the code cannot be redeemed. Activates at beginning of day UTC. | |
| 54 | 55 | - **Expiry date** — Optional. Set a date (YYYY-MM-DD) after which the code stops working. Expires at end of day UTC. | |
| 55 | 56 | - **Usage limit** — Optional. Set the maximum number of times the code can be redeemed. | |
| 56 | 57 | ||
| 57 | - | Codes with no expiry and no limit work indefinitely. | |
| 58 | + | Codes with no start date, no expiry, and no limit work immediately and indefinitely. | |
| 59 | + | ||
| 60 | + | ## Editing Codes | |
| 61 | + | ||
| 62 | + | After creating a code, you can update its **start date**, **expiry date**, and **usage limit**. The code text, discount type, and discount amount cannot be changed — delete and recreate the code if you need to change those. | |
| 63 | + | ||
| 64 | + | To edit a code, click the edit button next to it in the Promotions tab. | |
| 58 | 65 | ||
| 59 | 66 | ## Managing Codes | |
| 60 | 67 | ||
| 61 | - | From your dashboard you can: | |
| 68 | + | From your project's Promotions tab you can: | |
| 62 | 69 | ||
| 63 | 70 | - **List** all your promo codes with redemption counts | |
| 64 | - | - **Delete** a code to deactivate it immediately | |
| 71 | + | - **Edit** a code's dates and usage limit | |
| 72 | + | - **Delete** a single code to deactivate it immediately | |
| 73 | + | - **Bulk delete expired** — Remove all expired codes at once with the "Delete all expired" button | |
| 65 | 74 | ||
| 66 | 75 | ## How Fans Use Codes | |
| 67 | 76 |
| @@ -19,10 +19,12 @@ Yes. Fan accounts are completely free. You only pay for content you choose to bu | |||
| 19 | 19 | ||
| 20 | 20 | Creator tier fees (what you pay to host content): | |
| 21 | 21 | ||
| 22 | - | - Basic: $10/month | |
| 23 | - | - Small Files: $20/month | |
| 24 | - | - Big Files: $30/month | |
| 25 | - | - Everything: $60/month | |
| 22 | + | - **Basic** ($10/month) — Text, blog posts, promo codes, mailing lists | |
| 23 | + | - **Small Files** ($20/month) — Everything in Basic, plus audio uploads (MP3, WAV, FLAC, OGG, AAC) | |
| 24 | + | - **Big Files** ($30/month) — Everything in Small Files, plus video uploads (MP4, WebM, MOV) and larger file limits | |
| 25 | + | - **Everything** ($60/month) — All features including live streaming, all current and future features | |
| 26 | + | ||
| 27 | + | See [Pricing Tiers](../guide/tiers.md) for storage limits, per-file size limits, and detailed feature comparisons. | |
| 26 | 28 | ||
| 27 | 29 | ### How do I get paid? | |
| 28 | 30 | Through our payment system. Payments go directly to your linked bank account. You control payout timing. | |
| @@ -54,6 +56,15 @@ Payout timing is determined entirely by Stripe — we don't hold or delay funds. | |||
| 54 | 56 | ### What if Stripe suspends or closes my account? | |
| 55 | 57 | Your content and data on Makenot.work are unaffected — you can still log in, manage your projects, and export everything. However, you won't be able to receive payments until the Stripe issue is resolved. Stripe handles disputes directly with you (they're the payment processor, not us). If Stripe permanently closes your account, we don't currently offer an alternative payment processor. You'd need to export your data and sell elsewhere. We're monitoring alternative processors but have no timeline. See [Payouts](../guide/payouts.md) for more detail. | |
| 56 | 58 | ||
| 59 | + | ### Can I use a custom domain? | |
| 60 | + | Yes. You can point your own domain to your Makenot.work profile. Add the domain in your dashboard settings, then add a DNS TXT record for verification. Once verified, your profile, projects, and items are accessible at your domain. See [Custom Domains](../guide/custom-domains.md). | |
| 61 | + | ||
| 62 | + | ### What happens to my content if I delete my account? | |
| 63 | + | Export your data before deleting — once deleted, content is removed from the platform. If you're a creator with sales, your content stays accessible to buyers for 90 days after deletion so they can download their purchases. After 90 days, all content is permanently removed. See [Account Lifecycle](../guide/account-lifecycle.md). | |
| 64 | + | ||
| 65 | + | ### Are there minimum payout amounts? | |
| 66 | + | Payments go directly to your Stripe account. Payout frequency, minimums, and instant payout availability are all configured in your Stripe dashboard, not by us. | |
| 67 | + | ||
| 57 | 68 | ### Can I sell adult/NSFW content? | |
| 58 | 69 | Not on Makenot.work. We intend to launch a separate platform for adult creators with identical commitments when infrastructure is ready. | |
| 59 | 70 | ||
| @@ -68,6 +79,23 @@ Content you own (one-time purchases) stays in your library. Files remain downloa | |||
| 68 | 79 | ### Can I get a refund? | |
| 69 | 80 | Refund policies are set by individual creators. Contact them directly. | |
| 70 | 81 | ||
| 82 | + | ### How do I know if there's a new version of something I bought? | |
| 83 | + | Your library marks items with a "New" badge when the creator publishes a version you haven't downloaded yet. Once you download it, the badge clears. | |
| 84 | + | ||
| 85 | + | ### Can I save items to collections? | |
| 86 | + | Yes. Any signed-in user can save public items to personal collections. Use the "Save to collection" button on item pages, the "+" button on discover cards, or the context menu in your library. Collections can be public (visible on your profile) or private. See [Collections](../guide/collections.md). | |
| 87 | + | ||
| 88 | + | ## For Developers | |
| 89 | + | ||
| 90 | + | ### Is there a license key API? | |
| 91 | + | Yes. If a creator enables license keys on an item, buyers receive a key on purchase. Your app can validate, activate, and deactivate keys via the [License Key API](../developer/license-keys.md). The API supports offline grace periods with signed JWT tokens. | |
| 92 | + | ||
| 93 | + | ### Can I integrate cloud sync into my app? | |
| 94 | + | Yes. [SyncKit](../developer/synckit.md) provides E2E encrypted cloud sync, device registration, blob storage, and real-time notifications via SSE. Authenticate via [OAuth2 PKCE](../developer/oauth.md) or direct credentials. | |
| 95 | + | ||
| 96 | + | ### What are the API rate limits? | |
| 97 | + | Public endpoints (license keys, OTA updates) are rate-limited per IP. Authenticated endpoints have higher limits. See [API Overview](../developer/api-overview.md) for specifics. Exceeding limits returns HTTP 429. | |
| 98 | + | ||
| 71 | 99 | ## Technical | |
| 72 | 100 | ||
| 73 | 101 | ### Is there an API? | |
| @@ -136,6 +164,9 @@ This is a real risk and we won't pretend otherwise. Right now, Makenot.work is o | |||
| 136 | 164 | ### What content isn't allowed? | |
| 137 | 165 | Content that dehumanizes, harasses, or incites violence. See [Acceptable Use](../legal/acceptable-use.md) for the full policy. | |
| 138 | 166 | ||
| 167 | + | ### How are content violations handled? | |
| 168 | + | Users can report content via the report button on any item or project page. Reports are reviewed by the admin team. If a violation is confirmed, the content may be removed and the creator notified. Repeated violations may result in a warning, account suspension, or termination. Creators can appeal suspensions from their dashboard. | |
| 169 | + | ||
| 139 | 170 | ### What's your stance on AI-generated content? | |
| 140 | 171 | Every item on the platform must declare one of three tiers: **Handmade** (no generative AI), **Assisted** (human-made with AI tools, disclosure required), or **Generated** (primarily AI output). Fans can filter to see only Handmade, only human-led work (Handmade + Assisted), or everything. Misrepresenting your tier is treated as fraud. See our full [Generative AI Policy](../about/generative-ai.md) for definitions, examples, and enforcement. | |
| 141 | 172 |
| @@ -161,31 +161,6 @@ pub async fn update_app_sync_sub_period<'e>( | |||
| 161 | 161 | Ok(()) | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | - | /// Update the tier and storage limit (for AF tier upgrades/downgrades). | |
| 165 | - | #[tracing::instrument(skip_all)] | |
| 166 | - | pub async fn update_app_sync_sub_tier<'e>( | |
| 167 | - | executor: impl sqlx::PgExecutor<'e>, | |
| 168 | - | stripe_subscription_id: &str, | |
| 169 | - | tier: AppSyncTier, | |
| 170 | - | storage_limit_bytes: Option<i64>, | |
| 171 | - | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 172 | - | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 173 | - | r#" | |
| 174 | - | UPDATE app_sync_subscriptions | |
| 175 | - | SET tier = $2, storage_limit_bytes = $3 | |
| 176 | - | WHERE stripe_subscription_id = $1 | |
| 177 | - | RETURNING * | |
| 178 | - | "#, | |
| 179 | - | ) | |
| 180 | - | .bind(stripe_subscription_id) | |
| 181 | - | .bind(tier) | |
| 182 | - | .bind(storage_limit_bytes) | |
| 183 | - | .fetch_optional(executor) | |
| 184 | - | .await?; | |
| 185 | - | ||
| 186 | - | Ok(sub) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | 164 | /// Cancel an app sync subscription (set status + canceled_at). | |
| 190 | 165 | #[tracing::instrument(skip_all)] | |
| 191 | 166 | pub async fn cancel_app_sync_sub( |
| @@ -298,6 +298,28 @@ pub async fn reorder_collection_items( | |||
| 298 | 298 | Ok(()) | |
| 299 | 299 | } | |
| 300 | 300 | ||
| 301 | + | /// Count how many of a user's collections contain a specific item. | |
| 302 | + | #[tracing::instrument(skip_all)] | |
| 303 | + | pub async fn count_user_collections_containing_item( | |
| 304 | + | pool: &PgPool, | |
| 305 | + | user_id: UserId, | |
| 306 | + | item_id: ItemId, | |
| 307 | + | ) -> Result<i64> { | |
| 308 | + | let (count,): (i64,) = sqlx::query_as( | |
| 309 | + | r#" | |
| 310 | + | SELECT COUNT(*) FROM collections c | |
| 311 | + | JOIN collection_items ci ON ci.collection_id = c.id | |
| 312 | + | WHERE c.user_id = $1 AND ci.item_id = $2 | |
| 313 | + | "#, | |
| 314 | + | ) | |
| 315 | + | .bind(user_id) | |
| 316 | + | .bind(item_id) | |
| 317 | + | .fetch_one(pool) | |
| 318 | + | .await?; | |
| 319 | + | ||
| 320 | + | Ok(count) | |
| 321 | + | } | |
| 322 | + | ||
| 301 | 323 | /// Get a user's collections with membership state for a specific item. | |
| 302 | 324 | /// Returns (collection_id, title, is_in_collection) for the "add to collection" dropdown. | |
| 303 | 325 | #[tracing::instrument(skip_all)] |
| @@ -310,6 +310,7 @@ pub async fn discover_projects( | |||
| 310 | 310 | search: Option<&str>, | |
| 311 | 311 | category_slug: Option<&str>, | |
| 312 | 312 | sort_by: Option<DiscoverSort>, | |
| 313 | + | has_source_code: bool, | |
| 313 | 314 | limit: i64, | |
| 314 | 315 | offset: i64, | |
| 315 | 316 | ) -> Result<Vec<DbDiscoverProjectRow>> { | |
| @@ -398,6 +399,10 @@ pub async fn discover_projects( | |||
| 398 | 399 | query.push_str(" AND pc.slug = $2"); | |
| 399 | 400 | } | |
| 400 | 401 | ||
| 402 | + | if has_source_code { | |
| 403 | + | query.push_str(" AND EXISTS (SELECT 1 FROM git_repos gr WHERE gr.project_id = p.id)"); | |
| 404 | + | } | |
| 405 | + | ||
| 401 | 406 | query.push_str(" GROUP BY p.id, u.username, pc.name, pc.slug"); | |
| 402 | 407 | ||
| 403 | 408 | let order = if has_search && (sort_by.is_none() || sort_by == Some(DiscoverSort::Newest)) { | |
| @@ -428,6 +433,7 @@ pub async fn count_discover_projects( | |||
| 428 | 433 | pool: &PgPool, | |
| 429 | 434 | search: Option<&str>, | |
| 430 | 435 | category_slug: Option<&str>, | |
| 436 | + | has_source_code: bool, | |
| 431 | 437 | ) -> Result<i64> { | |
| 432 | 438 | let search_term = normalize_search(search); | |
| 433 | 439 | let has_search = search_term.is_some(); | |
| @@ -456,6 +462,10 @@ pub async fn count_discover_projects( | |||
| 456 | 462 | ); | |
| 457 | 463 | } | |
| 458 | 464 | ||
| 465 | + | if has_source_code { | |
| 466 | + | query.push_str(" AND EXISTS (SELECT 1 FROM git_repos gr WHERE gr.project_id = p.id)"); | |
| 467 | + | } | |
| 468 | + | ||
| 459 | 469 | let count: i64 = sqlx::query_scalar(&query) | |
| 460 | 470 | .bind(search_term.as_deref().unwrap_or("")) | |
| 461 | 471 | .bind(category_slug.unwrap_or("")) | |
| @@ -606,3 +616,113 @@ pub async fn get_price_range_counts( | |||
| 606 | 616 | over_100: row.over_100.unwrap_or(0), | |
| 607 | 617 | }) | |
| 608 | 618 | } | |
| 619 | + | ||
| 620 | + | /// Get AI tier counts for the discover page sidebar (items mode only). | |
| 621 | + | #[tracing::instrument(skip_all)] | |
| 622 | + | pub async fn get_ai_tier_counts( | |
| 623 | + | pool: &PgPool, | |
| 624 | + | search: Option<&str>, | |
| 625 | + | item_type: Option<ItemType>, | |
| 626 | + | tag: Option<&str>, | |
| 627 | + | ) -> Result<Vec<DbItemTypeCount>> { | |
| 628 | + | let search_term = normalize_search(search); | |
| 629 | + | let has_search = search_term.is_some(); | |
| 630 | + | let short_query = search_term.as_deref().map_or(false, is_short_query); | |
| 631 | + | ||
| 632 | + | let mut query = String::from( | |
| 633 | + | r#" | |
| 634 | + | SELECT i.ai_tier as category, COUNT(*) as count | |
| 635 | + | FROM items i | |
| 636 | + | JOIN projects p ON i.project_id = p.id | |
| 637 | + | JOIN users u ON p.user_id = u.id | |
| 638 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE | |
| 639 | + | "#, | |
| 640 | + | ); | |
| 641 | + | ||
| 642 | + | if has_search { | |
| 643 | + | if short_query { | |
| 644 | + | query.push_str(ITEM_SEARCH_CLAUSE_SHORT); | |
| 645 | + | } else { | |
| 646 | + | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 647 | + | } | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | if item_type.is_some() { | |
| 651 | + | query.push_str(" AND i.item_type = $2"); | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | if tag.is_some() { | |
| 655 | + | query.push_str( | |
| 656 | + | r#" AND EXISTS ( | |
| 657 | + | SELECT 1 FROM item_tags it2 | |
| 658 | + | JOIN tags t2 ON t2.id = it2.tag_id | |
| 659 | + | WHERE it2.item_id = i.id | |
| 660 | + | AND (t2.slug = $3 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $3)) | |
| 661 | + | )"#, | |
| 662 | + | ); | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | query.push_str(" GROUP BY i.ai_tier ORDER BY count DESC"); | |
| 666 | + | ||
| 667 | + | let counts = sqlx::query_as::<_, DbItemTypeCount>(&query) | |
| 668 | + | .bind(search_term.as_deref().unwrap_or("")) | |
| 669 | + | .bind(item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 670 | + | .bind(tag.unwrap_or("")) | |
| 671 | + | .fetch_all(pool) | |
| 672 | + | .await?; | |
| 673 | + | ||
| 674 | + | Ok(counts) | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | /// A search suggestion with a category label (tag, project, or creator). | |
| 678 | + | #[derive(Debug, FromRow)] | |
| 679 | + | pub struct DbSearchSuggestion { | |
| 680 | + | pub label: String, | |
| 681 | + | pub category: String, | |
| 682 | + | pub url: String, | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | /// Return combined search suggestions from tags, projects, and creators. | |
| 686 | + | /// Uses ILIKE prefix match for fast results, limited to 8 total. | |
| 687 | + | #[tracing::instrument(skip_all)] | |
| 688 | + | pub async fn search_suggestions(pool: &PgPool, query: &str) -> Result<Vec<DbSearchSuggestion>> { | |
| 689 | + | let q = query.trim(); | |
| 690 | + | if q.is_empty() { | |
| 691 | + | return Ok(vec![]); | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | let pattern = format!("{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")); | |
| 695 | + | ||
| 696 | + | let rows = sqlx::query_as::<_, DbSearchSuggestion>( | |
| 697 | + | r#" | |
| 698 | + | ( | |
| 699 | + | SELECT name AS label, 'tag' AS category, '/discover?mode=items&tag=' || slug AS url | |
| 700 | + | FROM tags | |
| 701 | + | WHERE name ILIKE $1 | |
| 702 | + | ORDER BY name | |
| 703 | + | LIMIT 3 | |
| 704 | + | ) | |
| 705 | + | UNION ALL | |
| 706 | + | ( | |
| 707 | + | SELECT title AS label, 'project' AS category, '/p/' || slug AS url | |
| 708 | + | FROM projects | |
| 709 | + | WHERE is_public = true AND title ILIKE $1 | |
| 710 | + | ORDER BY title | |
| 711 | + | LIMIT 3 | |
| 712 | + | ) | |
| 713 | + | UNION ALL | |
| 714 | + | ( | |
| 715 | + | SELECT username AS label, 'creator' AS category, '/u/' || username AS url | |
| 716 | + | FROM users | |
| 717 | + | WHERE is_sandbox = false AND username ILIKE $1 | |
| 718 | + | ORDER BY username | |
| 719 | + | LIMIT 2 | |
| 720 | + | ) | |
| 721 | + | "#, | |
| 722 | + | ) | |
| 723 | + | .bind(&pattern) | |
| 724 | + | .fetch_all(pool) | |
| 725 | + | .await?; | |
| 726 | + | ||
| 727 | + | Ok(rows) | |
| 728 | + | } |
| @@ -120,6 +120,8 @@ pub struct DbPurchaseRow { | |||
| 120 | 120 | pub is_free: bool, | |
| 121 | 121 | /// License key code for this item (if any, non-revoked). | |
| 122 | 122 | pub license_key_code: Option<KeyCode>, | |
| 123 | + | /// True if the item has a version the user hasn't downloaded yet. | |
| 124 | + | pub has_new_version: bool, | |
| 123 | 125 | } | |
| 124 | 126 | ||
| 125 | 127 | /// A one-time passwordless login token (magic link). |
| @@ -474,7 +474,15 @@ pub async fn get_user_purchases(pool: &PgPool, user_id: UserId) -> Result<Vec<Db | |||
| 474 | 474 | i.item_type, | |
| 475 | 475 | p.purchased_at, | |
| 476 | 476 | (i.price_cents = 0) as is_free, | |
| 477 | - | lk.key_code as license_key_code | |
| 477 | + | lk.key_code as license_key_code, | |
| 478 | + | EXISTS ( | |
| 479 | + | SELECT 1 FROM versions v | |
| 480 | + | WHERE v.item_id = i.id AND v.s3_key IS NOT NULL | |
| 481 | + | AND NOT EXISTS ( | |
| 482 | + | SELECT 1 FROM user_downloads ud | |
| 483 | + | WHERE ud.user_id = p.buyer_id AND ud.version_id = v.id | |
| 484 | + | ) | |
| 485 | + | ) as has_new_version | |
| 478 | 486 | FROM purchases p | |
| 479 | 487 | JOIN items i ON p.item_id = i.id | |
| 480 | 488 | JOIN projects proj ON i.project_id = proj.id |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | use sqlx::PgPool; | |
| 4 | 4 | ||
| 5 | 5 | use super::models::*; | |
| 6 | - | use super::{ItemId, VersionId}; | |
| 6 | + | use super::{ItemId, UserId, VersionId}; | |
| 7 | 7 | use crate::error::Result; | |
| 8 | 8 | ||
| 9 | 9 | /// Create a new version for an item, marking it as the current release. | |
| @@ -97,6 +97,54 @@ pub async fn increment_download_count(pool: &PgPool, version_id: VersionId) -> R | |||
| 97 | 97 | Ok(()) | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | + | /// Record that a user downloaded a specific version (idempotent). | |
| 101 | + | #[tracing::instrument(skip_all)] | |
| 102 | + | pub async fn record_user_download( | |
| 103 | + | pool: &PgPool, | |
| 104 | + | user_id: UserId, | |
| 105 | + | item_id: ItemId, | |
| 106 | + | version_id: VersionId, | |
| 107 | + | ) -> Result<()> { | |
| 108 | + | sqlx::query( | |
| 109 | + | r#" | |
| 110 | + | INSERT INTO user_downloads (user_id, item_id, version_id) | |
| 111 | + | VALUES ($1, $2, $3) | |
| 112 | + | ON CONFLICT DO NOTHING | |
| 113 | + | "#, | |
| 114 | + | ) | |
| 115 | + | .bind(user_id) | |
| 116 | + | .bind(item_id) | |
| 117 | + | .bind(version_id) | |
| 118 | + | .execute(pool) | |
| 119 | + | .await?; | |
| 120 | + | ||
| 121 | + | Ok(()) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Get the latest version ID a user has downloaded for an item, if any. | |
| 125 | + | #[tracing::instrument(skip_all)] | |
| 126 | + | pub async fn get_user_latest_download( | |
| 127 | + | pool: &PgPool, | |
| 128 | + | user_id: UserId, | |
| 129 | + | item_id: ItemId, | |
| 130 | + | ) -> Result<Option<VersionId>> { | |
| 131 | + | let row: Option<(VersionId,)> = sqlx::query_as( | |
| 132 | + | r#" | |
| 133 | + | SELECT ud.version_id FROM user_downloads ud | |
| 134 | + | JOIN versions v ON v.id = ud.version_id | |
| 135 | + | WHERE ud.user_id = $1 AND ud.item_id = $2 | |
| 136 | + | ORDER BY v.created_at DESC | |
| 137 | + | LIMIT 1 | |
| 138 | + | "#, | |
| 139 | + | ) | |
| 140 | + | .bind(user_id) | |
| 141 | + | .bind(item_id) | |
| 142 | + | .fetch_optional(pool) | |
| 143 | + | .await?; | |
| 144 | + | ||
| 145 | + | Ok(row.map(|(id,)| id)) | |
| 146 | + | } | |
| 147 | + | ||
| 100 | 148 | /// Fetch a version by primary key. Returns `None` if not found. | |
| 101 | 149 | #[tracing::instrument(skip_all)] | |
| 102 | 150 | pub async fn get_version_by_id(pool: &PgPool, version_id: VersionId) -> Result<Option<DbVersion>> { |
| @@ -185,6 +185,7 @@ async fn main() { | |||
| 185 | 185 | ], | |
| 186 | 186 | link_prefix: "/docs".to_string(), | |
| 187 | 187 | unpublished_pattern: Some("unpublished/".to_string()), | |
| 188 | + | examples_path: Some(std::path::Path::new(&docs_path).join("../examples").into()), | |
| 188 | 189 | }, | |
| 189 | 190 | )); | |
| 190 | 191 |