Skip to content

Commit a828453

Browse files
authored
Merge pull request #392 from Rustmail/365-add-possibilty-to-setup-custom-inbox-category
feat(category): add possibilty to setup custom inbox category
2 parents a2093c7 + 1bce5c6 commit a828453

37 files changed

Lines changed: 3098 additions & 7 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
use crate::db::operations::ticket_categories::CATEGORY_BUTTON_HARD_LIMIT;
2+
use crate::db::operations::{
3+
count_enabled_categories, create_category, delete_category, get_category_by_id,
4+
get_category_by_name, get_category_settings, list_all_categories, update_category,
5+
update_category_settings,
6+
};
7+
use crate::db::repr::{TicketCategory, TicketCategorySettings};
8+
use crate::prelude::types::*;
9+
use axum::Json;
10+
use axum::extract::{Path, State};
11+
use axum::http::StatusCode;
12+
use serde::{Deserialize, Serialize};
13+
use sqlx::SqlitePool;
14+
use std::sync::Arc;
15+
use tokio::sync::Mutex;
16+
17+
async fn pool(bot_state: &Arc<Mutex<BotState>>) -> Result<SqlitePool, (StatusCode, String)> {
18+
let state_lock = bot_state.lock().await;
19+
match &state_lock.db_pool {
20+
Some(p) => Ok(p.clone()),
21+
None => Err((
22+
StatusCode::INTERNAL_SERVER_ERROR,
23+
"Database not initialized".to_string(),
24+
)),
25+
}
26+
}
27+
28+
fn internal(e: impl ToString) -> (StatusCode, String) {
29+
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
30+
}
31+
32+
#[derive(Serialize, Deserialize)]
33+
pub struct CategoryDto {
34+
pub id: String,
35+
pub name: String,
36+
pub description: Option<String>,
37+
pub emoji: Option<String>,
38+
pub discord_category_id: String,
39+
pub position: i64,
40+
pub enabled: bool,
41+
pub created_at: i64,
42+
pub updated_at: i64,
43+
}
44+
45+
impl From<TicketCategory> for CategoryDto {
46+
fn from(c: TicketCategory) -> Self {
47+
Self {
48+
id: c.id,
49+
name: c.name,
50+
description: c.description,
51+
emoji: c.emoji,
52+
discord_category_id: c.discord_category_id,
53+
position: c.position,
54+
enabled: c.enabled,
55+
created_at: c.created_at,
56+
updated_at: c.updated_at,
57+
}
58+
}
59+
}
60+
61+
pub async fn list_categories_handler(
62+
State(bot_state): State<Arc<Mutex<BotState>>>,
63+
) -> Result<Json<Vec<CategoryDto>>, (StatusCode, String)> {
64+
let p = pool(&bot_state).await?;
65+
let cats = list_all_categories(&p).await.map_err(internal)?;
66+
Ok(Json(cats.into_iter().map(CategoryDto::from).collect()))
67+
}
68+
69+
#[derive(Deserialize)]
70+
pub struct CreateCategoryRequest {
71+
pub name: String,
72+
pub description: Option<String>,
73+
pub emoji: Option<String>,
74+
pub discord_category_id: String,
75+
}
76+
77+
pub async fn create_category_handler(
78+
State(bot_state): State<Arc<Mutex<BotState>>>,
79+
Json(req): Json<CreateCategoryRequest>,
80+
) -> Result<Json<CategoryDto>, (StatusCode, String)> {
81+
let name = req.name.trim();
82+
if name.is_empty() {
83+
return Err((StatusCode::BAD_REQUEST, "Name required".to_string()));
84+
}
85+
if req.discord_category_id.parse::<u64>().is_err() {
86+
return Err((
87+
StatusCode::BAD_REQUEST,
88+
"Invalid discord_category_id".to_string(),
89+
));
90+
}
91+
let p = pool(&bot_state).await?;
92+
93+
let enabled_count = count_enabled_categories(&p).await.map_err(internal)?;
94+
if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT {
95+
return Err((
96+
StatusCode::BAD_REQUEST,
97+
format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT),
98+
));
99+
}
100+
101+
if get_category_by_name(name, &p)
102+
.await
103+
.map_err(internal)?
104+
.is_some()
105+
{
106+
return Err((
107+
StatusCode::CONFLICT,
108+
"Category with this name already exists".to_string(),
109+
));
110+
}
111+
112+
let created = create_category(
113+
name,
114+
req.description.as_deref(),
115+
req.emoji.as_deref(),
116+
&req.discord_category_id,
117+
&p,
118+
)
119+
.await
120+
.map_err(internal)?;
121+
Ok(Json(created.into()))
122+
}
123+
124+
#[derive(Deserialize)]
125+
pub struct UpdateCategoryRequest {
126+
pub name: Option<String>,
127+
pub description: Option<Option<String>>,
128+
pub emoji: Option<Option<String>>,
129+
pub discord_category_id: Option<String>,
130+
pub position: Option<i64>,
131+
pub enabled: Option<bool>,
132+
}
133+
134+
pub async fn update_category_handler(
135+
State(bot_state): State<Arc<Mutex<BotState>>>,
136+
Path(id): Path<String>,
137+
Json(req): Json<UpdateCategoryRequest>,
138+
) -> Result<Json<CategoryDto>, (StatusCode, String)> {
139+
let p = pool(&bot_state).await?;
140+
141+
let existing = get_category_by_id(&id, &p)
142+
.await
143+
.map_err(internal)?
144+
.ok_or((StatusCode::NOT_FOUND, "Category not found".to_string()))?;
145+
146+
let trimmed_name = req.name.as_deref().map(str::trim);
147+
if let Some(name) = trimmed_name {
148+
if name.is_empty() {
149+
return Err((StatusCode::BAD_REQUEST, "Name required".to_string()));
150+
}
151+
152+
if let Some(conflict) = get_category_by_name(name, &p).await.map_err(internal)? {
153+
if conflict.id != existing.id {
154+
return Err((
155+
StatusCode::CONFLICT,
156+
"Category with this name already exists".to_string(),
157+
));
158+
}
159+
}
160+
}
161+
162+
if let Some(true) = req.enabled {
163+
if !existing.enabled {
164+
let enabled_count = count_enabled_categories(&p).await.map_err(internal)?;
165+
if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT {
166+
return Err((
167+
StatusCode::BAD_REQUEST,
168+
format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT),
169+
));
170+
}
171+
}
172+
}
173+
174+
if let Some(ref did) = req.discord_category_id {
175+
if did.parse::<u64>().is_err() {
176+
return Err((
177+
StatusCode::BAD_REQUEST,
178+
"Invalid discord_category_id".to_string(),
179+
));
180+
}
181+
}
182+
183+
update_category(
184+
&id,
185+
trimmed_name,
186+
req.description.as_ref().map(|o| o.as_deref()),
187+
req.emoji.as_ref().map(|o| o.as_deref()),
188+
req.discord_category_id.as_deref(),
189+
req.position,
190+
req.enabled,
191+
&p,
192+
)
193+
.await
194+
.map_err(internal)?;
195+
196+
let updated = get_category_by_id(&id, &p)
197+
.await
198+
.map_err(internal)?
199+
.ok_or((StatusCode::NOT_FOUND, "Category not found".to_string()))?;
200+
Ok(Json(updated.into()))
201+
}
202+
203+
pub async fn delete_category_handler(
204+
State(bot_state): State<Arc<Mutex<BotState>>>,
205+
Path(id): Path<String>,
206+
) -> Result<StatusCode, (StatusCode, String)> {
207+
let p = pool(&bot_state).await?;
208+
let existed = delete_category(&id, &p).await.map_err(internal)?;
209+
if existed {
210+
Ok(StatusCode::NO_CONTENT)
211+
} else {
212+
Err((StatusCode::NOT_FOUND, "Category not found".to_string()))
213+
}
214+
}
215+
216+
#[derive(Serialize, Deserialize)]
217+
pub struct CategorySettingsDto {
218+
pub enabled: bool,
219+
pub selection_timeout_s: i64,
220+
}
221+
222+
impl From<TicketCategorySettings> for CategorySettingsDto {
223+
fn from(s: TicketCategorySettings) -> Self {
224+
Self {
225+
enabled: s.enabled,
226+
selection_timeout_s: s.selection_timeout_s,
227+
}
228+
}
229+
}
230+
231+
pub async fn get_category_settings_handler(
232+
State(bot_state): State<Arc<Mutex<BotState>>>,
233+
) -> Result<Json<CategorySettingsDto>, (StatusCode, String)> {
234+
let p = pool(&bot_state).await?;
235+
let s = get_category_settings(&p).await.map_err(internal)?;
236+
Ok(Json(s.into()))
237+
}
238+
239+
pub async fn update_category_settings_handler(
240+
State(bot_state): State<Arc<Mutex<BotState>>>,
241+
Json(req): Json<CategorySettingsDto>,
242+
) -> Result<Json<CategorySettingsDto>, (StatusCode, String)> {
243+
if req.selection_timeout_s < 30 {
244+
return Err((
245+
StatusCode::BAD_REQUEST,
246+
"selection_timeout_s must be >= 30".to_string(),
247+
));
248+
}
249+
let p = pool(&bot_state).await?;
250+
update_category_settings(req.enabled, req.selection_timeout_s, &p)
251+
.await
252+
.map_err(internal)?;
253+
let s = get_category_settings(&p).await.map_err(internal)?;
254+
Ok(Json(s.into()))
255+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mod categories;
2+
3+
pub use categories::*;

crates/rustmail/src/api/handler/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod admin;
22
pub mod apikeys;
33
pub mod auth;
44
pub mod bot;
5+
pub mod categories;
56
pub mod externals;
67
pub mod panel;
78
pub mod user;
@@ -10,6 +11,7 @@ pub use admin::*;
1011
pub use apikeys::*;
1112
pub use auth::*;
1213
pub use bot::*;
14+
pub use categories::*;
1315
pub use externals::*;
1416
pub use panel::*;
1517
pub use user::*;

crates/rustmail/src/api/router.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use tokio::sync::Mutex;
77
pub fn create_api_router(bot_state: Arc<Mutex<BotState>>) -> Router {
88
let admin_router = create_admin_router(bot_state.clone());
99
let apikeys_router = create_apikeys_router(bot_state.clone());
10+
let categories_router = create_categories_router(bot_state.clone());
1011
let bot_router = create_bot_router(bot_state.clone());
1112
let auth_router = create_auth_router();
1213
let panel_router = create_panel_router(bot_state.clone());
@@ -16,6 +17,7 @@ pub fn create_api_router(bot_state: Arc<Mutex<BotState>>) -> Router {
1617
let app = Router::new()
1718
.nest("/api/admin", admin_router)
1819
.nest("/api/apikeys", apikeys_router)
20+
.nest("/api/categories", categories_router)
1921
.nest("/api/bot", bot_router)
2022
.nest("/api/auth", auth_router)
2123
.nest("/api/panel", panel_router)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use crate::prelude::api::*;
2+
use crate::prelude::types::*;
3+
use axum::Router;
4+
use axum::routing::{delete, get, patch, post, put};
5+
use rustmail_types::api::panel_permissions::PanelPermission;
6+
use std::sync::Arc;
7+
use tokio::sync::Mutex;
8+
9+
pub fn create_categories_router(bot_state: Arc<Mutex<BotState>>) -> Router<Arc<Mutex<BotState>>> {
10+
Router::new()
11+
.route("/", get(list_categories_handler))
12+
.route("/", post(create_category_handler))
13+
.route("/{id}", patch(update_category_handler))
14+
.route("/{id}", delete(delete_category_handler))
15+
.route("/settings", get(get_category_settings_handler))
16+
.route("/settings", put(update_category_settings_handler))
17+
.layer(axum::middleware::from_fn_with_state(
18+
bot_state.clone(),
19+
move |state, jar, req, next| {
20+
require_panel_permission(state, jar, req, next, PanelPermission::ManageCategories)
21+
},
22+
))
23+
.layer(axum::middleware::from_fn_with_state(
24+
bot_state,
25+
auth_middleware,
26+
))
27+
}

crates/rustmail/src/api/routes/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod admin;
22
pub mod apikeys;
33
pub mod auth;
44
pub mod bot;
5+
pub mod categories;
56
pub mod externals;
67
pub mod panel;
78
pub mod user;
@@ -10,6 +11,7 @@ pub use admin::*;
1011
pub use apikeys::*;
1112
pub use auth::*;
1213
pub use bot::*;
14+
pub use categories::*;
1315
pub use externals::*;
1416
pub use panel::*;
1517
pub use user::*;

crates/rustmail/src/api/utils/panel_permissions.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ pub async fn get_user_panel_permissions(
7272
PanelPermission::ManageTickets,
7373
PanelPermission::ManageApiKeys,
7474
PanelPermission::ManagePermissions,
75+
PanelPermission::ManageCategories,
7576
];
7677
cache.insert(user_id.to_string(), permissions.clone()).await;
7778
return permissions;
@@ -85,6 +86,7 @@ pub async fn get_user_panel_permissions(
8586
PanelPermission::ManageTickets,
8687
PanelPermission::ManageApiKeys,
8788
PanelPermission::ManagePermissions,
89+
PanelPermission::ManageCategories,
8890
];
8991
}
9092

crates/rustmail/src/bot.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ pub async fn run_bot(
147147
registry.register_command(PingCommand);
148148
registry.register_command(SnippetCommand);
149149
registry.register_command(StatusCommand);
150+
registry.register_command(CategoryCommand);
150151

151152
let registry = Arc::new(registry);
152153

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pub mod slash_command;
2+
pub mod text_command;
3+
4+
pub use slash_command::*;
5+
pub use text_command::*;

0 commit comments

Comments
 (0)