Skip to content
Merged
255 changes: 255 additions & 0 deletions crates/rustmail/src/api/handler/categories/categories.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
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<Mutex<BotState>>) -> Result<SqlitePool, (StatusCode, String)> {
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<String>,
pub emoji: Option<String>,
pub discord_category_id: String,
pub position: i64,
pub enabled: bool,
pub created_at: i64,
pub updated_at: i64,
}

impl From<TicketCategory> 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<Arc<Mutex<BotState>>>,
) -> Result<Json<Vec<CategoryDto>>, (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<String>,
pub emoji: Option<String>,
pub discord_category_id: String,
}

pub async fn create_category_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Json(req): Json<CreateCategoryRequest>,
) -> Result<Json<CategoryDto>, (StatusCode, String)> {
let name = req.name.trim();
if name.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name required".to_string()));
}
if req.discord_category_id.parse::<u64>().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 get_category_by_name(name, &p)
.await
.map_err(internal)?
.is_some()
{
return Err((
StatusCode::CONFLICT,
"Category with this name already exists".to_string(),
));
}
Comment on lines +93 to +110
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplicate-name check ignores DB errors (if let Ok(Some(_)) = ...). If the lookup fails, the handler will proceed and likely return a less clear internal error later. Prefer get_category_by_name(...).await.map_err(internal)? and then check the Option so DB failures are surfaced consistently.

Copilot uses AI. Check for mistakes.

let created = create_category(
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<String>,
pub description: Option<Option<String>>,
pub emoji: Option<Option<String>>,
pub discord_category_id: Option<String>,
pub position: Option<i64>,
pub enabled: Option<bool>,
}
Comment on lines +124 to +132
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_category_handler allows setting name to an empty/whitespace string (and doesn’t prevent renaming to an existing name). Add validation similar to create (non-empty trimmed name) and return a 409/400 when the new name conflicts with an existing category.

Copilot uses AI. Check for mistakes.

pub async fn update_category_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path(id): Path<String>,
Json(req): Json<UpdateCategoryRequest>,
) -> Result<Json<CategoryDto>, (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()))?;

let trimmed_name = req.name.as_deref().map(str::trim);
if let Some(name) = trimmed_name {
if name.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name required".to_string()));
}

if let Some(conflict) = get_category_by_name(name, &p).await.map_err(internal)? {
if conflict.id != existing.id {
return Err((
StatusCode::CONFLICT,
"Category with this name already exists".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::<u64>().is_err() {
return Err((
StatusCode::BAD_REQUEST,
"Invalid discord_category_id".to_string(),
));
}
}

update_category(
&id,
trimmed_name,
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<Arc<Mutex<BotState>>>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
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<TicketCategorySettings> 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<Arc<Mutex<BotState>>>,
) -> Result<Json<CategorySettingsDto>, (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<Arc<Mutex<BotState>>>,
Json(req): Json<CategorySettingsDto>,
) -> Result<Json<CategorySettingsDto>, (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()))
}
3 changes: 3 additions & 0 deletions crates/rustmail/src/api/handler/categories/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mod categories;

pub use categories::*;
2 changes: 2 additions & 0 deletions crates/rustmail/src/api/handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::*;
2 changes: 2 additions & 0 deletions crates/rustmail/src/api/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use tokio::sync::Mutex;
pub fn create_api_router(bot_state: Arc<Mutex<BotState>>) -> 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());
Expand All @@ -16,6 +17,7 @@ pub fn create_api_router(bot_state: Arc<Mutex<BotState>>) -> 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)
Expand Down
27 changes: 27 additions & 0 deletions crates/rustmail/src/api/routes/categories.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<BotState>>) -> Router<Arc<Mutex<BotState>>> {
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,
))
}
2 changes: 2 additions & 0 deletions crates/rustmail/src/api/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::*;
2 changes: 2 additions & 0 deletions crates/rustmail/src/api/utils/panel_permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -85,6 +86,7 @@ pub async fn get_user_panel_permissions(
PanelPermission::ManageTickets,
PanelPermission::ManageApiKeys,
PanelPermission::ManagePermissions,
PanelPermission::ManageCategories,
];
}

Expand Down
1 change: 1 addition & 0 deletions crates/rustmail/src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ pub async fn run_bot(
registry.register_command(PingCommand);
registry.register_command(SnippetCommand);
registry.register_command(StatusCommand);
registry.register_command(CategoryCommand);

let registry = Arc::new(registry);

Expand Down
5 changes: 5 additions & 0 deletions crates/rustmail/src/commands/category/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub mod slash_command;
pub mod text_command;

pub use slash_command::*;
pub use text_command::*;
Loading