Skip to content

feat(category): add possibilty to setup custom inbox category#392

Merged
Akinator31 merged 8 commits intomainfrom
365-add-possibilty-to-setup-custom-inbox-category
Apr 16, 2026
Merged

feat(category): add possibilty to setup custom inbox category#392
Akinator31 merged 8 commits intomainfrom
365-add-possibilty-to-setup-custom-inbox-category

Conversation

@Akinator31
Copy link
Copy Markdown
Member

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:

  • Introduced a new categories API handler module with endpoints for listing, creating, updating, deleting ticket categories, and managing category settings, including validation and error handling (categories.rs).
  • Added a router for the /api/categories path, wiring up all the new endpoints and applying middleware for authentication and permission checks (routes/categories.rs).
  • Registered the new categories router in the main API router setup, making the endpoints available under /api/categories (router.rs).

Permissions and Access Control:

  • Extended panel permissions to include ManageCategories, ensuring only authorized users can access the new category management endpoints (panel_permissions.rs).

Bot Command Registration:

  • Registered the new CategoryCommand in the bot command registry, enabling bot-side category management via commands (bot.rs).
  • Organized category command modules for both slash and text commands (commands/category/mod.rs, commands/category/slash_command/mod.rs).

Module Organization:

  • Added and re-exported new categories modules 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.

@Akinator31 Akinator31 requested a review from Copilot April 16, 2026 11:39
@Akinator31 Akinator31 self-assigned this Apr 16, 2026
@Akinator31 Akinator31 linked an issue Apr 16, 2026 that may be closed by this pull request
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 + !category management commands.
  • Added /api/categories CRUD/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.

Comment on lines +119 to +127
#[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>,
}
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.
Comment on lines +204 to +218
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(&params)).await
}
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +436 to +452
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
}
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +76
let _ = append_queued_message(msg.author.id.get() as i64, &msg.id.to_string(), pool).await;
return true;
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.

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).

Suggested change
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;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +194 to +205
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(());
}

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.

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.

Copilot uses AI. Check for mistakes.
Comment thread crates/rustmail/src/modules/threads.rs Outdated
Comment on lines +46 to +57
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;
}
}
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.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +105
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(),
));
}
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.
Comment on lines +110 to +122
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);
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 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.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +7
CREATE TABLE IF NOT EXISTS ticket_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
emoji TEXT,
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +214
Some(cat) => {
let parent = cat.discord_category_id.parse::<u64>().ok();
(parent, Some(cat.id.clone()))
}
None => (None, None),
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.

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.

Suggested change
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 uses AI. Check for mistakes.
@Akinator31
Copy link
Copy Markdown
Member Author

@copilot apply changes based on the comments in this thread

Copilot AI and others added 2 commits April 16, 2026 20:50
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 16, 2026

@copilot apply changes based on the comments in this thread

Implemented the requested review-thread fixes in commits 2fb9110 and a2fe3d0 (API/category-name validation + conflict handling, rename duplicate checks in text/slash commands, pending selection reliability improvements, canonical thread category assignment, case-insensitive unique category name constraint, and panel load error improvements with i18n + HTTP details).

UI screenshot (categories page error state): currently not capturable in this sandbox without a running panel/backend instance.

@Akinator31 Akinator31 merged commit a828453 into main Apr 16, 2026
2 checks passed
@Akinator31 Akinator31 deleted the 365-add-possibilty-to-setup-custom-inbox-category branch April 16, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add possibilty to setup custom inbox category

3 participants