generalbots/src/workspaces/mod.rs
Rodrigo Rodriguez (Pragmatismo) 5919aa6bf0 Add video module, RBAC, security features, billing, contacts, dashboards, learn, social, and multiple new modules
Major additions:
- Video editing engine with AI features (transcription, captions, TTS, scene detection)
- RBAC middleware and organization management
- Security enhancements (MFA, passkey, DLP, encryption, audit)
- Billing and subscription management
- Contacts management
- Dashboards module
- Learn/LMS module
- Social features
- Compliance (SOC2, SOP middleware, vulnerability scanner)
- New migrations for RBAC, learn, and video tables
2026-01-08 13:16:17 -03:00

1293 lines
38 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
pub mod blocks;
pub mod pages;
pub mod collaboration;
pub mod templates;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub id: Uuid,
pub organization_id: Uuid,
pub name: String,
pub description: Option<String>,
pub icon: Option<WorkspaceIcon>,
pub cover_image: Option<String>,
pub members: Vec<WorkspaceMember>,
pub settings: WorkspaceSettings,
pub root_pages: Vec<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: 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,
}
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSettings {
pub default_page_width: PageWidth,
pub allow_public_pages: bool,
pub enable_comments: bool,
pub enable_reactions: bool,
pub enable_gb_assist: bool,
pub gb_bot_id: Option<Uuid>,
}
impl Default for WorkspaceSettings {
fn default() -> Self {
Self {
default_page_width: PageWidth::Normal,
allow_public_pages: false,
enable_comments: true,
enable_reactions: true,
enable_gb_assist: true,
gb_bot_id: None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PageWidth {
Small,
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)]
pub struct PagePermissions {
pub inherit_from_parent: bool,
pub public: bool,
pub public_edit: bool,
pub allowed_users: Vec<Uuid>,
pub allowed_roles: Vec<WorkspaceRole>,
}
impl Default for PagePermissions {
fn default() -> Self {
Self {
inherit_from_parent: true,
public: false,
public_edit: false,
allowed_users: Vec::new(),
allowed_roles: Vec::new(),
}
}
}
#[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(untagged)]
pub enum BlockContent {
Text(RichText),
Media(MediaContent),
Table(TableContent),
Code(CodeContent),
Embed(EmbedContent),
Callout(CalloutContent),
Toggle(ToggleContent),
Checklist(ChecklistContent),
GbComponent(GbComponentContent),
Empty,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichText {
pub segments: Vec<TextSegment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextSegment {
pub text: String,
pub annotations: TextAnnotations,
pub link: Option<String>,
pub mention: Option<Mention>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TextAnnotations {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
pub code: bool,
pub color: Option<String>,
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 MediaContent {
pub url: String,
pub caption: Option<RichText>,
pub alt_text: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableContent {
pub rows: Vec<TableRow>,
pub has_header_row: bool,
pub has_header_column: bool,
pub column_widths: Vec<u32>,
}
#[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,
pub background_color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeContent {
pub code: String,
pub language: String,
pub caption: Option<RichText>,
pub wrap: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedContent {
pub url: String,
pub embed_type: EmbedType,
pub caption: Option<RichText>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EmbedType {
Youtube,
Vimeo,
Figma,
GoogleDrive,
GoogleMaps,
Twitter,
Github,
Codepen,
Generic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalloutContent {
pub icon: WorkspaceIcon,
pub text: RichText,
pub background_color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToggleContent {
pub title: RichText,
pub expanded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecklistContent {
pub items: Vec<ChecklistItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecklistItem {
pub id: Uuid,
pub text: RichText,
pub checked: bool,
pub assignee: Option<Uuid>,
pub due_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GbComponentContent {
pub component_type: GbComponentType,
pub bot_id: Option<Uuid>,
pub config: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GbComponentType {
AskGb,
SummarizePage,
CreateContent,
TranslateBlock,
FormEmbed,
DataTable,
Chart,
KbSearch,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BlockProperties {
pub color: Option<String>,
pub background_color: Option<String>,
pub indent_level: u8,
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>),
}
pub struct WorkspacesService {
workspaces: Arc<RwLock<HashMap<Uuid, Workspace>>>,
pages: Arc<RwLock<HashMap<Uuid, Page>>>,
page_versions: Arc<RwLock<HashMap<Uuid, Vec<PageVersion>>>>,
comments: Arc<RwLock<HashMap<Uuid, Vec<Comment>>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageVersion {
pub id: Uuid,
pub page_id: Uuid,
pub version_number: u32,
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: RichText,
pub resolved: bool,
pub resolved_by: Option<Uuid>,
pub resolved_at: Option<DateTime<Utc>>,
pub reactions: Vec<Reaction>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reaction {
pub emoji: String,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
}
impl WorkspacesService {
pub fn new() -> Self {
Self {
workspaces: Arc::new(RwLock::new(HashMap::new())),
pages: Arc::new(RwLock::new(HashMap::new())),
page_versions: Arc::new(RwLock::new(HashMap::new())),
comments: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn create_workspace(
&self,
organization_id: Uuid,
name: &str,
created_by: Uuid,
) -> Result<Workspace, WorkspacesError> {
let now = Utc::now();
let workspace = Workspace {
id: Uuid::new_v4(),
organization_id,
name: name.to_string(),
description: None,
icon: None,
cover_image: None,
members: vec![WorkspaceMember {
user_id: created_by,
role: WorkspaceRole::Owner,
joined_at: now,
invited_by: None,
}],
settings: WorkspaceSettings::default(),
root_pages: Vec::new(),
created_at: now,
updated_at: now,
created_by,
};
let mut workspaces = self.workspaces.write().await;
workspaces.insert(workspace.id, workspace.clone());
Ok(workspace)
}
pub async fn get_workspace(&self, workspace_id: Uuid) -> Option<Workspace> {
let workspaces = self.workspaces.read().await;
workspaces.get(&workspace_id).cloned()
}
pub async fn list_workspaces(&self, organization_id: Uuid) -> Vec<Workspace> {
let workspaces = self.workspaces.read().await;
workspaces
.values()
.filter(|w| w.organization_id == organization_id)
.cloned()
.collect()
}
pub async fn list_user_workspaces(&self, user_id: Uuid) -> Vec<Workspace> {
let workspaces = self.workspaces.read().await;
workspaces
.values()
.filter(|w| w.members.iter().any(|m| m.user_id == user_id))
.cloned()
.collect()
}
pub async fn update_workspace(
&self,
workspace_id: Uuid,
name: Option<String>,
description: Option<String>,
icon: Option<WorkspaceIcon>,
) -> Result<Workspace, WorkspacesError> {
let mut workspaces = self.workspaces.write().await;
let workspace = workspaces
.get_mut(&workspace_id)
.ok_or(WorkspacesError::WorkspaceNotFound)?;
if let Some(n) = name {
workspace.name = n;
}
if description.is_some() {
workspace.description = description;
}
if icon.is_some() {
workspace.icon = icon;
}
workspace.updated_at = Utc::now();
Ok(workspace.clone())
}
pub async fn delete_workspace(&self, workspace_id: Uuid) -> Result<(), WorkspacesError> {
let mut workspaces = self.workspaces.write().await;
workspaces
.remove(&workspace_id)
.ok_or(WorkspacesError::WorkspaceNotFound)?;
let mut pages = self.pages.write().await;
pages.retain(|_, p| p.workspace_id != workspace_id);
Ok(())
}
pub async fn add_member(
&self,
workspace_id: Uuid,
user_id: Uuid,
role: WorkspaceRole,
invited_by: Uuid,
) -> Result<(), WorkspacesError> {
let mut workspaces = self.workspaces.write().await;
let workspace = workspaces
.get_mut(&workspace_id)
.ok_or(WorkspacesError::WorkspaceNotFound)?;
if workspace.members.iter().any(|m| m.user_id == user_id) {
return Err(WorkspacesError::MemberAlreadyExists);
}
workspace.members.push(WorkspaceMember {
user_id,
role,
joined_at: Utc::now(),
invited_by: Some(invited_by),
});
workspace.updated_at = Utc::now();
Ok(())
}
pub async fn remove_member(
&self,
workspace_id: Uuid,
user_id: Uuid,
) -> Result<(), WorkspacesError> {
let mut workspaces = self.workspaces.write().await;
let workspace = workspaces
.get_mut(&workspace_id)
.ok_or(WorkspacesError::WorkspaceNotFound)?;
let owner_count = workspace
.members
.iter()
.filter(|m| m.role == WorkspaceRole::Owner)
.count();
if let Some(member) = workspace.members.iter().find(|m| m.user_id == user_id) {
if member.role == WorkspaceRole::Owner && owner_count <= 1 {
return Err(WorkspacesError::CannotRemoveLastOwner);
}
}
workspace.members.retain(|m| m.user_id != user_id);
workspace.updated_at = Utc::now();
Ok(())
}
pub async fn update_member_role(
&self,
workspace_id: Uuid,
user_id: Uuid,
new_role: WorkspaceRole,
) -> Result<(), WorkspacesError> {
let mut workspaces = self.workspaces.write().await;
let workspace = workspaces
.get_mut(&workspace_id)
.ok_or(WorkspacesError::WorkspaceNotFound)?;
let member = workspace
.members
.iter_mut()
.find(|m| m.user_id == user_id)
.ok_or(WorkspacesError::MemberNotFound)?;
member.role = new_role;
workspace.updated_at = Utc::now();
Ok(())
}
pub async fn create_page(
&self,
workspace_id: Uuid,
parent_id: Option<Uuid>,
title: &str,
created_by: Uuid,
) -> Result<Page, WorkspacesError> {
let workspaces = self.workspaces.read().await;
if !workspaces.contains_key(&workspace_id) {
return Err(WorkspacesError::WorkspaceNotFound);
}
drop(workspaces);
let now = Utc::now();
let page = Page {
id: Uuid::new_v4(),
workspace_id,
parent_id,
title: title.to_string(),
icon: None,
cover_image: None,
blocks: Vec::new(),
children: Vec::new(),
properties: HashMap::new(),
permissions: PagePermissions::default(),
is_template: false,
template_id: None,
created_at: now,
updated_at: now,
created_by,
last_edited_by: created_by,
};
let mut pages = self.pages.write().await;
pages.insert(page.id, page.clone());
drop(pages);
if let Some(pid) = parent_id {
let mut pages = self.pages.write().await;
if let Some(parent) = pages.get_mut(&pid) {
parent.children.push(page.id);
parent.updated_at = Utc::now();
}
} else {
let mut workspaces = self.workspaces.write().await;
if let Some(workspace) = workspaces.get_mut(&workspace_id) {
workspace.root_pages.push(page.id);
workspace.updated_at = Utc::now();
}
}
Ok(page)
}
pub async fn get_page(&self, page_id: Uuid) -> Option<Page> {
let pages = self.pages.read().await;
pages.get(&page_id).cloned()
}
pub async fn get_page_tree(&self, workspace_id: Uuid) -> Vec<PageTreeNode> {
let workspaces = self.workspaces.read().await;
let workspace = match workspaces.get(&workspace_id) {
Some(w) => w,
None => return Vec::new(),
};
let pages = self.pages.read().await;
let mut tree = Vec::new();
for page_id in &workspace.root_pages {
if let Some(node) = self.build_page_tree_node(*page_id, &pages) {
tree.push(node);
}
}
tree
}
fn build_page_tree_node(
&self,
page_id: Uuid,
pages: &HashMap<Uuid, Page>,
) -> Option<PageTreeNode> {
let page = pages.get(&page_id)?;
let children: Vec<PageTreeNode> = page
.children
.iter()
.filter_map(|child_id| self.build_page_tree_node(*child_id, pages))
.collect();
Some(PageTreeNode {
id: page.id,
title: page.title.clone(),
icon: page.icon.clone(),
children,
has_children: !page.children.is_empty(),
})
}
pub async fn update_page(
&self,
page_id: Uuid,
title: Option<String>,
icon: Option<WorkspaceIcon>,
cover_image: Option<String>,
edited_by: Uuid,
) -> Result<Page, WorkspacesError> {
let mut pages = self.pages.write().await;
let page = pages
.get_mut(&page_id)
.ok_or(WorkspacesError::PageNotFound)?;
if let Some(t) = title {
page.title = t;
}
if icon.is_some() {
page.icon = icon;
}
if cover_image.is_some() {
page.cover_image = cover_image;
}
page.updated_at = Utc::now();
page.last_edited_by = edited_by;
Ok(page.clone())
}
pub async fn update_page_blocks(
&self,
page_id: Uuid,
blocks: Vec<Block>,
edited_by: Uuid,
) -> Result<Page, WorkspacesError> {
let old_page = {
let pages = self.pages.read().await;
pages.get(&page_id).cloned()
};
if let Some(old) = old_page {
self.save_page_version(&old).await;
}
let mut pages = self.pages.write().await;
let page = pages
.get_mut(&page_id)
.ok_or(WorkspacesError::PageNotFound)?;
page.blocks = blocks;
page.updated_at = Utc::now();
page.last_edited_by = edited_by;
Ok(page.clone())
}
async fn save_page_version(&self, page: &Page) {
let mut versions = self.page_versions.write().await;
let page_versions = versions.entry(page.id).or_default();
let version_number = page_versions.len() as u32 + 1;
let version = PageVersion {
id: Uuid::new_v4(),
page_id: page.id,
version_number,
title: page.title.clone(),
blocks: page.blocks.clone(),
created_at: Utc::now(),
created_by: page.last_edited_by,
change_summary: None,
};
page_versions.push(version);
if page_versions.len() > 100 {
page_versions.remove(0);
}
}
pub async fn get_page_versions(&self, page_id: Uuid) -> Vec<PageVersion> {
let versions = self.page_versions.read().await;
versions.get(&page_id).cloned().unwrap_or_default()
}
pub async fn restore_page_version(
&self,
page_id: Uuid,
version_id: Uuid,
restored_by: Uuid,
) -> Result<Page, WorkspacesError> {
let version = {
let versions = self.page_versions.read().await;
versions
.get(&page_id)
.and_then(|v| v.iter().find(|pv| pv.id == version_id).cloned())
};
let version = version.ok_or(WorkspacesError::VersionNotFound)?;
self.update_page_blocks(page_id, version.blocks, restored_by)
.await
}
pub async fn delete_page(&self, page_id: Uuid) -> Result<(), WorkspacesError> {
let page = {
let pages = self.pages.read().await;
pages.get(&page_id).cloned()
};
let page = page.ok_or(WorkspacesError::PageNotFound)?;
for child_id in &page.children {
let _ = Box::pin(self.delete_page(*child_id)).await;
}
let mut pages = self.pages.write().await;
pages.remove(&page_id);
drop(pages);
if let Some(parent_id) = page.parent_id {
let mut pages = self.pages.write().await;
if let Some(parent) = pages.get_mut(&parent_id) {
parent.children.retain(|id| *id != page_id);
}
} else {
let mut workspaces = self.workspaces.write().await;
if let Some(workspace) = workspaces.get_mut(&page.workspace_id) {
workspace.root_pages.retain(|id| *id != page_id);
}
}
let mut versions = self.page_versions.write().await;
versions.remove(&page_id);
let mut comments = self.comments.write().await;
comments.remove(&page_id);
Ok(())
}
pub async fn move_page(
&self,
page_id: Uuid,
new_parent_id: Option<Uuid>,
new_workspace_id: Option<Uuid>,
) -> Result<Page, WorkspacesError> {
let mut pages = self.pages.write().await;
let page = pages
.get_mut(&page_id)
.ok_or(WorkspacesError::PageNotFound)?;
let old_parent_id = page.parent_id;
let old_workspace_id = page.workspace_id;
page.parent_id = new_parent_id;
if let Some(ws_id) = new_workspace_id {
page.workspace_id = ws_id;
}
page.updated_at = Utc::now();
let page_clone = page.clone();
drop(pages);
if let Some(old_pid) = old_parent_id {
let mut pages = self.pages.write().await;
if let Some(old_parent) = pages.get_mut(&old_pid) {
old_parent.children.retain(|id| *id != page_id);
}
} else {
let mut workspaces = self.workspaces.write().await;
if let Some(workspace) = workspaces.get_mut(&old_workspace_id) {
workspace.root_pages.retain(|id| *id != page_id);
}
}
if let Some(new_pid) = new_parent_id {
let mut pages = self.pages.write().await;
if let Some(new_parent) = pages.get_mut(&new_pid) {
if !new_parent.children.contains(&page_id) {
new_parent.children.push(page_id);
}
}
} else {
let ws_id = new_workspace_id.unwrap_or(old_workspace_id);
let mut workspaces = self.workspaces.write().await;
if let Some(workspace) = workspaces.get_mut(&ws_id) {
if !workspace.root_pages.contains(&page_id) {
workspace.root_pages.push(page_id);
}
}
}
Ok(page_clone)
}
pub async fn add_comment(
&self,
page_id: Uuid,
block_id: Option<Uuid>,
author_id: Uuid,
content: RichText,
parent_comment_id: Option<Uuid>,
) -> Result<Comment, WorkspacesError> {
let pages = self.pages.read().await;
if !pages.contains_key(&page_id) {
return Err(WorkspacesError::PageNotFound);
}
drop(pages);
let now = Utc::now();
let comment = Comment {
id: Uuid::new_v4(),
page_id,
block_id,
parent_comment_id,
author_id,
content,
resolved: false,
resolved_by: None,
resolved_at: None,
reactions: Vec::new(),
created_at: now,
updated_at: now,
};
let mut comments = self.comments.write().await;
comments.entry(page_id).or_default().push(comment.clone());
Ok(comment)
}
pub async fn get_page_comments(&self, page_id: Uuid) -> Vec<Comment> {
let comments = self.comments.read().await;
comments.get(&page_id).cloned().unwrap_or_default()
}
pub async fn resolve_comment(
&self,
page_id: Uuid,
comment_id: Uuid,
resolved_by: Uuid,
) -> Result<Comment, WorkspacesError> {
let mut comments = self.comments.write().await;
let page_comments = comments
.get_mut(&page_id)
.ok_or(WorkspacesError::CommentNotFound)?;
let comment = page_comments
.iter_mut()
.find(|c| c.id == comment_id)
.ok_or(WorkspacesError::CommentNotFound)?;
comment.resolved = true;
comment.resolved_by = Some(resolved_by);
comment.resolved_at = Some(Utc::now());
comment.updated_at = Utc::now();
Ok(comment.clone())
}
pub async fn add_reaction(
&self,
page_id: Uuid,
comment_id: Uuid,
user_id: Uuid,
emoji: &str,
) -> Result<(), WorkspacesError> {
let mut comments = self.comments.write().await;
let page_comments = comments
.get_mut(&page_id)
.ok_or(WorkspacesError::CommentNotFound)?;
let comment = page_comments
.iter_mut()
.find(|c| c.id == comment_id)
.ok_or(WorkspacesError::CommentNotFound)?;
if comment.reactions.iter().any(|r| r.user_id == user_id && r.emoji == emoji) {
return Ok(());
}
comment.reactions.push(Reaction {
emoji: emoji.to_string(),
user_id,
created_at: Utc::now(),
});
Ok(())
}
pub async fn search_pages(&self, workspace_id: Uuid, query: &str) -> Vec<PageSearchResult> {
let pages = self.pages.read().await;
let query_lower = query.to_lowercase();
pages
.values()
.filter(|p| p.workspace_id == workspace_id)
.filter(|p| {
p.title.to_lowercase().contains(&query_lower)
|| self.blocks_contain_text(&p.blocks, &query_lower)
})
.map(|p| PageSearchResult {
page_id: p.id,
title: p.title.clone(),
icon: p.icon.clone(),
snippet: self.extract_snippet(&p.blocks, &query_lower),
updated_at: p.updated_at,
})
.collect()
}
fn blocks_contain_text(&self, blocks: &[Block], query: &str) -> bool {
for block in blocks {
if let BlockContent::Text(rich_text) = &block.content {
for segment in &rich_text.segments {
if segment.text.to_lowercase().contains(query) {
return true;
}
}
}
if self.blocks_contain_text(&block.children, query) {
return true;
}
}
false
}
fn extract_snippet(&self, blocks: &[Block], query: &str) -> Option<String> {
for block in blocks {
if let BlockContent::Text(rich_text) = &block.content {
let full_text: String = rich_text.segments.iter().map(|s| s.text.as_str()).collect();
if full_text.to_lowercase().contains(query) {
let max_len = 150;
if full_text.len() <= max_len {
return Some(full_text);
}
return Some(format!("{}...", &full_text[..max_len]));
}
}
}
None
}
}
impl Default for WorkspacesService {
fn default() -> Self {
Self::new()
}
}
#[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: Option<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,
}
pub fn get_slash_commands() -> Vec<SlashCommand> {
vec![
SlashCommand {
id: "ask_gb".to_string(),
name: "Ask General Bots".to_string(),
description: "Ask GB to answer a question using your knowledge base".to_string(),
icon: "bot".to_string(),
category: SlashCommandCategory::GbAssist,
keywords: vec!["ai".to_string(), "ask".to_string(), "question".to_string()],
},
SlashCommand {
id: "create_content".to_string(),
name: "Create page content".to_string(),
description: "Use GB to generate content for this page".to_string(),
icon: "sparkles".to_string(),
category: SlashCommandCategory::GbAssist,
keywords: vec!["generate".to_string(), "write".to_string(), "create".to_string()],
},
SlashCommand {
id: "summarize".to_string(),
name: "Summarize page".to_string(),
description: "Generate a summary of this page using GB".to_string(),
icon: "file-text".to_string(),
category: SlashCommandCategory::GbAssist,
keywords: vec!["summary".to_string(), "tldr".to_string()],
},
SlashCommand {
id: "translate".to_string(),
name: "Translate block".to_string(),
description: "Translate selected content to another language".to_string(),
icon: "languages".to_string(),
category: SlashCommandCategory::GbAssist,
keywords: vec!["language".to_string(), "translate".to_string()],
},
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(), "title".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: "heading3".to_string(),
name: "Heading 3".to_string(),
description: "Small section heading".to_string(),
icon: "heading-3".to_string(),
category: SlashCommandCategory::General,
keywords: vec!["h3".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(), "ul".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(), "ol".to_string()],
},
SlashCommand {
id: "checklist".to_string(),
name: "Checklist".to_string(),
description: "Create a checklist with checkboxes".to_string(),
icon: "check-square".to_string(),
category: SlashCommandCategory::General,
keywords: vec!["todo".to_string(), "checkbox".to_string(), "task".to_string()],
},
SlashCommand {
id: "toggle".to_string(),
name: "Toggle".to_string(),
description: "Create a collapsible toggle block".to_string(),
icon: "chevron-right".to_string(),
category: SlashCommandCategory::General,
keywords: vec!["collapse".to_string(), "expand".to_string(), "toggle".to_string()],
},
SlashCommand {
id: "table".to_string(),
name: "Table".to_string(),
description: "Create a table".to_string(),
icon: "table".to_string(),
category: SlashCommandCategory::General,
keywords: vec!["table".to_string(), "grid".to_string()],
},
SlashCommand {
id: "divider".to_string(),
name: "Divider".to_string(),
description: "Create a horizontal divider".to_string(),
icon: "minus".to_string(),
category: SlashCommandCategory::General,
keywords: vec!["hr".to_string(), "line".to_string(), "separator".to_string()],
},
SlashCommand {
id: "quote".to_string(),
name: "Quote".to_string(),
description: "Create a quote block".to_string(),
icon: "quote".to_string(),
category: SlashCommandCategory::General,
keywords: vec!["blockquote".to_string(), "quote".to_string()],
},
SlashCommand {
id: "callout".to_string(),
name: "Callout".to_string(),
description: "Create a callout block with icon".to_string(),
icon: "alert-circle".to_string(),
category: SlashCommandCategory::General,
keywords: vec!["callout".to_string(), "note".to_string(), "tip".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(), "photo".to_string()],
},
SlashCommand {
id: "video".to_string(),
name: "Video".to_string(),
description: "Embed a video".to_string(),
icon: "video".to_string(),
category: SlashCommandCategory::Media,
keywords: vec!["video".to_string(), "youtube".to_string()],
},
SlashCommand {
id: "file".to_string(),
name: "File".to_string(),
description: "Upload a file".to_string(),
icon: "file".to_string(),
category: SlashCommandCategory::Media,
keywords: vec!["file".to_string(), "upload".to_string(), "attachment".to_string()],
},
SlashCommand {
id: "embed".to_string(),
name: "Embed".to_string(),
description: "Embed external content".to_string(),
icon: "globe".to_string(),
category: SlashCommandCategory::Embed,
keywords: vec!["embed".to_string(), "iframe".to_string()],
},
SlashCommand {
id: "link_to_page".to_string(),
name: "Link to page".to_string(),
description: "Create a link to another page".to_string(),
icon: "link".to_string(),
category: SlashCommandCategory::Advanced,
keywords: vec!["link".to_string(), "page".to_string()],
},
SlashCommand {
id: "toc".to_string(),
name: "Table of contents".to_string(),
description: "Generate a table of contents".to_string(),
icon: "list-tree".to_string(),
category: SlashCommandCategory::Advanced,
keywords: vec!["toc".to_string(), "contents".to_string()],
},
]
}
#[derive(Debug, Clone)]
pub enum WorkspacesError {
WorkspaceNotFound,
PageNotFound,
BlockNotFound,
CommentNotFound,
VersionNotFound,
MemberNotFound,
MemberAlreadyExists,
CannotRemoveLastOwner,
PermissionDenied,
InvalidOperation(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}"),
}
}
}
impl std::error::Error for WorkspacesError {}