Skip to main content

max / makenotwork

5.8 KB · 184 lines History Blame Raw
1 //! Admin waitlist dashboard, filtering, approval, and lottery.
2
3 use axum::{
4 extract::{Path, Query, State},
5 response::{IntoResponse, Response},
6 Form,
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 auth::AdminUser,
12 db::{self, SelectionMethod, WaitlistEntryId, WaitlistStatus},
13 error::{AppError, Result},
14 helpers::get_csrf_token,
15 templates::*,
16 types::*,
17 AppState,
18 };
19
20 #[derive(Debug, Deserialize)]
21 pub(super) struct WaitlistFilterQuery {
22 pub status: Option<String>,
23 }
24
25 /// Render the admin waitlist dashboard with stats and entries.
26 #[tracing::instrument(skip_all, name = "admin::admin_waitlist")]
27 pub(super) async fn admin_waitlist(
28 State(state): State<AppState>,
29 session: tower_sessions::Session,
30 AdminUser(user): AdminUser,
31 Query(query): Query<WaitlistFilterQuery>,
32 ) -> Result<impl IntoResponse> {
33
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(&state.db).await?;
38 let total_creators = db::waitlist::count_active_creators(&state.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(&state.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(state): State<AppState>,
65 AdminUser(_user): AdminUser,
66 Query(query): Query<WaitlistFilterQuery>,
67 ) -> Result<impl IntoResponse> {
68
69 let db_entries = db::waitlist::get_admin_waitlist(&state.db, query.status.as_deref()).await?;
70
71 let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect();
72
73 Ok(AdminWaitlistEntriesTemplate { entries })
74 }
75
76 /// Approve a waitlist entry and grant creator access to the user.
77 #[tracing::instrument(skip_all, name = "admin::admin_approve")]
78 pub(super) async fn admin_approve(
79 State(state): State<AppState>,
80 AdminUser(_user): AdminUser,
81 Path(id): Path<WaitlistEntryId>,
82 ) -> Result<Response> {
83
84 // Get entry to find user_id
85 let entry = db::waitlist::update_waitlist_status(&state.db, id, WaitlistStatus::Approved, Some(SelectionMethod::HandPicked), None).await?;
86
87 // Grant creator access
88 db::waitlist::grant_creator_access(&state.db, entry.user_id).await?;
89
90 tracing::info!(entry_id = %id, user_id = %entry.user_id, "admin approved waitlist entry");
91
92 // Return updated entries partial
93 let db_entries = db::waitlist::get_admin_waitlist(&state.db, Some("pending")).await?;
94 let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect();
95
96 Ok(AdminWaitlistEntriesTemplate { entries }.into_response())
97 }
98
99 /// Flag a waitlist entry as spam.
100 #[tracing::instrument(skip_all, name = "admin::admin_spam")]
101 pub(super) async fn admin_spam(
102 State(state): State<AppState>,
103 AdminUser(_user): AdminUser,
104 Path(id): Path<WaitlistEntryId>,
105 ) -> Result<Response> {
106
107 db::waitlist::update_waitlist_status(&state.db, id, WaitlistStatus::Spam, None, None).await?;
108
109 tracing::info!(entry_id = %id, "admin flagged waitlist entry as spam");
110
111 // Return updated entries partial
112 let db_entries = db::waitlist::get_admin_waitlist(&state.db, Some("pending")).await?;
113 let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect();
114
115 Ok(AdminWaitlistEntriesTemplate { entries }.into_response())
116 }
117
118 #[derive(Debug, Deserialize)]
119 pub(super) struct LotteryForm {
120 pub count: i32,
121 }
122
123 /// Run a creator lottery: create a wave, select random winners, and grant access.
124 #[tracing::instrument(skip_all, name = "admin::admin_lottery")]
125 pub(super) async fn admin_lottery(
126 State(state): State<AppState>,
127 AdminUser(_user): AdminUser,
128 Form(form): Form<LotteryForm>,
129 ) -> Result<Response> {
130
131 if form.count < 1 {
132 return Err(AppError::validation("Count must be at least 1".to_string()));
133 }
134
135 // Use a transaction for atomicity
136 let mut tx = state.db.begin().await?;
137
138 // Count hand-picks not yet assigned to a wave
139 let hand_picked_count = db::waitlist::count_unassigned_handpicks(&mut *tx).await?;
140
141 // Get next wave number
142 let wave_number = db::waitlist::get_next_wave_number(&mut *tx).await?;
143
144 // Count eligible pool
145 let eligible = db::waitlist::get_lottery_eligible_count(&mut *tx).await?;
146
147 // Create the wave
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 ).await?;
156
157 // Assign wave to unassigned hand-picks
158 db::waitlist::assign_wave_to_handpicks(&mut *tx, wave.id).await?;
159
160 // Run the lottery
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 ).into_response())
183 }
184