max / makenotwork
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 %} / ∞{% endif %} | |
| 32 | + | </button> | |
| 33 | + | {% else %} | |
| 27 | 34 | {{ code.use_count }}{% if let Some(max) = code.max_uses %} / {{ max }}{% else %} / ∞{% 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()">×</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, '&').replace(/</g, '<').replace(/>/g, '>') | |
| 214 | + | .replace(/"/g, '"').replace(/'/g, '''); | |
| 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() { |