max / makenotwork
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] |