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