use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::service::im::events::{CategoryAction, CategoryEvent}; use crate::models::channels::ChannelCategory; use crate::models::common::Role; use crate::service::ImService; use crate::service::im::events::ImEvent; use super::session::ImSession; use super::util::*; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateCategoryParams { pub name: String, pub position: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateCategoryParams { pub name: Option, pub position: Option, pub collapsed: Option, } impl ImService { async fn category_realtime( &self, workspace_name: &str, category_id: Uuid, action: CategoryAction, ) { let request_id = Uuid::nil(); let event = CategoryEvent { workspace_name: workspace_name.to_string(), category_id, action, }; self.publish(&format!("im.category.{workspace_name}"), request_id, &event) .await; self.emit_event(ImEvent::Category { request_id, data: event, }); } pub async fn category_list( &self, ctx: &ImSession, wk_name: &str, ) -> Result, AppError> { let user_uid = ctx.user; let ws = self.resolve_workspace(wk_name).await?; self.ensure_workspace_readable(user_uid, &ws).await?; sqlx::query_as::<_, ChannelCategory>( "SELECT id, workspace_id, name, position, collapsed, created_by, created_at, updated_at \ FROM channel_category WHERE workspace_id = $1 ORDER BY position ASC, name ASC", ) .bind(ws.id) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn category_create( &self, ctx: &ImSession, wk_name: &str, params: CreateCategoryParams, ) -> Result { let user_uid = ctx.user; let ws = self.resolve_workspace(wk_name).await?; self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member) .await?; let name = required_text(params.name, "name")?; let now = chrono::Utc::now(); let category = sqlx::query_as::<_, ChannelCategory>( "INSERT INTO channel_category (id, workspace_id, name, position, collapsed, created_by, created_at, updated_at) \ VALUES ($1, $2, $3, $4, false, $5, $6, $6) \ RETURNING id, workspace_id, name, position, collapsed, created_by, created_at, updated_at", ) .bind(Uuid::now_v7()) .bind(ws.id) .bind(&name) .bind(params.position.unwrap_or(0)) .bind(user_uid) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; self.category_realtime(wk_name, category.id, CategoryAction::Created) .await; Ok(category) } pub async fn category_update( &self, ctx: &ImSession, wk_name: &str, category_id: Uuid, params: UpdateCategoryParams, ) -> Result { let user_uid = ctx.user; let ws = self.resolve_workspace(wk_name).await?; self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member) .await?; let cat = self.resolve_category(category_id, ws.id).await?; let new_name = params.name.unwrap_or(cat.name); let now = chrono::Utc::now(); let category = sqlx::query_as::<_, ChannelCategory>( "UPDATE channel_category SET name = $1, position = COALESCE($2, position), \ collapsed = COALESCE($3, collapsed), updated_at = $4 \ WHERE id = $5 \ RETURNING id, workspace_id, name, position, collapsed, created_by, created_at, updated_at", ) .bind(&new_name) .bind(params.position) .bind(params.collapsed) .bind(now) .bind(category_id) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; self.category_realtime(wk_name, category_id, CategoryAction::Updated) .await; Ok(category) } pub async fn category_delete( &self, ctx: &ImSession, wk_name: &str, category_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user; let ws = self.resolve_workspace(wk_name).await?; self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) .await?; let result = sqlx::query("DELETE FROM channel_category WHERE id = $1 AND workspace_id = $2") .bind(category_id) .bind(ws.id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "category not found")?; self.category_realtime(wk_name, category_id, CategoryAction::Deleted) .await; Ok(()) } pub(crate) async fn resolve_category( &self, category_id: Uuid, workspace_id: Uuid, ) -> Result { sqlx::query_as::<_, ChannelCategory>( "SELECT id, workspace_id, name, position, collapsed, created_by, created_at, updated_at \ FROM channel_category WHERE id = $1 AND workspace_id = $2", ) .bind(category_id) .bind(workspace_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("category not found".into())) } }