diff --git a/crates/rustmail/src/api/handler/categories/categories.rs b/crates/rustmail/src/api/handler/categories/categories.rs index 0a38f97..62a63ed 100644 --- a/crates/rustmail/src/api/handler/categories/categories.rs +++ b/crates/rustmail/src/api/handler/categories/categories.rs @@ -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::*; @@ -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, +} + +async fn ensure_category_exists( + p: &SqlitePool, + id: &str, +) -> Result { + 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 { + let trimmed = raw.trim(); + trimmed + .parse::() + .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>>, + Path(id): Path, +) -> Result, (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>>, + Path(id): Path, + Json(req): Json, +) -> Result, (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>>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let p = pool(&bot_state).await?; + let _ = ensure_category_exists(&p, &id).await?; + let mut validated: Vec = 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>>, + Path((id, role_id)): Path<(String, String)>, +) -> Result, (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>>, + Path(id): Path, +) -> Result { + 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) +} diff --git a/crates/rustmail/src/api/routes/categories.rs b/crates/rustmail/src/api/routes/categories.rs index f51f882..8a2593a 100644 --- a/crates/rustmail/src/api/routes/categories.rs +++ b/crates/rustmail/src/api/routes/categories.rs @@ -14,6 +14,14 @@ pub fn create_categories_router(bot_state: Arc>) -> Router 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, } }) @@ -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)> { + 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 { + 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 { + 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::>() + .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, + } +} diff --git a/crates/rustmail/src/commands/category/text_command/category.rs b/crates/rustmail/src/commands/category/text_command/category.rs index 0f17ba4..5f89307 100644 --- a/crates/rustmail/src/commands/category/text_command/category.rs +++ b/crates/rustmail/src/commands/category/text_command/category.rs @@ -42,6 +42,7 @@ pub async fn category_command( "timeout" => handle_timeout(&ctx, &msg, args, pool, config).await, "on" => handle_feature_toggle(&ctx, &msg, pool, config, true).await, "off" => handle_feature_toggle(&ctx, &msg, pool, config, false).await, + "roles" => handle_roles(&ctx, &msg, args, pool, config).await, _ => send_translated(&ctx, config, &msg, "category.unknown_subcommand", None).await, } } @@ -338,3 +339,113 @@ async fn handle_feature_toggle( }; send_translated(ctx, config, msg, key, None).await } + +fn parse_role_id(raw: &str) -> Option { + let trimmed = raw.trim(); + let stripped = trimmed + .strip_prefix("<@&") + .and_then(|r| r.strip_suffix('>')) + .unwrap_or(trimmed); + stripped.parse::().ok() +} + +async fn handle_roles( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let mut parts = args.splitn(2, ' '); + let action = parts.next().unwrap_or("").trim(); + let rest = parts.next().unwrap_or("").trim(); + + match action { + "add" | "remove" => { + let mut segs = rest.rsplitn(2, ' '); + let role_raw = segs.next().unwrap_or("").trim(); + let name = segs.next().unwrap_or("").trim(); + if name.is_empty() || role_raw.is_empty() { + return send_translated(ctx, config, msg, "category.roles_usage", None).await; + } + let role = match parse_role_id(role_raw) { + Some(r) => r, + None => { + return send_translated(ctx, config, msg, "category.roles_usage", None).await; + } + }; + let cat = match get_category_by_name(name, pool).await? { + Some(c) => c, + None => return send_translated(ctx, config, msg, "category.not_found", None).await, + }; + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name.clone()); + params.insert("role".to_string(), format!("<@&{}>", role)); + if action == "add" { + let added = add_category_role(&cat.id, &role.to_string(), pool).await?; + let key = if added { + "category.role_added" + } else { + "category.role_already_linked" + }; + send_translated(ctx, config, msg, key, Some(¶ms)).await + } else { + let removed = remove_category_role(&cat.id, &role.to_string(), pool).await?; + let key = if removed { + "category.role_removed" + } else { + "category.role_not_linked" + }; + send_translated(ctx, config, msg, key, Some(¶ms)).await + } + } + "list" => { + let name = rest; + if name.is_empty() { + return send_translated(ctx, config, msg, "category.roles_usage", None).await; + } + let cat = match get_category_by_name(name, pool).await? { + Some(c) => c, + None => return send_translated(ctx, config, msg, "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 send_translated( + ctx, + config, + msg, + "category.roles_list_empty", + Some(¶ms), + ) + .await; + } + let mentions = roles + .iter() + .map(|r| format!("<@&{}>", r)) + .collect::>() + .join(", "); + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name); + params.insert("roles".to_string(), mentions); + send_translated(ctx, config, msg, "category.roles_list", Some(¶ms)).await + } + "clear" => { + let name = rest; + if name.is_empty() { + return send_translated(ctx, config, msg, "category.roles_usage", None).await; + } + let cat = match get_category_by_name(name, pool).await? { + Some(c) => c, + None => return send_translated(ctx, config, msg, "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()); + send_translated(ctx, config, msg, "category.roles_cleared", Some(¶ms)).await + } + _ => send_translated(ctx, config, msg, "category.roles_usage", None).await, + } +} diff --git a/crates/rustmail/src/db/operations/ticket_categories.rs b/crates/rustmail/src/db/operations/ticket_categories.rs index 92909e5..6d25d23 100644 --- a/crates/rustmail/src/db/operations/ticket_categories.rs +++ b/crates/rustmail/src/db/operations/ticket_categories.rs @@ -276,6 +276,15 @@ pub async fn update_category( } pub async fn delete_category(id: &str, pool: &SqlitePool) -> ModmailResult { + sqlx::query("DELETE FROM ticket_category_roles WHERE category_id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to delete category roles: {e:?}"); + validation_failed("Failed to delete category roles") + })?; + let res = sqlx::query("DELETE FROM ticket_categories WHERE id = ?") .bind(id) .execute(pool) @@ -288,6 +297,129 @@ pub async fn delete_category(id: &str, pool: &SqlitePool) -> ModmailResult Ok(res.rows_affected() > 0) } +pub async fn list_category_role_ids( + category_id: &str, + pool: &SqlitePool, +) -> ModmailResult> { + let rows = sqlx::query_scalar::<_, String>( + "SELECT role_id FROM ticket_category_roles WHERE category_id = ? ORDER BY role_id ASC", + ) + .bind(category_id) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Failed to list category roles: {e:?}"); + validation_failed("Failed to list category roles") + })?; + + Ok(rows) +} + +pub async fn add_category_role( + category_id: &str, + role_id: &str, + pool: &SqlitePool, +) -> ModmailResult { + let now = Utc::now().timestamp(); + let res = sqlx::query( + r#" + INSERT INTO ticket_category_roles (category_id, role_id, created_at) + VALUES (?, ?, ?) + ON CONFLICT(category_id, role_id) DO NOTHING + "#, + ) + .bind(category_id) + .bind(role_id) + .bind(now) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to add category role: {e:?}"); + validation_failed("Failed to add category role") + })?; + + Ok(res.rows_affected() > 0) +} + +pub async fn remove_category_role( + category_id: &str, + role_id: &str, + pool: &SqlitePool, +) -> ModmailResult { + let res = + sqlx::query("DELETE FROM ticket_category_roles WHERE category_id = ? AND role_id = ?") + .bind(category_id) + .bind(role_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to remove category role: {e:?}"); + validation_failed("Failed to remove category role") + })?; + + Ok(res.rows_affected() > 0) +} + +pub async fn clear_category_roles(category_id: &str, pool: &SqlitePool) -> ModmailResult { + let res = sqlx::query("DELETE FROM ticket_category_roles WHERE category_id = ?") + .bind(category_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to clear category roles: {e:?}"); + validation_failed("Failed to clear category roles") + })?; + + Ok(res.rows_affected()) +} + +pub async fn set_category_roles( + category_id: &str, + role_ids: &[String], + pool: &SqlitePool, +) -> ModmailResult<()> { + let mut tx = pool.begin().await.map_err(|e| { + eprintln!("Failed to begin transaction: {e:?}"); + validation_failed("Failed to begin transaction") + })?; + + sqlx::query("DELETE FROM ticket_category_roles WHERE category_id = ?") + .bind(category_id) + .execute(&mut *tx) + .await + .map_err(|e| { + eprintln!("Failed to clear category roles in tx: {e:?}"); + validation_failed("Failed to clear category roles") + })?; + + let now = Utc::now().timestamp(); + for role_id in role_ids { + sqlx::query( + r#" + INSERT INTO ticket_category_roles (category_id, role_id, created_at) + VALUES (?, ?, ?) + ON CONFLICT(category_id, role_id) DO NOTHING + "#, + ) + .bind(category_id) + .bind(role_id) + .bind(now) + .execute(&mut *tx) + .await + .map_err(|e| { + eprintln!("Failed to insert category role in tx: {e:?}"); + validation_failed("Failed to insert category role") + })?; + } + + tx.commit().await.map_err(|e| { + eprintln!("Failed to commit category roles tx: {e:?}"); + validation_failed("Failed to commit category roles") + })?; + + Ok(()) +} + pub async fn set_thread_category( thread_id: &str, category_id: Option<&str>, diff --git a/crates/rustmail/src/i18n/language/en.rs b/crates/rustmail/src/i18n/language/en.rs index 344a7f4..bd6f7b9 100644 --- a/crates/rustmail/src/i18n/language/en.rs +++ b/crates/rustmail/src/i18n/language/en.rs @@ -956,7 +956,11 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { `disable ` - Disables a specific category.\n\ `on` - Enables the category selection feature globally.\n\ `off` - Disables the category selection feature globally.\n\ - `timeout ` - Sets the time limit (in seconds) users have to select a category before defaulting." + `timeout ` - Sets the time limit (in seconds) users have to select a category before defaulting.\n\ + `roles add ` - Links a role to a category; the role is mentioned when a ticket is opened in the category.\n\ + `roles remove ` - Unlinks a role from a category.\n\ + `roles list ` - Lists roles linked to a category.\n\ + `roles clear ` - Unlinks all roles from a category." ), ); dict.messages.insert( @@ -2129,14 +2133,48 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "category.unknown_subcommand".to_string(), - DictionaryMessage::new("Unknown subcommand. Use one of: create, list, rename, move, delete, enable, disable, timeout, on, off."), + DictionaryMessage::new("Unknown subcommand. Use one of: create, list, rename, move, delete, enable, disable, timeout, on, off, roles."), ); dict.messages.insert( "category.text_usage".to_string(), - DictionaryMessage::new("Usage: `{prefix}category ...`"), + DictionaryMessage::new("Usage: `{prefix}category ...`"), ); dict.messages.insert( "category.create_usage".to_string(), DictionaryMessage::new("Usage: `{prefix}category create [| description] [| emoji]`"), ); + dict.messages.insert( + "category.roles_usage".to_string(), + DictionaryMessage::new( + "Usage: `{prefix}category roles [role_id]`", + ), + ); + dict.messages.insert( + "category.role_added".to_string(), + DictionaryMessage::new("Linked {role} to category **{name}**."), + ); + dict.messages.insert( + "category.role_already_linked".to_string(), + DictionaryMessage::new("{role} is already linked to category **{name}**."), + ); + dict.messages.insert( + "category.role_removed".to_string(), + DictionaryMessage::new("Unlinked {role} from category **{name}**."), + ); + dict.messages.insert( + "category.role_not_linked".to_string(), + DictionaryMessage::new("{role} is not linked to category **{name}**."), + ); + dict.messages.insert( + "category.roles_list".to_string(), + DictionaryMessage::new("Roles linked to **{name}**: {roles}"), + ); + dict.messages.insert( + "category.roles_list_empty".to_string(), + DictionaryMessage::new("No roles linked to category **{name}**."), + ); + dict.messages.insert( + "category.roles_cleared".to_string(), + DictionaryMessage::new("Cleared {count} role link(s) from category **{name}**."), + ); } diff --git a/crates/rustmail/src/i18n/language/fr.rs b/crates/rustmail/src/i18n/language/fr.rs index 8687af1..33fb5e5 100644 --- a/crates/rustmail/src/i18n/language/fr.rs +++ b/crates/rustmail/src/i18n/language/fr.rs @@ -972,7 +972,11 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { `disable ` - Désactive une catégorie spécifique.\n\ `on` - Active globalement la fonctionnalité de sélection de catégorie.\n\ `off` - Désactive globalement la fonctionnalité de sélection de catégorie.\n\ - `timeout ` - Définit le délai (en secondes) dont disposent les utilisateurs pour choisir une catégorie avant le choix par défaut." + `timeout ` - Définit le délai (en secondes) dont disposent les utilisateurs pour choisir une catégorie avant le choix par défaut.\n\ + `roles add ` - Associe un rôle à une catégorie ; le rôle est mentionné à l'ouverture d'un ticket dans la catégorie.\n\ + `roles remove ` - Dissocie un rôle d'une catégorie.\n\ + `roles list ` - Affiche les rôles associés à une catégorie.\n\ + `roles clear ` - Dissocie tous les rôles d'une catégorie." ), ); dict.messages.insert( @@ -2142,14 +2146,50 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "category.unknown_subcommand".to_string(), - DictionaryMessage::new("Sous-commande inconnue. Utilisez : create, list, rename, move, delete, enable, disable, timeout, on, off."), + DictionaryMessage::new("Sous-commande inconnue. Utilisez : create, list, rename, move, delete, enable, disable, timeout, on, off, roles."), ); dict.messages.insert( "category.text_usage".to_string(), - DictionaryMessage::new("Utilisation : `{prefix}category ...`"), + DictionaryMessage::new("Utilisation : `{prefix}category ...`"), ); dict.messages.insert( "category.create_usage".to_string(), DictionaryMessage::new("Utilisation : `{prefix}category create [| description] [| emoji]`"), ); + dict.messages.insert( + "category.roles_usage".to_string(), + DictionaryMessage::new( + "Utilisation : `{prefix}category roles [role_id]`", + ), + ); + dict.messages.insert( + "category.role_added".to_string(), + DictionaryMessage::new("{role} a été associé à la catégorie **{name}**."), + ); + dict.messages.insert( + "category.role_already_linked".to_string(), + DictionaryMessage::new("{role} est déjà associé à la catégorie **{name}**."), + ); + dict.messages.insert( + "category.role_removed".to_string(), + DictionaryMessage::new("{role} a été dissocié de la catégorie **{name}**."), + ); + dict.messages.insert( + "category.role_not_linked".to_string(), + DictionaryMessage::new("{role} n'est pas associé à la catégorie **{name}**."), + ); + dict.messages.insert( + "category.roles_list".to_string(), + DictionaryMessage::new("Rôles associés à **{name}** : {roles}"), + ); + dict.messages.insert( + "category.roles_list_empty".to_string(), + DictionaryMessage::new("Aucun rôle associé à la catégorie **{name}**."), + ); + dict.messages.insert( + "category.roles_cleared".to_string(), + DictionaryMessage::new( + "{count} association(s) de rôle supprimée(s) de la catégorie **{name}**.", + ), + ); } diff --git a/crates/rustmail/src/modules/categories.rs b/crates/rustmail/src/modules/categories.rs index b1f016b..7d3ec32 100644 --- a/crates/rustmail/src/modules/categories.rs +++ b/crates/rustmail/src/modules/categories.rs @@ -5,8 +5,9 @@ use crate::prelude::i18n::*; use crate::prelude::utils::*; use chrono::Utc; use serenity::all::{ - ButtonStyle, ChannelId, ComponentInteraction, Context, CreateInteractionResponse, - CreateInteractionResponseMessage, Message, ReactionType, UserId, + ButtonStyle, ChannelId, ComponentInteraction, Context, CreateAllowedMentions, + CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, Message, + ReactionType, RoleId, UserId, }; use serenity::builder::{CreateActionRow, CreateButton}; use std::collections::HashMap; @@ -227,7 +228,7 @@ pub async fn finalize_with_category( (None, None) }; - let (target_channel_id, _is_new) = create_or_get_thread_for_user( + let (target_channel_id, is_new) = create_or_get_thread_for_user( ctx, config, user, @@ -236,6 +237,14 @@ pub async fn finalize_with_category( ) .await?; + if is_new { + if let Some(cat_id) = ticket_cat_id.as_deref() { + if let Err(e) = mention_category_roles(ctx, pool, target_channel_id, cat_id).await { + eprintln!("Failed to mention category roles: {e:?}"); + } + } + } + let dm_channel = ChannelId::new(pending.dm_channel_id.parse::().unwrap_or(0)); for mid in &pending.queued_msg_ids { if let Ok(id_u64) = mid.parse::() { @@ -287,6 +296,39 @@ pub async fn handle_category_component_interaction( Ok(true) } +async fn mention_category_roles( + ctx: &Context, + pool: &sqlx::SqlitePool, + channel_id: ChannelId, + category_id: &str, +) -> Result<(), Box> { + let role_ids = list_category_role_ids(category_id, pool).await?; + if role_ids.is_empty() { + return Ok(()); + } + + let parsed: Vec = role_ids + .iter() + .filter_map(|s| s.parse::().ok().map(RoleId::new)) + .collect(); + if parsed.is_empty() { + return Ok(()); + } + + let content = parsed + .iter() + .map(|r| format!("<@&{}>", r.get())) + .collect::>() + .join(" "); + + let msg = CreateMessage::new() + .content(content) + .allowed_mentions(CreateAllowedMentions::new().roles(parsed.clone())); + + channel_id.send_message(&ctx.http, msg).await?; + Ok(()) +} + pub async fn hydrate_pending_category_selections(ctx: &Context, config: &Config) { let Some(pool) = config.db_pool.as_ref() else { return; diff --git a/crates/rustmail_panel/src/components/categories.rs b/crates/rustmail_panel/src/components/categories.rs index cdb226b..643952e 100644 --- a/crates/rustmail_panel/src/components/categories.rs +++ b/crates/rustmail_panel/src/components/categories.rs @@ -42,6 +42,16 @@ struct UpdateCategoryRequest { enabled: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct CategoryRolesDto { + pub role_ids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct CategoryRoleRequest { + role_id: String, +} + async fn fetch_categories() -> Result, String> { let resp = Request::get("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/api/categories") .send() @@ -376,6 +386,19 @@ struct CategoryCardProps { on_delete: Callback, } +async fn fetch_category_roles(id: &str) -> Result { + let url = format!("/api/categories/{}/roles", id); + let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?; + if resp.status() != 200 { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {}: {}", status, body)); + } + resp.json::() + .await + .map_err(|e| e.to_string()) +} + #[function_component(CategoryCard)] fn category_card(props: &CategoryCardProps) -> Html { let (i18n, _set_language) = use_translation(); @@ -385,8 +408,125 @@ fn category_card(props: &CategoryCardProps) -> Html { let on_delete = props.on_delete.clone(); let cat_id = c.id.clone(); + let roles = use_state(|| Vec::::new()); + let roles_loaded = use_state(|| false); + let role_error = use_state(|| None::); + let role_input_ref = use_node_ref(); + + let load_roles = { + let id = c.id.clone(); + let roles = roles.clone(); + let roles_loaded = roles_loaded.clone(); + let role_error = role_error.clone(); + Callback::from(move |_| { + let id = id.clone(); + let roles = roles.clone(); + let roles_loaded = roles_loaded.clone(); + let role_error = role_error.clone(); + spawn_local(async move { + match fetch_category_roles(&id).await { + Ok(dto) => { + roles.set(dto.role_ids); + roles_loaded.set(true); + role_error.set(None); + } + Err(e) => { + roles.set(Vec::new()); + roles_loaded.set(true); + role_error.set(Some(e)); + } + } + }); + }) + }; + + { + let load_roles = load_roles.clone(); + use_effect_with(c.id.clone(), move |_| { + load_roles.emit(()); + || () + }); + } + + let on_add_role = { + let id = c.id.clone(); + let roles = roles.clone(); + let role_error = role_error.clone(); + let role_input_ref = role_input_ref.clone(); + let i18n = i18n.clone(); + Callback::from(move |_| { + let input = match role_input_ref.cast::() { + Some(i) => i, + None => return, + }; + let raw = input.value(); + let trimmed = raw.trim().trim_start_matches("<@&").trim_end_matches('>'); + if trimmed.parse::().is_err() { + role_error.set(Some(i18n.t("panel.categories.error_role_invalid"))); + return; + } + let role_id = trimmed.to_string(); + let id = id.clone(); + let roles = roles.clone(); + let role_error = role_error.clone(); + let input = input.clone(); + spawn_local(async move { + let url = format!("/api/categories/{}/roles", id); + let body = CategoryRoleRequest { + role_id: role_id.clone(), + }; + match Request::post(&url).json(&body) { + Ok(req) => match req.send().await { + Ok(resp) if resp.status() == 200 => { + if let Ok(dto) = resp.json::().await { + roles.set(dto.role_ids); + } + role_error.set(None); + input.set_value(""); + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + role_error.set(Some(format!("HTTP {}: {}", status, body))); + } + Err(e) => role_error.set(Some(e.to_string())), + }, + Err(e) => role_error.set(Some(format!("{:?}", e))), + } + }); + }) + }; + + let on_remove_role = { + let id = c.id.clone(); + let roles = roles.clone(); + let role_error = role_error.clone(); + Callback::from(move |role_id: String| { + let id = id.clone(); + let roles = roles.clone(); + let role_error = role_error.clone(); + spawn_local(async move { + let url = format!("/api/categories/{}/roles/{}", id, role_id); + match Request::delete(&url).send().await { + Ok(resp) if resp.status() == 200 => { + if let Ok(dto) = resp.json::().await { + roles.set(dto.role_ids); + } + role_error.set(None); + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + role_error.set(Some(format!("HTTP {}: {}", status, body))); + } + Err(e) => role_error.set(Some(e.to_string())), + } + }); + }) + }; + html! { -
+

@@ -425,6 +565,67 @@ fn category_card(props: &CategoryCardProps) -> Html {

+ +
+
+

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

+

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

+
+ { + if let Some(err) = (*role_error).clone() { + html! { +
{err}
+ } + } else { html! {} } + } + { + if !*roles_loaded { + html! { +

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

+ } + } else if roles.is_empty() { + html! { +

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

+ } + } else { + html! { +
+ { + roles.iter().map(|r| { + let role_id = r.clone(); + let on_remove_role = on_remove_role.clone(); + let role_id_click = role_id.clone(); + html! { + + {role_id.clone()} + + + } + }).collect::() + } +
+ } + } + } +
+ + +
+
} } @@ -442,6 +643,7 @@ fn create_category_modal(props: &CreateCategoryModalProps) -> Html { let desc_ref = use_node_ref(); let emoji_ref = use_node_ref(); let discord_id_ref = use_node_ref(); + let roles_ref = use_node_ref(); let creating = use_state(|| false); let error = use_state(|| None::); @@ -450,6 +652,7 @@ fn create_category_modal(props: &CreateCategoryModalProps) -> Html { let desc_ref = desc_ref.clone(); let emoji_ref = emoji_ref.clone(); let discord_id_ref = discord_id_ref.clone(); + let roles_ref = roles_ref.clone(); let creating = creating.clone(); let error = error.clone(); let on_created = props.on_created.clone(); @@ -472,6 +675,10 @@ fn create_category_modal(props: &CreateCategoryModalProps) -> Html { .cast::() .map(|i| i.value()) .filter(|s| !s.trim().is_empty()); + let roles_raw = roles_ref + .cast::() + .map(|i| i.value()) + .unwrap_or_default(); if name.trim().is_empty() { error.set(Some(i18n_clone.t("panel.categories.error_name_required"))); @@ -484,6 +691,19 @@ fn create_category_modal(props: &CreateCategoryModalProps) -> Html { return; } + let mut role_ids: Vec = Vec::new(); + for token in roles_raw + .split(|c: char| c == ',' || c.is_whitespace()) + .map(|s| s.trim().trim_start_matches("<@&").trim_end_matches('>')) + .filter(|s| !s.is_empty()) + { + if token.parse::().is_err() { + error.set(Some(i18n_clone.t("panel.categories.error_role_invalid"))); + return; + } + role_ids.push(token.to_string()); + } + let req = CreateCategoryRequest { name, description: desc, @@ -501,6 +721,18 @@ fn create_category_modal(props: &CreateCategoryModalProps) -> Html { Ok(r) => match r.send().await { Ok(resp) => { if resp.status() == 200 { + let created = resp.json::().await; + if !role_ids.is_empty() { + if let Ok(ref cat) = created { + let url = format!("/api/categories/{}/roles", cat.id); + let body = CategoryRolesDto { + role_ids: role_ids.clone(), + }; + if let Ok(req) = Request::put(&url).json(&body) { + let _ = req.send().await; + } + } + } error.set(None); on_created.emit(()); } else { @@ -582,6 +814,17 @@ fn create_category_modal(props: &CreateCategoryModalProps) -> Html { />
+
+ + +

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

+
+