use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::immediate::{DraftAction, DraftEvent}; use crate::models::channels::MessageDraft; 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 SaveDraftParams { pub content: String, pub thread_id: Option, pub reply_to_message_id: Option, } impl ImService { async fn draft_realtime( &self, channel_id: Uuid, user_id: Uuid, thread_id: Option, action: DraftAction, ) { let request_id = Uuid::nil(); let event = DraftEvent { channel_id, user_id, thread_id, action, }; self.publish(&format!("im.draft.{user_id}"), request_id, &event) .await; self.emit_event(ImEvent::Draft { request_id, data: event, }); } pub async fn draft_save( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, params: SaveDraftParams, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; if params.content.len() > MAX_MESSAGE_BODY { return Err(AppError::BadRequest("draft content too long".into())); } // NOTE: COALESCE(thread_id, nil_uuid) in ON CONFLICT requires a matching // UNIQUE index with the identical COALESCE expression. let now = chrono::Utc::now(); let draft = sqlx::query_as::<_, MessageDraft>( "INSERT INTO message_draft \ (id, user_id, channel_id, thread_id, reply_to_message_id, content, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ ON CONFLICT (user_id, channel_id, COALESCE(thread_id, '00000000-0000-0000-0000-000000000000'::uuid)) \ DO UPDATE SET content = $6, reply_to_message_id = $5, updated_at = $7 \ RETURNING id, user_id, channel_id, thread_id, reply_to_message_id, content, \ attachments, created_at, updated_at", ) .bind(Uuid::now_v7()) .bind(user_uid) .bind(channel_id) .bind(params.thread_id) .bind(params.reply_to_message_id) .bind(¶ms.content) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; self.draft_realtime(channel_id, user_uid, draft.thread_id, DraftAction::Saved) .await; Ok(draft) } pub async fn draft_get( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, thread_id: Option, ) -> Result, AppError> { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; sqlx::query_as::<_, MessageDraft>( "SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \ attachments, created_at, updated_at \ FROM message_draft \ WHERE user_id = $1 AND channel_id = $2 \ AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))", ) .bind(user_uid) .bind(channel_id) .bind(thread_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn draft_delete( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, thread_id: Option, ) -> Result<(), AppError> { let user_uid = ctx.user; let result = sqlx::query( "DELETE FROM message_draft \ WHERE user_id = $1 AND channel_id = $2 \ AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))", ) .bind(user_uid) .bind(channel_id) .bind(thread_id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "draft not found")?; self.draft_realtime(channel_id, user_uid, thread_id, DraftAction::Deleted) .await; Ok(()) } pub async fn draft_list( &self, ctx: &ImSession, wk_name: &str, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user; let _ = self.resolve_workspace(wk_name).await?; let (limit, offset) = clamp_limit_offset(limit, offset); sqlx::query_as::<_, MessageDraft>( "SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \ attachments, created_at, updated_at \ FROM message_draft WHERE user_id = $1 \ ORDER BY updated_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } }