//! Read handlers — forum directory, community pages, category listings, user profiles. use axum::{ extract::{Path, Query}, http::StatusCode, response::{IntoResponse, Response}, Json, }; use tower_sessions::Session; use crate::auth::MaybeUser; use crate::csrf; use crate::templates::*; use crate::AppState; use std::collections::HashMap; use mt_core::types::{SortColumn, SortOrder}; use super::super::{ check_community_access, get_community, get_role, is_mod_or_owner, is_owner, parse_uuid, template_user, CategoryQuery, ForumDirectoryQuery, }; /// Forum directory — lists local communities (paginated). /// /// `?filter=archived` switches to the archived-only listing. The default view /// excludes archived communities; they remain reachable by direct URL. #[tracing::instrument(skip_all)] pub(in crate::routes) async fn forum_directory( axum::extract::State(state): axum::extract::State, Query(query): Query, session: Session, MaybeUser(session_user): MaybeUser, ) -> impl IntoResponse { let csrf_token = Some(csrf::get_or_create_token(&session).await); let viewing_archived = query.filter.as_deref() == Some("archived"); let per_page: i64 = 25; let total = if viewing_archived { mt_db::queries::count_archived_communities(&state.db).await } else { mt_db::queries::count_communities(&state.db).await } .unwrap_or(0); let pagination = Pagination::new(query.page.unwrap_or(1), total, per_page); let offset = (pagination.current_page as i64 - 1) * per_page; let rows = if viewing_archived { mt_db::queries::list_archived_communities(&state.db, per_page, offset).await } else { mt_db::queries::list_communities(&state.db, per_page, offset).await }; let communities = rows .unwrap_or_default() .into_iter() .map(|c| CommunityDirectoryRow { slug: c.slug, name: c.name, description: c.description, category_count: c.category_count as u32, thread_count: c.thread_count as u32, }) .collect(); let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id)); ForumDirectoryTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), communities, pagination, viewing_archived, } } /// Project forum — categories within a project. #[tracing::instrument(skip_all)] pub(in crate::routes) async fn project_forum( axum::extract::State(state): axum::extract::State, Path(slug): Path, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let community = get_community(&state.db, &slug).await?; check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; let db_categories = mt_db::queries::list_categories_with_counts(&state.db, &slug) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing categories"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let role = if let Some(ref user) = session_user { get_role(&state.db, user.user_id, community.id).await? } else { None }; let categories = db_categories .into_iter() .map(|c| CategoryRow { name: c.name, slug: c.slug, description: c.description, thread_count: c.thread_count as u32, }) .collect(); let owner = is_owner(&role); let mod_or_owner = is_mod_or_owner(&role); let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id)); Ok(CommunityTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: community.slug, community_description: community.description, categories, is_owner: owner, is_mod_or_owner: mod_or_owner, }) } #[tracing::instrument(skip_all)] pub(in crate::routes) async fn community_members( axum::extract::State(state): axum::extract::State, Path(slug): Path, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let community = get_community(&state.db, &slug).await?; check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; let db_members = mt_db::queries::list_community_members(&state.db, community.id, 500, 0) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing members"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let members = db_members .into_iter() .map(|m| MemberListRow { display_name: m.display_name.unwrap_or_else(|| m.username.clone()), username: m.username, role: m.role.to_string(), joined: mt_core::time_format::relative_timestamp(m.joined_at), }) .collect(); let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id)); Ok(MembersTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, members, }) } #[tracing::instrument(skip_all)] pub(in crate::routes) async fn category( axum::extract::State(state): axum::extract::State, Path((slug, category_slug)): Path<(String, String)>, Query(query): Query, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let community = get_community(&state.db, &slug).await?; check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; let cat = mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching category"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; let per_page: i64 = 25; // Parse sort column — only allow known values, default to "activity" let sort = SortColumn::from_query(query.sort.as_deref()); let order = SortOrder::from_query(query.order.as_deref()); let tag_filter = query.tag.as_deref().filter(|t| !t.is_empty()); let total = mt_db::queries::count_threads_in_category_filtered( &state.db, &slug, &category_slug, tag_filter, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error counting threads"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page); let offset = pagination.offset(per_page); let db_threads = mt_db::queries::list_threads_in_category_sorted_filtered( &state.db, &slug, &category_slug, sort.as_str(), order.as_str(), per_page, offset, tag_filter, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing threads"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; // Batch-fetch tags for all threads let thread_ids: Vec = db_threads.iter().map(|t| t.id).collect(); let all_thread_tags = mt_db::queries::list_tags_for_threads(&state.db, &thread_ids) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching thread tags"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let mut tags_by_thread: HashMap> = HashMap::new(); for tt in all_thread_tags { tags_by_thread .entry(tt.thread_id.to_string()) .or_default() .push(TagBadge { id: String::new(), name: tt.tag_name, slug: tt.tag_slug }); } // Batch-fetch mention status for logged-in user let mention_thread_ids: std::collections::HashSet = if let Some(ref user) = session_user { mt_db::queries::get_threads_with_mentions_for_user(&state.db, user.user_id, &thread_ids) .await .unwrap_or_default() .into_iter() .map(|id| id.to_string()) .collect() } else { std::collections::HashSet::new() }; let threads = db_threads .into_iter() .map(|t| { let tid = t.id.to_string(); let tags = tags_by_thread.remove(&tid).unwrap_or_default(); let has_mention = mention_thread_ids.contains(&tid); ThreadRow { id: tid, title: t.title, author_name: t.author_name, author_username: t.author_username, reply_count: t.reply_count.max(0) as u32, last_activity: mt_core::time_format::relative_timestamp(t.last_activity_at), pinned: t.pinned, locked: t.locked, has_mention, tags, } }) .collect(); // Load available tags for filter UI let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing tags"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let available_tags = db_tags .into_iter() .map(|t| TagBadge { id: t.id.to_string(), name: t.name, slug: t.slug }) .collect(); let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id)); Ok(CategoryTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, category_name: cat.name, category_slug, threads, pagination, sort_column: sort.as_str().to_string(), sort_order: order.as_str().to_string(), available_tags, active_tag: tag_filter.map(|t| t.to_string()), }) } #[tracing::instrument(skip_all)] pub(in crate::routes) async fn new_thread( axum::extract::State(state): axum::extract::State, Path((slug, category_slug)): Path<(String, String)>, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let community = get_community(&state.db, &slug).await?; // Check suspension + ban for logged-in users (form display only; POST enforces fully) check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; let cat = mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching category"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing tags"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let available_tags = db_tags .into_iter() .map(|t| TagBadge { id: t.id.to_string(), name: t.name, slug: t.slug }) .collect(); let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id)); Ok(NewThreadTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, category_name: cat.name, category_slug, available_tags, }) } /// User profile within a community. #[tracing::instrument(skip_all)] pub(in crate::routes) async fn user_profile( axum::extract::State(state): axum::extract::State, Path((slug, username)): Path<(String, String)>, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let community = get_community(&state.db, &slug).await?; check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; let profile = mt_db::queries::get_user_profile_in_community(&state.db, &slug, &username) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching user profile"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; let activity = mt_db::queries::get_user_activity_in_community( &state.db, community.id, profile.user_id, 20, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching user activity"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let activity_rows = activity .into_iter() .map(|a| ProfileActivityRow { thread_id: a.thread_id.to_string(), thread_title: a.thread_title, category_name: a.category_name, category_slug: a.category_slug, timestamp: mt_core::time_format::relative_timestamp(a.post_created_at), is_thread_author: a.is_thread_author, }) .collect(); let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id)); Ok(UserProfileTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, display_name: profile.display_name.unwrap_or_else(|| profile.username.clone()), username: profile.username, avatar_url: profile.avatar_url, role: profile.role.to_string(), joined: mt_core::time_format::relative_timestamp(profile.joined_at), post_count: profile.post_count, endorsement_count: profile.endorsement_count, activity: activity_rows, }) } /// API: user membership summary (for MNW dashboard). #[tracing::instrument(skip_all)] pub(in crate::routes) async fn user_summary_api( axum::extract::State(state): axum::extract::State, Path(user_id_str): Path, MaybeUser(session_user): MaybeUser, ) -> Result, Response> { let user = session_user .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; let user_id = parse_uuid(&user_id_str)?; if user.user_id != user_id { return Err(StatusCode::FORBIDDEN.into_response()); } let memberships = mt_db::queries::get_user_membership_summary(&state.db, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching membership summary"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(Json(serde_json::json!({ "memberships": memberships }))) }