use models::chat::room_bookmarks; use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::service::ChatService; use crate::share::lookup::require_room_readable; #[derive(Debug, Clone, Serialize, ToSchema)] pub struct BookmarkDto { pub id: Uuid, pub room_id: Uuid, pub title: String, pub url: Option, pub emoji: Option, pub position: i64, pub created_by_id: i64, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateBookmarkCmd { pub title: String, #[serde(default)] pub url: Option, #[serde(default)] pub emoji: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateBookmarkCmd { #[serde(default)] pub title: Option, #[serde(default)] pub url: Option, #[serde(default)] pub emoji: Option, #[serde(default)] pub position: Option, } impl From for BookmarkDto { fn from(b: room_bookmarks::Model) -> Self { Self { id: b.id, room_id: b.room_id, title: b.title, url: b.url, emoji: b.emoji, position: b.position, created_by_id: b.user_id, created_at: b.created_at, } } } impl ChatService { pub async fn list_bookmarks( &self, caller: i64, room_id: Uuid, ) -> Result, ChatError> { let _ctx = require_room_readable(self, room_id, caller).await?; let rows = room_bookmarks::Entity::find() .filter(room_bookmarks::Column::RoomId.eq(room_id)) .order_by_asc(room_bookmarks::Column::Position) .order_by_asc(room_bookmarks::Column::CreatedAt) .all(self.db().read()) .await?; Ok(rows.into_iter().map(BookmarkDto::from).collect()) } pub async fn create_bookmark( &self, caller: i64, room_id: Uuid, cmd: CreateBookmarkCmd, ) -> Result { let _ctx = require_room_readable(self, room_id, caller).await?; let title = cmd.title.trim().to_string(); if title.is_empty() || title.chars().count() > self.config().chat.max_bookmark_title_len { return Err(ChatError::BadRequest("invalid bookmark title".to_string())); } if cmd.url.is_none() && cmd.emoji.is_none() { return Err(ChatError::BadRequest( "bookmark requires url or emoji".to_string(), )); } let max_pos: i64 = room_bookmarks::Entity::find() .filter(room_bookmarks::Column::RoomId.eq(room_id)) .select_only() .column_as(Expr::col(room_bookmarks::Column::Position).max(), "max_pos") .into_tuple::>() .one(self.db().read()) .await? .flatten() .unwrap_or(-1); let now = OffsetDateTime::now_utc(); let row = room_bookmarks::ActiveModel { id: Set(Uuid::now_v7()), room_id: Set(room_id), user_id: Set(caller), title: Set(title), url: Set(cmd.url), emoji: Set(cmd.emoji), position: Set(max_pos + 1), created_at: Set(now), updated_at: Set(now), } .insert(self.db().write()) .await?; Ok(BookmarkDto::from(row)) } pub async fn update_bookmark( &self, caller: i64, room_id: Uuid, bookmark_id: Uuid, cmd: UpdateBookmarkCmd, ) -> Result { let ctx = require_room_readable(self, room_id, caller).await?; let row = room_bookmarks::Entity::find_by_id(bookmark_id) .filter(room_bookmarks::Column::RoomId.eq(room_id)) .one(self.db().write()) .await? .ok_or(ChatError::NotFound)?; if row.user_id != caller && !self.bookmark_manager(&ctx, caller).await? { return Err(ChatError::Forbidden); } let mut am: room_bookmarks::ActiveModel = row.into(); if let Some(title) = cmd.title { let title = title.trim().to_string(); if title.is_empty() || title.chars().count() > self.config().chat.max_bookmark_title_len { return Err(ChatError::BadRequest("invalid bookmark title".to_string())); } am.title = Set(title); } if let Some(url) = cmd.url { am.url = Set(Some(url)); } if let Some(emoji) = cmd.emoji { am.emoji = Set(Some(emoji)); } if let Some(position) = cmd.position { am.position = Set(position); } am.updated_at = Set(OffsetDateTime::now_utc()); let updated = am.update(self.db().write()).await?; Ok(BookmarkDto::from(updated)) } pub async fn delete_bookmark( &self, caller: i64, room_id: Uuid, bookmark_id: Uuid, ) -> Result<(), ChatError> { let ctx = require_room_readable(self, room_id, caller).await?; let row = room_bookmarks::Entity::find_by_id(bookmark_id) .filter(room_bookmarks::Column::RoomId.eq(room_id)) .one(self.db().write()) .await? .ok_or(ChatError::NotFound)?; if row.user_id != caller && !self.bookmark_manager(&ctx, caller).await? { return Err(ChatError::Forbidden); } room_bookmarks::Entity::delete_by_id(row.id) .exec(self.db().write()) .await?; Ok(()) } async fn bookmark_manager( &self, ctx: &crate::share::lookup::RoomContext, caller: i64, ) -> Result { match &ctx.space { Some(space) => crate::share::authz::can_manage_space(self, space, caller).await, None => Ok(false), } } }