generalbots/src/workspaces/mod.rs
Rodrigo Rodriguez 5ea171d126
Some checks failed
BotServer CI / build (push) Failing after 1m34s
Refactor: Split large files into modular subdirectories
Split 20+ files over 1000 lines into focused subdirectories for better
maintainability and code organization. All changes maintain backward
compatibility through re-export wrappers.

Major splits:
- attendance/llm_assist.rs (2074→7 modules)
- basic/keywords/face_api.rs → face_api/ (7 modules)
- basic/keywords/file_operations.rs → file_ops/ (8 modules)
- basic/keywords/hear_talk.rs → hearing/ (6 modules)
- channels/wechat.rs → wechat/ (10 modules)
- channels/youtube.rs → youtube/ (5 modules)
- contacts/mod.rs → contacts_api/ (6 modules)
- core/bootstrap/mod.rs → bootstrap/ (5 modules)
- core/shared/admin.rs → admin_*.rs (5 modules)
- designer/canvas.rs → canvas_api/ (6 modules)
- designer/mod.rs → designer_api/ (6 modules)
- docs/handlers.rs → handlers_api/ (11 modules)
- drive/mod.rs → drive_handlers.rs, drive_types.rs
- learn/mod.rs → types.rs
- main.rs → main_module/ (7 modules)
- meet/webinar.rs → webinar_api/ (8 modules)
- paper/mod.rs → (10 modules)
- security/auth.rs → auth_api/ (7 modules)
- security/passkey.rs → (4 modules)
- sources/mod.rs → sources_api/ (5 modules)
- tasks/mod.rs → task_api/ (5 modules)

Stats: 38,040 deletions, 1,315 additions across 318 files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 21:09:30 +00:00

