Skip to main content

max / makenotwork

4.1 KB · 119 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 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 }
119