use crate::error::RoomError; use crate::service::RoomService; use crate::ws_context::WsUserContext; use chrono::Utc; use models::rooms::{room_message, room_message_reaction}; use models::{DateTimeUtc, MessageId, RoomId, RoomThreadId, Seq, UserId}; use sea_orm::*; use uuid::Uuid; impl RoomService { pub async fn room_message_search( &self, room_id: Uuid, query: &str, limit: Option, offset: Option, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; self.require_room_member(room_id, user_id).await?; if query.trim().is_empty() { return Ok(super::MessageSearchResponse { messages: Vec::new(), total: 0, }); } let limit = std::cmp::min(limit.unwrap_or(20), 100); let offset = offset.unwrap_or(0); // PostgreSQL full-text search via raw SQL with parameterized query. // plainto_tsquery('simple', $1) is injection-safe — it treats input as text. let sql = r#" SELECT id, seq, room, sender_type, sender_id, thread, in_reply_to, content, content_type, edited_at, send_at, revoked, revoked_by FROM room_message WHERE room = $1 AND content_tsv @@ plainto_tsquery('simple', $2) AND revoked IS NULL ORDER BY send_at DESC LIMIT $3 OFFSET $4"#; let stmt = Statement::from_sql_and_values( DbBackend::Postgres, sql, vec![ room_id.into(), query.trim().into(), limit.into(), offset.into(), ], ); let rows: Vec = self .db .query_all_raw(stmt) .await? .into_iter() .map(|row| { let sender_type = row .try_get::("", "sender_type") .map(|s| match s.as_str() { "admin" => models::rooms::MessageSenderType::Admin, "owner" => models::rooms::MessageSenderType::Owner, "ai" => models::rooms::MessageSenderType::Ai, "system" => models::rooms::MessageSenderType::System, "tool" => models::rooms::MessageSenderType::Tool, "guest" => models::rooms::MessageSenderType::Guest, _ => models::rooms::MessageSenderType::Member, }) .unwrap_or(models::rooms::MessageSenderType::Member); let content_type = row .try_get::("", "content_type") .map(|s| match s.as_str() { "image" => models::rooms::MessageContentType::Image, "audio" => models::rooms::MessageContentType::Audio, "video" => models::rooms::MessageContentType::Video, "file" => models::rooms::MessageContentType::File, _ => models::rooms::MessageContentType::Text, }) .unwrap_or(models::rooms::MessageContentType::Text); room_message::Model { id: row.try_get::("", "id").unwrap_or_default(), seq: row.try_get::("", "seq").unwrap_or_default(), room: row.try_get::("", "room").unwrap_or_default(), sender_type, sender_id: row .try_get::>("", "sender_id") .ok() .flatten(), thread: row .try_get::>("", "thread") .ok() .flatten(), in_reply_to: row .try_get::>("", "in_reply_to") .ok() .flatten(), content: row.try_get::("", "content").unwrap_or_default(), content_type, edited_at: row .try_get::>("", "edited_at") .ok() .flatten(), send_at: row .try_get::("", "send_at") .unwrap_or_default(), revoked: row .try_get::>("", "revoked") .ok() .flatten(), revoked_by: row .try_get::>("", "revoked_by") .ok() .flatten(), content_tsv: None, } }) .collect(); // Efficient COUNT query. let count_sql = r#" SELECT COUNT(*) AS count FROM room_message WHERE room = $1 AND content_tsv @@ plainto_tsquery('simple', $2) AND revoked IS NULL"#; let count_stmt = Statement::from_sql_and_values( DbBackend::Postgres, count_sql, vec![room_id.into(), query.trim().into()], ); let count_row = self.db.query_one_raw(count_stmt).await?; let total: i64 = count_row .and_then(|r| r.try_get::("", "count").ok()) .unwrap_or(0); let response_messages = self.build_messages_with_display_names(rows).await; Ok(super::MessageSearchResponse { messages: response_messages, total, }) } pub async fn room_message_reaction_list( &self, room_id: Uuid, message_id: Uuid, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; self.require_room_member(room_id, user_id).await?; let _msg = room_message::Entity::find_by_id(message_id) .one(&self.db) .await? .ok_or_else(|| RoomError::NotFound("Message not found".to_string()))?; self.get_message_reactions(message_id, Some(user_id)).await } pub async fn room_message_reaction_toggle( &self, room_id: Uuid, message_id: Uuid, emoji: String, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; self.require_room_member(room_id, user_id).await?; if emoji.is_empty() || emoji.len() > 50 { return Err(RoomError::BadRequest("Invalid emoji format".to_string())); } if let Some(existing) = room_message_reaction::Entity::find() .filter(room_message_reaction::Column::Room.eq(room_id)) .filter(room_message_reaction::Column::Message.eq(message_id)) .filter(room_message_reaction::Column::User.eq(user_id)) .filter(room_message_reaction::Column::Emoji.eq(&emoji)) .one(&self.db) .await? { room_message_reaction::Entity::delete_by_id(existing.id) .exec(&self.db) .await?; } else { room_message_reaction::ActiveModel { id: Set(Uuid::now_v7()), room: Set(room_id), message: Set(message_id), user: Set(user_id), emoji: Set(emoji), created_at: Set(Utc::now()), } .insert(&self.db) .await?; } self.get_message_reactions(message_id, Some(user_id)).await } pub async fn room_message_edit_history( &self, room_id: Uuid, message_id: Uuid, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; self.require_room_member(room_id, user_id).await?; let _msg = room_message::Entity::find_by_id(message_id) .one(&self.db) .await? .ok_or_else(|| RoomError::NotFound("Message not found".to_string()))?; let history = models::rooms::room_message_edit_history::Entity::find() .filter(models::rooms::room_message_edit_history::Column::Message.eq(message_id)) .order_by_asc(models::rooms::room_message_edit_history::Column::EditedAt) .all(&self.db) .await?; let total_edits = history.len() as i64; let entries: Vec = history .into_iter() .map(|h| super::MessageEditHistoryEntry { old_content: h.old_content, new_content: h.new_content, edited_at: h.edited_at, }) .collect(); Ok(super::MessageEditHistoryResponse { message_id, history: entries, total_edits, }) } pub async fn room_member_leave( &self, room_id: Uuid, ctx: &WsUserContext, ) -> Result<(), RoomError> { let user_id = ctx.user_id; let member = self .find_room_member(room_id, user_id) .await? .ok_or_else(|| RoomError::NotFound("You are not a member of this room".to_string()))?; if member.role.to_string() == "owner" { return Err(RoomError::BadRequest( "Owner cannot leave the room. Transfer ownership first.".to_string(), )); } models::rooms::room_member::Entity::delete_by_id((room_id, user_id)) .exec(&self.db) .await?; self.room_manager.unsubscribe(room_id, user_id).await; let room = self.find_room_or_404(room_id).await?; self.publish_room_event( room.project, super::RoomEventType::MemberRemoved, Some(room_id), None, None, None, ) .await; Ok(()) } }