1370 lines
40 KiB
Rust

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<String>,
pub icon_type: Option<String>,
pub icon_value: Option<String>,
pub cover_image: Option<String>,
pub settings: serde_json::Value,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<Uuid>,
pub joined_at: DateTime<Utc>,
}
#[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<Uuid>,
pub title: String,
pub icon_type: Option<String>,
pub icon_value: Option<String>,
pub cover_image: Option<String>,
pub content: serde_json::Value,
pub properties: serde_json::Value,
pub is_template: bool,
pub template_id: Option<Uuid>,
pub is_public: bool,
pub public_edit: bool,
pub position: i32,
pub created_by: Uuid,
pub last_edited_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
}
#[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<Uuid>,
pub parent_comment_id: Option<Uuid>,
pub author_id: Uuid,
pub content: String,
pub resolved: bool,
pub resolved_by: Option<Uuid>,
pub resolved_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub id: Uuid,
pub org_id: Uuid,
pub name: String,
pub description: Option<String>,
pub icon: Option<WorkspaceIcon>,
pub cover_image: Option<String>,
pub settings: WorkspaceSettings,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub members: Vec<WorkspaceMember>,
pub root_pages: Vec<Uuid>,
}
#[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<Utc>,
pub invited_by: Option<Uuid>,
}
#[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<Uuid>,
}
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<Uuid>,
pub title: String,
pub icon: Option<WorkspaceIcon>,
pub cover_image: Option<String>,
pub blocks: Vec<Block>,
pub children: Vec<Uuid>,
pub properties: HashMap<String, PropertyValue>,
pub permissions: PagePermissions,
pub is_template: bool,
pub template_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
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<Uuid>,
#[serde(default)]
pub allowed_roles: Vec<WorkspaceRole>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Block {
pub id: Uuid,
pub block_type: BlockType,
pub content: BlockContent,
pub properties: BlockProperties,
pub children: Vec<Block>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
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<String> },
Table { rows: Vec<TableRow> },
Code { code: String, language: Option<String> },
Embed { url: String, embed_type: Option<String> },
Callout { icon: Option<String>, text: RichText },
Toggle { title: RichText, expanded: bool },
Checklist { items: Vec<ChecklistItem> },
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<TextSegment>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mention: Option<Mention>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_color: Option<String>,
}
#[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<TableCell>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableCell {
pub content: RichText,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_color: Option<String>,
}
#[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<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BlockProperties {
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_color: Option<String>,
#[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<Utc>),
Select(String),
MultiSelect(Vec<String>),
User(Uuid),
Users(Vec<Uuid>),
Url(String),
Email(String),
Phone(String),
Relation(Vec<Uuid>),
}
#[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<Block>,
pub created_at: DateTime<Utc>,
pub created_by: Uuid,
pub change_summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: Uuid,
pub page_id: Uuid,
pub block_id: Option<Uuid>,
pub parent_comment_id: Option<Uuid>,
pub author_id: Uuid,
pub content: String,
pub resolved: bool,
pub resolved_by: Option<Uuid>,
pub resolved_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageTreeNode {
pub id: Uuid,
pub title: String,
pub icon: Option<WorkspaceIcon>,
pub children: Vec<PageTreeNode>,
pub has_children: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageSearchResult {
pub page_id: Uuid,
pub title: String,
pub icon: Option<WorkspaceIcon>,
pub snippet: String,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
}
#[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<WorkspaceMember>, root_pages: Vec<Uuid>) -> 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<Uuid>) -> 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<Block> = serde_json::from_value(db.content).unwrap_or_default();
let properties: HashMap<String, PropertyValue> =
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<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateWorkspaceRequest {
pub name: Option<String>,
pub description: Option<String>,
pub icon: Option<WorkspaceIcon>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePageRequest {
pub title: String,
pub parent_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePageRequest {
pub title: Option<String>,
pub icon: Option<WorkspaceIcon>,
pub blocks: Option<Vec<Block>>,
}
#[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<Uuid>,
pub parent_comment_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub search: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
async fn list_workspaces(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<Workspace>>, 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<DbWorkspace> = 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<DbWorkspaceMember> = workspace_members::table
.filter(workspace_members::workspace_id.eq(ws.id))
.load(&mut conn)
.unwrap_or_default();
let members: Vec<WorkspaceMember> = 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<Uuid> = 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<Arc<AppState>>,
Json(req): Json<CreateWorkspaceRequest>,
) -> Result<Json<Workspace>, 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<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
) -> Result<Json<Workspace>, 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<DbWorkspaceMember> = workspace_members::table
.filter(workspace_members::workspace_id.eq(workspace_id))
.load(&mut conn)
.unwrap_or_default();
let members: Vec<WorkspaceMember> = 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<Uuid> = 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<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
Json(req): Json<UpdateWorkspaceRequest>,
) -> Result<Json<Workspace>, 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<DbWorkspaceMember> = workspace_members::table
.filter(workspace_members::workspace_id.eq(workspace_id))
.load(&mut conn)
.unwrap_or_default();
let members: Vec<WorkspaceMember> = 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<Uuid> = 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<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
) -> Result<StatusCode, WorkspacesError> {
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<Uuid> = 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<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
) -> Result<Json<Vec<PageTreeNode>>, WorkspacesError> {
let mut conn = state
.conn
.get()
.map_err(|e| WorkspacesError::DbError(e.to_string()))?;
let db_pages: Vec<DbWorkspacePage> = 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<Uuid>) -> Vec<PageTreeNode> {
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<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
Json(req): Json<CreatePageRequest>,
) -> Result<Json<Page>, 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<i32> = 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<Arc<AppState>>,
Path(page_id): Path<Uuid>,
) -> Result<Json<Page>, 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<Uuid> = 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<Arc<AppState>>,
Path(page_id): Path<Uuid>,
Json(req): Json<UpdatePageRequest>,
) -> Result<Json<Page>, 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<Uuid> = 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<Arc<AppState>>,
Path(page_id): Path<Uuid>,
) -> Result<StatusCode, WorkspacesError> {
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<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
Json(req): Json<AddMemberRequest>,
) -> Result<StatusCode, WorkspacesError> {
let mut conn = state
.conn
.get()
.map_err(|e| WorkspacesError::DbError(e.to_string()))?;
let existing: Option<DbWorkspaceMember> = 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<Arc<AppState>>,
Path((workspace_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, WorkspacesError> {
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<DbWorkspaceMember> = 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<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
Query(params): Query<SearchQuery>,
) -> Result<Json<Vec<PageSearchResult>>, 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<DbWorkspacePage> = 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<PageSearchResult> = 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<Arc<AppState>>,
) -> Json<Vec<SlashCommand>> {
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<Arc<AppState>> {
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))
}