use chrono::{DateTime, Utc}; use uuid::Uuid; use crate::event::{RoomInfo, UserInfo, thread}; use crate::{ChannelBus, ChannelError, ChannelResult}; use super::WsHandler; use super::WsOutEvent; /// Helper struct for thread_list JOIN query result #[derive(db::sqlx::FromRow)] struct ThreadListRow { id: Uuid, room: Uuid, seq: i64, starter_message: Option, title: String, created_by: Uuid, archived: bool, locked: bool, last_message_at: Option>, created_at: DateTime, updated_at: DateTime, archived_at: Option>, parent_seq: i64, } impl WsHandler { pub(super) async fn thread_list( bus: &ChannelBus, user_id: Uuid, room: Uuid, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; // Join room_thread with room_message to get the parent message's seq let rows = db::sqlx::query_as::<_, ThreadListRow>( "SELECT rt.id, rt.room, rt.seq, rt.starter_message, rt.title, rt.created_by, \ rt.archived, rt.locked, rt.last_message_at, rt.created_at, rt.updated_at, rt.archived_at, \ COALESCE(rm.seq, 0) as parent_seq \ FROM room_thread rt \ LEFT JOIN room_message rm ON rm.id = rt.starter_message \ WHERE rt.room = $1 ORDER BY rt.last_message_at DESC NULLS LAST", ) .bind(room) .fetch_all(bus.inner.db.reader()) .await?; let mut items = Vec::new(); for row in rows { let tc_room = bus .lookup_room(row.room) .await .unwrap_or_else(|_| RoomInfo::unknown(row.room)); let created_by = bus .lookup_user(row.created_by) .await .unwrap_or_else(|_| UserInfo::unknown(row.created_by)); // Get last message preview let preview: Option<(String,)> = db::sqlx::query_as( "SELECT content FROM room_message \ WHERE thread = $1 AND deleted_at IS NULL \ ORDER BY seq DESC LIMIT 1", ) .bind(row.id) .fetch_optional(bus.inner.db.reader()) .await?; items.push(thread::ThreadListItem { id: row.id, room: tc_room, seq: row.seq, parent_seq: row.parent_seq, title: row.title, created_by, archived: row.archived, locked: row.locked, last_message_at: row.last_message_at, last_message_preview: preview.map(|p| p.0), created_at: row.created_at, }); } Ok(Some(WsOutEvent::ThreadList { data: thread::ThreadListService { threads: items }, })) } pub(super) async fn thread_create( bus: &ChannelBus, user_id: Uuid, room: Uuid, parent: i64, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; // Look up the message UUID from seq + room let parent_id: Option<(Uuid,)> = db::sqlx::query_as( "SELECT id FROM room_message WHERE room = $1 AND seq = $2 AND deleted_at IS NULL", ) .bind(room) .bind(parent) .fetch_optional(bus.inner.db.reader()) .await?; let parent_msg_id = parent_id.ok_or(ChannelError::RoomNotFound)?.0; let seq = bus.inner.seq.seq(room).await?; let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>( "INSERT INTO room_thread (room, seq, starter_message, title, created_by, created_at, updated_at) \ VALUES ($1, $2, $3, '', $4, now(), now()) \ RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \ last_message_at, created_at, updated_at, archived_at", ) .bind(room) .bind(seq) .bind(parent_msg_id) // UUID of the starter message .bind(user_id) .fetch_one(bus.inner.db.writer()) .await?; let tc_room = bus .lookup_room(room) .await .unwrap_or_else(|_| RoomInfo::unknown(room)); let created_by = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let data = thread::ThreadCreatedService { id: row.id, room: tc_room, parent, created_by, participants: serde_json::Value::Null, created_at: row.created_at, }; bus.publish_room_event(room, "thread.created", &data) .await?; Ok(Some(WsOutEvent::ThreadCreated { room: data.room.clone(), data, })) } pub(super) async fn thread_resolve( bus: &ChannelBus, user_id: Uuid, thread_id: Uuid, ) -> ChannelResult> { let existing: (Uuid,) = db::sqlx::query_as("SELECT room FROM room_thread WHERE id = $1") .bind(thread_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, existing.0).await?; let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>( "UPDATE room_thread SET locked = true, updated_at = now() \ WHERE id = $1 \ RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \ last_message_at, created_at, updated_at, archived_at", ) .bind(thread_id) .fetch_one(bus.inner.db.writer()) .await?; let tr_room = bus .lookup_room(row.room) .await .unwrap_or_else(|_| RoomInfo::unknown(row.room)); let resolved_by = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let data = thread::ThreadResolvedService { id: row.id, room: tr_room, resolved_by, resolved_at: Utc::now(), }; bus.publish_room_event(row.room, "thread.resolved", &data) .await?; Ok(Some(WsOutEvent::ThreadResolved { room: data.room.clone(), data, })) } pub(super) async fn thread_archive( bus: &ChannelBus, user_id: Uuid, thread_id: Uuid, ) -> ChannelResult> { let existing: (Uuid,) = db::sqlx::query_as("SELECT room FROM room_thread WHERE id = $1") .bind(thread_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, existing.0).await?; let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>( "UPDATE room_thread SET archived = true, archived_at = now(), updated_at = now() \ WHERE id = $1 \ RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \ last_message_at, created_at, updated_at, archived_at", ) .bind(thread_id) .fetch_one(bus.inner.db.writer()) .await?; let ta_room = bus .lookup_room(row.room) .await .unwrap_or_else(|_| RoomInfo::unknown(row.room)); let archived_by = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let data = thread::ThreadArchivedService { id: row.id, room: ta_room, archived_by, archived_at: Utc::now(), }; bus.publish_room_event(row.room, "thread.archived", &data) .await?; Ok(Some(WsOutEvent::ThreadArchived { room: data.room.clone(), data, })) } }