Skip to main content

max / makenotwork

phase 3: trial presets, promo redemption tracking, scan-flag surfacing Three smaller Phase 3 items. The five large ones (background uploads, browser multipart, desktop/CLI bulk, resumable, batch ZIP) need their own dedicated effort. Trial day presets - The `trial_days` number input in the project promotions tab is now a select with 7/14/30/60/90 + Custom. Custom toggles a number input back. Submitted form field stays `trial_days` so the server-side contract is unchanged. Promo redemption tracking - New `db::promo_codes::list_redemptions(id)` joins transactions → users (or guest_email fallback) and returns up to 500 rows of (redeemed_at, who, item, amount). - New `GET /api/promo-codes/{id}/redemptions` endpoint, auth-gated to the code's owning creator. Codes that hit the 500 cap need a CSV export flow — not yet built. - The use-count column in the promo list is now a clickable button (when count > 0) that opens a modal with the redemption list. Renders client-side from JSON to keep the page-render path free of per-promo joins. Scan-flag surfacing - The confirm endpoints already returned `pending_review: Some(true)` when the scan held the file; no UI consumed it. Both upload paths now read it: - `item-upload.js`: toast on audio/video confirm and on both batch-version and single-version confirm. - `wizards/steps/item/content.html`: inline warning on the content-step audio/video upload + toast on the batch uploadFile / uploadVersion flows. Creators now see "held for review" feedback at upload time instead of only discovering it when their item doesn't go public.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 14:22 UTC
Commit: 8c8e4ca1744650a2a2545746e7065e47f7246f9c
Parent: da13a0c
7 files changed, +222 insertions, -3 deletions
@@ -287,6 +287,56 @@ pub async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> {
287 287 Ok(())
288 288 }
289 289
290 + /// A single row in the "who redeemed this code" view.
291 + ///
292 + /// `display_name` / `username` are `None` for guest checkouts; `guest_email`
293 + /// fills that gap. `item_title` is denormalized on the transaction row so
294 + /// renaming an item later doesn't strand the audit trail.
295 + #[derive(Debug, sqlx::FromRow, serde::Serialize)]
296 + pub struct PromoRedemption {
297 + pub redeemed_at: chrono::DateTime<chrono::Utc>,
298 + pub display_name: Option<String>,
299 + pub username: Option<String>,
300 + pub guest_email: Option<String>,
301 + pub item_title: Option<String>,
302 + pub amount_cents: i32,
303 + }
304 +
305 + /// List redemptions of a single promo code, newest first.
306 + ///
307 + /// Joins through to `users` for buyer identity but falls back to the
308 + /// transaction's `guest_email` for unauthenticated checkouts. Capped at 500
309 + /// rows — promo codes that exceed that bound are an outlier worth its own
310 + /// CSV-export flow rather than a paginated UI.
311 + #[tracing::instrument(skip_all)]
312 + pub async fn list_redemptions(
313 + pool: &PgPool,
314 + id: PromoCodeId,
315 + ) -> Result<Vec<PromoRedemption>> {
316 + let rows = sqlx::query_as::<_, PromoRedemption>(
317 + r#"
318 + SELECT
319 + COALESCE(t.completed_at, t.created_at) AS redeemed_at,
320 + u.display_name AS display_name,
321 + u.username AS username,
322 + t.guest_email AS guest_email,
323 + t.item_title AS item_title,
324 + t.amount_cents AS amount_cents
325 + FROM transactions t
326 + LEFT JOIN users u ON u.id = t.buyer_id
327 + WHERE t.promo_code_id = $1
328 + AND t.status = 'completed'
329 + ORDER BY redeemed_at DESC
330 + LIMIT 500
331 + "#,
332 + )
333 + .bind(id)
334 + .fetch_all(pool)
335 + .await?;
336 +
337 + Ok(rows)
338 + }
339 +
290 340 /// Create a platform-wide promo code (used for Fan+ monthly credits).
291 341 ///
292 342 /// Same as `create_promo_code` but sets `is_platform_wide = true`.
@@ -283,6 +283,7 @@ pub fn api_routes() -> Router<AppState> {
283 283 .route("/api/promo-codes/expired", delete(promo_codes::delete_expired_promo_codes))
284 284 .route("/api/promo-codes/{id}", put(promo_codes::update_promo_code))
285 285 .route("/api/promo-codes/{id}", delete(promo_codes::delete_promo_code))
286 + .route("/api/promo-codes/{id}/redemptions", get(promo_codes::list_redemptions))
286 287 // Promo code claim (buyer — free_access codes)
287 288 .route("/api/promo-codes/claim", post(promo_codes::claim_promo_code))
288 289 // Subscription tier management (creator)
@@ -337,6 +337,31 @@ pub(super) async fn list_promo_codes(
337 337 Ok(Json(ListResponse { data }).into_response())
338 338 }
339 339
340 + /// List redemptions of a promo code (creator dashboard).
341 + ///
342 + /// Authenticated; the caller must own the code. Returns at most 500 rows of
343 + /// `(redeemed_at, buyer, item, amount)`; guest checkouts surface as the
344 + /// guest's email with `username = None`. Codes that exceed 500 redemptions
345 + /// should be exported via the CSV flow (separate endpoint, not built yet —
346 + /// log a TODO if you hit it).
347 + #[tracing::instrument(skip_all, name = "promo_codes::list_redemptions")]
348 + pub(super) async fn list_redemptions(
349 + State(state): State<AppState>,
350 + AuthUser(user): AuthUser,
351 + Path(code_id): Path<PromoCodeId>,
352 + ) -> Result<Response> {
353 + let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
354 + .await?
355 + .ok_or(AppError::NotFound)?;
356 +
357 + if promo_code.creator_id != user.id {
358 + return Err(AppError::Forbidden);
359 + }
360 +
361 + let rows = db::promo_codes::list_redemptions(&state.db, code_id).await?;
362 + Ok(Json(serde_json::json!({ "redemptions": rows })).into_response())
363 + }
364 +
340 365 /// Delete a promo code.
341 366 #[tracing::instrument(skip_all, name = "promo_codes::delete_promo_code")]
342 367 pub(super) async fn delete_promo_code(
@@ -116,8 +116,20 @@
116 116 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
117 117 throw new Error(d.error || 'Failed to confirm upload');
118 118 });
119 + return res.json().catch(function() { return {}; });
120 + })
121 + .then(function(result) {
119 122 document.getElementById('upload-progress').classList.add('hidden');
120 123 document.getElementById('upload-success').classList.remove('hidden');
124 + // Scan flagged the file for manual review — surface it as a
125 + // toast so the creator knows their content isn't public yet.
126 + if (result && result.pending_review) {
127 + showToast(
128 + 'Upload accepted but held for review — our scanner flagged it. ' +
129 + 'You’ll get an email once it’s cleared.',
130 + 'warning'
131 + );
132 + }
121 133 setTimeout(function() { window.location.href = '/dashboard/item/' + itemId + '#tab-files'; }, 1500);
122 134 })
123 135 .catch(function(err) {
@@ -306,6 +318,12 @@
306 318 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
307 319 throw new Error(d.error || 'Failed to confirm upload');
308 320 });
321 + return res.json().catch(function() { return {}; });
322 + })
323 + .then(function(confirmData) {
324 + if (confirmData && confirmData.pending_review) {
325 + showToast('Version upload held for review — our scanner flagged it.', 'warning');
326 + }
309 327 updateQueueStatus(entry.idx, 'done');
310 328 uploadSequentially(entries, i + 1, versionNumber, changelog);
311 329 })
@@ -350,8 +368,14 @@
350 368 })
351 369 .then(function(res) {
352 370 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Confirm failed'); });
371 + return res.json().catch(function() { return {}; });
372 + })
373 + .then(function(confirmData) {
353 374 document.getElementById('version-upload-progress').classList.add('hidden');
354 375 document.getElementById('version-upload-success').classList.remove('hidden');
376 + if (confirmData && confirmData.pending_review) {
377 + showToast('Version upload held for review — our scanner flagged it.', 'warning');
378 + }
355 379 setTimeout(function() {
356 380 var filesBtn = document.getElementById('tab-files');
357 381 if (filesBtn) filesBtn.click();
@@ -24,7 +24,15 @@
24 24 </td>
25 25 <td>{{ code.description }}</td>
26 26 <td>
27 + {% if code.use_count > 0 %}
28 + <button class="btn-link btn-sm" type="button"
29 + onclick="showPromoRedemptions('{{ code.id }}', '{{ code.code }}')"
30 + title="Show who redeemed this code">
31 + {{ code.use_count }}{% if let Some(max) = code.max_uses %} / {{ max }}{% else %} / &infin;{% endif %}
32 + </button>
33 + {% else %}
27 34 {{ code.use_count }}{% if let Some(max) = code.max_uses %} / {{ max }}{% else %} / &infin;{% endif %}
35 + {% endif %}
28 36 </td>
29 37 <td>
30 38 {% if let Some(title) = code.item_title %}
@@ -49,8 +49,18 @@
49 49
50 50 <div id="trial-fields" class="proj-promo-trial-fields">
51 51 <div class="form-group proj-promo-trial-group">
52 - <label for="pc-trial-days">Trial days</label>
53 - <input type="number" id="pc-trial-days" name="trial_days" min="1" placeholder="e.g. 14">
52 + <label for="pc-trial-preset">Trial length</label>
53 + <select id="pc-trial-preset" onchange="onTrialPresetChange()">
54 + <option value="7">7 days</option>
55 + <option value="14" selected>14 days</option>
56 + <option value="30">30 days</option>
57 + <option value="60">60 days</option>
58 + <option value="90">90 days</option>
59 + <option value="custom">Custom...</option>
60 + </select>
61 + <input type="number" id="pc-trial-days" name="trial_days" min="1" value="14" hidden>
62 + <input type="number" id="pc-trial-days-custom" min="1" placeholder="e.g. 21" hidden
63 + oninput="document.getElementById('pc-trial-days').value = this.value">
54 64 </div>
55 65 </div>
56 66
@@ -136,4 +146,90 @@ function togglePromoFields(purpose) {
136 146 codeInput.placeholder = 'Auto-generated';
137 147 }
138 148 }
149 +
150 + /// Show a modal listing redemptions of a single promo code. Fetched on
151 + /// demand so the page-render path doesn't pay for it. Guest checkouts
152 + /// surface as the guest's email; missing item titles render as "—".
153 + function showPromoRedemptions(codeId, codeLabel) {
154 + var existing = document.getElementById('promo-redemptions-modal');
155 + if (existing) existing.remove();
156 +
157 + var overlay = document.createElement('div');
158 + overlay.id = 'promo-redemptions-modal';
159 + overlay.className = 'modal-overlay';
160 + overlay.style.display = 'flex';
161 + overlay.onclick = function (e) { if (e.target === overlay) overlay.remove(); };
162 +
163 + var content = document.createElement('div');
164 + content.className = 'modal-content';
165 + content.style.maxWidth = '640px';
166 + content.style.padding = '2rem';
167 + content.innerHTML =
168 + '<div class="modal-header" style="margin-bottom: 1rem;">'
169 + + '<h2>Redemptions: <code>' + codeLabel + '</code></h2>'
170 + + '<button type="button" class="modal-close" aria-label="Dismiss"'
171 + + ' onclick="document.getElementById(\'promo-redemptions-modal\').remove()">&times;</button>'
172 + + '</div>'
173 + + '<div id="promo-redemptions-body" class="muted">Loading…</div>';
174 + overlay.appendChild(content);
175 + document.body.appendChild(overlay);
176 +
177 + fetch('/api/promo-codes/' + codeId + '/redemptions', { credentials: 'same-origin' })
178 + .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
179 + .then(function (data) {
180 + var body = document.getElementById('promo-redemptions-body');
181 + if (!body) return;
182 + var rows = (data && data.redemptions) || [];
183 + if (rows.length === 0) {
184 + body.textContent = 'No redemptions yet.';
185 + return;
186 + }
187 + var html = '<table class="data-table" style="width:100%; font-size: 0.9rem;">'
188 + + '<thead><tr><th>When</th><th>Who</th><th>Item</th><th>Paid</th></tr></thead><tbody>';
189 + for (var i = 0; i < rows.length; i++) {
190 + var r = rows[i];
191 + var when = new Date(r.redeemed_at).toLocaleDateString();
192 + var who = r.username
193 + ? '@' + escapeHtml(r.username)
194 + : (r.guest_email ? escapeHtml(r.guest_email) + ' (guest)' : '—');
195 + var item = r.item_title ? escapeHtml(r.item_title) : '—';
196 + var paid = r.amount_cents === 0 ? 'Free' : '$' + (r.amount_cents / 100).toFixed(2);
197 + html += '<tr><td>' + when + '</td><td>' + who + '</td><td>' + item + '</td><td>' + paid + '</td></tr>';
198 + }
199 + html += '</tbody></table>';
200 + if (rows.length === 500) {
201 + html += '<p class="muted" style="margin-top: 0.75rem;">Showing the 500 most recent redemptions.</p>';
202 + }
203 + body.innerHTML = html;
204 + })
205 + .catch(function () {
206 + var body = document.getElementById('promo-redemptions-body');
207 + if (body) body.textContent = 'Could not load redemptions.';
208 + });
209 + }
210 +
211 + function escapeHtml(s) {
212 + return String(s)
213 + .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
214 + .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
215 + }
216 +
217 + /// Switch between preset trial lengths and a custom number input. The
218 + /// hidden #pc-trial-days carries the actual submitted value so we can keep
219 + /// the server-side contract (a single `trial_days` field) untouched.
220 + function onTrialPresetChange() {
221 + var preset = document.getElementById('pc-trial-preset');
222 + var hidden = document.getElementById('pc-trial-days');
223 + var custom = document.getElementById('pc-trial-days-custom');
224 + if (preset.value === 'custom') {
225 + custom.hidden = false;
226 + custom.required = true;
227 + custom.focus();
228 + hidden.value = custom.value || '';
229 + } else {
230 + custom.hidden = true;
231 + custom.required = false;
232 + hidden.value = preset.value;
233 + }
234 + }
139 235 </script>
@@ -105,7 +105,14 @@
105 105 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
106 106 throw new Error(d.error || 'Failed to confirm upload');
107 107 });
108 - statusEl.innerHTML = '<div class="upload-status-msg is-success">Upload complete</div>';
108 + return res.json().catch(function() { return {}; });
109 + })
110 + .then(function(result) {
111 + if (result && result.pending_review) {
112 + statusEl.innerHTML = '<div class="upload-status-msg is-warning">Upload accepted but held for review — our scanner flagged it.</div>';
113 + } else {
114 + statusEl.innerHTML = '<div class="upload-status-msg is-success">Upload complete</div>';
115 + }
109 116 })
110 117 .catch(function(err) {
111 118 statusEl.innerHTML = '<div class="upload-status-msg is-error">' + (err.message || 'Upload failed') + '</div>';
@@ -335,6 +342,10 @@
335 342 body: JSON.stringify({ item_id: itemId, file_type: 'audio', s3_key: presignData.s3_key })
336 343 });
337 344 if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
345 + var confirmData = await confirmRes.json().catch(function() { return {}; });
346 + if (confirmData && confirmData.pending_review) {
347 + showToast('Upload held for review — our scanner flagged it.', 'warning');
348 + }
338 349 }
339 350
340 351 async function uploadVersion(entry, itemId, progressEls) {
@@ -366,6 +377,10 @@
366 377 body: JSON.stringify({ s3_key: presignData.s3_key, file_size_bytes: file.size })
367 378 });
368 379 if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
380 + var confirmData = await confirmRes.json().catch(function() { return {}; });
381 + if (confirmData && confirmData.pending_review) {
382 + showToast('Version upload held for review — our scanner flagged it.', 'warning');
383 + }
369 384 }
370 385
371 386 async function uploadAll() {