From a972f8c22223005f5fa4c5e0062f1b00fff1e4ed Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Mon, 13 Apr 2026 22:36:21 +0200 Subject: [PATCH 1/8] feat(db): add ticket categories migration and models --- crates/rustmail/src/db/operations/mod.rs | 2 + .../src/db/operations/ticket_categories.rs | 439 ++++++++++++++++++ crates/rustmail/src/db/repr.rs | 29 ++ .../20260413120000_ticket_categories.sql | 39 ++ 4 files changed, 509 insertions(+) create mode 100644 crates/rustmail/src/db/operations/ticket_categories.rs create mode 100644 migrations/20260413120000_ticket_categories.sql diff --git a/crates/rustmail/src/db/operations/mod.rs b/crates/rustmail/src/db/operations/mod.rs index 9cb336f8..9938b3ea 100644 --- a/crates/rustmail/src/db/operations/mod.rs +++ b/crates/rustmail/src/db/operations/mod.rs @@ -9,6 +9,7 @@ pub mod scheduled; pub mod snippets; pub mod statistics; pub mod threads; +pub mod ticket_categories; pub use api_keys::*; pub use features::*; @@ -21,3 +22,4 @@ pub use scheduled::*; pub use snippets::*; pub use statistics::*; pub use threads::*; +pub use ticket_categories::*; diff --git a/crates/rustmail/src/db/operations/ticket_categories.rs b/crates/rustmail/src/db/operations/ticket_categories.rs new file mode 100644 index 00000000..44c892bf --- /dev/null +++ b/crates/rustmail/src/db/operations/ticket_categories.rs @@ -0,0 +1,439 @@ +use crate::db::repr::{PendingCategorySelection, TicketCategory, TicketCategorySettings}; +use crate::prelude::errors::*; +use chrono::Utc; +use sqlx::{Row, SqlitePool}; +use uuid::Uuid; + +pub const CATEGORY_BUTTON_HARD_LIMIT: usize = 24; + +pub async fn get_category_settings(pool: &SqlitePool) -> ModmailResult { + let row = sqlx::query( + "SELECT enabled, selection_timeout_s FROM ticket_category_settings WHERE id = 1", + ) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch category settings: {e:?}"); + validation_failed("Failed to fetch category settings") + })?; + + Ok(match row { + Some(row) => TicketCategorySettings { + enabled: row.get::("enabled") != 0, + selection_timeout_s: row.get::("selection_timeout_s"), + }, + None => TicketCategorySettings { + enabled: false, + selection_timeout_s: 300, + }, + }) +} + +pub async fn update_category_settings( + enabled: bool, + selection_timeout_s: i64, + pool: &SqlitePool, +) -> ModmailResult<()> { + sqlx::query( + r#" + INSERT INTO ticket_category_settings (id, enabled, selection_timeout_s) + VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET + enabled = excluded.enabled, + selection_timeout_s = excluded.selection_timeout_s + "#, + ) + .bind(enabled as i64) + .bind(selection_timeout_s) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to update category settings: {e:?}"); + validation_failed("Failed to update category settings") + })?; + + Ok(()) +} + +fn row_to_category(row: sqlx::sqlite::SqliteRow) -> TicketCategory { + TicketCategory { + id: row.get::("id"), + name: row.get::("name"), + description: row.get::, _>("description"), + emoji: row.get::, _>("emoji"), + discord_category_id: row.get::("discord_category_id"), + position: row.get::("position"), + enabled: row.get::("enabled") != 0, + created_at: row.get::("created_at"), + updated_at: row.get::("updated_at"), + } +} + +pub async fn list_all_categories(pool: &SqlitePool) -> ModmailResult> { + let rows = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + ORDER BY position ASC, created_at ASC + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Failed to list categories: {e:?}"); + validation_failed("Failed to list categories") + })?; + + Ok(rows.into_iter().map(row_to_category).collect()) +} + +pub async fn list_enabled_categories(pool: &SqlitePool) -> ModmailResult> { + let rows = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + WHERE enabled = 1 + ORDER BY position ASC, created_at ASC + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Failed to list enabled categories: {e:?}"); + validation_failed("Failed to list enabled categories") + })?; + + Ok(rows.into_iter().map(row_to_category).collect()) +} + +pub async fn count_enabled_categories(pool: &SqlitePool) -> ModmailResult { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ticket_categories WHERE enabled = 1") + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Failed to count enabled categories: {e:?}"); + validation_failed("Failed to count enabled categories") + })?; + Ok(count) +} + +pub async fn get_category_by_id( + id: &str, + pool: &SqlitePool, +) -> ModmailResult> { + let row = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + WHERE id = ? + "#, + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch category: {e:?}"); + validation_failed("Failed to fetch category") + })?; + + Ok(row.map(row_to_category)) +} + +pub async fn get_category_by_name( + name: &str, + pool: &SqlitePool, +) -> ModmailResult> { + let row = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + WHERE name = ? + LIMIT 1 + "#, + ) + .bind(name) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch category by name: {e:?}"); + validation_failed("Failed to fetch category by name") + })?; + + Ok(row.map(row_to_category)) +} + +pub async fn create_category( + name: &str, + description: Option<&str>, + emoji: Option<&str>, + discord_category_id: &str, + pool: &SqlitePool, +) -> ModmailResult { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp(); + + let max_position: Option = + sqlx::query_scalar("SELECT MAX(position) FROM ticket_categories") + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Failed to compute category position: {e:?}"); + validation_failed("Failed to compute category position") + })?; + let position = max_position.unwrap_or(-1) + 1; + + sqlx::query( + r#" + INSERT INTO ticket_categories + (id, name, description, emoji, discord_category_id, position, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) + "#, + ) + .bind(&id) + .bind(name) + .bind(description) + .bind(emoji) + .bind(discord_category_id) + .bind(position) + .bind(now) + .bind(now) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to create category: {e:?}"); + validation_failed("Failed to create category") + })?; + + Ok(TicketCategory { + id, + name: name.to_string(), + description: description.map(|s| s.to_string()), + emoji: emoji.map(|s| s.to_string()), + discord_category_id: discord_category_id.to_string(), + position, + enabled: true, + created_at: now, + updated_at: now, + }) +} + +pub async fn update_category( + id: &str, + name: Option<&str>, + description: Option>, + emoji: Option>, + discord_category_id: Option<&str>, + position: Option, + enabled: Option, + pool: &SqlitePool, +) -> ModmailResult<()> { + let existing = get_category_by_id(id, pool) + .await? + .ok_or_else(|| validation_failed("Category not found"))?; + + let new_name = name.unwrap_or(&existing.name); + let new_description = match description { + Some(opt) => opt.map(|s| s.to_string()), + None => existing.description.clone(), + }; + let new_emoji = match emoji { + Some(opt) => opt.map(|s| s.to_string()), + None => existing.emoji.clone(), + }; + let new_discord_category_id = discord_category_id.unwrap_or(&existing.discord_category_id); + let new_position = position.unwrap_or(existing.position); + let new_enabled = enabled.unwrap_or(existing.enabled); + let now = Utc::now().timestamp(); + + sqlx::query( + r#" + UPDATE ticket_categories + SET name = ?, description = ?, emoji = ?, discord_category_id = ?, + position = ?, enabled = ?, updated_at = ? + WHERE id = ? + "#, + ) + .bind(new_name) + .bind(new_description) + .bind(new_emoji) + .bind(new_discord_category_id) + .bind(new_position) + .bind(new_enabled as i64) + .bind(now) + .bind(id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to update category: {e:?}"); + validation_failed("Failed to update category") + })?; + + Ok(()) +} + +pub async fn delete_category(id: &str, pool: &SqlitePool) -> ModmailResult { + let res = sqlx::query("DELETE FROM ticket_categories WHERE id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to delete category: {e:?}"); + validation_failed("Failed to delete category") + })?; + + Ok(res.rows_affected() > 0) +} + +pub async fn set_thread_category( + thread_id: &str, + category_id: Option<&str>, + pool: &SqlitePool, +) -> ModmailResult<()> { + sqlx::query("UPDATE threads SET ticket_category_id = ? WHERE id = ?") + .bind(category_id) + .bind(thread_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to set thread category: {e:?}"); + validation_failed("Failed to set thread category") + })?; + Ok(()) +} + +pub async fn upsert_pending_selection( + user_id: i64, + prompt_msg_id: &str, + dm_channel_id: &str, + started_at: i64, + expires_at: i64, + queued_msg_ids: &[String], + pool: &SqlitePool, +) -> ModmailResult<()> { + let queued_json = serde_json::to_string(queued_msg_ids) + .map_err(|_| validation_failed("Failed to serialize queued messages"))?; + + sqlx::query( + r#" + INSERT INTO pending_category_selections + (user_id, prompt_msg_id, dm_channel_id, started_at, expires_at, queued_msg_ids) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + prompt_msg_id = excluded.prompt_msg_id, + dm_channel_id = excluded.dm_channel_id, + started_at = excluded.started_at, + expires_at = excluded.expires_at, + queued_msg_ids = excluded.queued_msg_ids + "#, + ) + .bind(user_id) + .bind(prompt_msg_id) + .bind(dm_channel_id) + .bind(started_at) + .bind(expires_at) + .bind(&queued_json) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to upsert pending selection: {e:?}"); + validation_failed("Failed to upsert pending selection") + })?; + + Ok(()) +} + +fn row_to_pending(row: sqlx::sqlite::SqliteRow) -> PendingCategorySelection { + let queued_json: String = row.get::("queued_msg_ids"); + let queued_msg_ids: Vec = serde_json::from_str(&queued_json).unwrap_or_default(); + + PendingCategorySelection { + user_id: row.get::("user_id"), + prompt_msg_id: row.get::("prompt_msg_id"), + dm_channel_id: row.get::("dm_channel_id"), + started_at: row.get::("started_at"), + expires_at: row.get::("expires_at"), + queued_msg_ids, + } +} + +pub async fn get_pending_selection( + user_id: i64, + pool: &SqlitePool, +) -> ModmailResult> { + let row = sqlx::query( + r#" + SELECT user_id, prompt_msg_id, dm_channel_id, started_at, expires_at, queued_msg_ids + FROM pending_category_selections + WHERE user_id = ? + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch pending selection: {e:?}"); + validation_failed("Failed to fetch pending selection") + })?; + + Ok(row.map(row_to_pending)) +} + +pub async fn append_queued_message( + user_id: i64, + message_id: &str, + pool: &SqlitePool, +) -> ModmailResult<()> { + if let Some(mut pending) = get_pending_selection(user_id, pool).await? { + if pending.queued_msg_ids.iter().any(|id| id == message_id) { + return Ok(()); + } + pending.queued_msg_ids.push(message_id.to_string()); + let queued_json = serde_json::to_string(&pending.queued_msg_ids) + .map_err(|_| validation_failed("Failed to serialize queued messages"))?; + sqlx::query("UPDATE pending_category_selections SET queued_msg_ids = ? WHERE user_id = ?") + .bind(&queued_json) + .bind(user_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to append queued message: {e:?}"); + validation_failed("Failed to append queued message") + })?; + } + Ok(()) +} + +pub async fn delete_pending_selection(user_id: i64, pool: &SqlitePool) -> ModmailResult { + let res = sqlx::query("DELETE FROM pending_category_selections WHERE user_id = ?") + .bind(user_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to delete pending selection: {e:?}"); + validation_failed("Failed to delete pending selection") + })?; + Ok(res.rows_affected() > 0) +} + +pub async fn list_all_pending_selections( + pool: &SqlitePool, +) -> ModmailResult> { + let rows = sqlx::query( + r#" + SELECT user_id, prompt_msg_id, dm_channel_id, started_at, expires_at, queued_msg_ids + FROM pending_category_selections + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Failed to list pending selections: {e:?}"); + validation_failed("Failed to list pending selections") + })?; + + Ok(rows.into_iter().map(row_to_pending).collect()) +} diff --git a/crates/rustmail/src/db/repr.rs b/crates/rustmail/src/db/repr.rs index 1321a077..a336c88b 100644 --- a/crates/rustmail/src/db/repr.rs +++ b/crates/rustmail/src/db/repr.rs @@ -18,6 +18,35 @@ pub struct ApiKey { pub is_active: bool, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TicketCategory { + pub id: String, + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, + pub position: i64, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TicketCategorySettings { + pub enabled: bool, + pub selection_timeout_s: i64, +} + +#[derive(Debug, Clone)] +pub struct PendingCategorySelection { + pub user_id: i64, + pub prompt_msg_id: String, + pub dm_channel_id: String, + pub started_at: i64, + pub expires_at: i64, + pub queued_msg_ids: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Permission { diff --git a/migrations/20260413120000_ticket_categories.sql b/migrations/20260413120000_ticket_categories.sql new file mode 100644 index 00000000..8d8395b7 --- /dev/null +++ b/migrations/20260413120000_ticket_categories.sql @@ -0,0 +1,39 @@ +-- Ticket categories feature: user-selectable categories at ticket creation + +CREATE TABLE IF NOT EXISTS ticket_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + emoji TEXT, + discord_category_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ticket_categories_enabled + ON ticket_categories(enabled, position); + +CREATE TABLE IF NOT EXISTS ticket_category_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + selection_timeout_s INTEGER NOT NULL DEFAULT 300 +); + +INSERT OR IGNORE INTO ticket_category_settings (id, enabled, selection_timeout_s) +VALUES (1, 0, 300); + +CREATE TABLE IF NOT EXISTS pending_category_selections ( + user_id INTEGER PRIMARY KEY, + prompt_msg_id TEXT NOT NULL, + dm_channel_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + queued_msg_ids TEXT NOT NULL DEFAULT '[]' +); + +CREATE INDEX IF NOT EXISTS idx_pending_category_selections_expires_at + ON pending_category_selections(expires_at); + +ALTER TABLE threads ADD COLUMN ticket_category_id TEXT; From 6b67b19702dcd1dcadb615f85cd8558548762be1 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Mon, 13 Apr 2026 22:36:42 +0200 Subject: [PATCH 2/8] feat(api): add categories management endpoints --- .../src/api/handler/categories/categories.rs | 234 ++++++++++++++++++ .../src/api/handler/categories/mod.rs | 3 + crates/rustmail/src/api/handler/mod.rs | 2 + crates/rustmail/src/api/router.rs | 2 + crates/rustmail/src/api/routes/categories.rs | 27 ++ crates/rustmail/src/api/routes/mod.rs | 2 + .../src/api/utils/panel_permissions.rs | 2 + .../src/api/panel_permissions.rs | 3 + 8 files changed, 275 insertions(+) create mode 100644 crates/rustmail/src/api/handler/categories/categories.rs create mode 100644 crates/rustmail/src/api/handler/categories/mod.rs create mode 100644 crates/rustmail/src/api/routes/categories.rs diff --git a/crates/rustmail/src/api/handler/categories/categories.rs b/crates/rustmail/src/api/handler/categories/categories.rs new file mode 100644 index 00000000..4ab6cced --- /dev/null +++ b/crates/rustmail/src/api/handler/categories/categories.rs @@ -0,0 +1,234 @@ +use crate::db::operations::ticket_categories::CATEGORY_BUTTON_HARD_LIMIT; +use crate::db::operations::{ + count_enabled_categories, create_category, delete_category, get_category_by_id, + get_category_by_name, get_category_settings, list_all_categories, update_category, + update_category_settings, +}; +use crate::db::repr::{TicketCategory, TicketCategorySettings}; +use crate::prelude::types::*; +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::sync::Arc; +use tokio::sync::Mutex; + +async fn pool(bot_state: &Arc>) -> Result { + let state_lock = bot_state.lock().await; + match &state_lock.db_pool { + Some(p) => Ok(p.clone()), + None => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Database not initialized".to_string(), + )), + } +} + +fn internal(e: impl ToString) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +#[derive(Serialize, Deserialize)] +pub struct CategoryDto { + pub id: String, + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, + pub position: i64, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for CategoryDto { + fn from(c: TicketCategory) -> Self { + Self { + id: c.id, + name: c.name, + description: c.description, + emoji: c.emoji, + discord_category_id: c.discord_category_id, + position: c.position, + enabled: c.enabled, + created_at: c.created_at, + updated_at: c.updated_at, + } + } +} + +pub async fn list_categories_handler( + State(bot_state): State>>, +) -> Result>, (StatusCode, String)> { + let p = pool(&bot_state).await?; + let cats = list_all_categories(&p).await.map_err(internal)?; + Ok(Json(cats.into_iter().map(CategoryDto::from).collect())) +} + +#[derive(Deserialize)] +pub struct CreateCategoryRequest { + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, +} + +pub async fn create_category_handler( + State(bot_state): State>>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + if req.name.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "Name required".to_string())); + } + if req.discord_category_id.parse::().is_err() { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid discord_category_id".to_string(), + )); + } + let p = pool(&bot_state).await?; + + let enabled_count = count_enabled_categories(&p).await.map_err(internal)?; + if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT { + return Err(( + StatusCode::BAD_REQUEST, + format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT), + )); + } + + if let Ok(Some(_)) = get_category_by_name(&req.name, &p).await { + return Err(( + StatusCode::CONFLICT, + "Category with this name already exists".to_string(), + )); + } + + let created = create_category( + &req.name, + req.description.as_deref(), + req.emoji.as_deref(), + &req.discord_category_id, + &p, + ) + .await + .map_err(internal)?; + Ok(Json(created.into())) +} + +#[derive(Deserialize)] +pub struct UpdateCategoryRequest { + pub name: Option, + pub description: Option>, + pub emoji: Option>, + pub discord_category_id: Option, + pub position: Option, + pub enabled: Option, +} + +pub async fn update_category_handler( + State(bot_state): State>>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let p = pool(&bot_state).await?; + + let existing = get_category_by_id(&id, &p) + .await + .map_err(internal)? + .ok_or((StatusCode::NOT_FOUND, "Category not found".to_string()))?; + + if let Some(true) = req.enabled { + if !existing.enabled { + let enabled_count = count_enabled_categories(&p).await.map_err(internal)?; + if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT { + return Err(( + StatusCode::BAD_REQUEST, + format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT), + )); + } + } + } + + if let Some(ref did) = req.discord_category_id { + if did.parse::().is_err() { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid discord_category_id".to_string(), + )); + } + } + + update_category( + &id, + req.name.as_deref(), + req.description.as_ref().map(|o| o.as_deref()), + req.emoji.as_ref().map(|o| o.as_deref()), + req.discord_category_id.as_deref(), + req.position, + req.enabled, + &p, + ) + .await + .map_err(internal)?; + + let updated = get_category_by_id(&id, &p) + .await + .map_err(internal)? + .ok_or((StatusCode::NOT_FOUND, "Category not found".to_string()))?; + Ok(Json(updated.into())) +} + +pub async fn delete_category_handler( + State(bot_state): State>>, + Path(id): Path, +) -> Result { + let p = pool(&bot_state).await?; + let existed = delete_category(&id, &p).await.map_err(internal)?; + if existed { + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, "Category not found".to_string())) + } +} + +#[derive(Serialize, Deserialize)] +pub struct CategorySettingsDto { + pub enabled: bool, + pub selection_timeout_s: i64, +} + +impl From for CategorySettingsDto { + fn from(s: TicketCategorySettings) -> Self { + Self { + enabled: s.enabled, + selection_timeout_s: s.selection_timeout_s, + } + } +} + +pub async fn get_category_settings_handler( + State(bot_state): State>>, +) -> Result, (StatusCode, String)> { + let p = pool(&bot_state).await?; + let s = get_category_settings(&p).await.map_err(internal)?; + Ok(Json(s.into())) +} + +pub async fn update_category_settings_handler( + State(bot_state): State>>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + if req.selection_timeout_s < 30 { + return Err(( + StatusCode::BAD_REQUEST, + "selection_timeout_s must be >= 30".to_string(), + )); + } + let p = pool(&bot_state).await?; + update_category_settings(req.enabled, req.selection_timeout_s, &p) + .await + .map_err(internal)?; + let s = get_category_settings(&p).await.map_err(internal)?; + Ok(Json(s.into())) +} diff --git a/crates/rustmail/src/api/handler/categories/mod.rs b/crates/rustmail/src/api/handler/categories/mod.rs new file mode 100644 index 00000000..c5436771 --- /dev/null +++ b/crates/rustmail/src/api/handler/categories/mod.rs @@ -0,0 +1,3 @@ +mod categories; + +pub use categories::*; diff --git a/crates/rustmail/src/api/handler/mod.rs b/crates/rustmail/src/api/handler/mod.rs index 1d9f19be..2afc135f 100644 --- a/crates/rustmail/src/api/handler/mod.rs +++ b/crates/rustmail/src/api/handler/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod apikeys; pub mod auth; pub mod bot; +pub mod categories; pub mod externals; pub mod panel; pub mod user; @@ -10,6 +11,7 @@ pub use admin::*; pub use apikeys::*; pub use auth::*; pub use bot::*; +pub use categories::*; pub use externals::*; pub use panel::*; pub use user::*; diff --git a/crates/rustmail/src/api/router.rs b/crates/rustmail/src/api/router.rs index 9ce14537..61286b6e 100644 --- a/crates/rustmail/src/api/router.rs +++ b/crates/rustmail/src/api/router.rs @@ -7,6 +7,7 @@ use tokio::sync::Mutex; pub fn create_api_router(bot_state: Arc>) -> Router { let admin_router = create_admin_router(bot_state.clone()); let apikeys_router = create_apikeys_router(bot_state.clone()); + let categories_router = create_categories_router(bot_state.clone()); let bot_router = create_bot_router(bot_state.clone()); let auth_router = create_auth_router(); let panel_router = create_panel_router(bot_state.clone()); @@ -16,6 +17,7 @@ pub fn create_api_router(bot_state: Arc>) -> Router { let app = Router::new() .nest("/api/admin", admin_router) .nest("/api/apikeys", apikeys_router) + .nest("/api/categories", categories_router) .nest("/api/bot", bot_router) .nest("/api/auth", auth_router) .nest("/api/panel", panel_router) diff --git a/crates/rustmail/src/api/routes/categories.rs b/crates/rustmail/src/api/routes/categories.rs new file mode 100644 index 00000000..f51f8824 --- /dev/null +++ b/crates/rustmail/src/api/routes/categories.rs @@ -0,0 +1,27 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Router; +use axum::routing::{delete, get, patch, post, put}; +use rustmail_types::api::panel_permissions::PanelPermission; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub fn create_categories_router(bot_state: Arc>) -> Router>> { + Router::new() + .route("/", get(list_categories_handler)) + .route("/", post(create_category_handler)) + .route("/{id}", patch(update_category_handler)) + .route("/{id}", delete(delete_category_handler)) + .route("/settings", get(get_category_settings_handler)) + .route("/settings", put(update_category_settings_handler)) + .layer(axum::middleware::from_fn_with_state( + bot_state.clone(), + move |state, jar, req, next| { + require_panel_permission(state, jar, req, next, PanelPermission::ManageCategories) + }, + )) + .layer(axum::middleware::from_fn_with_state( + bot_state, + auth_middleware, + )) +} diff --git a/crates/rustmail/src/api/routes/mod.rs b/crates/rustmail/src/api/routes/mod.rs index 1d9f19be..2afc135f 100644 --- a/crates/rustmail/src/api/routes/mod.rs +++ b/crates/rustmail/src/api/routes/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod apikeys; pub mod auth; pub mod bot; +pub mod categories; pub mod externals; pub mod panel; pub mod user; @@ -10,6 +11,7 @@ pub use admin::*; pub use apikeys::*; pub use auth::*; pub use bot::*; +pub use categories::*; pub use externals::*; pub use panel::*; pub use user::*; diff --git a/crates/rustmail/src/api/utils/panel_permissions.rs b/crates/rustmail/src/api/utils/panel_permissions.rs index 80f2d17c..d33db331 100644 --- a/crates/rustmail/src/api/utils/panel_permissions.rs +++ b/crates/rustmail/src/api/utils/panel_permissions.rs @@ -72,6 +72,7 @@ pub async fn get_user_panel_permissions( PanelPermission::ManageTickets, PanelPermission::ManageApiKeys, PanelPermission::ManagePermissions, + PanelPermission::ManageCategories, ]; cache.insert(user_id.to_string(), permissions.clone()).await; return permissions; @@ -85,6 +86,7 @@ pub async fn get_user_panel_permissions( PanelPermission::ManageTickets, PanelPermission::ManageApiKeys, PanelPermission::ManagePermissions, + PanelPermission::ManageCategories, ]; } diff --git a/crates/rustmail_types/src/api/panel_permissions.rs b/crates/rustmail_types/src/api/panel_permissions.rs index f2c61c4d..37b44b6c 100644 --- a/crates/rustmail_types/src/api/panel_permissions.rs +++ b/crates/rustmail_types/src/api/panel_permissions.rs @@ -9,6 +9,7 @@ pub enum PanelPermission { ManageTickets, ManageApiKeys, ManagePermissions, + ManageCategories, } impl PanelPermission { @@ -20,6 +21,7 @@ impl PanelPermission { PanelPermission::ManageTickets => "manage_tickets", PanelPermission::ManageApiKeys => "manage_api_keys", PanelPermission::ManagePermissions => "manage_permissions", + PanelPermission::ManageCategories => "manage_categories", } } @@ -31,6 +33,7 @@ impl PanelPermission { "manage_tickets" => Some(PanelPermission::ManageTickets), "manage_api_keys" => Some(PanelPermission::ManageApiKeys), "manage_permissions" => Some(PanelPermission::ManagePermissions), + "manage_categories" => Some(PanelPermission::ManageCategories), _ => None, } } From 5ab3d2d4549187f5b69a88baf93aa2b06490877b Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Mon, 13 Apr 2026 22:37:07 +0200 Subject: [PATCH 3/8] feat(panel): add categories management UI --- .../src/components/categories.rs | 585 ++++++++++++++++++ crates/rustmail_panel/src/components/mod.rs | 1 + .../rustmail_panel/src/components/navbar.rs | 58 ++ crates/rustmail_panel/src/i18n/en/en.json | 33 +- crates/rustmail_panel/src/i18n/fr/fr.json | 33 +- .../src/pages/administration.rs | 3 + crates/rustmail_panel/src/pages/panel.rs | 4 + crates/rustmail_panel/src/types.rs | 2 + 8 files changed, 717 insertions(+), 2 deletions(-) create mode 100644 crates/rustmail_panel/src/components/categories.rs diff --git a/crates/rustmail_panel/src/components/categories.rs b/crates/rustmail_panel/src/components/categories.rs new file mode 100644 index 00000000..d27c1087 --- /dev/null +++ b/crates/rustmail_panel/src/components/categories.rs @@ -0,0 +1,585 @@ +use crate::components::forbidden::Forbidden403; +use crate::i18n::yew::use_translation; +use crate::types::PanelPermission; +use gloo_net::http::Request; +use serde::{Deserialize, Serialize}; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlInputElement; +use yew::prelude::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CategoryDto { + pub id: String, + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, + pub position: i64, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CategorySettingsDto { + pub enabled: bool, + pub selection_timeout_s: i64, +} + +#[derive(Debug, Clone, Serialize)] +struct CreateCategoryRequest { + name: String, + description: Option, + emoji: Option, + discord_category_id: String, +} + +#[derive(Debug, Clone, Serialize, Default)] +struct UpdateCategoryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enabled: Option, +} + +async fn fetch_categories() -> Option> { + let resp = Request::get("/api/categories").send().await.ok()?; + if resp.status() != 200 { + return None; + } + resp.json::>().await.ok() +} + +async fn fetch_settings() -> Option { + let resp = Request::get("/api/categories/settings").send().await.ok()?; + if resp.status() != 200 { + return None; + } + resp.json::().await.ok() +} + +#[function_component(CategoriesPage)] +pub fn categories_page() -> Html { + let (i18n, _set_language) = use_translation(); + + let permissions = use_state(|| None::>); + { + let permissions = permissions.clone(); + use_effect_with((), move |_| { + spawn_local(async move { + if let Ok(resp) = Request::get("/api/user/permissions").send().await { + if let Ok(perms) = resp.json::>().await { + permissions.set(Some(perms)); + } + } + }); + || () + }); + } + + if let Some(perms) = (*permissions).as_ref() { + if !perms.contains(&PanelPermission::ManageCategories) { + return html! { + + }; + } + } else { + return html! { +
+
{i18n.t("panel.forbidden.checking_permissions")}
+
+ }; + } + + let categories = use_state(|| Vec::::new()); + let settings = use_state(|| None::); + let loading = use_state(|| true); + let error = use_state(|| None::); + let show_create_modal = use_state(|| false); + + let reload = { + let categories = categories.clone(); + let settings = settings.clone(); + let loading = loading.clone(); + let error = error.clone(); + Callback::from(move |_| { + let categories = categories.clone(); + let settings = settings.clone(); + let loading = loading.clone(); + let error = error.clone(); + spawn_local(async move { + loading.set(true); + let cats = fetch_categories().await; + let s = fetch_settings().await; + match (cats, s) { + (Some(c), Some(s)) => { + categories.set(c); + settings.set(Some(s)); + error.set(None); + } + _ => error.set(Some("Failed to load".to_string())), + } + loading.set(false); + }); + }) + }; + + { + let reload = reload.clone(); + use_effect_with((), move |_| { + reload.emit(()); + || () + }); + } + + let on_toggle_enabled = { + let settings = settings.clone(); + let reload = reload.clone(); + Callback::from(move |_| { + let current = match (*settings).clone() { + Some(s) => s, + None => return, + }; + let new_settings = CategorySettingsDto { + enabled: !current.enabled, + selection_timeout_s: current.selection_timeout_s, + }; + let reload = reload.clone(); + spawn_local(async move { + if let Ok(req) = Request::put("/api/categories/settings").json(&new_settings) { + let _ = req.send().await; + } + reload.emit(()); + }); + }) + }; + + let on_timeout_change = { + let settings = settings.clone(); + let reload = reload.clone(); + Callback::from(move |e: Event| { + let current = match (*settings).clone() { + Some(s) => s, + None => return, + }; + let input: HtmlInputElement = e.target_unchecked_into(); + let val = input + .value() + .parse::() + .unwrap_or(current.selection_timeout_s); + if val < 30 { + return; + } + let new_settings = CategorySettingsDto { + enabled: current.enabled, + selection_timeout_s: val, + }; + let reload = reload.clone(); + spawn_local(async move { + if let Ok(req) = Request::put("/api/categories/settings").json(&new_settings) { + let _ = req.send().await; + } + reload.emit(()); + }); + }) + }; + + let on_toggle_category = { + let reload = reload.clone(); + Callback::from(move |(id, enabled): (String, bool)| { + let body = UpdateCategoryRequest { + enabled: Some(enabled), + ..Default::default() + }; + let reload = reload.clone(); + spawn_local(async move { + let url = format!("/api/categories/{}", id); + if let Ok(req) = Request::patch(&url).json(&body) { + let _ = req.send().await; + } + reload.emit(()); + }); + }) + }; + + let on_delete_category = { + let reload = reload.clone(); + Callback::from(move |id: String| { + let reload = reload.clone(); + spawn_local(async move { + let url = format!("/api/categories/{}", id); + let _ = Request::delete(&url).send().await; + reload.emit(()); + }); + }) + }; + + let on_create_click = { + let show_create_modal = show_create_modal.clone(); + Callback::from(move |_| show_create_modal.set(true)) + }; + + let on_close_modal = { + let show_create_modal = show_create_modal.clone(); + Callback::from(move |_| show_create_modal.set(false)) + }; + + let on_created = { + let show_create_modal = show_create_modal.clone(); + let reload = reload.clone(); + Callback::from(move |_| { + show_create_modal.set(false); + reload.emit(()); + }) + }; + + html! { +
+
+

{i18n.t("panel.categories.title")}

+ +
+ + { + if let Some(s) = (*settings).clone() { + html! { +
+

{i18n.t("panel.categories.settings")}

+
+
+

{i18n.t("panel.categories.feature_enabled")}

+

{i18n.t("panel.categories.feature_enabled_help")}

+
+ +
+
+ + +

{i18n.t("panel.categories.timeout_help")}

+
+
+ } + } else { + html! {} + } + } + + { + if *loading { + html! { +
+

{i18n.t("panel.categories.loading")}

+
+ } + } else if let Some(err) = (*error).clone() { + html! { +
{err}
+ } + } else if categories.is_empty() { + html! { +
+

{i18n.t("panel.categories.no_categories")}

+
+ } + } else { + html! { +
+ { + categories.iter().map(|c| { + let cat = c.clone(); + let cat_id = cat.id.clone(); + let toggle_id = cat.id.clone(); + let on_toggle_category = on_toggle_category.clone(); + let on_delete_category = on_delete_category.clone(); + html! { + + } + }).collect::() + } +
+ } + } + } + + { + if *show_create_modal { + html! { + + } + } else { + html! {} + } + } +
+ } +} + +#[derive(Properties, PartialEq)] +struct CategoryCardProps { + category: CategoryDto, + on_toggle: Callback, + on_delete: Callback, +} + +#[function_component(CategoryCard)] +fn category_card(props: &CategoryCardProps) -> Html { + let (i18n, _set_language) = use_translation(); + let c = &props.category; + let enabled = c.enabled; + let on_toggle = props.on_toggle.clone(); + let on_delete = props.on_delete.clone(); + let cat_id = c.id.clone(); + + html! { +
+
+
+

