use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, post}, Json, Router, }; use chrono::{DateTime, Utc}; use diesel::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; use crate::core::bot::get_default_bot; use crate::core::shared::schema::workspaces::{ workspace_comments, workspace_members, workspace_page_versions, workspace_pages, workspaces as workspaces_table, }; use crate::core::shared::state::AppState; pub mod blocks; pub mod collaboration; pub mod pages; pub mod templates; pub mod ui; #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] #[diesel(table_name = workspaces_table)] pub struct DbWorkspace { pub id: Uuid, pub org_id: Uuid, pub bot_id: Uuid, pub name: String, pub description: Option, pub icon_type: Option, pub icon_value: Option, pub cover_image: Option, pub settings: serde_json::Value, pub created_by: Uuid, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)] #[diesel(table_name = workspace_members)] pub struct DbWorkspaceMember { pub id: Uuid, pub workspace_id: Uuid, pub user_id: Uuid, pub role: String, pub invited_by: Option, pub joined_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] #[diesel(table_name = workspace_pages)] pub struct DbWorkspacePage { pub id: Uuid, pub workspace_id: Uuid, pub parent_id: Option, pub title: String, pub icon_type: Option, pub icon_value: Option, pub cover_image: Option, pub content: serde_json::Value, pub properties: serde_json::Value, pub is_template: bool, pub template_id: Option, pub is_public: bool, pub public_edit: bool, pub position: i32, pub created_by: Uuid, pub last_edited_by: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)] #[diesel(table_name = workspace_page_versions)] pub struct DbPageVersion { pub id: Uuid, pub page_id: Uuid, pub version_number: i32, pub title: String, pub content: serde_json::Value, pub change_summary: Option, pub created_by: Uuid, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] #[diesel(table_name = workspace_comments)] pub struct DbWorkspaceComment { pub id: Uuid, pub workspace_id: Uuid, pub page_id: Uuid, pub block_id: Option, pub parent_comment_id: Option, pub author_id: Uuid, pub content: String, pub resolved: bool, pub resolved_by: Option, pub resolved_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Workspace { pub id: Uuid, pub org_id: Uuid, pub name: String, pub description: Option, pub icon: Option, pub cover_image: Option, pub settings: WorkspaceSettings, pub created_by: Uuid, pub created_at: DateTime, pub updated_at: DateTime, pub members: Vec, pub root_pages: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceIcon { pub icon_type: IconType, pub value: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum IconType { Emoji, Image, Lucide, } impl IconType { fn as_str(&self) -> &'static str { match self { Self::Emoji => "emoji", Self::Image => "image", Self::Lucide => "lucide", } } fn from_str(s: &str) -> Self { match s { "emoji" => Self::Emoji, "image" => Self::Image, "lucide" => Self::Lucide, _ => Self::Emoji, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceMember { pub user_id: Uuid, pub role: WorkspaceRole, pub joined_at: DateTime, pub invited_by: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum WorkspaceRole { Owner, Admin, Editor, Commenter, Viewer, } impl WorkspaceRole { fn as_str(&self) -> &'static str { match self { Self::Owner => "owner", Self::Admin => "admin", Self::Editor => "editor", Self::Commenter => "commenter", Self::Viewer => "viewer", } } fn from_str(s: &str) -> Self { match s { "owner" => Self::Owner, "admin" => Self::Admin, "editor" => Self::Editor, "commenter" => Self::Commenter, "viewer" => Self::Viewer, _ => Self::Viewer, } } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WorkspaceSettings { #[serde(default)] pub default_page_width: PageWidth, #[serde(default)] pub allow_public_pages: bool, #[serde(default = "default_true")] pub enable_comments: bool, #[serde(default = "default_true")] pub enable_reactions: bool, #[serde(default = "default_true")] pub enable_gb_assist: bool, #[serde(skip_serializing_if = "Option::is_none")] pub gb_bot_id: Option, } fn default_true() -> bool { true } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum PageWidth { Small, #[default] Normal, Wide, Full, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Page { pub id: Uuid, pub workspace_id: Uuid, pub parent_id: Option, pub title: String, pub icon: Option, pub cover_image: Option, pub blocks: Vec, pub children: Vec, pub properties: HashMap, pub permissions: PagePermissions, pub is_template: bool, pub template_id: Option, pub created_at: DateTime, pub updated_at: DateTime, pub created_by: Uuid, pub last_edited_by: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PagePermissions { #[serde(default = "default_true")] pub inherit_from_parent: bool, #[serde(default)] pub public: bool, #[serde(default)] pub public_edit: bool, #[serde(default)] pub allowed_users: Vec, #[serde(default)] pub allowed_roles: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Block { pub id: Uuid, pub block_type: BlockType, pub content: BlockContent, pub properties: BlockProperties, pub children: Vec, pub created_at: DateTime, pub updated_at: DateTime, pub created_by: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum BlockType { Paragraph, Heading1, Heading2, Heading3, BulletedList, NumberedList, Checklist, Toggle, Quote, Callout, Divider, Table, Code, Image, Video, File, Embed, Bookmark, LinkToPage, SyncedBlock, TableOfContents, Breadcrumb, Equation, ColumnList, Column, GbComponent, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BlockContent { Text { text: RichText }, Media { url: String, caption: Option }, Table { rows: Vec }, Code { code: String, language: Option }, Embed { url: String, embed_type: Option }, Callout { icon: Option, text: RichText }, Toggle { title: RichText, expanded: bool }, Checklist { items: Vec }, GbComponent { component_type: String, config: serde_json::Value }, Empty, } impl Default for BlockContent { fn default() -> Self { Self::Empty } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RichText { pub segments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TextSegment { pub text: String, #[serde(default)] pub annotations: TextAnnotations, #[serde(skip_serializing_if = "Option::is_none")] pub link: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mention: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TextAnnotations { #[serde(default)] pub bold: bool, #[serde(default)] pub italic: bool, #[serde(default)] pub underline: bool, #[serde(default)] pub strikethrough: bool, #[serde(default)] pub code: bool, #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub background_color: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Mention { pub mention_type: MentionType, pub target_id: Uuid, pub display_text: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum MentionType { User, Page, Date, Database, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableRow { pub id: Uuid, pub cells: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableCell { pub content: RichText, #[serde(skip_serializing_if = "Option::is_none")] pub background_color: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChecklistItem { pub id: Uuid, pub text: RichText, #[serde(default)] pub checked: bool, #[serde(skip_serializing_if = "Option::is_none")] pub assignee: Option, #[serde(skip_serializing_if = "Option::is_none")] pub due_date: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct BlockProperties { #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub background_color: Option, #[serde(default)] pub indent_level: u32, #[serde(default)] pub collapsed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum PropertyValue { Text(String), Number(f64), Boolean(bool), Date(DateTime), Select(String), MultiSelect(Vec), User(Uuid), Users(Vec), Url(String), Email(String), Phone(String), Relation(Vec), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PageVersion { pub id: Uuid, pub page_id: Uuid, pub version_number: i32, pub title: String, pub blocks: Vec, pub created_at: DateTime, pub created_by: Uuid, pub change_summary: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Comment { pub id: Uuid, pub page_id: Uuid, pub block_id: Option, pub parent_comment_id: Option, pub author_id: Uuid, pub content: String, pub resolved: bool, pub resolved_by: Option, pub resolved_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PageTreeNode { pub id: Uuid, pub title: String, pub icon: Option, pub children: Vec, pub has_children: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PageSearchResult { pub page_id: Uuid, pub title: String, pub icon: Option, pub snippet: String, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlashCommand { pub id: String, pub name: String, pub description: String, pub icon: String, pub category: SlashCommandCategory, pub keywords: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SlashCommandCategory { GbAssist, General, Media, Embed, Advanced, } #[derive(Debug, Clone)] pub enum WorkspacesError { WorkspaceNotFound, PageNotFound, BlockNotFound, CommentNotFound, VersionNotFound, MemberNotFound, MemberAlreadyExists, CannotRemoveLastOwner, PermissionDenied, InvalidOperation(String), DbError(String), } impl std::fmt::Display for WorkspacesError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::WorkspaceNotFound => write!(f, "Workspace not found"), Self::PageNotFound => write!(f, "Page not found"), Self::BlockNotFound => write!(f, "Block not found"), Self::CommentNotFound => write!(f, "Comment not found"), Self::VersionNotFound => write!(f, "Version not found"), Self::MemberNotFound => write!(f, "Member not found"), Self::MemberAlreadyExists => write!(f, "Member already exists in workspace"), Self::CannotRemoveLastOwner => write!(f, "Cannot remove the last owner"), Self::PermissionDenied => write!(f, "Permission denied"), Self::InvalidOperation(e) => write!(f, "Invalid operation: {e}"), Self::DbError(e) => write!(f, "Database error: {e}"), } } } impl std::error::Error for WorkspacesError {} impl IntoResponse for WorkspacesError { fn into_response(self) -> axum::response::Response { let (status, message) = match &self { Self::WorkspaceNotFound | Self::PageNotFound | Self::BlockNotFound | Self::CommentNotFound | Self::VersionNotFound | Self::MemberNotFound => (StatusCode::NOT_FOUND, self.to_string()), Self::PermissionDenied => (StatusCode::FORBIDDEN, self.to_string()), Self::MemberAlreadyExists | Self::CannotRemoveLastOwner | Self::InvalidOperation(_) => { (StatusCode::BAD_REQUEST, self.to_string()) } Self::DbError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()), }; (status, Json(serde_json::json!({"error": message}))).into_response() } } fn get_bot_context(state: &AppState) -> (Uuid, Uuid) { let Ok(mut conn) = state.conn.get() else { return (Uuid::nil(), Uuid::nil()); }; let (bot_id, _bot_name) = get_default_bot(&mut conn); let org_id = Uuid::nil(); (org_id, bot_id) } fn db_to_workspace(db: DbWorkspace, members: Vec, root_pages: Vec) -> Workspace { let icon = match (&db.icon_type, &db.icon_value) { (Some(t), Some(v)) => Some(WorkspaceIcon { icon_type: IconType::from_str(t), value: v.clone(), }), _ => None, }; let settings: WorkspaceSettings = serde_json::from_value(db.settings).unwrap_or_default(); Workspace { id: db.id, org_id: db.org_id, name: db.name, description: db.description, icon, cover_image: db.cover_image, settings, created_by: db.created_by, created_at: db.created_at, updated_at: db.updated_at, members, root_pages, } } fn db_to_page(db: DbWorkspacePage, children: Vec) -> Page { let icon = match (&db.icon_type, &db.icon_value) { (Some(t), Some(v)) => Some(WorkspaceIcon { icon_type: IconType::from_str(t), value: v.clone(), }), _ => None, }; let blocks: Vec = serde_json::from_value(db.content).unwrap_or_default(); let properties: HashMap = serde_json::from_value(db.properties).unwrap_or_default(); Page { id: db.id, workspace_id: db.workspace_id, parent_id: db.parent_id, title: db.title, icon, cover_image: db.cover_image, blocks, children, properties, permissions: PagePermissions { inherit_from_parent: true, public: db.is_public, public_edit: db.public_edit, allowed_users: vec![], allowed_roles: vec![], }, is_template: db.is_template, template_id: db.template_id, created_at: db.created_at, updated_at: db.updated_at, created_by: db.created_by, last_edited_by: db.last_edited_by.unwrap_or(db.created_by), } } #[derive(Debug, Deserialize)] pub struct CreateWorkspaceRequest { pub name: String, pub description: Option, } #[derive(Debug, Deserialize)] pub struct UpdateWorkspaceRequest { pub name: Option, pub description: Option, pub icon: Option, } #[derive(Debug, Deserialize)] pub struct CreatePageRequest { pub title: String, pub parent_id: Option, } #[derive(Debug, Deserialize)] pub struct UpdatePageRequest { pub title: Option, pub icon: Option, pub blocks: Option>, } #[derive(Debug, Deserialize)] pub struct AddMemberRequest { pub user_id: Uuid, pub role: WorkspaceRole, } #[derive(Debug, Deserialize)] pub struct CreateCommentRequest { pub content: String, pub block_id: Option, pub parent_comment_id: Option, } #[derive(Debug, Deserialize)] pub struct ListQuery { pub search: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Deserialize)] pub struct SearchQuery { pub q: Option, } async fn list_workspaces( State(state): State>, Query(query): Query, ) -> Result>, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let (org_id, bot_id) = get_bot_context(&state); let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); let mut q = workspaces_table::table .filter(workspaces_table::org_id.eq(org_id)) .filter(workspaces_table::bot_id.eq(bot_id)) .into_boxed(); if let Some(search) = query.search { let pattern = format!("%{search}%"); q = q.filter( workspaces_table::name .ilike(pattern.clone()) .or(workspaces_table::description.ilike(pattern)), ); } let db_workspaces: Vec = q .order(workspaces_table::updated_at.desc()) .limit(limit) .offset(offset) .load(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let mut result = Vec::with_capacity(db_workspaces.len()); for ws in db_workspaces { let db_members: Vec = workspace_members::table .filter(workspace_members::workspace_id.eq(ws.id)) .load(&mut conn) .unwrap_or_default(); let members: Vec = db_members .into_iter() .map(|m| WorkspaceMember { user_id: m.user_id, role: WorkspaceRole::from_str(&m.role), joined_at: m.joined_at, invited_by: m.invited_by, }) .collect(); let root_pages: Vec = workspace_pages::table .filter(workspace_pages::workspace_id.eq(ws.id)) .filter(workspace_pages::parent_id.is_null()) .select(workspace_pages::id) .order(workspace_pages::position.asc()) .load(&mut conn) .unwrap_or_default(); result.push(db_to_workspace(ws, members, root_pages)); } Ok(Json(result)) } async fn create_workspace( State(state): State>, Json(req): Json, ) -> Result, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let (org_id, bot_id) = get_bot_context(&state); let id = Uuid::new_v4(); let now = Utc::now(); let user_id = Uuid::nil(); let settings = WorkspaceSettings::default(); let settings_json = serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({})); let db_workspace = DbWorkspace { id, org_id, bot_id, name: req.name, description: req.description, icon_type: None, icon_value: None, cover_image: None, settings: settings_json, created_by: user_id, created_at: now, updated_at: now, }; diesel::insert_into(workspaces_table::table) .values(&db_workspace) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let member = DbWorkspaceMember { id: Uuid::new_v4(), workspace_id: id, user_id, role: WorkspaceRole::Owner.as_str().to_string(), invited_by: None, joined_at: now, }; diesel::insert_into(workspace_members::table) .values(&member) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let members = vec![WorkspaceMember { user_id, role: WorkspaceRole::Owner, joined_at: now, invited_by: None, }]; let workspace = db_to_workspace(db_workspace, members, vec![]); Ok(Json(workspace)) } async fn get_workspace( State(state): State>, Path(workspace_id): Path, ) -> Result, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let db_workspace: DbWorkspace = workspaces_table::table .filter(workspaces_table::id.eq(workspace_id)) .first(&mut conn) .map_err(|_| WorkspacesError::WorkspaceNotFound)?; let db_members: Vec = workspace_members::table .filter(workspace_members::workspace_id.eq(workspace_id)) .load(&mut conn) .unwrap_or_default(); let members: Vec = db_members .into_iter() .map(|m| WorkspaceMember { user_id: m.user_id, role: WorkspaceRole::from_str(&m.role), joined_at: m.joined_at, invited_by: m.invited_by, }) .collect(); let root_pages: Vec = workspace_pages::table .filter(workspace_pages::workspace_id.eq(workspace_id)) .filter(workspace_pages::parent_id.is_null()) .select(workspace_pages::id) .order(workspace_pages::position.asc()) .load(&mut conn) .unwrap_or_default(); let workspace = db_to_workspace(db_workspace, members, root_pages); Ok(Json(workspace)) } async fn update_workspace( State(state): State>, Path(workspace_id): Path, Json(req): Json, ) -> Result, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let mut db_workspace: DbWorkspace = workspaces_table::table .filter(workspaces_table::id.eq(workspace_id)) .first(&mut conn) .map_err(|_| WorkspacesError::WorkspaceNotFound)?; if let Some(name) = req.name { db_workspace.name = name; } if let Some(desc) = req.description { db_workspace.description = Some(desc); } if let Some(icon) = req.icon { db_workspace.icon_type = Some(icon.icon_type.as_str().to_string()); db_workspace.icon_value = Some(icon.value); } db_workspace.updated_at = Utc::now(); diesel::update(workspaces_table::table.filter(workspaces_table::id.eq(workspace_id))) .set(&db_workspace) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let db_members: Vec = workspace_members::table .filter(workspace_members::workspace_id.eq(workspace_id)) .load(&mut conn) .unwrap_or_default(); let members: Vec = db_members .into_iter() .map(|m| WorkspaceMember { user_id: m.user_id, role: WorkspaceRole::from_str(&m.role), joined_at: m.joined_at, invited_by: m.invited_by, }) .collect(); let root_pages: Vec = workspace_pages::table .filter(workspace_pages::workspace_id.eq(workspace_id)) .filter(workspace_pages::parent_id.is_null()) .select(workspace_pages::id) .order(workspace_pages::position.asc()) .load(&mut conn) .unwrap_or_default(); let workspace = db_to_workspace(db_workspace, members, root_pages); Ok(Json(workspace)) } async fn delete_workspace( State(state): State>, Path(workspace_id): Path, ) -> Result { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; diesel::delete(workspace_comments::table.filter(workspace_comments::workspace_id.eq(workspace_id))) .execute(&mut conn) .ok(); let page_ids: Vec = workspace_pages::table .filter(workspace_pages::workspace_id.eq(workspace_id)) .select(workspace_pages::id) .load(&mut conn) .unwrap_or_default(); if !page_ids.is_empty() { diesel::delete(workspace_page_versions::table.filter( workspace_page_versions::page_id.eq_any(&page_ids) )) .execute(&mut conn) .ok(); } diesel::delete(workspace_pages::table.filter(workspace_pages::workspace_id.eq(workspace_id))) .execute(&mut conn) .ok(); diesel::delete(workspace_members::table.filter(workspace_members::workspace_id.eq(workspace_id))) .execute(&mut conn) .ok(); let deleted = diesel::delete(workspaces_table::table.filter(workspaces_table::id.eq(workspace_id))) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; if deleted > 0 { Ok(StatusCode::NO_CONTENT) } else { Err(WorkspacesError::WorkspaceNotFound) } } async fn list_pages( State(state): State>, Path(workspace_id): Path, ) -> Result>, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let db_pages: Vec = workspace_pages::table .filter(workspace_pages::workspace_id.eq(workspace_id)) .order(workspace_pages::position.asc()) .load(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; fn build_tree(pages: &[DbWorkspacePage], parent_id: Option) -> Vec { pages .iter() .filter(|p| p.parent_id == parent_id) .map(|p| { let icon = match (&p.icon_type, &p.icon_value) { (Some(t), Some(v)) => Some(WorkspaceIcon { icon_type: IconType::from_str(t), value: v.clone(), }), _ => None, }; let children = build_tree(pages, Some(p.id)); PageTreeNode { id: p.id, title: p.title.clone(), icon, has_children: !children.is_empty(), children, } }) .collect() } let tree = build_tree(&db_pages, None); Ok(Json(tree)) } async fn create_page( State(state): State>, Path(workspace_id): Path, Json(req): Json, ) -> Result, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let _: DbWorkspace = workspaces_table::table .filter(workspaces_table::id.eq(workspace_id)) .first(&mut conn) .map_err(|_| WorkspacesError::WorkspaceNotFound)?; let now = Utc::now(); let user_id = Uuid::nil(); let id = Uuid::new_v4(); let max_position: Option = workspace_pages::table .filter(workspace_pages::workspace_id.eq(workspace_id)) .filter(workspace_pages::parent_id.is_not_distinct_from(req.parent_id)) .select(diesel::dsl::max(workspace_pages::position)) .first(&mut conn) .ok() .flatten(); let db_page = DbWorkspacePage { id, workspace_id, parent_id: req.parent_id, title: req.title, icon_type: None, icon_value: None, cover_image: None, content: serde_json::json!([]), properties: serde_json::json!({}), is_template: false, template_id: None, is_public: false, public_edit: false, position: max_position.unwrap_or(0) + 1, created_by: user_id, last_edited_by: Some(user_id), created_at: now, updated_at: now, }; diesel::insert_into(workspace_pages::table) .values(&db_page) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let page = db_to_page(db_page, vec![]); Ok(Json(page)) } async fn get_page( State(state): State>, Path(page_id): Path, ) -> Result, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let db_page: DbWorkspacePage = workspace_pages::table .filter(workspace_pages::id.eq(page_id)) .first(&mut conn) .map_err(|_| WorkspacesError::PageNotFound)?; let children: Vec = workspace_pages::table .filter(workspace_pages::parent_id.eq(page_id)) .select(workspace_pages::id) .order(workspace_pages::position.asc()) .load(&mut conn) .unwrap_or_default(); let page = db_to_page(db_page, children); Ok(Json(page)) } async fn update_page( State(state): State>, Path(page_id): Path, Json(req): Json, ) -> Result, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let mut db_page: DbWorkspacePage = workspace_pages::table .filter(workspace_pages::id.eq(page_id)) .first(&mut conn) .map_err(|_| WorkspacesError::PageNotFound)?; if let Some(title) = req.title { db_page.title = title; } if let Some(icon) = req.icon { db_page.icon_type = Some(icon.icon_type.as_str().to_string()); db_page.icon_value = Some(icon.value); } if let Some(blocks) = req.blocks { db_page.content = serde_json::to_value(&blocks).unwrap_or_else(|_| serde_json::json!([])); } db_page.updated_at = Utc::now(); db_page.last_edited_by = Some(Uuid::nil()); diesel::update(workspace_pages::table.filter(workspace_pages::id.eq(page_id))) .set(&db_page) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let children: Vec = workspace_pages::table .filter(workspace_pages::parent_id.eq(page_id)) .select(workspace_pages::id) .order(workspace_pages::position.asc()) .load(&mut conn) .unwrap_or_default(); let page = db_to_page(db_page, children); Ok(Json(page)) } async fn delete_page( State(state): State>, Path(page_id): Path, ) -> Result { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; diesel::delete(workspace_comments::table.filter(workspace_comments::page_id.eq(page_id))) .execute(&mut conn) .ok(); diesel::delete(workspace_page_versions::table.filter(workspace_page_versions::page_id.eq(page_id))) .execute(&mut conn) .ok(); diesel::delete(workspace_pages::table.filter(workspace_pages::parent_id.eq(page_id))) .execute(&mut conn) .ok(); let deleted = diesel::delete(workspace_pages::table.filter(workspace_pages::id.eq(page_id))) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; if deleted > 0 { Ok(StatusCode::NO_CONTENT) } else { Err(WorkspacesError::PageNotFound) } } async fn add_member( State(state): State>, Path(workspace_id): Path, Json(req): Json, ) -> Result { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let existing: Option = workspace_members::table .filter(workspace_members::workspace_id.eq(workspace_id)) .filter(workspace_members::user_id.eq(req.user_id)) .first(&mut conn) .optional() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; if existing.is_some() { return Err(WorkspacesError::MemberAlreadyExists); } let now = Utc::now(); let member = DbWorkspaceMember { id: Uuid::new_v4(), workspace_id, user_id: req.user_id, role: req.role.as_str().to_string(), invited_by: Some(Uuid::nil()), joined_at: now, }; diesel::insert_into(workspace_members::table) .values(&member) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; Ok(StatusCode::CREATED) } async fn remove_member( State(state): State>, Path((workspace_id, user_id)): Path<(Uuid, Uuid)>, ) -> Result { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let owner_count: i64 = workspace_members::table .filter(workspace_members::workspace_id.eq(workspace_id)) .filter(workspace_members::role.eq("owner")) .count() .get_result(&mut conn) .unwrap_or(0); let member: Option = workspace_members::table .filter(workspace_members::workspace_id.eq(workspace_id)) .filter(workspace_members::user_id.eq(user_id)) .first(&mut conn) .optional() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; if let Some(m) = member { if m.role == "owner" && owner_count <= 1 { return Err(WorkspacesError::CannotRemoveLastOwner); } } else { return Err(WorkspacesError::MemberNotFound); } diesel::delete( workspace_members::table .filter(workspace_members::workspace_id.eq(workspace_id)) .filter(workspace_members::user_id.eq(user_id)), ) .execute(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; Ok(StatusCode::NO_CONTENT) } async fn search_pages( State(state): State>, Path(workspace_id): Path, Query(params): Query, ) -> Result>, WorkspacesError> { let mut conn = state .conn .get() .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let query = params.q.unwrap_or_default(); if query.is_empty() { return Ok(Json(vec![])); } let pattern = format!("%{query}%"); let db_pages: Vec = workspace_pages::table .filter(workspace_pages::workspace_id.eq(workspace_id)) .filter(workspace_pages::title.ilike(&pattern)) .order(workspace_pages::updated_at.desc()) .limit(20) .load(&mut conn) .map_err(|e| WorkspacesError::DbError(e.to_string()))?; let results: Vec = db_pages .into_iter() .map(|p| { let icon = match (&p.icon_type, &p.icon_value) { (Some(t), Some(v)) => Some(WorkspaceIcon { icon_type: IconType::from_str(t), value: v.clone(), }), _ => None, }; PageSearchResult { page_id: p.id, title: p.title, icon, snippet: String::new(), updated_at: p.updated_at, } }) .collect(); Ok(Json(results)) } async fn get_slash_commands_handler( State(_state): State>, ) -> Json> { Json(vec![ SlashCommand { id: "paragraph".to_string(), name: "Text".to_string(), description: "Plain text paragraph".to_string(), icon: "type".to_string(), category: SlashCommandCategory::General, keywords: vec!["text".to_string(), "paragraph".to_string()], }, SlashCommand { id: "heading1".to_string(), name: "Heading 1".to_string(), description: "Large section heading".to_string(), icon: "heading-1".to_string(), category: SlashCommandCategory::General, keywords: vec!["h1".to_string(), "heading".to_string()], }, SlashCommand { id: "heading2".to_string(), name: "Heading 2".to_string(), description: "Medium section heading".to_string(), icon: "heading-2".to_string(), category: SlashCommandCategory::General, keywords: vec!["h2".to_string(), "heading".to_string()], }, SlashCommand { id: "bulleted_list".to_string(), name: "Bulleted list".to_string(), description: "Create a bulleted list".to_string(), icon: "list".to_string(), category: SlashCommandCategory::General, keywords: vec!["bullet".to_string(), "list".to_string()], }, SlashCommand { id: "numbered_list".to_string(), name: "Numbered list".to_string(), description: "Create a numbered list".to_string(), icon: "list-ordered".to_string(), category: SlashCommandCategory::General, keywords: vec!["number".to_string(), "list".to_string()], }, SlashCommand { id: "checklist".to_string(), name: "Checklist".to_string(), description: "Create a checklist".to_string(), icon: "check-square".to_string(), category: SlashCommandCategory::General, keywords: vec!["todo".to_string(), "checkbox".to_string()], }, SlashCommand { id: "code".to_string(), name: "Code".to_string(), description: "Create a code block".to_string(), icon: "code".to_string(), category: SlashCommandCategory::General, keywords: vec!["code".to_string(), "snippet".to_string()], }, SlashCommand { id: "image".to_string(), name: "Image".to_string(), description: "Upload or embed an image".to_string(), icon: "image".to_string(), category: SlashCommandCategory::Media, keywords: vec!["image".to_string(), "picture".to_string()], }, ]) } pub fn configure_workspaces_routes() -> Router> { Router::new() .route("/api/workspaces", get(list_workspaces).post(create_workspace)) .route( "/api/workspaces/{workspace_id}", get(get_workspace).put(update_workspace).delete(delete_workspace), ) .route( "/api/workspaces/{workspace_id}/pages", get(list_pages).post(create_page), ) .route("/api/workspaces/{workspace_id}/members", post(add_member)) .route( "/api/workspaces/{workspace_id}/members/{user_id}", delete(remove_member), ) .route("/api/workspaces/{workspace_id}/search", get(search_pages)) .route( "/api/pages/{page_id}", get(get_page).put(update_page).delete(delete_page), ) .route("/api/workspaces/commands", get(get_slash_commands_handler)) }