Skip to main content

max / makenotwork

4.1 KB · 122 lines History Blame Raw
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 Form,
10 extract::State,
11 response::{IntoResponse, Response},
12 };
13 use serde::Deserialize;
14
15 use sqlx::PgPool;
16
17 use crate::{
18 auth::AdminUser,
19 db,
20 error::{AppError, Result},
21 helpers::get_csrf_token,
22 templates::{AdminCompCodesEntriesTemplate, AdminCompCodesTemplate},
23 types::AdminCompCodeRow,
24 };
25
26 /// Render the comp-codes dashboard: mint form plus a status list.
27 #[tracing::instrument(skip_all, name = "admin::admin_comp_codes")]
28 pub(super) async fn admin_comp_codes(
29 State(db): State<PgPool>,
30 session: tower_sessions::Session,
31 AdminUser(user): AdminUser,
32 ) -> Result<impl IntoResponse> {
33 let csrf_token = get_csrf_token(&session).await;
34 let comp_codes = load_comp_code_rows(&db).await?;
35 Ok(AdminCompCodesTemplate {
36 csrf_token,
37 session_user: Some(user),
38 admin_active_page: "comp-codes",
39 comp_codes,
40 })
41 }
42
43 /// Form for minting a creator-tier comp code.
44 #[derive(Debug, Deserialize)]
45 pub(super) struct CompCodeForm {
46 code: String,
47 trial_days: i32,
48 /// Cap on redemptions (omit/blank for unlimited).
49 #[serde(default, deserialize_with = "empty_string_as_none")]
50 max_uses: Option<i32>,
51 /// Days from now until the code expires (omit/blank for never).
52 #[serde(default, deserialize_with = "empty_string_as_none")]
53 expires_in_days: Option<i64>,
54 }
55
56 /// Treat an empty form field as `None` (HTML forms post "" for blank numbers).
57 fn empty_string_as_none<'de, D, T>(de: D) -> std::result::Result<Option<T>, D::Error>
58 where
59 D: serde::Deserializer<'de>,
60 T: std::str::FromStr,
61 T::Err: std::fmt::Display,
62 {
63 let opt = Option::<String>::deserialize(de)?;
64 match opt.as_deref().map(str::trim) {
65 None | Some("") => Ok(None),
66 Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
67 }
68 }
69
70 /// Mint a platform-wide free-trial code redeemable at creator-tier checkout.
71 ///
72 /// The redeemer gets `trial_days` free with no card collected up front, after
73 /// which the subscription rolls to the price chosen at checkout (the founder
74 /// price while the founder window is open). Owned by the minting admin for
75 /// audit, redeemable by any holder, control distribution via `max_uses` /
76 /// `expires_in_days`. On success the comp-codes table is re-rendered so the
77 /// new code appears immediately.
78 #[tracing::instrument(skip_all, name = "admin::admin_create_comp_code")]
79 pub(super) async fn admin_create_comp_code(
80 State(db): State<PgPool>,
81 AdminUser(admin): AdminUser,
82 Form(form): Form<CompCodeForm>,
83 ) -> Result<Response> {
84 let code = form.code.trim().to_uppercase();
85 if code.is_empty() {
86 return Err(AppError::BadRequest("Code is required".to_string()));
87 }
88 if form.trial_days <= 0 {
89 return Err(AppError::BadRequest(
90 "Trial days must be positive".to_string(),
91 ));
92 }
93 let expires_at = form
94 .expires_in_days
95 .map(|d| chrono::Utc::now() + chrono::Duration::days(d));
96
97 db::promo_codes::create_platform_promo_code(
98 &db,
99 admin.id,
100 &code,
101 db::CodePurpose::FreeTrial,
102 None, // discount_type
103 None, // discount_value
104 0, // min_price_cents (unused for trials)
105 Some(form.trial_days),
106 form.max_uses,
107 expires_at,
108 )
109 .await?;
110
111 tracing::info!(code = %code, trial_days = form.trial_days, "minted creator-tier comp code");
112
113 let comp_codes = load_comp_code_rows(&db).await?;
114 Ok(AdminCompCodesEntriesTemplate { comp_codes }.into_response())
115 }
116
117 /// Load and format the comp-code rows for the dashboard.
118 async fn load_comp_code_rows(db: &PgPool) -> Result<Vec<AdminCompCodeRow>> {
119 let codes = db::promo_codes::get_platform_trial_codes(db).await?;
120 Ok(codes.iter().map(AdminCompCodeRow::from_db).collect())
121 }
122