Skip to main content

max / makenotwork

Sprints 7-9: collections, discovery, docs, UI examples Sprint 7 — Collections Everywhere: - Shared collections.js picker (item page, discover cards, library) - "Saved (N)" button state with toast feedback - Save buttons on discover list/grid views (auth-gated) Sprint 8 — Discovery Improvements: - AI tier filter in discover sidebar with facet counts - Search suggestions/autocomplete (tags, projects, creators) - "Has source code" checkbox filter for projects - User download tracking (migration 100) with "New" badge in library Sprint 9 — Documentation: - Updated 6 doc files against feature map: collections, promo codes, git, license keys, SyncKit, FAQ - Fixed wrong limits, added missing features, expanded FAQ by role DocEngine [!UI] directive: - New directive converts > [!UI] name to placeholder figure elements - Doc loader resolves placeholders from site-docs/examples/*.html - Proof of concept: discover-filters and collection-picker examples - CSS: .doc-ui frame with pointer-events:none and figcaption Audit remediations: - Fixed XSS in search suggestion category (unescaped innerHTML) - Fixed XSS in collection picker (inline onsubmit → addEventListener) - Fixed a11y: span onclick → button with aria-label on discover cards - Removed unused import (cart.rs) and dead code (app_sync.rs) - 0 warnings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-08 18:32 UTC
Commit: 942a6c1a53cb7c3aeb4a5df02b5a0f4b728327bc
Parent: 67b770c
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