Skip to content
102 changes: 99 additions & 3 deletions crates/rustmail/src/api/handler/categories/categories.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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,
add_category_role, clear_category_roles, count_enabled_categories, create_category,
delete_category, get_category_by_id, get_category_by_name, get_category_settings,
list_all_categories, list_category_role_ids, remove_category_role, set_category_roles,
update_category, update_category_settings,
};
use crate::db::repr::{TicketCategory, TicketCategorySettings};
use crate::prelude::types::*;
Expand Down Expand Up @@ -253,3 +254,98 @@ pub async fn update_category_settings_handler(
let s = get_category_settings(&p).await.map_err(internal)?;
Ok(Json(s.into()))
}

#[derive(Serialize, Deserialize)]
pub struct CategoryRolesDto {
pub role_ids: Vec<String>,
}

async fn ensure_category_exists(
p: &SqlitePool,
id: &str,
) -> Result<TicketCategory, (StatusCode, String)> {
get_category_by_id(id, p)
.await
.map_err(internal)?
.ok_or((StatusCode::NOT_FOUND, "Category not found".to_string()))
}

fn validate_role_id(raw: &str) -> Result<String, (StatusCode, String)> {
let trimmed = raw.trim();
trimmed
.parse::<u64>()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid role_id".to_string()))?;
Ok(trimmed.to_string())
}

pub async fn list_category_roles_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path(id): Path<String>,
) -> Result<Json<CategoryRolesDto>, (StatusCode, String)> {
let p = pool(&bot_state).await?;
let _ = ensure_category_exists(&p, &id).await?;
let role_ids = list_category_role_ids(&id, &p).await.map_err(internal)?;
Ok(Json(CategoryRolesDto { role_ids }))
}

#[derive(Deserialize)]
pub struct CategoryRoleRequest {
pub role_id: String,
}

pub async fn add_category_role_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path(id): Path<String>,
Json(req): Json<CategoryRoleRequest>,
) -> Result<Json<CategoryRolesDto>, (StatusCode, String)> {
let p = pool(&bot_state).await?;
let _ = ensure_category_exists(&p, &id).await?;
let role_id = validate_role_id(&req.role_id)?;
add_category_role(&id, &role_id, &p)
.await
.map_err(internal)?;
let role_ids = list_category_role_ids(&id, &p).await.map_err(internal)?;
Ok(Json(CategoryRolesDto { role_ids }))
}

pub async fn set_category_roles_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path(id): Path<String>,
Json(req): Json<CategoryRolesDto>,
) -> Result<Json<CategoryRolesDto>, (StatusCode, String)> {
let p = pool(&bot_state).await?;
let _ = ensure_category_exists(&p, &id).await?;
let mut validated: Vec<String> = Vec::with_capacity(req.role_ids.len());
for raw in &req.role_ids {
validated.push(validate_role_id(raw)?);
}
set_category_roles(&id, &validated, &p)
.await
.map_err(internal)?;
let role_ids = list_category_role_ids(&id, &p).await.map_err(internal)?;
Ok(Json(CategoryRolesDto { role_ids }))
}

pub async fn remove_category_role_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path((id, role_id)): Path<(String, String)>,
) -> Result<Json<CategoryRolesDto>, (StatusCode, String)> {
let p = pool(&bot_state).await?;
let _ = ensure_category_exists(&p, &id).await?;
let role_id = validate_role_id(&role_id)?;
remove_category_role(&id, &role_id, &p)
.await
.map_err(internal)?;
let role_ids = list_category_role_ids(&id, &p).await.map_err(internal)?;
Ok(Json(CategoryRolesDto { role_ids }))
}

pub async fn clear_category_roles_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let p = pool(&bot_state).await?;
let _ = ensure_category_exists(&p, &id).await?;
clear_category_roles(&id, &p).await.map_err(internal)?;
Ok(StatusCode::NO_CONTENT)
}
8 changes: 8 additions & 0 deletions crates/rustmail/src/api/routes/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ pub fn create_categories_router(bot_state: Arc<Mutex<BotState>>) -> Router<Arc<M
.route("/{id}", delete(delete_category_handler))
.route("/settings", get(get_category_settings_handler))
.route("/settings", put(update_category_settings_handler))
.route("/{id}/roles", get(list_category_roles_handler))
.route("/{id}/roles", post(add_category_role_handler))
.route("/{id}/roles", put(set_category_roles_handler))
.route("/{id}/roles", delete(clear_category_roles_handler))
.route(
"/{id}/roles/{role_id}",
delete(remove_category_role_handler),
)
Comment on lines +17 to +24

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

PR description mentions an API endpoint for “clearing” category roles, but the router only exposes GET/POST/PUT on /{id}/roles and DELETE on /{id}/roles/{role_id} (no clear route like DELETE /{id}/roles). Either add the clear endpoint (e.g., map to clear_category_roles) or update the description to clarify that PUT { role_ids: [] } is the supported way to clear roles.

