Skip to main content

max / makenotwork

5.9 KB · 185 lines History Blame Raw
1 //! Admin waitlist dashboard, filtering, approval, and lottery.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::{
5 Form,
6 extract::{Path, State},
7 response::{IntoResponse, Response},
8 };
9 use serde::Deserialize;
10 use sqlx::PgPool;
11
12 use crate::{
13 auth::AdminUser,
14 db::{self, SelectionMethod, WaitlistEntryId, WaitlistStatus},
15 error::{AppError, Result},
16 helpers::get_csrf_token,
17 templates::{AdminWaitlistEntriesTemplate, AdminWaitlistTemplate},
18 types::{AdminWaitlistRow, WaitlistStats},
19 };
20
21 #[derive(Debug, Deserialize)]
22 pub(super) struct WaitlistFilterQuery {
23 pub status: Option<String>,
24 }
25
26 /// Render the admin waitlist dashboard with stats and entries.
27 #[tracing::instrument(skip_all, name = "admin::admin_waitlist")]
28 pub(super) async fn admin_waitlist(
29 State(db): State<PgPool>,
30 session: tower_sessions::Session,
31 AdminUser(user): AdminUser,
32 ValidatedQuery(query): ValidatedQuery<WaitlistFilterQuery>,
33 ) -> Result<impl IntoResponse> {
34 let csrf_token = get_csrf_token(&session).await;
35 let current_filter = query.status.clone().unwrap_or_default();
36
37 let db_stats = db::waitlist::get_waitlist_stats(&db).await?;
38 let total_creators = db::waitlist::count_active_creators(&db).await?;
39
40 let stats = WaitlistStats {
41 total_pending: db_stats.pending as u32,
42 total_approved: db_stats.approved as u32,
43 total_spam: db_stats.spam as u32,
44 total_creators: total_creators as u32,
45 };
46
47 let db_entries = db::waitlist::get_admin_waitlist(&db, query.status.as_deref()).await?;
48
49 let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect();
50
51 Ok(AdminWaitlistTemplate {
52 csrf_token,
53 session_user: Some(user),
54 stats,
55 entries,
56 current_filter,
57 admin_active_page: "waitlist",
58 })
59 }
60
61 /// Return filtered waitlist entries as an HTMX partial.
62 #[tracing::instrument(skip_all, name = "admin::admin_waitlist_entries")]
63 pub(super) async fn admin_waitlist_entries(
64 State(db): State<PgPool>,
65 AdminUser(_user): AdminUser,
66 ValidatedQuery(query): ValidatedQuery<WaitlistFilterQuery>,
67 ) -> Result<impl IntoResponse> {
68 let db_entries = db::waitlist::get_admin_waitlist(&db, query.status.as_deref()).await?;
69
70 let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect();
71
72 Ok(AdminWaitlistEntriesTemplate { entries })
73 }
74
75 /// Approve a waitlist entry and grant creator access to the user.
76 #[tracing::instrument(skip_all, name = "admin::admin_approve")]
77 pub(super) async fn admin_approve(
78 State(db): State<PgPool>,
79 AdminUser(_user): AdminUser,
80 Path(id): Path<WaitlistEntryId>,
81 ) -> Result<Response> {
82 // Get entry to find user_id
83 let entry = db::waitlist::update_waitlist_status(
84 &db,
85 id,
86 WaitlistStatus::Approved,
87 Some(SelectionMethod::HandPicked),
88 None,
89 )
90 .await?;
91
92 db::waitlist::grant_creator_access(&db, entry.user_id).await?;
93
94 tracing::info!(entry_id = %id, user_id = %entry.user_id, "admin approved waitlist entry");
95
96 // Return updated entries partial
97 let db_entries = db::waitlist::get_admin_waitlist(&db, Some("pending")).await?;
98 let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect();
99
100 Ok(AdminWaitlistEntriesTemplate { entries }.into_response())
101 }
102
103 /// Flag a waitlist entry as spam.
104 #[tracing::instrument(skip_all, name = "admin::admin_spam")]
105 pub(super) async fn admin_spam(
106 State(db): State<PgPool>,
107 AdminUser(_user): AdminUser,
108 Path(id): Path<WaitlistEntryId>,
109 ) -> Result<Response> {
110 db::waitlist::update_waitlist_status(&db, id, WaitlistStatus::Spam, None, None).await?;
111
112 tracing::info!(entry_id = %id, "admin flagged waitlist entry as spam");
113
114 // Return updated entries partial
115 let db_entries = db::waitlist::get_admin_waitlist(&db, Some("pending")).await?;
116 let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect();
117
118 Ok(AdminWaitlistEntriesTemplate { entries }.into_response())
119 }
120
121 #[derive(Debug, Deserialize)]
122 pub(super) struct LotteryForm {
123 pub count: i32,
124 }
125
126 /// Run a creator lottery: create a wave, select random winners, and grant access.
127 #[tracing::instrument(skip_all, name = "admin::admin_lottery")]
128 pub(super) async fn admin_lottery(
129 State(db): State<PgPool>,
130 AdminUser(_user): AdminUser,
131 Form(form): Form<LotteryForm>,
132 ) -> Result<Response> {
133 if form.count < 1 {
134 return Err(AppError::validation("Count must be at least 1".to_string()));
135 }
136
137 // Use a transaction for atomicity
138 let mut tx = db.begin().await?;
139
140 // Count hand-picks not yet assigned to a wave
141 let hand_picked_count = db::waitlist::count_unassigned_handpicks(&mut *tx).await?;
142
143 let wave_number = db::waitlist::get_next_wave_number(&mut *tx).await?;
144
145 // Count eligible pool
146 let eligible = db::waitlist::get_lottery_eligible_count(&mut *tx).await?;
147
148 let wave = db::waitlist::create_wave(
149 &mut *tx,
150 wave_number,
151 hand_picked_count as i32,
152 form.count,
153 eligible as i32,
154 None,
155 )
156 .await?;
157
158 // Assign wave to unassigned hand-picks
159 db::waitlist::assign_wave_to_handpicks(&mut *tx, wave.id).await?;
160
161 let winners = db::waitlist::run_lottery(&mut *tx, wave.id, form.count).await?;
162
163 // Grant creator access to all lottery winners (batch)
164 let winner_ids: Vec<_> = winners.iter().map(|w| w.user_id).collect();
165 db::waitlist::grant_creator_access_batch(&mut *tx, &winner_ids).await?;
166
167 tx.commit().await?;
168
169 tracing::info!(
170 wave_number = wave_number,
171 hand_picked = hand_picked_count,
172 lottery_winners = winners.len(),
173 eligible = eligible,
174 "wave created"
175 );
176
177 // Redirect back to admin waitlist
178 Ok((
179 axum::http::StatusCode::OK,
180 [("HX-Redirect", "/admin/waitlist")],
181 "",
182 )
183 .into_response())
184 }
185