use crate::error::RoomError; use crate::service::RoomService; use models::agents::model as ai_model; use models::projects::{MemberRole, project, project_history_name, project_members}; use models::rooms::{ MessageContentType, RoomMemberRole, room, room_ai, room_category, room_member, room_message, room_notifications, room_pin, room_thread, }; use models::users::user as user_model; use sea_orm::*; use uuid::Uuid; impl From for super::RoomCategoryResponse { fn from(value: room_category::Model) -> Self { Self { id: value.id, project: value.project, name: value.name, position: value.position, created_by: value.created_by, created_at: value.created_at, } } } impl From for super::RoomResponse { fn from(value: room::Model) -> Self { Self { id: value.id, project: value.project, room_name: value.room_name, public: value.public, category: value.category, created_by: value.created_by, created_at: value.created_at, last_msg_at: value.last_msg_at, unread_count: 0, } } } impl From for super::RoomMemberResponse { fn from(value: room_member::Model) -> Self { Self { room: value.room, user: value.user, user_info: None, role: value.role.to_string(), first_msg_in: value.first_msg_in, joined_at: value.joined_at, last_read_seq: value.last_read_seq, do_not_disturb: value.do_not_disturb, dnd_start_hour: value.dnd_start_hour, dnd_end_hour: value.dnd_end_hour, } } } impl From for super::RoomMessageResponse { fn from(value: room_message::Model) -> Self { Self { id: value.id, seq: value.seq, room: value.room, sender_type: value.sender_type.to_string(), sender_id: value.sender_id, display_name: None, thread: value.thread, content: value.content, content_type: value.content_type.to_string(), edited_at: value.edited_at, send_at: value.send_at, revoked: value.revoked, revoked_by: value.revoked_by, in_reply_to: value.in_reply_to, } } } impl From for super::RoomThreadResponse { fn from(value: room_thread::Model) -> Self { Self { id: value.id, room: value.room, parent: value.parent, created_by: value.created_by, participants: value.participants, last_message_at: value.last_message_at, last_message_preview: value.last_message_preview, created_at: value.created_at, updated_at: value.updated_at, } } } impl From for super::RoomPinResponse { fn from(value: room_pin::Model) -> Self { Self { room: value.room, message: value.message, pinned_by: value.pinned_by, pinned_at: value.pinned_at, } } } impl From for super::RoomAiResponse { fn from(value: room_ai::Model) -> Self { Self { room: value.room, model: value.model, version: value.version, call_count: value.call_count, last_call_at: value.last_call_at, history_limit: value.history_limit, system_prompt: value.system_prompt, temperature: value.temperature, max_tokens: value.max_tokens, use_exact: value.use_exact, think: value.think, stream: value.stream, min_score: value.min_score, created_at: value.created_at, updated_at: value.updated_at, } } } impl From for super::NotificationResponse { fn from(value: room_notifications::Model) -> Self { Self { id: value.id, room: value.room, project: value.project, user_id: value.user_id, user_info: None, notification_type: value.notification_type.to_string(), title: value.title, content: value.content, related_message_id: value.related_message_id, related_user_id: value.related_user_id, related_room_id: value.related_room_id, metadata: value.metadata.unwrap_or(serde_json::json!({})), is_read: value.is_read, is_archived: value.is_archived, created_at: value.created_at, read_at: value.read_at, expires_at: value.expires_at, } } } impl RoomService { pub(crate) fn parse_room_member_role(role: &str) -> Result { match role { "owner" => Ok(RoomMemberRole::Owner), "admin" => Ok(RoomMemberRole::Admin), "member" => Ok(RoomMemberRole::Member), "guest" => Ok(RoomMemberRole::Guest), _ => Err(RoomError::BadRequest("invalid room role".to_string())), } } pub(crate) fn parse_message_content_type( content_type: Option, ) -> Result { match content_type .unwrap_or_else(|| "text".to_string()) .to_lowercase() .as_str() { "text" => Ok(MessageContentType::Text), "image" => Ok(MessageContentType::Image), "audio" => Ok(MessageContentType::Audio), "video" => Ok(MessageContentType::Video), "file" => Ok(MessageContentType::File), _ => Err(RoomError::BadRequest( "invalid message content_type".to_string(), )), } } pub(crate) async fn find_room_member( &self, room_id: Uuid, user_id: Uuid, ) -> Result, RoomError> { room_member::Entity::find_by_id((room_id, user_id)) .one(&self.db) .await .map_err(RoomError::from) } pub(crate) async fn require_room_member_model( &self, room_id: Uuid, user_id: Uuid, ) -> Result { self.find_room_member(room_id, user_id) .await? .ok_or(RoomError::NoPower) } pub(crate) fn is_room_admin(role: &RoomMemberRole) -> bool { matches!(role, RoomMemberRole::Owner | RoomMemberRole::Admin) } pub(crate) async fn require_room_admin( &self, room_id: Uuid, user_id: Uuid, ) -> Result { let member = self.require_room_member_model(room_id, user_id).await?; if Self::is_room_admin(&member.role) { Ok(member) } else { Err(RoomError::NoPower) } } pub(crate) async fn require_project_admin( &self, project_id: Uuid, user_id: Uuid, ) -> Result { let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project_id)) .filter(project_members::Column::User.eq(user_id)) .one(&self.db) .await? .ok_or(RoomError::NoPower)?; let role = member.scope_role().map_err(|_| RoomError::RoleParseError)?; if matches!(role, MemberRole::Owner | MemberRole::Admin) { Ok(member) } else { Err(RoomError::NoPower) } } pub(crate) async fn ensure_room_visible_for_user( &self, room: &room::Model, user_id: Uuid, ) -> Result<(), RoomError> { if self.find_room_member(room.id, user_id).await?.is_some() { return Ok(()); } let project_member = project_members::Entity::find() .filter(project_members::Column::Project.eq(room.project)) .filter(project_members::Column::User.eq(user_id)) .one(&self.db) .await?; if room.public && project_member.is_some() { Ok(()) } else { Err(RoomError::NoPower) } } pub async fn utils_find_project_by_name( &self, name: String, ) -> Result { match project::Entity::find() .filter(project::Column::Name.eq(name.clone())) .one(&self.db) .await .ok() .flatten() { Some(project) => Ok(project), None => match project_history_name::Entity::find() .filter(project_history_name::Column::HistoryName.eq(name)) .one(&self.db) .await .ok() .flatten() { Some(project) => self.utils_find_project_by_uid(project.project_uid).await, None => Err(RoomError::NotFound("Project not found".to_string())), }, } } pub async fn utils_find_project_by_uid(&self, uid: Uuid) -> Result { project::Entity::find_by_id(uid) .one(&self.db) .await .ok() .flatten() .ok_or_else(|| RoomError::NotFound("Project not found".to_string())) } pub async fn check_project_access( &self, project_uid: Uuid, user_uid: Uuid, ) -> Result<(), RoomError> { let project = project::Entity::find_by_id(project_uid) .one(&self.db) .await .ok() .flatten() .ok_or_else(|| RoomError::NotFound("Project not found".to_string()))?; if project.is_public { return Ok(()); } let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project_uid)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await?; if member.is_some() { Ok(()) } else { Err(RoomError::NoPower) } } pub(crate) fn validate_name(name: &str, max_len: usize) -> Result<(), RoomError> { if name.trim().is_empty() { return Err(RoomError::BadRequest("name cannot be empty".to_string())); } if name.len() > max_len { return Err(RoomError::BadRequest(format!( "name exceeds maximum length of {} characters", max_len ))); } Ok(()) } pub(crate) fn validate_content(content: &str, max_len: usize) -> Result<(), RoomError> { if content.trim().is_empty() { return Err(RoomError::BadRequest("content cannot be empty".to_string())); } if content.len() > max_len { return Err(RoomError::BadRequest(format!( "content exceeds maximum length of {} characters", max_len ))); } Ok(()) } pub(crate) fn sanitize_content(content: &str) -> String { use std::sync::LazyLock; static SCRIPT_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)]*>.*?").unwrap()); static STYLE_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)]*>.*?").unwrap()); static ONERROR_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonerror\s*=").unwrap()); static ONLOAD_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonload\s*=").unwrap()); static ONCLICK_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonclick\s*=").unwrap()); static ONMOUSEOVER_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonmouseover\s*=").unwrap()); static JAVASCRIPT_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)javascript:").unwrap()); static DATA_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"(?i)data:").unwrap()); let mut result = content.to_string(); result = SCRIPT_RE.replace_all(&result, "").to_string(); result = STYLE_RE.replace_all(&result, "").to_string(); result = ONERROR_RE.replace_all(&result, "blocked=").to_string(); result = ONLOAD_RE.replace_all(&result, "blocked=").to_string(); result = ONCLICK_RE.replace_all(&result, "blocked=").to_string(); result = ONMOUSEOVER_RE.replace_all(&result, "blocked=").to_string(); result = JAVASCRIPT_RE.replace_all(&result, "blocked:").to_string(); result = DATA_RE.replace_all(&result, "blocked:").to_string(); result } pub async fn resolve_display_name( &self, msg: room_message::Model, _room_id: Uuid, ) -> super::RoomMessageResponse { let sender_type = msg.sender_type.to_string(); let display_name = match sender_type.as_str() { "ai" => { if let Some(sender_id) = msg.sender_id { ai_model::Entity::find_by_id(sender_id) .one(&self.db) .await .ok() .flatten() .map(|m| m.name) } else { None } } _ => { if let Some(sender_id) = msg.sender_id { let user = user_model::Entity::find() .filter(user_model::Column::Uid.eq(sender_id)) .one(&self.db) .await .ok() .flatten(); user.map(|u| u.display_name.unwrap_or_else(|| u.username)) } else { None } } }; super::RoomMessageResponse { id: msg.id, seq: msg.seq, room: msg.room, sender_type, sender_id: msg.sender_id, display_name, thread: msg.thread, content: msg.content, content_type: msg.content_type.to_string(), edited_at: msg.edited_at, send_at: msg.send_at, revoked: msg.revoked, revoked_by: msg.revoked_by, in_reply_to: msg.in_reply_to, } } }