Copilot uses AI. Check for mistakes.
.layer(axum::middleware::from_fn_with_state(
bot_state.clone(),
move |state, jar, req, next| {
Expand Down
231 changes: 230 additions & 1 deletion crates/rustmail/src/commands/category/slash_command/category.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,90 @@ impl RegistrableCommand for CategoryCommand {
CommandOptionType::SubCommand,
"off",
"Disable category selection feature",
)),
))
.add_option(
CreateCommandOption::new(
CommandOptionType::SubCommandGroup,
"roles",
"Manage roles linked to a category",
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::SubCommand,
"add",
"Link a role to a category",
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::String,
"name",
"Category name",
)
.required(true),
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::Role,
"role",
"Role to link",
)
.required(true),
),
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::SubCommand,
"remove",
"Unlink a role from a category",
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::String,
"name",
"Category name",
)
.required(true),
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::Role,
"role",
"Role to unlink",
)
.required(true),
),
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::SubCommand,
"list",
"List roles linked to a category",
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::String,
"name",
"Category name",
)
.required(true),
),
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::SubCommand,
"clear",
"Unlink all roles from a category",
)
.add_sub_option(
CreateCommandOption::new(
CommandOptionType::String,
"name",
"Category name",
)
.required(true),
),
),
),
]
})
}
Expand Down Expand Up @@ -230,6 +313,7 @@ impl RegistrableCommand for CategoryCommand {
}
"on" => sub_feature(&ctx, &command, pool, &config, true).await,
"off" => sub_feature(&ctx, &command, pool, &config, false).await,
"roles" => sub_roles(&ctx, &command, subcommand, pool, &config).await,
_ => reply(&ctx, &command, &config, "category.unknown_subcommand", None).await,
}
})
Expand Down Expand Up @@ -569,3 +653,148 @@ async fn sub_feature(
};
reply(ctx, command, config, key, None).await
}

fn get_sub_group_options(options: &[CommandDataOption]) -> Option<(&str, &Vec<CommandDataOption>)> {
let sub_group = options.first()?;
if let CommandDataOptionValue::SubCommandGroup(sub_opts) = &sub_group.value {
let sub = sub_opts.first()?;
if let CommandDataOptionValue::SubCommand(leaf_opts) = &sub.value {
return Some((sub.name.as_str(), leaf_opts));
}
}
None
}

fn leaf_string(opts: &[CommandDataOption], key: &str) -> Option<String> {
for o in opts {
if o.name == key {
if let CommandDataOptionValue::String(s) = &o.value {
return Some(s.clone());
}
}
}
None
}

fn leaf_role(opts: &[CommandDataOption], key: &str) -> Option<u64> {
for o in opts {
if o.name == key {
if let CommandDataOptionValue::Role(r) = &o.value {
return Some(r.get());
}
}
}
None
}

async fn sub_roles(
ctx: &Context,
command: &CommandInteraction,
_sub_group: &CommandDataOption,
pool: &sqlx::SqlitePool,
config: &Config,
) -> ModmailResult<()> {
let (action, leaf_opts) = match get_sub_group_options(&command.data.options) {
Some(v) => v,
None => return reply(ctx, command, config, "category.roles_usage", None).await,
};

match action {
"add" => {
let name = match leaf_string(leaf_opts, "name") {
Some(v) => v,
None => return reply(ctx, command, config, "category.roles_usage", None).await,
};
let role = match leaf_role(leaf_opts, "role") {
Some(v) => v,
None => return reply(ctx, command, config, "category.roles_usage", None).await,
};
let cat = match get_category_by_name(&name, pool).await? {
Some(c) => c,
None => return reply(ctx, command, config, "category.not_found", None).await,
};
let added = add_category_role(&cat.id, &role.to_string(), pool).await?;
let mut params = HashMap::new();
params.insert("name".to_string(), cat.name);
params.insert("role".to_string(), format!("<@&{}>", role));
let key = if added {
"category.role_added"
} else {
"category.role_already_linked"
};
reply(ctx, command, config, key, Some(params)).await
}
"remove" => {
let name = match leaf_string(leaf_opts, "name") {
Some(v) => v,
None => return reply(ctx, command, config, "category.roles_usage", None).await,
};
let role = match leaf_role(leaf_opts, "role") {
Some(v) => v,
None => return reply(ctx, command, config, "category.roles_usage", None).await,
};
let cat = match get_category_by_name(&name, pool).await? {
Some(c) => c,
None => return reply(ctx, command, config, "category.not_found", None).await,
};
let removed = remove_category_role(&cat.id, &role.to_string(), pool).await?;
let mut params = HashMap::new();
params.insert("name".to_string(), cat.name);
params.insert("role".to_string(), format!("<@&{}>", role));
let key = if removed {
"category.role_removed"
} else {
"category.role_not_linked"
};
reply(ctx, command, config, key, Some(params)).await
}
"list" => {
let name = match leaf_string(leaf_opts, "name") {
Some(v) => v,
None => return reply(ctx, command, config, "category.roles_usage", None).await,
};
let cat = match get_category_by_name(&name, pool).await? {
Some(c) => c,
None => return reply(ctx, command, config, "category.not_found", None).await,
};
let roles = list_category_role_ids(&cat.id, pool).await?;
if roles.is_empty() {
let mut params = HashMap::new();
params.insert("name".to_string(), cat.name);
return reply(
ctx,
command,
config,
"category.roles_list_empty",
Some(params),
)
.await;
}
let mentions = roles
.iter()
.map(|r| format!("<@&{}>", r))
.collect::<Vec<_>>()
.join(", ");
let mut params = HashMap::new();
params.insert("name".to_string(), cat.name);
params.insert("roles".to_string(), mentions);
reply(ctx, command, config, "category.roles_list", Some(params)).await
}
"clear" => {
let name = match leaf_string(leaf_opts, "name") {
Some(v) => v,
None => return reply(ctx, command, config, "category.roles_usage", None).await,
};
let cat = match get_category_by_name(&name, pool).await? {
Some(c) => c,
None => return reply(ctx, command, config, "category.not_found", None).await,
};
let removed = clear_category_roles(&cat.id, pool).await?;
let mut params = HashMap::new();
params.insert("name".to_string(), cat.name);
params.insert("count".to_string(), removed.to_string());
reply(ctx, command, config, "category.roles_cleared", Some(params)).await
}
_ => reply(ctx, command, config, "category.roles_usage", None).await,
}
}
Loading
Loading