Skip to main content

chasm/teams/
workspace.rs

1// Copyright (c) 2024-2027 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Team workspace management
4//!
5//! Provides shared team workspaces with real-time collaboration features.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::{broadcast, RwLock};
12use uuid::Uuid;
13
14use super::rbac::{Permission, Role, RoleAssignment};
15
16// ============================================================================
17// Types
18// ============================================================================
19
20/// Unique identifier for a team
21pub type TeamId = Uuid;
22
23/// Unique identifier for a team member
24pub type MemberId = Uuid;
25
26/// Team workspace representing a collaborative environment
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct TeamWorkspace {
29    /// Unique team identifier
30    pub id: TeamId,
31    /// Team name
32    pub name: String,
33    /// Team description
34    pub description: Option<String>,
35    /// Team avatar URL
36    pub avatar_url: Option<String>,
37    /// Team owner ID
38    pub owner_id: MemberId,
39    /// Team settings
40    pub settings: TeamSettings,
41    /// Team members with their roles
42    pub members: Vec<TeamMember>,
43    /// Shared session IDs
44    pub shared_sessions: Vec<String>,
45    /// Team creation timestamp
46    pub created_at: DateTime<Utc>,
47    /// Last updated timestamp
48    pub updated_at: DateTime<Utc>,
49}
50
51/// Team member information
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct TeamMember {
54    /// Member user ID
55    pub user_id: MemberId,
56    /// Member display name
57    pub display_name: String,
58    /// Member email
59    pub email: String,
60    /// Member avatar URL
61    pub avatar_url: Option<String>,
62    /// Member role in the team
63    pub role: Role,
64    /// Custom permissions (overrides role defaults)
65    pub custom_permissions: Option<Vec<Permission>>,
66    /// Join timestamp
67    pub joined_at: DateTime<Utc>,
68    /// Last active timestamp
69    pub last_active: Option<DateTime<Utc>>,
70    /// Member status
71    pub status: MemberStatus,
72}
73
74/// Member status
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum MemberStatus {
78    /// Active member
79    Active,
80    /// Invited, pending acceptance
81    Invited,
82    /// Deactivated by admin
83    Deactivated,
84    /// Left the team
85    Left,
86}
87
88/// Team settings
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct TeamSettings {
91    /// Whether new members can view all team sessions
92    pub default_session_visibility: SessionVisibility,
93    /// Whether to allow session sharing outside team
94    pub allow_external_sharing: bool,
95    /// Default role for new members
96    pub default_member_role: Role,
97    /// Require approval for join requests
98    pub require_join_approval: bool,
99    /// Enable real-time collaboration features
100    pub enable_realtime_collaboration: bool,
101    /// Session retention policy (days, 0 = forever)
102    pub session_retention_days: u32,
103    /// Maximum team size (0 = unlimited)
104    pub max_members: u32,
105}
106
107impl Default for TeamSettings {
108    fn default() -> Self {
109        Self {
110            default_session_visibility: SessionVisibility::TeamOnly,
111            allow_external_sharing: false,
112            default_member_role: Role::Member,
113            require_join_approval: true,
114            enable_realtime_collaboration: true,
115            session_retention_days: 0,
116            max_members: 0,
117        }
118    }
119}
120
121/// Session visibility within team
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum SessionVisibility {
125    /// Only the owner can see
126    Private,
127    /// All team members can see
128    TeamOnly,
129    /// Anyone with link can see
130    Public,
131}
132
133/// Team invitation
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct TeamInvitation {
136    /// Invitation ID
137    pub id: Uuid,
138    /// Team ID
139    pub team_id: TeamId,
140    /// Inviter user ID
141    pub inviter_id: MemberId,
142    /// Invitee email
143    pub invitee_email: String,
144    /// Assigned role
145    pub role: Role,
146    /// Invitation message
147    pub message: Option<String>,
148    /// Expiration timestamp
149    pub expires_at: DateTime<Utc>,
150    /// Creation timestamp
151    pub created_at: DateTime<Utc>,
152    /// Invitation status
153    pub status: InvitationStatus,
154}
155
156/// Invitation status
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum InvitationStatus {
160    Pending,
161    Accepted,
162    Declined,
163    Expired,
164    Cancelled,
165}
166
167/// Real-time presence information
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct PresenceInfo {
170    /// User ID
171    pub user_id: MemberId,
172    /// Display name
173    pub display_name: String,
174    /// Current session ID (if viewing a session)
175    pub current_session: Option<String>,
176    /// Current cursor position (for collaborative editing)
177    pub cursor_position: Option<CursorPosition>,
178    /// User status
179    pub status: PresenceStatus,
180    /// Last heartbeat
181    pub last_heartbeat: DateTime<Utc>,
182}
183
184/// Cursor position for collaborative editing
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct CursorPosition {
187    /// Session ID
188    pub session_id: String,
189    /// Message index
190    pub message_index: usize,
191    /// Character offset
192    pub offset: usize,
193}
194
195/// User presence status
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum PresenceStatus {
199    Online,
200    Away,
201    Busy,
202    Offline,
203}
204
205/// Real-time collaboration event
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(tag = "type", rename_all = "snake_case")]
208pub enum CollaborationEvent {
209    /// Member joined the session
210    MemberJoined {
211        user_id: MemberId,
212        session_id: String,
213    },
214    /// Member left the session
215    MemberLeft {
216        user_id: MemberId,
217        session_id: String,
218    },
219    /// Cursor moved
220    CursorMoved {
221        user_id: MemberId,
222        position: CursorPosition,
223    },
224    /// Session content changed
225    ContentChanged {
226        user_id: MemberId,
227        session_id: String,
228        change_type: ContentChangeType,
229    },
230    /// Presence updated
231    PresenceUpdated {
232        user_id: MemberId,
233        status: PresenceStatus,
234    },
235    /// Session shared
236    SessionShared {
237        user_id: MemberId,
238        session_id: String,
239    },
240    /// Comment added
241    CommentAdded {
242        user_id: MemberId,
243        session_id: String,
244        comment_id: String,
245    },
246}
247
248/// Content change type
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum ContentChangeType {
252    MessageAdded,
253    MessageEdited,
254    MessageDeleted,
255    TagsUpdated,
256    TitleChanged,
257    Annotated,
258}
259
260// ============================================================================
261// Team Manager
262// ============================================================================
263
264/// Manager for team workspaces
265pub struct TeamManager {
266    /// Teams by ID
267    teams: Arc<RwLock<HashMap<TeamId, TeamWorkspace>>>,
268    /// User's team memberships (user_id -> team_ids)
269    user_teams: Arc<RwLock<HashMap<MemberId, Vec<TeamId>>>>,
270    /// Active presences by team
271    presences: Arc<RwLock<HashMap<TeamId, Vec<PresenceInfo>>>>,
272    /// Collaboration event broadcaster
273    event_tx: broadcast::Sender<(TeamId, CollaborationEvent)>,
274    /// Pending invitations
275    invitations: Arc<RwLock<HashMap<Uuid, TeamInvitation>>>,
276}
277
278impl TeamManager {
279    /// Create a new team manager
280    pub fn new() -> Self {
281        let (event_tx, _) = broadcast::channel(1000);
282        Self {
283            teams: Arc::new(RwLock::new(HashMap::new())),
284            user_teams: Arc::new(RwLock::new(HashMap::new())),
285            presences: Arc::new(RwLock::new(HashMap::new())),
286            event_tx,
287            invitations: Arc::new(RwLock::new(HashMap::new())),
288        }
289    }
290
291    /// Create a new team
292    pub async fn create_team(
293        &self,
294        name: String,
295        description: Option<String>,
296        owner_id: MemberId,
297        owner_name: String,
298        owner_email: String,
299    ) -> TeamWorkspace {
300        let team_id = Uuid::new_v4();
301        let now = Utc::now();
302
303        let owner_member = TeamMember {
304            user_id: owner_id,
305            display_name: owner_name,
306            email: owner_email,
307            avatar_url: None,
308            role: Role::Owner,
309            custom_permissions: None,
310            joined_at: now,
311            last_active: Some(now),
312            status: MemberStatus::Active,
313        };
314
315        let team = TeamWorkspace {
316            id: team_id,
317            name,
318            description,
319            avatar_url: None,
320            owner_id,
321            settings: TeamSettings::default(),
322            members: vec![owner_member],
323            shared_sessions: vec![],
324            created_at: now,
325            updated_at: now,
326        };
327
328        // Store team
329        self.teams.write().await.insert(team_id, team.clone());
330
331        // Track user's team membership
332        self.user_teams
333            .write()
334            .await
335            .entry(owner_id)
336            .or_default()
337            .push(team_id);
338
339        team
340    }
341
342    /// Get a team by ID
343    pub async fn get_team(&self, team_id: TeamId) -> Option<TeamWorkspace> {
344        self.teams.read().await.get(&team_id).cloned()
345    }
346
347    /// Get all teams for a user
348    pub async fn get_user_teams(&self, user_id: MemberId) -> Vec<TeamWorkspace> {
349        let user_teams = self.user_teams.read().await;
350        let teams = self.teams.read().await;
351
352        user_teams
353            .get(&user_id)
354            .map(|team_ids| {
355                team_ids
356                    .iter()
357                    .filter_map(|id| teams.get(id).cloned())
358                    .collect()
359            })
360            .unwrap_or_default()
361    }
362
363    /// Update team settings
364    pub async fn update_team_settings(
365        &self,
366        team_id: TeamId,
367        settings: TeamSettings,
368    ) -> Option<TeamWorkspace> {
369        let mut teams = self.teams.write().await;
370        if let Some(team) = teams.get_mut(&team_id) {
371            team.settings = settings;
372            team.updated_at = Utc::now();
373            Some(team.clone())
374        } else {
375            None
376        }
377    }
378
379    /// Invite a member to a team
380    pub async fn invite_member(
381        &self,
382        team_id: TeamId,
383        inviter_id: MemberId,
384        invitee_email: String,
385        role: Role,
386        message: Option<String>,
387    ) -> Option<TeamInvitation> {
388        let teams = self.teams.read().await;
389        if !teams.contains_key(&team_id) {
390            return None;
391        }
392
393        let invitation = TeamInvitation {
394            id: Uuid::new_v4(),
395            team_id,
396            inviter_id,
397            invitee_email,
398            role,
399            message,
400            expires_at: Utc::now() + chrono::Duration::days(7),
401            created_at: Utc::now(),
402            status: InvitationStatus::Pending,
403        };
404
405        self.invitations
406            .write()
407            .await
408            .insert(invitation.id, invitation.clone());
409
410        Some(invitation)
411    }
412
413    /// Accept an invitation
414    pub async fn accept_invitation(
415        &self,
416        invitation_id: Uuid,
417        user_id: MemberId,
418        display_name: String,
419        email: String,
420    ) -> Result<TeamWorkspace, String> {
421        let mut invitations = self.invitations.write().await;
422        let invitation = invitations
423            .get_mut(&invitation_id)
424            .ok_or("Invitation not found")?;
425
426        if invitation.status != InvitationStatus::Pending {
427            return Err("Invitation is no longer pending".to_string());
428        }
429
430        if invitation.expires_at < Utc::now() {
431            invitation.status = InvitationStatus::Expired;
432            return Err("Invitation has expired".to_string());
433        }
434
435        invitation.status = InvitationStatus::Accepted;
436
437        // Add member to team
438        let mut teams = self.teams.write().await;
439        let team = teams
440            .get_mut(&invitation.team_id)
441            .ok_or("Team not found")?;
442
443        let now = Utc::now();
444        let member = TeamMember {
445            user_id,
446            display_name,
447            email,
448            avatar_url: None,
449            role: invitation.role,
450            custom_permissions: None,
451            joined_at: now,
452            last_active: Some(now),
453            status: MemberStatus::Active,
454        };
455
456        team.members.push(member);
457        team.updated_at = now;
458
459        // Track user's team membership
460        self.user_teams
461            .write()
462            .await
463            .entry(user_id)
464            .or_default()
465            .push(invitation.team_id);
466
467        Ok(team.clone())
468    }
469
470    /// Remove a member from a team
471    pub async fn remove_member(
472        &self,
473        team_id: TeamId,
474        member_id: MemberId,
475    ) -> Result<(), String> {
476        let mut teams = self.teams.write().await;
477        let team = teams.get_mut(&team_id).ok_or("Team not found")?;
478
479        // Cannot remove owner
480        if team.owner_id == member_id {
481            return Err("Cannot remove team owner".to_string());
482        }
483
484        team.members.retain(|m| m.user_id != member_id);
485        team.updated_at = Utc::now();
486
487        // Remove from user's team memberships
488        if let Some(user_teams) = self.user_teams.write().await.get_mut(&member_id) {
489            user_teams.retain(|id| *id != team_id);
490        }
491
492        Ok(())
493    }
494
495    /// Update member role
496    pub async fn update_member_role(
497        &self,
498        team_id: TeamId,
499        member_id: MemberId,
500        new_role: Role,
501    ) -> Result<(), String> {
502        let mut teams = self.teams.write().await;
503        let team = teams.get_mut(&team_id).ok_or("Team not found")?;
504
505        // Cannot change owner's role
506        if team.owner_id == member_id && new_role != Role::Owner {
507            return Err("Cannot change owner's role".to_string());
508        }
509
510        if let Some(member) = team.members.iter_mut().find(|m| m.user_id == member_id) {
511            member.role = new_role;
512            team.updated_at = Utc::now();
513            Ok(())
514        } else {
515            Err("Member not found".to_string())
516        }
517    }
518
519    /// Share a session with the team
520    pub async fn share_session(
521        &self,
522        team_id: TeamId,
523        session_id: String,
524        sharer_id: MemberId,
525    ) -> Result<(), String> {
526        let mut teams = self.teams.write().await;
527        let team = teams.get_mut(&team_id).ok_or("Team not found")?;
528
529        if !team.shared_sessions.contains(&session_id) {
530            team.shared_sessions.push(session_id.clone());
531            team.updated_at = Utc::now();
532
533            // Broadcast event
534            let _ = self.event_tx.send((
535                team_id,
536                CollaborationEvent::SessionShared {
537                    user_id: sharer_id,
538                    session_id,
539                },
540            ));
541        }
542
543        Ok(())
544    }
545
546    /// Update presence for a user
547    pub async fn update_presence(&self, team_id: TeamId, presence: PresenceInfo) {
548        let mut presences = self.presences.write().await;
549        let team_presences = presences.entry(team_id).or_default();
550
551        // Update or add presence
552        if let Some(existing) = team_presences
553            .iter_mut()
554            .find(|p| p.user_id == presence.user_id)
555        {
556            *existing = presence.clone();
557        } else {
558            team_presences.push(presence.clone());
559        }
560
561        // Broadcast presence update
562        let _ = self.event_tx.send((
563            team_id,
564            CollaborationEvent::PresenceUpdated {
565                user_id: presence.user_id,
566                status: presence.status,
567            },
568        ));
569    }
570
571    /// Get active presences for a team
572    pub async fn get_presences(&self, team_id: TeamId) -> Vec<PresenceInfo> {
573        self.presences
574            .read()
575            .await
576            .get(&team_id)
577            .cloned()
578            .unwrap_or_default()
579    }
580
581    /// Subscribe to collaboration events
582    pub fn subscribe(&self) -> broadcast::Receiver<(TeamId, CollaborationEvent)> {
583        self.event_tx.subscribe()
584    }
585
586    /// Broadcast a collaboration event
587    pub async fn broadcast_event(&self, team_id: TeamId, event: CollaborationEvent) {
588        let _ = self.event_tx.send((team_id, event));
589    }
590
591    /// Delete a team
592    pub async fn delete_team(&self, team_id: TeamId, requester_id: MemberId) -> Result<(), String> {
593        let teams = self.teams.read().await;
594        let team = teams.get(&team_id).ok_or("Team not found")?;
595
596        if team.owner_id != requester_id {
597            return Err("Only the owner can delete the team".to_string());
598        }
599
600        drop(teams);
601
602        // Remove team
603        self.teams.write().await.remove(&team_id);
604
605        // Remove from all user memberships
606        let mut user_teams = self.user_teams.write().await;
607        for team_ids in user_teams.values_mut() {
608            team_ids.retain(|id| *id != team_id);
609        }
610
611        // Remove presences
612        self.presences.write().await.remove(&team_id);
613
614        Ok(())
615    }
616}
617
618impl Default for TeamManager {
619    fn default() -> Self {
620        Self::new()
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[tokio::test]
629    async fn test_create_team() {
630        let manager = TeamManager::new();
631        let owner_id = Uuid::new_v4();
632
633        let team = manager
634            .create_team(
635                "Test Team".to_string(),
636                Some("A test team".to_string()),
637                owner_id,
638                "Owner".to_string(),
639                "owner@example.com".to_string(),
640            )
641            .await;
642
643        assert_eq!(team.name, "Test Team");
644        assert_eq!(team.owner_id, owner_id);
645        assert_eq!(team.members.len(), 1);
646        assert_eq!(team.members[0].role, Role::Owner);
647    }
648
649    #[tokio::test]
650    async fn test_invite_and_accept() {
651        let manager = TeamManager::new();
652        let owner_id = Uuid::new_v4();
653        let member_id = Uuid::new_v4();
654
655        let team = manager
656            .create_team(
657                "Test Team".to_string(),
658                None,
659                owner_id,
660                "Owner".to_string(),
661                "owner@example.com".to_string(),
662            )
663            .await;
664
665        let invitation = manager
666            .invite_member(
667                team.id,
668                owner_id,
669                "member@example.com".to_string(),
670                Role::Member,
671                None,
672            )
673            .await
674            .unwrap();
675
676        let updated_team = manager
677            .accept_invitation(
678                invitation.id,
679                member_id,
680                "Member".to_string(),
681                "member@example.com".to_string(),
682            )
683            .await
684            .unwrap();
685
686        assert_eq!(updated_team.members.len(), 2);
687    }
688}