Skip to main content

max / makenotwork

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