Skip to content

feat(bans): add the ability to view information about a banned user - #395

Merged
Akinator31 merged 10 commits into
mainfrom
372-add-the-ability-to-view-information-about-a-banned-user
Apr 18, 2026
Merged

Akinator31 merged 10 commits into
mainfrom
372-add-the-ability-to-view-information-about-a-banned-user

Conversation

@Akinator31

Copy link
Copy Markdown
Member

This pull request introduces a new "ban info" feature to the project, allowing staff to view detailed information about banned users through both API endpoints and Discord commands. It adds a new /bans admin API route, a baninfo slash and text command, and the necessary permission and handler infrastructure to support these features. Additionally, it updates permissions to control access to ban information.

Ban Info Feature Implementation:

  • Added a new baninfo command (both slash and text) that allows staff to query and display detailed information about banned users, including roles, ban reason, and who issued the ban.
  • Registered the BaninfoCommand in the bot initialization, enabling its use in Discord.

Admin API Enhancements:

  • Introduced a new /bans admin API endpoint (handle_list_bans) to retrieve and resolve information about all banned users, including role and staff name resolution.
  • Integrated the /bans endpoint into the admin router with a new ViewBans panel permission for access control.

Bot Event Handling:

  • Registered a new GuildBanHandler event handler to process ban-related events in Discord.

Command and Module Organization:

  • Added the baninfo module to the main commands module for unified access and re-exported its components.

These changes collectively provide staff with powerful tools for managing and auditing bans, both through Discord and the API, with appropriate permission checks.

@Akinator31
Akinator31 requested a review from Copilot April 18, 2026 09:34
@Akinator31 Akinator31 self-assigned this Apr 18, 2026
@Akinator31 Akinator31 linked an issue Apr 18, 2026 that may be closed by this pull request

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 “ban info” support by persisting member snapshots at ban time, exposing banned-user data via a new admin API endpoint, and surfacing it in both the web panel and Discord (slash + text) commands.

Changes:

  • Add SQLite tables + Rust DB operations to track members and persist banned-user records (including roles, reason, executor).
  • Add /api/admin/bans protected by a new ViewBans panel permission, and a new Panel “Bans” page/route.
  • Add baninfo (slash + text) command and a GuildBanHandler to record ban events.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