+ { c.emoji.clone().unwrap_or_default() }{" "}{&c.name} +

+ { + if let Some(desc) = &c.description { + html! {

{desc}

} + } else { + html! {} + } + } +

+ {i18n.t("panel.categories.discord_category_id")}{": "}{&c.discord_category_id} +

+
+
+ + +
+
+
+ } +} + +#[derive(Properties, PartialEq)] +struct CreateCategoryModalProps { + on_close: Callback<()>, + on_created: Callback<()>, +} + +#[function_component(CreateCategoryModal)] +fn create_category_modal(props: &CreateCategoryModalProps) -> Html { + let (i18n, _set_language) = use_translation(); + let name_ref = use_node_ref(); + let desc_ref = use_node_ref(); + let emoji_ref = use_node_ref(); + let discord_id_ref = use_node_ref(); + let creating = use_state(|| false); + let error = use_state(|| None::); + + let on_submit = { + let name_ref = name_ref.clone(); + let desc_ref = desc_ref.clone(); + let emoji_ref = emoji_ref.clone(); + let discord_id_ref = discord_id_ref.clone(); + let creating = creating.clone(); + let error = error.clone(); + let on_created = props.on_created.clone(); + let i18n_clone = i18n.clone(); + + Callback::from(move |_| { + let name = name_ref + .cast::() + .map(|i| i.value()) + .unwrap_or_default(); + let discord_id = discord_id_ref + .cast::() + .map(|i| i.value()) + .unwrap_or_default(); + let desc = desc_ref + .cast::() + .map(|i| i.value()) + .filter(|s| !s.trim().is_empty()); + let emoji = emoji_ref + .cast::() + .map(|i| i.value()) + .filter(|s| !s.trim().is_empty()); + + if name.trim().is_empty() { + error.set(Some(i18n_clone.t("panel.categories.error_name_required"))); + return; + } + if discord_id.trim().is_empty() { + error.set(Some( + i18n_clone.t("panel.categories.error_discord_id_required"), + )); + return; + } + + let req = CreateCategoryRequest { + name, + description: desc, + emoji, + discord_category_id: discord_id, + }; + + let creating = creating.clone(); + let error = error.clone(); + let on_created = on_created.clone(); + let i18n_clone2 = i18n_clone.clone(); + creating.set(true); + spawn_local(async move { + match Request::post("/api/categories").json(&req) { + Ok(r) => match r.send().await { + Ok(resp) => { + if resp.status() == 200 { + error.set(None); + on_created.emit(()); + } else { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + error.set(Some(format!( + "{}: {} {}", + i18n_clone2.t("panel.categories.error_create"), + status, + text + ))); + } + } + Err(e) => { + error.set(Some(format!("{:?}", e))); + } + }, + Err(e) => { + error.set(Some(format!("{:?}", e))); + } + } + creating.set(false); + }); + }) + }; + + html! { +
+
+
+

