Skip to main content

max / multithreaded

14.9 KB · 425 lines History Blame Raw
1 //! Read handlers — forum directory, community pages, category listings, user profiles.
2
3 use axum::{
4 extract::{Path, Query},
5 http::StatusCode,
6 response::{IntoResponse, Response},
7 Json,
8 };
9 use tower_sessions::Session;
10
11 use crate::auth::MaybeUser;
12 use crate::csrf;
13 use crate::templates::*;
14 use crate::AppState;
15
16 use std::collections::HashMap;
17
18 use mt_core::types::{SortColumn, SortOrder};
19
20 use super::super::{
21 check_community_access, get_community, get_role, is_mod_or_owner, is_owner,
22 parse_uuid, template_user, CategoryQuery, PageQuery,
23 };
24
25 /// Forum directory — lists local communities (paginated).
26 #[tracing::instrument(skip_all)]
27 pub(in crate::routes) async fn forum_directory(
28 axum::extract::State(state): axum::extract::State<AppState>,
29 Query(page_query): Query<PageQuery>,
30 session: Session,
31 MaybeUser(session_user): MaybeUser,
32 ) -> impl IntoResponse {
33 let csrf_token = Some(csrf::get_or_create_token(&session).await);
34
35 let per_page: i64 = 25;
36 let total = mt_db::queries::count_communities(&state.db)
37 .await
38 .unwrap_or(0);
39 let pagination = Pagination::new(page_query.page.unwrap_or(1), total, per_page);
40 let offset = (pagination.current_page as i64 - 1) * per_page;
41
42 let communities = mt_db::queries::list_communities(&state.db, per_page, offset)
43 .await
44 .unwrap_or_default()
45 .into_iter()
46 .map(|c| CommunityDirectoryRow {
47 slug: c.slug,
48 name: c.name,
49 description: c.description,
50 category_count: c.category_count as u32,
51 thread_count: c.thread_count as u32,
52 })
53 .collect();
54
55 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
56
57 ForumDirectoryTemplate {
58 csrf_token,
59 session_user,
60 mnw_base_url: state.config.mnw_base_url.clone(),
61 communities,
62 pagination,
63 }
64 }
65
66 /// Project forum — categories within a project.
67 #[tracing::instrument(skip_all)]
68 pub(in crate::routes) async fn project_forum(
69 axum::extract::State(state): axum::extract::State<AppState>,
70 Path(slug): Path<String>,
71 session: Session,
72 MaybeUser(session_user): MaybeUser,
73 ) -> Result<impl IntoResponse, Response> {
74 let csrf_token = Some(csrf::get_or_create_token(&session).await);
75 let community = get_community(&state.db, &slug).await?;
76
77 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
78
79 let db_categories = mt_db::queries::list_categories_with_counts(&state.db, &slug)
80 .await
81 .map_err(|e| {
82 tracing::error!(error = ?e, "db error listing categories");
83 StatusCode::INTERNAL_SERVER_ERROR.into_response()
84 })?;
85
86 let role = if let Some(ref user) = session_user {
87 get_role(&state.db, user.user_id, community.id).await?
88 } else {
89 None
90 };
91
92 let categories = db_categories
93 .into_iter()
94 .map(|c| CategoryRow {
95 name: c.name,
96 slug: c.slug,
97 description: c.description,
98 thread_count: c.thread_count as u32,
99 })
100 .collect();
101
102 let owner = is_owner(&role);
103 let mod_or_owner = is_mod_or_owner(&role);
104 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
105
106 Ok(CommunityTemplate {
107 csrf_token,
108 session_user,
109 mnw_base_url: state.config.mnw_base_url.clone(),
110 community_name: community.name,
111 community_slug: community.slug,
112 community_description: community.description,
113 categories,
114 is_owner: owner,
115 is_mod_or_owner: mod_or_owner,
116 })
117 }
118
119 #[tracing::instrument(skip_all)]
120 pub(in crate::routes) async fn community_members(
121 axum::extract::State(state): axum::extract::State<AppState>,
122 Path(slug): Path<String>,
123 session: Session,
124 MaybeUser(session_user): MaybeUser,
125 ) -> Result<impl IntoResponse, Response> {
126 let csrf_token = Some(csrf::get_or_create_token(&session).await);
127 let community = get_community(&state.db, &slug).await?;
128
129 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
130
131 let db_members = mt_db::queries::list_community_members(&state.db, community.id)
132 .await
133 .map_err(|e| {
134 tracing::error!(error = ?e, "db error listing members");
135 StatusCode::INTERNAL_SERVER_ERROR.into_response()
136 })?;
137
138 let members = db_members
139 .into_iter()
140 .map(|m| MemberListRow {
141 display_name: m.display_name.unwrap_or_else(|| m.username.clone()),
142 username: m.username,
143 role: m.role,
144 joined: mt_core::time_format::relative_timestamp(m.joined_at),
145 })
146 .collect();
147
148 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
149
150 Ok(MembersTemplate {
151 csrf_token,
152 session_user,
153 mnw_base_url: state.config.mnw_base_url.clone(),
154 community_name: community.name,
155 community_slug: slug,
156 members,
157 })
158 }
159
160 #[tracing::instrument(skip_all)]
161 pub(in crate::routes) async fn category(
162 axum::extract::State(state): axum::extract::State<AppState>,
163 Path((slug, category_slug)): Path<(String, String)>,
164 Query(query): Query<CategoryQuery>,
165 session: Session,
166 MaybeUser(session_user): MaybeUser,
167 ) -> Result<impl IntoResponse, Response> {
168 let csrf_token = Some(csrf::get_or_create_token(&session).await);
169 let community = get_community(&state.db, &slug).await?;
170
171 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
172
173 let cat = mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug)
174 .await
175 .map_err(|e| {
176 tracing::error!(error = ?e, "db error fetching category");
177 StatusCode::INTERNAL_SERVER_ERROR.into_response()
178 })?
179 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
180
181 let per_page: i64 = 25;
182
183 // Parse sort column — only allow known values, default to "activity"
184 let sort = SortColumn::from_query(query.sort.as_deref());
185 let order = SortOrder::from_query(query.order.as_deref());
186
187 let tag_filter = query.tag.as_deref().filter(|t| !t.is_empty());
188
189 let total = mt_db::queries::count_threads_in_category_filtered(
190 &state.db, &slug, &category_slug, tag_filter,
191 )
192 .await
193 .map_err(|e| {
194 tracing::error!(error = ?e, "db error counting threads");
195 StatusCode::INTERNAL_SERVER_ERROR.into_response()
196 })?;
197
198 let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
199 let total_pages = total_pages.max(1);
200 let page = query.page.unwrap_or(1).max(1).min(total_pages);
201 let offset = (page as i64 - 1) * per_page;
202
203 let db_threads = mt_db::queries::list_threads_in_category_sorted_filtered(
204 &state.db, &slug, &category_slug, sort.as_str(), order.as_str(), per_page, offset, tag_filter,
205 )
206 .await
207 .map_err(|e| {
208 tracing::error!(error = ?e, "db error listing threads");
209 StatusCode::INTERNAL_SERVER_ERROR.into_response()
210 })?;
211
212 // Batch-fetch tags for all threads
213 let thread_ids: Vec<uuid::Uuid> = db_threads.iter().map(|t| t.id).collect();
214 let all_thread_tags = mt_db::queries::list_tags_for_threads(&state.db, &thread_ids)
215 .await
216 .map_err(|e| {
217 tracing::error!(error = ?e, "db error fetching thread tags");
218 StatusCode::INTERNAL_SERVER_ERROR.into_response()
219 })?;
220
221 let mut tags_by_thread: HashMap<String, Vec<TagBadge>> = HashMap::new();
222 for tt in all_thread_tags {
223 tags_by_thread
224 .entry(tt.thread_id.to_string())
225 .or_default()
226 .push(TagBadge { id: String::new(), name: tt.tag_name, slug: tt.tag_slug });
227 }
228
229 // Batch-fetch mention status for logged-in user
230 let mention_thread_ids: std::collections::HashSet<String> = if let Some(ref user) = session_user {
231 mt_db::queries::get_threads_with_mentions_for_user(&state.db, user.user_id, &thread_ids)
232 .await
233 .unwrap_or_default()
234 .into_iter()
235 .map(|id| id.to_string())
236 .collect()
237 } else {
238 std::collections::HashSet::new()
239 };
240
241 let threads = db_threads
242 .into_iter()
243 .map(|t| {
244 let tid = t.id.to_string();
245 let tags = tags_by_thread.remove(&tid).unwrap_or_default();
246 let has_mention = mention_thread_ids.contains(&tid);
247 ThreadRow {
248 id: tid,
249 title: t.title,
250 author_name: t.author_name,
251 author_username: t.author_username,
252 reply_count: t.reply_count.max(0) as u32,
253 last_activity: mt_core::time_format::relative_timestamp(t.last_activity_at),
254 pinned: t.pinned,
255 locked: t.locked,
256 has_mention,
257 tags,
258 }
259 })
260 .collect();
261
262 // Load available tags for filter UI
263 let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id)
264 .await
265 .map_err(|e| {
266 tracing::error!(error = ?e, "db error listing tags");
267 StatusCode::INTERNAL_SERVER_ERROR.into_response()
268 })?;
269 let available_tags = db_tags
270 .into_iter()
271 .map(|t| TagBadge { id: t.id.to_string(), name: t.name, slug: t.slug })
272 .collect();
273
274 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
275
276 Ok(CategoryTemplate {
277 csrf_token,
278 session_user,
279 mnw_base_url: state.config.mnw_base_url.clone(),
280 community_name: community.name,
281 community_slug: slug,
282 category_name: cat.name,
283 category_slug,
284 threads,
285 pagination: Pagination::new(page, total, per_page),
286 sort_column: sort.as_str().to_string(),
287 sort_order: order.as_str().to_string(),
288 available_tags,
289 active_tag: tag_filter.map(|t| t.to_string()),
290 })
291 }
292
293 #[tracing::instrument(skip_all)]
294 pub(in crate::routes) async fn new_thread(
295 axum::extract::State(state): axum::extract::State<AppState>,
296 Path((slug, category_slug)): Path<(String, String)>,
297 session: Session,
298 MaybeUser(session_user): MaybeUser,
299 ) -> Result<impl IntoResponse, Response> {
300 let csrf_token = Some(csrf::get_or_create_token(&session).await);
301 let community = get_community(&state.db, &slug).await?;
302
303 // Check suspension + ban for logged-in users (form display only; POST enforces fully)
304 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
305
306 let cat = mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug)
307 .await
308 .map_err(|e| {
309 tracing::error!(error = ?e, "db error fetching category");
310 StatusCode::INTERNAL_SERVER_ERROR.into_response()
311 })?
312 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
313
314 let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id)
315 .await
316 .map_err(|e| {
317 tracing::error!(error = ?e, "db error listing tags");
318 StatusCode::INTERNAL_SERVER_ERROR.into_response()
319 })?;
320 let available_tags = db_tags
321 .into_iter()
322 .map(|t| TagBadge { id: t.id.to_string(), name: t.name, slug: t.slug })
323 .collect();
324
325 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
326
327 Ok(NewThreadTemplate {
328 csrf_token,
329 session_user,
330 mnw_base_url: state.config.mnw_base_url.clone(),
331 community_name: community.name,
332 community_slug: slug,
333 category_name: cat.name,
334 category_slug,
335 available_tags,
336 })
337 }
338
339 /// User profile within a community.
340 #[tracing::instrument(skip_all)]
341 pub(in crate::routes) async fn user_profile(
342 axum::extract::State(state): axum::extract::State<AppState>,
343 Path((slug, username)): Path<(String, String)>,
344 session: Session,
345 MaybeUser(session_user): MaybeUser,
346 ) -> Result<impl IntoResponse, Response> {
347 let csrf_token = Some(csrf::get_or_create_token(&session).await);
348 let community = get_community(&state.db, &slug).await?;
349
350 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
351
352 let profile = mt_db::queries::get_user_profile_in_community(&state.db, &slug, &username)
353 .await
354 .map_err(|e| {
355 tracing::error!(error = ?e, "db error fetching user profile");
356 StatusCode::INTERNAL_SERVER_ERROR.into_response()
357 })?
358 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
359
360 let activity = mt_db::queries::get_user_activity_in_community(
361 &state.db, community.id, profile.user_id, 20,
362 )
363 .await
364 .map_err(|e| {
365 tracing::error!(error = ?e, "db error fetching user activity");
366 StatusCode::INTERNAL_SERVER_ERROR.into_response()
367 })?;
368
369 let activity_rows = activity
370 .into_iter()
371 .map(|a| ProfileActivityRow {
372 thread_id: a.thread_id.to_string(),
373 thread_title: a.thread_title,
374 category_name: a.category_name,
375 category_slug: a.category_slug,
376 timestamp: mt_core::time_format::relative_timestamp(a.post_created_at),
377 is_thread_author: a.is_thread_author,
378 })
379 .collect();
380
381 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
382
383 Ok(UserProfileTemplate {
384 csrf_token,
385 session_user,
386 mnw_base_url: state.config.mnw_base_url.clone(),
387 community_name: community.name,
388 community_slug: slug,
389 display_name: profile.display_name.unwrap_or_else(|| profile.username.clone()),
390 username: profile.username,
391 avatar_url: profile.avatar_url,
392 role: profile.role,
393 joined: mt_core::time_format::relative_timestamp(profile.joined_at),
394 post_count: profile.post_count,
395 endorsement_count: profile.endorsement_count,
396 activity: activity_rows,
397 })
398 }
399
400 /// API: user membership summary (for MNW dashboard).
401 #[tracing::instrument(skip_all)]
402 pub(in crate::routes) async fn user_summary_api(
403 axum::extract::State(state): axum::extract::State<AppState>,
404 Path(user_id_str): Path<String>,
405 MaybeUser(session_user): MaybeUser,
406 ) -> Result<Json<serde_json::Value>, Response> {
407 let user = session_user
408 .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
409
410 let user_id = parse_uuid(&user_id_str)?;
411
412 if user.user_id != user_id {
413 return Err(StatusCode::FORBIDDEN.into_response());
414 }
415
416 let memberships = mt_db::queries::get_user_membership_summary(&state.db, user_id)
417 .await
418 .map_err(|e| {
419 tracing::error!(error = ?e, "db error fetching membership summary");
420 StatusCode::INTERNAL_SERVER_ERROR.into_response()
421 })?;
422
423 Ok(Json(serde_json::json!({ "memberships": memberships })))
424 }
425