Skip to main content

max / makenotwork

Add admin dashboard page for creator-tier comp codes Move comp-code minting from a raw API route into the admin dashboard: - /admin/comp-codes page: a mint form (code named after the recipient, trial days, optional max-uses and expiry) plus a status table listing every comp code with uses, expiry, and a derived status (Unused / Partially used / Redeemed / Expired). For a one-use code the status shows whether that recipient has redeemed. - Minting refreshes the list inline (HTMX); blank max-uses/expiry fields are treated as unlimited/never. - New admin nav tab; handler moved to routes/admin/comp_codes.rs. - db::promo_codes::get_platform_trial_codes lists the codes; AdminCompCodeRow formats them for display. - 2 new integration tests (dashboard lists codes; mint refreshes the list), on top of the existing mint + redemption coverage. 6 total, all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 04:43 UTC
Commit: fc71c2fee711c13ec10116c85555badf30e9340c
Parent: 33f7408
11 files changed, +321 insertions, -73 deletions
@@ -4291,7 +4291,7 @@ dependencies = [
4291 4291
4292 4292 [[package]]
4293 4293 name = "makenotwork"
4294 - version = "0.10.3"
4294 + version = "0.10.4"
4295 4295 dependencies = [
4296 4296 "ammonia",
4297 4297 "anyhow",
@@ -476,6 +476,21 @@ pub async fn get_platform_trial_code_by_code(
476 476 Ok(promo_code)
477 477 }
478 478
479 + /// List all creator-tier comp codes (platform-wide free-trial), newest first.
480 + /// Powers the admin comp-codes dashboard. Capped at 500.
481 + #[tracing::instrument(skip_all)]
482 + pub async fn get_platform_trial_codes(pool: &PgPool) -> Result<Vec<DbPromoCode>> {
483 + let codes = sqlx::query_as::<_, DbPromoCode>(
484 + "SELECT * FROM promo_codes \
485 + WHERE code_purpose = 'free_trial' AND is_platform_wide = true \
486 + ORDER BY created_at DESC LIMIT 500",
487 + )
488 + .fetch_all(pool)
489 + .await?;
490 +
491 + Ok(codes)
492 + }
493 +
479 494 /// Apply a discount to a price, returning the discounted price in cents (minimum 0).
480 495 /// Negative discount values are clamped to 0 to prevent price increases.
481 496 #[tracing::instrument(skip_all)]
@@ -0,0 +1,118 @@
1 + //! Admin comp-codes dashboard: mint creator-tier comp codes and monitor status.
2 + //!
3 + //! A comp code is a platform-wide free-trial promo code redeemable at
4 + //! creator-tier checkout. Codes are named after their recipient (e.g.
5 + //! `ALPHA-JAMIE`); for a one-use code the status column shows whether that
6 + //! recipient has redeemed. See `meta/creator_invite_checklist.md`.
7 +
8 + use axum::{
9 + extract::State,
10 + response::{IntoResponse, Response},
11 + Form,
12 + };
13 + use serde::Deserialize;
14 +
15 + use crate::{
16 + auth::AdminUser,
17 + db,
18 + error::{AppError, Result},
19 + helpers::get_csrf_token,
20 + templates::*,
21 + types::*,
22 + AppState,
23 + };
24 +
25 + /// Render the comp-codes dashboard: mint form plus a status list.
26 + #[tracing::instrument(skip_all, name = "admin::admin_comp_codes")]
27 + pub(super) async fn admin_comp_codes(
28 + State(state): State<AppState>,
29 + session: tower_sessions::Session,
30 + AdminUser(user): AdminUser,
31 + ) -> Result<impl IntoResponse> {
32 + let csrf_token = get_csrf_token(&session).await;
33 + let comp_codes = load_comp_code_rows(&state).await?;
34 + Ok(AdminCompCodesTemplate {
35 + csrf_token,
36 + session_user: Some(user),
37 + admin_active_page: "comp-codes",
38 + comp_codes,
39 + })
40 + }
41 +
42 + /// Form for minting a creator-tier comp code.
43 + #[derive(Debug, Deserialize)]
44 + pub(super) struct CompCodeForm {
45 + code: String,
46 + trial_days: i32,
47 + /// Cap on redemptions (omit/blank for unlimited).
48 + #[serde(default, deserialize_with = "empty_string_as_none")]
49 + max_uses: Option<i32>,
50 + /// Days from now until the code expires (omit/blank for never).
51 + #[serde(default, deserialize_with = "empty_string_as_none")]
52 + expires_in_days: Option<i64>,
53 + }
54 +
55 + /// Treat an empty form field as `None` (HTML forms post "" for blank numbers).
56 + fn empty_string_as_none<'de, D, T>(de: D) -> std::result::Result<Option<T>, D::Error>
57 + where
58 + D: serde::Deserializer<'de>,
59 + T: std::str::FromStr,
60 + T::Err: std::fmt::Display,
61 + {
62 + let opt = Option::<String>::deserialize(de)?;
63 + match opt.as_deref().map(str::trim) {
64 + None | Some("") => Ok(None),
65 + Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
66 + }
67 + }
68 +
69 + /// Mint a platform-wide free-trial code redeemable at creator-tier checkout.
70 + ///
71 + /// The redeemer gets `trial_days` free with no card collected up front, after
72 + /// which the subscription rolls to the price chosen at checkout (the founder
73 + /// price while the founder window is open). Owned by the minting admin for
74 + /// audit, redeemable by any holder — control distribution via `max_uses` /
75 + /// `expires_in_days`. On success the comp-codes table is re-rendered so the
76 + /// new code appears immediately.
77 + #[tracing::instrument(skip_all, name = "admin::admin_create_comp_code")]
78 + pub(super) async fn admin_create_comp_code(
79 + State(state): State<AppState>,
80 + AdminUser(admin): AdminUser,
81 + Form(form): Form<CompCodeForm>,
82 + ) -> Result<Response> {
83 + let code = form.code.trim().to_uppercase();
84 + if code.is_empty() {
85 + return Err(AppError::BadRequest("Code is required".to_string()));
86 + }
87 + if form.trial_days <= 0 {
88 + return Err(AppError::BadRequest("Trial days must be positive".to_string()));
89 + }
90 + let expires_at = form
91 + .expires_in_days
92 + .map(|d| chrono::Utc::now() + chrono::Duration::days(d));
93 +
94 + db::promo_codes::create_platform_promo_code(
95 + &state.db,
96 + admin.id,
97 + &code,
98 + db::CodePurpose::FreeTrial,
99 + None, // discount_type
100 + None, // discount_value
101 + 0, // min_price_cents (unused for trials)
102 + Some(form.trial_days),
103 + form.max_uses,
104 + expires_at,
105 + )
106 + .await?;
107 +
108 + tracing::info!(code = %code, trial_days = form.trial_days, "minted creator-tier comp code");
109 +
110 + let comp_codes = load_comp_code_rows(&state).await?;
111 + Ok(AdminCompCodesEntriesTemplate { comp_codes }.into_response())
112 + }
113 +
114 + /// Load and format the comp-code rows for the dashboard.
115 + async fn load_comp_code_rows(state: &AppState) -> Result<Vec<AdminCompCodeRow>> {
116 + let codes = db::promo_codes::get_platform_trial_codes(&state.db).await?;
117 + Ok(codes.iter().map(AdminCompCodeRow::from_db).collect())
118 + }
@@ -1,5 +1,6 @@
1 1 //! Admin routes for creator waitlist management, user moderation, and platform operations.
2 2
3 + mod comp_codes;
3 4 mod moderation;
4 5 mod signups;
5 6 mod uploads;
@@ -75,7 +76,8 @@ pub fn admin_routes() -> CsrfRouter<AppState> {
75 76 .route("/api/admin/shutdown-notice", post_csrf(admin_shutdown_notice))
76 77 // Founder pricing
77 78 .route("/api/admin/founder-window/close", post_csrf(admin_close_founder_window))
78 - .route("/api/admin/comp-codes/create", post_csrf(admin_create_comp_code))
79 + .route_get("/admin/comp-codes", get(comp_codes::admin_comp_codes))
80 + .route("/api/admin/comp-codes/create", post_csrf(comp_codes::admin_create_comp_code))
79 81 // Metrics
80 82 .route_get("/admin/metrics", get(admin_metrics))
81 83 }
@@ -252,77 +254,6 @@ async fn admin_close_founder_window(
252 254 ).into_response())
253 255 }
254 256
255 - // ── Comp codes ──
256 -
257 - /// Form for minting a creator-tier comp code.
258 - #[derive(Debug, Deserialize)]
259 - struct CompCodeForm {
260 - code: String,
261 - trial_days: i32,
262 - /// Cap on redemptions (omit for unlimited).
263 - #[serde(default)]
264 - max_uses: Option<i32>,
265 - /// Days from now until the code expires (omit for never).
266 - #[serde(default)]
267 - expires_in_days: Option<i64>,
268 - }
269 -
270 - /// Mint a platform-wide free-trial code redeemable at creator-tier checkout.
271 - ///
272 - /// Used for operator comps (e.g. alpha-tester 6-month grants): the redeemer
273 - /// gets `trial_days` free with no card collected up front, after which the
274 - /// subscription rolls to the price chosen at checkout — the founder price while
275 - /// the founder window is open. The code is owned by the minting admin for audit
276 - /// but is redeemable by any holder; control distribution via `max_uses` and
277 - /// `expires_in_days`. See `meta/creator_invite_checklist.md`.
278 - #[tracing::instrument(skip_all, name = "admin::admin_create_comp_code")]
279 - async fn admin_create_comp_code(
280 - State(state): State<AppState>,
281 - AdminUser(admin): AdminUser,
282 - Form(form): Form<CompCodeForm>,
283 - ) -> Result<Response> {
284 - let code = form.code.trim().to_uppercase();
285 - if code.is_empty() {
286 - return Err(AppError::BadRequest("Code is required".to_string()));
287 - }
288 - if form.trial_days <= 0 {
289 - return Err(AppError::BadRequest("trial_days must be positive".to_string()));
290 - }
291 - let expires_at = form
292 - .expires_in_days
293 - .map(|d| chrono::Utc::now() + chrono::Duration::days(d));
294 -
295 - let pc = db::promo_codes::create_platform_promo_code(
296 - &state.db,
297 - admin.id,
298 - &code,
299 - db::CodePurpose::FreeTrial,
300 - None, // discount_type
301 - None, // discount_value
302 - 0, // min_price_cents (unused for trials)
303 - Some(form.trial_days),
304 - form.max_uses,
305 - expires_at,
306 - )
307 - .await?;
308 -
309 - tracing::info!(code = %pc.code, trial_days = form.trial_days, "minted creator-tier comp code");
310 - Ok((
311 - axum::http::StatusCode::OK,
312 - format!(
313 - "Comp code '{}' created: {} trial day{}{}.",
314 - pc.code,
315 - form.trial_days,
316 - if form.trial_days == 1 { "" } else { "s" },
317 - match form.max_uses {
318 - Some(m) => format!(", max {m} use(s)"),
319 - None => String::new(),
320 - }
321 - ),
322 - )
323 - .into_response())
324 - }
325 -
326 257 // ── Metrics ──
327 258
328 259 /// Render the admin metrics dashboard with live Prometheus data.
@@ -199,6 +199,23 @@ pub struct AdminMetricsTemplate {
199 199 pub error_breakdown: Vec<ErrorMetric>,
200 200 }
201 201
202 + /// Admin comp-codes dashboard page (mint form + status list).
203 + #[derive(Template)]
204 + #[template(path = "dashboards/admin-comp-codes.html")]
205 + pub struct AdminCompCodesTemplate {
206 + pub csrf_token: CsrfTokenOption,
207 + pub session_user: Option<SessionUser>,
208 + pub admin_active_page: &'static str,
209 + pub comp_codes: Vec<AdminCompCodeRow>,
210 + }
211 +
212 + /// The comp-codes table body, re-rendered after a mint to refresh the list.
213 + #[derive(Template)]
214 + #[template(path = "partials/admin_comp_codes_entries.html")]
215 + pub struct AdminCompCodesEntriesTemplate {
216 + pub comp_codes: Vec<AdminCompCodeRow>,
217 + }
218 +
202 219 /// A row in the top routes table.
203 220 pub struct RouteMetric {
204 221 pub method: String,
@@ -172,6 +172,7 @@ impl_into_response!(
172 172 AdminReportsTemplate,
173 173 AdminSignupsTemplate,
174 174 AdminMetricsTemplate,
175 + AdminCompCodesTemplate,
175 176 // Export, import & account management
176 177 ExportPortalTemplate,
177 178 ImportPortalTemplate,
@@ -204,6 +205,7 @@ impl_into_response!(
204 205 ItemEditRowTemplate,
205 206 // Admin partials
206 207 AdminWaitlistEntriesTemplate,
208 + AdminCompCodesEntriesTemplate,
207 209 AdminUserEntriesTemplate,
208 210 AdminUploadEntriesTemplate,
209 211 AdminQueueSummaryTemplate,
@@ -19,6 +19,59 @@ pub struct AdminWaitlistRow {
19 19 pub invited_by_username: Option<String>,
20 20 }
21 21
22 + /// Admin view of a creator-tier comp code (platform-wide free-trial).
23 + ///
24 + /// The code string doubles as the recipient label — codes are named after the
25 + /// person they're minted for (e.g. `ALPHA-JAMIE`), so for a one-use code the
26 + /// status column tells you whether that recipient has redeemed yet.
27 + #[derive(Clone)]
28 + #[allow(dead_code)] // Fields used by Askama templates
29 + pub struct AdminCompCodeRow {
30 + pub code: String,
31 + pub trial_label: String,
32 + pub uses_label: String,
33 + pub status: String,
34 + pub created_at: String,
35 + pub expires_at: String,
36 + }
37 +
38 + impl AdminCompCodeRow {
39 + pub fn from_db(c: &crate::db::DbPromoCode) -> Self {
40 + let now = chrono::Utc::now();
41 + let is_expired = c.expires_at.is_some_and(|e| e < now);
42 + let is_used_up = c.max_uses.is_some_and(|m| c.use_count >= m);
43 + let status = if is_expired {
44 + "Expired"
45 + } else if is_used_up {
46 + "Redeemed"
47 + } else if c.use_count > 0 {
48 + "Partially used"
49 + } else {
50 + "Unused"
51 + }
52 + .to_string();
53 + let uses_label = match c.max_uses {
54 + Some(m) => format!("{} / {}", c.use_count, m),
55 + None => format!("{} / unlimited", c.use_count),
56 + };
57 + let trial_label = match c.trial_days {
58 + Some(d) => format!("{d} days"),
59 + None => "none".to_string(),
60 + };
61 + Self {
62 + code: c.code.clone(),
63 + trial_label,
64 + uses_label,
65 + status,
66 + created_at: c.created_at.format(DATE_FMT_FULL).to_string(),
67 + expires_at: c
68 + .expires_at
69 + .map(|e| e.format(DATE_FMT_FULL).to_string())
70 + .unwrap_or_else(|| "never".to_string()),
71 + }
72 + }
73 + }
74 +
22 75 /// Admin view of an email signup (landing page notify-me)
23 76 #[derive(Clone)]
24 77 #[allow(dead_code)] // Fields used by Askama templates
@@ -0,0 +1,50 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}Admin: Comp codes - Makenot.work{% endblock %}
4 + {% block body_attrs %} class="padded-page admin-page"{% endblock %}
5 +
6 + {% block head %}
7 + {% endblock %}
8 +
9 + {% block content %}
10 + {% include "partials/site_header.html" %}
11 +
12 + <div class="container">
13 + {% include "partials/admin_nav.html" %}
14 +
15 + <h1 class="page-title">Comp codes</h1>
16 +
17 + <p class="text-sm dimmed">
18 + Mint a free-trial code redeemable at creator-tier checkout. Name it after the
19 + recipient (for example ALPHA-JAMIE) so the status below tells you whether that
20 + person has redeemed. No card is collected up front; after the trial the
21 + subscription rolls to the founder price only if the recipient opts in.
22 + </p>
23 +
24 + <div class="lottery-form">
25 + <form hx-post="/api/admin/comp-codes/create" hx-target="#comp-codes-table" hx-swap="innerHTML">
26 + <div class="form-group">
27 + <label for="cc-code">Code (name after recipient)</label>
28 + <input type="text" id="cc-code" name="code" placeholder="ALPHA-JAMIE" required>
29 + </div>
30 + <div class="form-group">
31 + <label for="cc-trial-days">Trial days</label>
32 + <input type="number" id="cc-trial-days" name="trial_days" min="1" max="3650" value="180">
33 + </div>
34 + <div class="form-group">
35 + <label for="cc-max-uses">Max uses (blank for unlimited)</label>
36 + <input type="number" id="cc-max-uses" name="max_uses" min="1" value="1">
37 + </div>
38 + <div class="form-group">
39 + <label for="cc-expires">Expires in days (blank for never)</label>
40 + <input type="number" id="cc-expires" name="expires_in_days" min="1">
41 + </div>
42 + <button type="submit" class="btn-primary">Mint code</button>
43 + </form>
44 + </div>
45 +
46 + <div id="comp-codes-table">
47 + {% include "partials/admin_comp_codes_entries.html" %}
48 + </div>
49 + </div>
50 + {% endblock %}
@@ -0,0 +1,31 @@
1 + {%- import "partials/_ui.html" as ui -%}
2 + {% if comp_codes.is_empty() %}
3 + {% call ui::empty_state("", "No comp codes yet.") %}
4 + {% else %}
5 + <div class="scroll-x">
6 + <table class="compact-table minw-700" aria-label="Comp codes">
7 + <thead>
8 + <tr>
9 + <th>Code (recipient)</th>
10 + <th>Trial</th>
11 + <th>Uses</th>
12 + <th>Status</th>
13 + <th>Expires</th>
14 + <th>Created</th>
15 + </tr>
16 + </thead>
17 + <tbody>
18 + {% for cc in comp_codes %}
19 + <tr>
20 + <td>{{ cc.code }}</td>
21 + <td class="text-sm nowrap">{{ cc.trial_label }}</td>
22 + <td class="text-sm nowrap">{{ cc.uses_label }}</td>
23 + <td><span class="badge">{{ cc.status }}</span></td>
24 + <td class="text-sm nowrap">{{ cc.expires_at }}</td>
25 + <td class="text-sm nowrap">{{ cc.created_at }}</td>
26 + </tr>
27 + {% endfor %}
28 + </tbody>
29 + </table>
30 + </div>
31 + {% endif %}
@@ -6,4 +6,5 @@
6 6 <a href="/admin/reports" class="{% if admin_active_page == "reports" %}primary{% else %}secondary{% endif %}">Reports</a>
7 7 <a href="/admin/signups" class="{% if admin_active_page == "signups" %}primary{% else %}secondary{% endif %}">Signups</a>
8 8 <a href="/admin/metrics" class="{% if admin_active_page == "metrics" %}primary{% else %}secondary{% endif %}">Metrics</a>
9 + <a href="/admin/comp-codes" class="{% if admin_active_page == "comp-codes" %}primary{% else %}secondary{% endif %}">Comp codes</a>
9 10 </nav>
@@ -36,6 +36,36 @@ async fn admin_mints_creator_tier_comp_code() {
36 36 assert!(platform, "comp code must be platform-wide so it resolves at creator-tier checkout");
37 37 assert_eq!(trial_days, Some(180));
38 38 assert_eq!(max_uses, Some(10));
39 +
40 + // The mint response is the refreshed list partial, so the new code shows up.
41 + assert!(
42 + resp.text.contains("ALPHA6MO"),
43 + "mint response should re-render the list with the new code: {}",
44 + resp.text
45 + );
46 + }
47 +
48 + #[tokio::test]
49 + async fn comp_codes_dashboard_lists_codes() {
50 + let (mut h, admin_id) = TestHarness::with_admin().await;
51 + h.login("admin", "password123").await;
52 +
53 + sqlx::query(
54 + "INSERT INTO promo_codes \
55 + (creator_id, code, code_purpose, min_price_cents, trial_days, max_uses, is_platform_wide) \
56 + VALUES ($1, 'DASH-JAMIE', 'free_trial', 0, 180, 1, true)",
57 + )
58 + .bind(*admin_id)
59 + .execute(&h.db)
60 + .await
61 + .expect("seed comp code");
62 +
63 + let resp = h.client.get("/admin/comp-codes").await;
64 + assert!(resp.status.is_success(), "page should render: {} {}", resp.status, resp.text);
65 + assert!(resp.text.contains("Comp codes"), "page should have the heading");
66 + assert!(resp.text.contains("DASH-JAMIE"), "page should list the seeded code");
67 + // One-use code that hasn't been redeemed reads as Unused.
68 + assert!(resp.text.contains("Unused"), "an unredeemed code should show status Unused");
39 69 }
40 70
41 71 #[tokio::test]