feat(category): add possibilty to setup custom inbox category#392
feat(category): add possibilty to setup custom inbox category#392Akinator31 merged 8 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds end-to-end “ticket categories” support so users can choose a category before a DM ticket is created, with management via REST API, bot commands, and the admin panel (permission-gated).
Changes:
- Added DB schema for categories, global category-selection settings, and pending DM selections + thread linkage.
- Implemented category selection flow in the bot (prompt, queue messages, finalize on click/timeout) and added
/category+!categorymanagement commands. - Added
/api/categoriesCRUD/settings endpoints plus panel UI and permission wiring (ManageCategories).
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| migrations/20260413120000_ticket_categories.sql | Creates tables/indexes for categories, settings, pending selections; adds threads.ticket_category_id. |
| docs/guides/tickets.md | Documents the category selection feature behavior and staff management commands. |
| docs/guides/commands.md | Adds /category / !category documentation and subcommand list. |
| crates/rustmail_types/src/api/panel_permissions.rs | Adds ManageCategories permission and string mapping for API/types. |
| crates/rustmail_panel/src/types.rs | Adds ManageCategories permission display name for panel UI. |
| crates/rustmail_panel/src/pages/panel.rs | Adds /panel/categories route and renders CategoriesPage. |
| crates/rustmail_panel/src/pages/administration.rs | Adds manage_categories permission checkbox + mapping in admin UI. |
| crates/rustmail_panel/src/i18n/fr/fr.json | Adds navbar label, permission label, and categories page strings (FR). |
| crates/rustmail_panel/src/i18n/en/en.json | Adds navbar label, permission label, and categories page strings (EN). |
| crates/rustmail_panel/src/components/navbar.rs | Adds “Categories” nav entry gated by ManageCategories. |
| crates/rustmail_panel/src/components/mod.rs | Exposes new categories component module. |
| crates/rustmail_panel/src/components/categories.rs | New panel page to list/create/delete/enable categories and edit global settings. |
| crates/rustmail/src/modules/threads.rs | Extends thread creation to accept a Discord category override + persist selected ticket category id. |
| crates/rustmail/src/modules/mod.rs | Registers and re-exports the new categories module. |
| crates/rustmail/src/modules/categories.rs | Implements DM category prompt, pending selection persistence, timeout scheduling, and finalize logic. |
| crates/rustmail/src/i18n/language/fr.rs | Adds translated help text and category-selection messages (FR). |
| crates/rustmail/src/i18n/language/en.rs | Adds translated help text and category-selection messages (EN). |
| crates/rustmail/src/handlers/ready_handler.rs | Hydrates pending category selections on startup to reschedule timeouts/finalize expired. |
| crates/rustmail/src/handlers/guild_messages_handler.rs | Hooks category selection into DM handling path before opening ticket. |
| crates/rustmail/src/handlers/guild_interaction_handler.rs | Routes component interactions to category selection handler. |
| crates/rustmail/src/db/repr.rs | Adds DB representation structs for categories, settings, and pending selections. |
| crates/rustmail/src/db/operations/ticket_categories.rs | New DB ops for category CRUD, settings, and pending selection queueing. |
| crates/rustmail/src/db/operations/mod.rs | Exposes ticket_categories operations module. |
| crates/rustmail/src/commands/mod.rs | Registers the new category command module. |
| crates/rustmail/src/commands/category/text_command/mod.rs | Adds text command module wiring for !category. |
| crates/rustmail/src/commands/category/text_command/category.rs | Implements !category subcommands (create/list/rename/move/delete/enable/disable/on/off/timeout). |
| crates/rustmail/src/commands/category/slash_command/mod.rs | Adds slash command module wiring for /category. |
| crates/rustmail/src/commands/category/slash_command/category.rs | Implements /category subcommands and registration. |
| crates/rustmail/src/commands/category/mod.rs | Re-exports slash + text category commands. |
| crates/rustmail/src/bot.rs | Registers CategoryCommand with the command registry. |
| crates/rustmail/src/api/utils/panel_permissions.rs | Adds ManageCategories to returned permission sets. |
| crates/rustmail/src/api/routes/mod.rs | Registers new categories route module. |
| crates/rustmail/src/api/routes/categories.rs | New router for /api/categories with auth + permission middleware. |
| crates/rustmail/src/api/router.rs | Nests the categories router under /api/categories. |
| crates/rustmail/src/api/handler/mod.rs | Registers new categories handler module. |
| crates/rustmail/src/api/handler/categories/mod.rs | Exposes categories handlers. |
| crates/rustmail/src/api/handler/categories/categories.rs | Implements categories CRUD + settings REST handlers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #[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>, | ||
| } |
There was a problem hiding this comment.
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.
| let mut parts = args.splitn(2, ' '); | ||
| let old = parts.next().unwrap_or("").trim(); | ||
| let new = parts.next().unwrap_or("").trim(); | ||
| if old.is_empty() || new.is_empty() { | ||
| return send_translated(ctx, config, msg, "category.text_usage", None).await; | ||
| } | ||
| let cat = match get_category_by_name(old, pool).await? { | ||
| Some(c) => c, | ||
| None => return send_translated(ctx, config, msg, "category.not_found", None).await, | ||
| }; | ||
| update_category(&cat.id, Some(new), None, None, None, None, None, pool).await?; | ||
| let mut params = HashMap::new(); | ||
| params.insert("name".to_string(), new.to_string()); | ||
| send_translated(ctx, config, msg, "category.renamed", Some(¶ms)).await | ||
| } |
There was a problem hiding this comment.
rename doesn’t check whether new_name already exists, which can create duplicates/ambiguity (or will start failing once a UNIQUE constraint is added). Add a pre-check for get_category_by_name(new) and return the existing category.already_exists message if it’s taken.
| let old = match get_string(options, "old_name") { | ||
| Some(v) => v, | ||
| None => return reply(ctx, command, config, "category.text_usage", None).await, | ||
| }; | ||
| let new = match get_string(options, "new_name") { | ||
| Some(v) => v, | ||
| None => return reply(ctx, command, config, "category.text_usage", None).await, | ||
| }; | ||
| let cat = match get_category_by_name(&old, pool).await? { | ||
| Some(c) => c, | ||
| None => return reply(ctx, command, config, "category.not_found", None).await, | ||
| }; | ||
| update_category(&cat.id, Some(&new), None, None, None, None, None, pool).await?; | ||
| let mut params = HashMap::new(); | ||
| params.insert("name".to_string(), new); | ||
| reply(ctx, command, config, "category.renamed", Some(params)).await | ||
| } |
There was a problem hiding this comment.
rename doesn’t check whether new_name already exists, which can create duplicates/ambiguity (or will start failing once a UNIQUE constraint is added). Add a pre-check for get_category_by_name(new_name) and return category.already_exists when it’s taken.
| let _ = append_queued_message(msg.author.id.get() as i64, &msg.id.to_string(), pool).await; | ||
| return true; |
There was a problem hiding this comment.
When a pending selection already exists, append_queued_message errors are ignored but the function still returns true, causing the caller to skip normal ticket creation/forwarding. If the DB update fails, the user’s message can be dropped. Handle the error (e.g., log + return false so normal flow proceeds, or surface an error to the user).
| let _ = append_queued_message(msg.author.id.get() as i64, &msg.id.to_string(), pool).await; | |
| return true; | |
| match append_queued_message(msg.author.id.get() as i64, &msg.id.to_string(), pool).await { | |
| Ok(_) => return true, | |
| Err(err) => { | |
| eprintln!( | |
| "failed to append queued message for user {} and message {}: {}", | |
| msg.author.id.get(), | |
| msg.id, | |
| err | |
| ); | |
| return false; | |
| } | |
| } |
| let pending = match get_pending_selection(user_id, pool).await? { | ||
| Some(p) => p, | ||
| None => return Ok(()), | ||
| }; | ||
|
|
||
| if !delete_pending_selection(user_id, pool) | ||
| .await | ||
| .unwrap_or(false) | ||
| { | ||
| return Ok(()); | ||
| } | ||
|
|
There was a problem hiding this comment.
finalize_with_category deletes the pending selection before creating the thread and forwarding queued messages. If thread creation/forwarding fails after deletion, the pending state is lost and queued messages won’t be retried. Consider deleting the pending row only after successful thread creation (and ideally after forwarding), or perform the whole finalize flow in a transaction/compensate on failure.
| if let Some(thread) = get_thread_by_channel_id(&channel.id.to_string(), pool).await { | ||
| let _ = set_thread_category(&thread.id, Some(cat_id), pool).await; | ||
| } | ||
| } |
There was a problem hiding this comment.
set_thread_category is best-effort and only attempts to set the category for the newly created channel. In the common “duplicate thread” path (create_thread_for_user returns an existing thread id and the new channel gets deleted), this will never set ticket_category_id on the canonical thread. Capture the thread id returned by create_thread_for_user and set the category on that thread id (and consider logging/propagating errors instead of silently ignoring them).
| 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(), | ||
| )); | ||
| } |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
The page sets a hardcoded English error ("Failed to load") and ignores response status/body on failures, which makes troubleshooting difficult and bypasses i18n. Consider using an i18n key (similar to panel.apikeys.*) and include HTTP status/text for non-200 responses so users get actionable feedback.
| CREATE TABLE IF NOT EXISTS ticket_categories ( | ||
| id TEXT PRIMARY KEY, | ||
| name TEXT NOT NULL, | ||
| description TEXT, | ||
| emoji TEXT, |
There was a problem hiding this comment.
ticket_categories.name is used as a natural key in multiple commands/endpoints (rename/delete/enable by name), but the table definition doesn’t enforce uniqueness. This allows duplicate names and makes get_category_by_name ambiguous. Add a UNIQUE constraint (ideally case-insensitive via COLLATE NOCASE) on name (or a unique index) and handle conflicts accordingly.
| Some(cat) => { | ||
| let parent = cat.discord_category_id.parse::<u64>().ok(); | ||
| (parent, Some(cat.id.clone())) | ||
| } | ||
| None => (None, None), |
There was a problem hiding this comment.
A category picked from the prompt is accepted even if it has been disabled since the prompt was sent. finalize_with_category should verify the category is still enabled (or treat it as default/None) to avoid routing tickets into disabled categories.
| Some(cat) => { | |
| let parent = cat.discord_category_id.parse::<u64>().ok(); | |
| (parent, Some(cat.id.clone())) | |
| } | |
| None => (None, None), | |
| Some(cat) if cat.enabled => { | |
| let parent = cat.discord_category_id.parse::<u64>().ok(); | |
| (parent, Some(cat.id.clone())) | |
| } | |
| Some(_) | None => (None, None), |
|
@copilot apply changes based on the comments in this thread |
… panel Agent-Logs-Url: https://github.com/Rustmail/rustmail/sessions/7f12ccf8-887b-4e84-8b8f-9f4629b1f1a4 Co-authored-by: Akinator31 <99099121+Akinator31@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Rustmail/rustmail/sessions/7f12ccf8-887b-4e84-8b8f-9f4629b1f1a4 Co-authored-by: Akinator31 <99099121+Akinator31@users.noreply.github.com>
Implemented the requested review-thread fixes in commits UI screenshot (categories page error state): currently not capturable in this sandbox without a running panel/backend instance. |
This pull request introduces a full-featured API and command set for managing ticket categories in the application. It adds new REST API endpoints for CRUD operations and settings management on categories, integrates permission checks, and registers new bot commands for category management. The changes are organized into new modules, update the router, and extend panel permissions to support category management.
API and Handler Additions:
categoriesAPI handler module with endpoints for listing, creating, updating, deleting ticket categories, and managing category settings, including validation and error handling (categories.rs)./api/categoriespath, wiring up all the new endpoints and applying middleware for authentication and permission checks (routes/categories.rs)./api/categories(router.rs).Permissions and Access Control:
ManageCategories, ensuring only authorized users can access the new category management endpoints (panel_permissions.rs).Bot Command Registration:
CategoryCommandin the bot command registry, enabling bot-side category management via commands (bot.rs).commands/category/mod.rs,commands/category/slash_command/mod.rs).Module Organization:
categoriesmodules in both handler and route layer module trees to ensure correct visibility and import paths (handler/mod.rs,routes/mod.rs,handler/categories/mod.rs).These changes collectively enable robust, permissioned management of ticket categories through both the API and bot interface.