generalbots/src/workspaces/collaboration.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

500 lines
14 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;
use super::blocks::BlockOperation;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollaborationSession {
pub id: Uuid,
pub page_id: Uuid,
pub active_users: Vec<ActiveUser>,
pub created_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveUser {
pub user_id: Uuid,
pub display_name: String,
pub avatar_url: Option<String>,
pub color: String,
pub cursor_position: Option<CursorPosition>,
pub selection: Option<TextSelection>,
pub joined_at: DateTime<Utc>,
pub last_seen: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorPosition {
pub block_id: Uuid,
pub offset: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextSelection {
pub block_id: Uuid,
pub start_offset: usize,
pub end_offset: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollaborationEvent {
pub event_type: CollaborationEventType,
pub page_id: Uuid,
pub user_id: Uuid,
pub timestamp: DateTime<Utc>,
pub payload: CollaborationPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CollaborationEventType {
UserJoined,
UserLeft,
CursorMoved,
SelectionChanged,
BlockOperation,
PageUpdated,
CommentAdded,
CommentResolved,
Ping,
Pong,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CollaborationPayload {
User(ActiveUser),
Cursor(CursorPosition),
Selection(TextSelection),
Operation(BlockOperation),
PageUpdate(PageUpdatePayload),
Comment(CommentPayload),
Empty,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageUpdatePayload {
pub title: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentPayload {
pub comment_id: Uuid,
pub block_id: Option<Uuid>,
pub content: String,
}
pub struct CollaborationManager {
sessions: Arc<RwLock<HashMap<Uuid, CollaborationSession>>>,
event_channels: Arc<RwLock<HashMap<Uuid, broadcast::Sender<CollaborationEvent>>>>,
user_colors: Vec<String>,
}
impl CollaborationManager {
pub fn new() -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
event_channels: Arc::new(RwLock::new(HashMap::new())),
user_colors: vec![
"#E53935".to_string(),
"#8E24AA".to_string(),
"#3949AB".to_string(),
"#039BE5".to_string(),
"#00ACC1".to_string(),
"#43A047".to_string(),
"#7CB342".to_string(),
"#FDD835".to_string(),
"#FB8C00".to_string(),
"#6D4C41".to_string(),
],
}
}
pub async fn join_session(
&self,
page_id: Uuid,
user_id: Uuid,
display_name: String,
avatar_url: Option<String>,
) -> Result<(CollaborationSession, broadcast::Receiver<CollaborationEvent>), CollaborationError> {
let now = Utc::now();
let mut sessions = self.sessions.write().await;
let mut channels = self.event_channels.write().await;
let color = self.assign_color(&sessions, page_id);
let active_user = ActiveUser {
user_id,
display_name,
avatar_url,
color,
cursor_position: None,
selection: None,
joined_at: now,
last_seen: now,
};
let session = sessions.entry(page_id).or_insert_with(|| CollaborationSession {
id: Uuid::new_v4(),
page_id,
active_users: Vec::new(),
created_at: now,
last_activity: now,
});
if !session.active_users.iter().any(|u| u.user_id == user_id) {
session.active_users.push(active_user.clone());
}
session.last_activity = now;
let (tx, rx) = if let Some(existing_tx) = channels.get(&page_id) {
(existing_tx.clone(), existing_tx.subscribe())
} else {
let (tx, rx) = broadcast::channel(256);
channels.insert(page_id, tx.clone());
(tx, rx)
};
let event = CollaborationEvent {
event_type: CollaborationEventType::UserJoined,
page_id,
user_id,
timestamp: now,
payload: CollaborationPayload::User(active_user),
};
let _ = tx.send(event);
Ok((session.clone(), rx))
}
pub async fn leave_session(&self, page_id: Uuid, user_id: Uuid) -> Result<(), CollaborationError> {
let now = Utc::now();
let mut sessions = self.sessions.write().await;
let channels = self.event_channels.read().await;
if let Some(session) = sessions.get_mut(&page_id) {
session.active_users.retain(|u| u.user_id != user_id);
session.last_activity = now;
if let Some(tx) = channels.get(&page_id) {
let event = CollaborationEvent {
event_type: CollaborationEventType::UserLeft,
page_id,
user_id,
timestamp: now,
payload: CollaborationPayload::Empty,
};
let _ = tx.send(event);
}
if session.active_users.is_empty() {
sessions.remove(&page_id);
}
}
Ok(())
}
pub async fn update_cursor(
&self,
page_id: Uuid,
user_id: Uuid,
cursor: CursorPosition,
) -> Result<(), CollaborationError> {
let now = Utc::now();
let mut sessions = self.sessions.write().await;
let channels = self.event_channels.read().await;
if let Some(session) = sessions.get_mut(&page_id) {
if let Some(user) = session.active_users.iter_mut().find(|u| u.user_id == user_id) {
user.cursor_position = Some(cursor.clone());
user.last_seen = now;
}
session.last_activity = now;
if let Some(tx) = channels.get(&page_id) {
let event = CollaborationEvent {
event_type: CollaborationEventType::CursorMoved,
page_id,
user_id,
timestamp: now,
payload: CollaborationPayload::Cursor(cursor),
};
let _ = tx.send(event);
}
}
Ok(())
}
pub async fn update_selection(
&self,
page_id: Uuid,
user_id: Uuid,
selection: Option<TextSelection>,
) -> Result<(), CollaborationError> {
let now = Utc::now();
let mut sessions = self.sessions.write().await;
let channels = self.event_channels.read().await;
if let Some(session) = sessions.get_mut(&page_id) {
if let Some(user) = session.active_users.iter_mut().find(|u| u.user_id == user_id) {
user.selection = selection.clone();
user.last_seen = now;
}
session.last_activity = now;
if let Some(tx) = channels.get(&page_id) {
if let Some(sel) = selection {
let event = CollaborationEvent {
event_type: CollaborationEventType::SelectionChanged,
page_id,
user_id,
timestamp: now,
payload: CollaborationPayload::Selection(sel),
};
let _ = tx.send(event);
}
}
}
Ok(())
}
pub async fn broadcast_operation(
&self,
page_id: Uuid,
user_id: Uuid,
operation: BlockOperation,
) -> Result<(), CollaborationError> {
let now = Utc::now();
let mut sessions = self.sessions.write().await;
let channels = self.event_channels.read().await;
if let Some(session) = sessions.get_mut(&page_id) {
session.last_activity = now;
if let Some(tx) = channels.get(&page_id) {
let event = CollaborationEvent {
event_type: CollaborationEventType::BlockOperation,
page_id,
user_id,
timestamp: now,
payload: CollaborationPayload::Operation(operation),
};
let _ = tx.send(event);
}
}
Ok(())
}
pub async fn get_session(&self, page_id: Uuid) -> Option<CollaborationSession> {
let sessions = self.sessions.read().await;
sessions.get(&page_id).cloned()
}
pub async fn get_active_users(&self, page_id: Uuid) -> Vec<ActiveUser> {
let sessions = self.sessions.read().await;
sessions
.get(&page_id)
.map(|s| s.active_users.clone())
.unwrap_or_default()
}
pub async fn cleanup_stale_sessions(&self, timeout_seconds: i64) {
let now = Utc::now();
let cutoff = now - chrono::Duration::seconds(timeout_seconds);
let mut sessions = self.sessions.write().await;
let mut channels = self.event_channels.write().await;
let stale_pages: Vec<Uuid> = sessions
.iter()
.filter(|(_, s)| s.last_activity < cutoff)
.map(|(id, _)| *id)
.collect();
for page_id in stale_pages {
sessions.remove(&page_id);
channels.remove(&page_id);
}
for session in sessions.values_mut() {
session.active_users.retain(|u| u.last_seen >= cutoff);
}
}
fn assign_color(&self, sessions: &HashMap<Uuid, CollaborationSession>, page_id: Uuid) -> String {
if let Some(session) = sessions.get(&page_id) {
let used_colors: Vec<&String> = session.active_users.iter().map(|u| &u.color).collect();
for color in &self.user_colors {
if !used_colors.contains(&color) {
return color.clone();
}
}
}
self.user_colors[0].clone()
}
}
impl Default for CollaborationManager {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationalTransform {
pub base_version: u64,
pub operations: Vec<TransformOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformOperation {
pub op_type: TransformOpType,
pub path: Vec<usize>,
pub value: Option<serde_json::Value>,
pub old_value: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TransformOpType {
Insert,
Delete,
Replace,
Move,
}
pub fn transform_operations(
op1: &TransformOperation,
op2: &TransformOperation,
) -> (TransformOperation, TransformOperation) {
let mut transformed_op1 = op1.clone();
let mut transformed_op2 = op2.clone();
if op1.path.is_empty() || op2.path.is_empty() {
return (transformed_op1, transformed_op2);
}
let common_prefix_len = op1
.path
.iter()
.zip(op2.path.iter())
.take_while(|(a, b)| a == b)
.count();
if common_prefix_len == 0 {
return (transformed_op1, transformed_op2);
}
match (&op1.op_type, &op2.op_type) {
(TransformOpType::Insert, TransformOpType::Insert) => {
if op1.path <= op2.path {
if let Some(idx) = transformed_op2.path.get_mut(common_prefix_len) {
*idx += 1;
}
} else if let Some(idx) = transformed_op1.path.get_mut(common_prefix_len) {
*idx += 1;
}
}
(TransformOpType::Delete, TransformOpType::Insert) => {
if op1.path < op2.path {
if let Some(idx) = transformed_op2.path.get_mut(common_prefix_len) {
*idx = idx.saturating_sub(1);
}
}
}
(TransformOpType::Insert, TransformOpType::Delete) => {
if op2.path < op1.path {
if let Some(idx) = transformed_op1.path.get_mut(common_prefix_len) {
*idx = idx.saturating_sub(1);
}
}
}
(TransformOpType::Delete, TransformOpType::Delete) => {
if op1.path == op2.path {
transformed_op2.op_type = TransformOpType::Replace;
transformed_op2.value = None;
}
}
_ => {}
}
(transformed_op1, transformed_op2)
}
#[derive(Debug, Clone)]
pub enum CollaborationError {
SessionNotFound,
UserNotInSession,
BroadcastError(String),
InvalidOperation(String),
}
impl std::fmt::Display for CollaborationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SessionNotFound => write!(f, "Collaboration session not found"),
Self::UserNotInSession => write!(f, "User is not in the session"),
Self::BroadcastError(e) => write!(f, "Broadcast error: {e}"),
Self::InvalidOperation(e) => write!(f, "Invalid operation: {e}"),
}
}
}
impl std::error::Error for CollaborationError {}
pub async fn collaboration_cleanup_job(manager: Arc<CollaborationManager>, interval_seconds: u64) {
let mut ticker = tokio::time::interval(tokio::time::Duration::from_secs(interval_seconds));
loop {
ticker.tick().await;
manager.cleanup_stale_sessions(300).await;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresenceInfo {
pub page_id: Uuid,
pub users: Vec<PresenceUser>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresenceUser {
pub user_id: Uuid,
pub display_name: String,
pub avatar_url: Option<String>,
pub color: String,
pub is_typing: bool,
pub current_block: Option<Uuid>,
}
impl From<&ActiveUser> for PresenceUser {
fn from(user: &ActiveUser) -> Self {
Self {
user_id: user.user_id,
display_name: user.display_name.clone(),
avatar_url: user.avatar_url.clone(),
color: user.color.clone(),
is_typing: false,
current_block: user.cursor_position.as_ref().map(|c| c.block_id),
}
}
}