//! Read handlers, forum directory, community pages, category listings, user profiles. use axum::{ Json, extract::{Path, Query}, http::StatusCode, response::{IntoResponse, Response}, }; use tower_sessions::Session; use crate::AppState; use crate::auth::MaybeUser; use crate::csrf; use crate::templates::{ CategoryRow, CategoryTemplate, CommunityDirectoryRow, CommunityTemplate, ForumDirectoryTemplate, MemberListRow, MembersTemplate, NewThreadTemplate, Pagination, ProfileActivityRow, TagBadge, ThreadRow, UserProfileTemplate, }; use std::collections::HashMap; use livechat::RoomState; use mt_core::types::{SortColumn, SortOrder}; use super::super::{ CategoryQuery, ForumDirectoryQuery, PageQuery, check_community_access, db_error, get_community, get_role, is_mod_or_owner, is_owner, parse_uuid, template_user, }; /// 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, ) -> Result { 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 } .inspect_err( |e| tracing::error!(error = ?e, viewing_archived, "db error counting communities for directory"), ) .unwrap_or(0); let mut pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page); // Keep the archived filter across page links (else page 2+ reverts to the // default, non-archived listing). if viewing_archived { pagination = pagination.with_query_suffix("&filter=archived"); } let offset = pagination.offset(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 .inspect_err( |e| tracing::error!(error = ?e, viewing_archived, "db error listing communities for directory"), ) .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)); Ok(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(db_error)?; 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 chat_open = chat_is_reachable(&state, &slug).await?; 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, chat_open, }) } /// Whether this community's chat room is worth linking to from the forum page. /// /// Answered through [`crate::chat::rooms::room_state`] rather than by reading /// `chat_policy` here, so the link appears exactly when `/p/{slug}/chat` does /// not 404. Re-deriving the condition would be a second copy of a fold that /// already has three inputs, and the two would drift the first time one of them /// changed. A read-only room still links: the backlog is worth reading. /// /// Deliberately not folded into `CommunityRow`. That row loads on nearly every /// request in the app and chat is on almost none of them, which is the same /// reasoning that made `get_chat_room_by_slug` a dedicated query. async fn chat_is_reachable(state: &AppState, slug: &str) -> Result { let Some(row) = mt_db::queries::get_chat_room_by_slug(&state.db, slug) .await .map_err(db_error)? else { return Ok(false); }; Ok(crate::chat::rooms::room_state(row.policy, row.state, row.suspended) != RoomState::Closed) } #[tracing::instrument(skip_all)] pub(in crate::routes) async fn community_members( axum::extract::State(state): axum::extract::State, Path(slug): Path, Query(page_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 per_page: i64 = 100; let total = mt_db::queries::count_community_members(&state.db, community.id) .await .map_err(db_error)?; let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page); let offset = pagination.offset(per_page); let db_members = mt_db::queries::list_community_members(&state.db, community.id, per_page, offset) .await .map_err(db_error)?; 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, pagination, }) } #[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 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()); // Community, category, and the thread count are all keyed off the slugs and // independent of each other, run them concurrently rather than as three // serial round-trips (matches thread.rs's batched fetch). let (community, cat, total) = tokio::try_join!( async { get_community(&state.db, &slug).await }, async { mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug) .await .map_err(db_error)? .ok_or_else(crate::error_page::not_found) }, async { mt_db::queries::count_threads_in_category_filtered( &state.db, &slug, &category_slug, tag_filter, ) .await .map_err(db_error) }, )?; check_community_access( &state.db, &community, session_user.as_ref().map(|u| u.user_id), ) .await?; // Carry the sort/order (and optional tag) across page links so page 2+ keeps // the current view. Feeding this into the shared pagination partial via // query_suffix means category.html no longer hand-rolls its own pagination // markup (which had drifted from partials/pagination.html). sort/order are // enum-derived and tag is a validated slug, so the suffix is URL-safe. let mut page_suffix = format!("&sort={}&order={}", sort.as_str(), order.as_str()); if let Some(tag) = tag_filter { use std::fmt::Write as _; let _ = write!(page_suffix, "&tag={tag}"); } let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page) .with_query_suffix(&page_suffix); let offset = pagination.offset(per_page); let db_threads = mt_db::queries::list_threads_in_category_sorted( &state.db, &slug, &category_slug, sort, order, per_page, offset, tag_filter, ) .await .map_err(db_error)?; // Per-thread tags, the user's mention status, and the community's tag list // are all independent given the thread ids (and community id), fetch them // concurrently instead of three serial round-trips. let thread_ids: Vec = db_threads.iter().map(|t| t.id).collect(); let user_id = session_user.as_ref().map(|u| u.user_id); let (all_thread_tags, mention_thread_ids, db_tags) = tokio::try_join!( async { mt_db::queries::list_tags_for_threads(&state.db, &thread_ids) .await .map_err(db_error) }, async { // Mention status for the logged-in user; a query error degrades to // "no mentions" rather than failing the page (as before). let set: std::collections::HashSet = match user_id { Some(uid) => { mt_db::queries::get_threads_with_mentions_for_user(&state.db, uid, &thread_ids) .await .unwrap_or_default() .into_iter() .map(|id| id.to_string()) .collect() } None => std::collections::HashSet::new(), }; Ok::<_, Response>(set) }, async { mt_db::queries::list_tags_for_community(&state.db, community.id) .await .map_err(db_error) }, )?; 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, }); } 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(); 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(std::string::ToString::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(db_error)? .ok_or_else(crate::error_page::not_found)?; let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id) .await .map_err(db_error)?; 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(db_error)? .ok_or_else(crate::error_page::not_found)?; let activity = mt_db::queries::get_user_activity_in_community( &state.db, community.id, profile.user_id, 20, ) .await .map_err(db_error)?; 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(db_error)?; Ok(Json(serde_json::json!({ "memberships": memberships }))) }