migrations/20260418120000_banned_users.sql Introduces tracked_members and banned_users tables + username indexes to persist ban-related snapshots.
crates/rustmail_types/src/api/panel_permissions.rs Adds ViewBans permission string mappings for backend permission checks.
crates/rustmail_panel/src/types.rs Adds ViewBans to the panel’s deserializable permission enum and display label.
crates/rustmail_panel/src/pages/panel.rs Adds /panel/bans route and renders the new bans page.
crates/rustmail_panel/src/pages/administration.rs Adds view_bans to permission management UI/options and enum mapping.
crates/rustmail_panel/src/i18n/fr/fr.json Adds navbar + panel strings for bans page and new permission label (FR).
crates/rustmail_panel/src/i18n/en/en.json Adds navbar + panel strings for bans page and new permission label (EN).
crates/rustmail_panel/src/components/navbar.rs Adds conditional navbar entries (desktop + mobile) for the bans page gated by ViewBans.
crates/rustmail_panel/src/components/mod.rs Exposes the new bans component module.
crates/rustmail_panel/src/components/bans.rs Implements the “Banned Users” page: permission check + fetch/render of /api/admin/bans.
crates/rustmail/src/i18n/language/fr.rs Adds French dictionary messages for the baninfo command output.
crates/rustmail/src/i18n/language/en.rs Adds English dictionary messages for the baninfo command output.
crates/rustmail/src/handlers/mod.rs Registers the new GuildBanHandler module export.
crates/rustmail/src/handlers/guild_messages_handler.rs Registers baninfo/bi as a text command.
crates/rustmail/src/handlers/guild_ban_handler.rs New event handler to upsert tracked members and persist banned-user records on ban events.
crates/rustmail/src/db/repr.rs Adds TrackedMember and BannedUser representations for DB operations and API/command use.
crates/rustmail/src/db/operations/mod.rs Exposes the new banned_users operations module.
crates/rustmail/src/db/operations/banned_users.rs Implements CRUD/search operations for tracked members and banned users.
crates/rustmail/src/commands/mod.rs Adds the baninfo command module exports.
crates/rustmail/src/commands/baninfo/mod.rs New baninfo command module (slash + text).
crates/rustmail/src/commands/baninfo/text_command/mod.rs Exposes baninfo text-command implementation.
crates/rustmail/src/commands/baninfo/text_command/baninfo.rs Implements !baninfo/!bi lookup + response embed generation.
crates/rustmail/src/commands/baninfo/slash_command/mod.rs Exposes baninfo slash-command implementation.
crates/rustmail/src/commands/baninfo/slash_command/baninfo.rs Implements /baninfo registration + execution logic using shared resolver/response.
crates/rustmail/src/bot.rs Registers BaninfoCommand and attaches GuildBanHandler to the Serenity client.
crates/rustmail/src/api/utils/panel_permissions.rs Grants ViewBans to super-admin/admin permission sets.
crates/rustmail/src/api/routes/admin.rs Adds /bans admin route protected by ViewBans.
crates/rustmail/src/api/handler/admin/mod.rs Wires the new bans admin handler module.
crates/rustmail/src/api/handler/admin/bans.rs Implements /api/admin/bans list endpoint with role + staff-name resolution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +162 to +166
if let Err(e) = save_banned_user(&record, pool).await {
eprintln!("Failed to save banned user {}: {:?}", banned_user.id, e);
}

let _ = delete_tracked_member(&guild_id_str, &user_id_str, pool).await;

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

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

delete_tracked_member is executed unconditionally after attempting save_banned_user. If save_banned_user fails (DB transient error, schema mismatch, etc.), this still deletes the tracked member record and can permanently lose the only copy of roles/nickname/join data you were trying to preserve. Only delete from tracked_members after the ban record was successfully persisted (or at least gate deletion on save_banned_user returning Ok).