{i18n.t("panel.categories.modal.title")}

+ + { + if let Some(err) = (*error).clone() { + html! { +
{err}
+ } + } else { + html! {} + } + } + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ } +} diff --git a/crates/rustmail_panel/src/components/mod.rs b/crates/rustmail_panel/src/components/mod.rs index a223c752..5239cab9 100644 --- a/crates/rustmail_panel/src/components/mod.rs +++ b/crates/rustmail_panel/src/components/mod.rs @@ -1,4 +1,5 @@ pub mod api_keys; +pub mod categories; pub mod configuration; pub mod forbidden; pub mod home; diff --git a/crates/rustmail_panel/src/components/navbar.rs b/crates/rustmail_panel/src/components/navbar.rs index b01d1660..919bf700 100644 --- a/crates/rustmail_panel/src/components/navbar.rs +++ b/crates/rustmail_panel/src/components/navbar.rs @@ -35,6 +35,7 @@ pub fn rustmail_navbar(props: &RustmailNavbarProps) -> Html { let home_active = current_path == "/panel"; let config_active = current_path == "/panel/configuration"; let apikeys_active = current_path == "/panel/apikeys"; + let categories_active = current_path == "/panel/categories"; let tickets_active = current_path.starts_with("/panel/tickets"); let admin_active = current_path == "/admin"; @@ -45,6 +46,9 @@ pub fn rustmail_navbar(props: &RustmailNavbarProps) -> Html { let has_manage_permissions = props .permissions .contains(&PanelPermission::ManagePermissions); + let has_manage_categories = props + .permissions + .contains(&PanelPermission::ManageCategories); html! {