Suggested change
if let Err(e) = save_banned_user(&record, pool).await {
eprintln!("Failed to save banned user {}: {:?}", banned_user.id, e);
}
let _ = delete_tracked_member(&guild_id_str, &user_id_str, pool).await;
match save_banned_user(&record, pool).await {
Ok(()) => {
if let Err(e) = delete_tracked_member(&guild_id_str, &user_id_str, pool).await {
eprintln!(
"Failed to delete tracked member {} from guild {} after saving banned user: {:?}",
banned_user.id, guild_id, e
);
}
}
Err(e) => {
eprintln!("Failed to save banned user {}: {:?}", banned_user.id, e);
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +146
ON CONFLICT(guild_id, user_id) DO UPDATE SET
username = excluded.username,
global_name = excluded.global_name,
nickname = excluded.nickname,
avatar_url = excluded.avatar_url,
roles = excluded.roles,
joined_at = excluded.joined_at,
banned_at = excluded.banned_at,
banned_by = excluded.banned_by,
ban_reason = excluded.ban_reason,
roles_unknown = excluded.roles_unknown

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

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

In the ON CONFLICT ... DO UPDATE clause for banned_users, joined_at is overwritten with excluded.joined_at even when the new value is NULL. If a user gets banned again and the new record lacks joined_at, this will wipe previously stored join info. Consider using joined_at = COALESCE(excluded.joined_at, banned_users.joined_at) (similar to the tracked_members upsert) to preserve existing data when the incoming value is NULL.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +34
pub async fn baninfo(
ctx: Context,
msg: Message,
config: &Config,
_handler: Arc<GuildMessagesHandler>,
) -> ModmailResult<()> {
let pool = config
.db_pool
.as_ref()
.ok_or_else(database_connection_failed)?;

let query = extract_reply_content(&msg.content, &config.command.prefix, &["baninfo", "bi"])
.unwrap_or_default();

if query.is_empty() {
return Err(ModmailError::Command(CommandError::MissingArguments));
}

let guild_id = config.bot.get_community_guild_id().to_string();
let matches = resolve_banned_users(&guild_id, &query, pool).await?;

send_baninfo_response(&ctx, config, msg.channel_id, &query, &matches).await;
Ok(())

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

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

The text !baninfo/!bi command can be triggered from any guild channel because guild-message commands are dispatched purely by prefix. This implementation doesn't restrict execution to the staff guild (or staff thread channels), so regular community members could query and receive ban details if the bot can post in that channel. Add an explicit guild/channel authorization check (e.g., require msg.guild_id == staff_guild_id or require the channel to be a staff thread) and return a permission error otherwise.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +49
pub async fn resolve_banned_users(
guild_id: &str,
query: &str,
pool: &sqlx::SqlitePool,
) -> ModmailResult<Vec<BannedUser>> {
let trimmed = query.trim().trim_start_matches('@');
if let Ok(parsed) = trimmed.parse::<u64>() {
let user_id_str = parsed.to_string();
if let Some(user) = get_banned_user_by_id(guild_id, &user_id_str, pool).await? {
return Ok(vec![user]);
}
}
find_banned_users_by_username(guild_id, trimmed, pool).await

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

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

resolve_banned_users only trims a leading '@' before attempting to parse a numeric ID. Common Discord inputs like <@123> / <@!123> won't parse, so looking up by “user id” via mention will silently fall back to username search and likely fail. Consider extracting the ID from mention formats (regex like <@!?(\d+)>) before the numeric parse.

Suggested change
pub async fn resolve_banned_users(
guild_id: &str,
query: &str,
pool: &sqlx::SqlitePool,
) -> ModmailResult<Vec<BannedUser>> {
let trimmed = query.trim().trim_start_matches('@');
if let Ok(parsed) = trimmed.parse::<u64>() {
let user_id_str = parsed.to_string();
if let Some(user) = get_banned_user_by_id(guild_id, &user_id_str, pool).await? {
return Ok(vec![user]);
}
}
find_banned_users_by_username(guild_id, trimmed, pool).await
fn normalize_banned_user_query(query: &str) -> &str {
let trimmed = query.trim();
if let Some(inner) = trimmed
.strip_prefix("<@!")
.and_then(|value| value.strip_suffix('>'))
.or_else(|| trimmed.strip_prefix("<@").and_then(|value| value.strip_suffix('>')))
{
return inner;
}
trimmed.trim_start_matches('@')
}
pub async fn resolve_banned_users(
guild_id: &str,
query: &str,
pool: &sqlx::SqlitePool,
) -> ModmailResult<Vec<BannedUser>> {
let normalized = normalize_banned_user_query(query);
if let Ok(parsed) = normalized.parse::<u64>() {
let user_id_str = parsed.to_string();
if let Some(user) = get_banned_user_by_id(guild_id, &user_id_str, pool).await? {
return Ok(vec![user]);
}
}
find_banned_users_by_username(guild_id, normalized, pool).await

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +67
let community_guild_obj = GuildId::new(community_guild_id);
let roles_map = match community_guild_obj.roles(&bot_http).await {
Ok(r) => r,
Err(_) => HashMap::new(),
};

let staff_guild_obj = GuildId::new(staff_guild_id);
let staff_members = match staff_guild_obj.members(&bot_http, None, None).await {
Ok(m) => m,
Err(_) => Vec::new(),
};
let staff_map: HashMap<String, String> = staff_members
.into_iter()
.map(|m| (m.user.id.to_string(), m.user.name.clone()))
.collect();

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

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

handle_list_bans performs multiple Discord API calls on every request (fetching all community roles and all staff guild members) just to resolve role names and banned_by. This can be slow and may hit Discord rate limits as the bans page is used more. Prefer resolving only the IDs you need (e.g., collect unique banned_by IDs from the DB results and fetch those users), and/or cache roles/staff-name lookups for a short TTL.

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +93
Ok(mut users) => {
for user in &mut users {
// Resolve roles
user.roles = user
.roles
.iter()
.map(|role_id_str| {
if let Ok(id) = role_id_str.parse::<u64>() {
if let Some(role) = roles_map.get(&serenity::all::RoleId::new(id)) {
return role.name.clone();
}
}
role_id_str.clone()
})
.collect();

// Resolve banned_by
if let Some(banned_by_id) = &user.banned_by {
if let Some(staff_name) = staff_map.get(banned_by_id) {
user.banned_by = Some(staff_name.clone());
}
}
}
(StatusCode::OK, Json(users)).into_response()

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

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

This handler mutates BannedUser.banned_by from a Discord user ID into a display name before returning JSON. Since the same BannedUser type is also used elsewhere with banned_by meaning an ID (e.g., for building Discord mentions), this makes the field semantics inconsistent across endpoints and harder to reason about. Consider returning a dedicated DTO for the panel API (e.g., banned_by_id + banned_by_name) while keeping the DB representation unchanged.

Suggested change
Ok(mut users) => {
for user in &mut users {
// Resolve roles
user.roles = user
.roles
.iter()
.map(|role_id_str| {
if let Ok(id) = role_id_str.parse::<u64>() {
if let Some(role) = roles_map.get(&serenity::all::RoleId::new(id)) {
return role.name.clone();
}
}
role_id_str.clone()
})
.collect();
// Resolve banned_by
if let Some(banned_by_id) = &user.banned_by {
if let Some(staff_name) = staff_map.get(banned_by_id) {
user.banned_by = Some(staff_name.clone());
}
}
}
(StatusCode::OK, Json(users)).into_response()
Ok(users) => {
let panel_users: Vec<serde_json::Value> = users
.into_iter()
.map(|user| {
let resolved_roles: Vec<String> = user
.roles
.iter()
.map(|role_id_str| {
if let Ok(id) = role_id_str.parse::<u64>() {
if let Some(role) = roles_map.get(&serenity::all::RoleId::new(id)) {
return role.name.clone();
}
}
role_id_str.clone()
})
.collect();
let banned_by_id = user.banned_by.clone();
let banned_by_name = banned_by_id
.as_ref()
.and_then(|id| staff_map.get(id).cloned());
let mut user_json = match serde_json::to_value(&user) {
Ok(value) => value,
Err(_) => serde_json::json!({}),
};
if let Some(user_obj) = user_json.as_object_mut() {
user_obj.insert("roles".to_string(), serde_json::json!(resolved_roles));
user_obj.remove("banned_by");
user_obj.insert("banned_by_id".to_string(), serde_json::json!(banned_by_id));
user_obj.insert(
"banned_by_name".to_string(),
serde_json::json!(banned_by_name),
);
}
user_json
})
.collect();
(StatusCode::OK, Json(panel_users)).into_response()

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 commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Implemented the requested review-thread fixes in commit 08e733a.

Included changes: guarded tracked-member deletion on successful ban save, preserved joined_at on conflict when incoming value is null, restricted text !baninfo/!bi to staff guild, added mention-aware ID normalization (<@...>/<@!...>), and switched /api/admin/bans to a dedicated DTO with banned_by_id + banned_by_name while resolving only needed moderator IDs.

UI screenshot: N/A (no visible UI layout change).

@Akinator31
Akinator31 merged commit cdf5c29 into main Apr 18, 2026
2 of 3 checks passed
@Akinator31
Akinator31 deleted the 372-add-the-ability-to-view-information-about-a-banned-user branch April 18, 2026 10:08
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 the ability to view information about a banned user

3 participants