subcog/models/
group.rs

1//! Group and membership models for shared memory graphs.
2//!
3//! This module provides types for organizing memories into groups
4//! that can be shared across team members within an organization.
5//!
6//! # Overview
7//!
8//! Groups exist within an organization scope and allow multiple users
9//! to share memories. Each group has:
10//! - A unique identifier within the organization
11//! - Members with roles (admin, write, read)
12//! - Token-based invite system for adding members
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use subcog::models::group::{Group, GroupRole, GroupMember};
18//!
19//! let group = Group::new("research-team", "acme-corp", "alice@example.com");
20//! let member = GroupMember::new(group.id.clone(), "bob@example.com", GroupRole::Write);
21//! ```
22
23use serde::{Deserialize, Serialize};
24use std::fmt;
25
26/// Unique identifier for a group.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(transparent)]
29pub struct GroupId(String);
30
31impl GroupId {
32    /// Creates a new group ID from the given string.
33    #[must_use]
34    pub fn new(id: impl Into<String>) -> Self {
35        Self(id.into())
36    }
37
38    /// Generates a new random group ID using UUID v4.
39    ///
40    /// Uses v4 (random) instead of v7 (time-based) to ensure uniqueness
41    /// even when generating multiple IDs in rapid succession.
42    #[must_use]
43    pub fn generate() -> Self {
44        Self(uuid::Uuid::new_v4().simple().to_string()[..12].to_string())
45    }
46
47    /// Returns the ID as a string slice.
48    #[must_use]
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl fmt::Display for GroupId {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}", self.0)
57    }
58}
59
60impl From<String> for GroupId {
61    fn from(s: String) -> Self {
62        Self(s)
63    }
64}
65
66impl From<&str> for GroupId {
67    fn from(s: &str) -> Self {
68        Self(s.to_string())
69    }
70}
71
72/// Role-based access control for group members.
73///
74/// Roles determine what actions a member can perform on group memories:
75/// - `Admin`: Full control (manage members, delete group, capture, recall)
76/// - `Write`: Capture and recall memories
77/// - `Read`: Recall memories only
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum GroupRole {
81    /// Full control over the group.
82    ///
83    /// Admins can:
84    /// - Add and remove members
85    /// - Change member roles
86    /// - Delete the group
87    /// - Capture and recall memories
88    Admin,
89
90    /// Read and write access.
91    ///
92    /// Writers can:
93    /// - Capture memories to the group
94    /// - Recall group memories
95    Write,
96
97    /// Read-only access.
98    ///
99    /// Readers can:
100    /// - Recall group memories
101    Read,
102}
103
104impl GroupRole {
105    /// Returns the role as a string slice.
106    #[must_use]
107    pub const fn as_str(&self) -> &'static str {
108        match self {
109            Self::Admin => "admin",
110            Self::Write => "write",
111            Self::Read => "read",
112        }
113    }
114
115    /// Parses a role from a string.
116    ///
117    /// # Examples
118    ///
119    /// ```rust,ignore
120    /// assert_eq!(GroupRole::parse("admin"), Some(GroupRole::Admin));
121    /// assert_eq!(GroupRole::parse("WRITE"), Some(GroupRole::Write));
122    /// assert_eq!(GroupRole::parse("invalid"), None);
123    /// ```
124    #[must_use]
125    pub fn parse(s: &str) -> Option<Self> {
126        match s.to_lowercase().as_str() {
127            "admin" => Some(Self::Admin),
128            "write" => Some(Self::Write),
129            "read" => Some(Self::Read),
130            _ => None,
131        }
132    }
133
134    /// Returns `true` if this role can capture memories to the group.
135    #[must_use]
136    pub const fn can_write(&self) -> bool {
137        matches!(self, Self::Admin | Self::Write)
138    }
139
140    /// Returns `true` if this role can recall memories from the group.
141    #[must_use]
142    pub const fn can_read(&self) -> bool {
143        // All roles can read
144        true
145    }
146
147    /// Returns `true` if this role can manage group members.
148    #[must_use]
149    pub const fn can_manage(&self) -> bool {
150        matches!(self, Self::Admin)
151    }
152}
153
154impl fmt::Display for GroupRole {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "{}", self.as_str())
157    }
158}
159
160impl std::str::FromStr for GroupRole {
161    type Err = String;
162
163    fn from_str(s: &str) -> Result<Self, Self::Err> {
164        Self::parse(s).ok_or_else(|| format!("unknown group role: {s}"))
165    }
166}
167
168/// A group for sharing memories within an organization.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct Group {
171    /// Unique identifier for the group.
172    pub id: GroupId,
173
174    /// Organization this group belongs to.
175    pub org_id: String,
176
177    /// Human-readable name for the group.
178    pub name: String,
179
180    /// Optional description of the group's purpose.
181    pub description: String,
182
183    /// When the group was created (Unix timestamp).
184    pub created_at: u64,
185
186    /// When the group was last updated (Unix timestamp).
187    pub updated_at: u64,
188
189    /// Email of the user who created the group.
190    pub created_by: String,
191}
192
193impl Group {
194    /// Creates a new group with the given name and organization.
195    ///
196    /// # Arguments
197    ///
198    /// * `name` - Human-readable name for the group
199    /// * `org_id` - Organization identifier
200    /// * `created_by` - Email of the creator
201    #[must_use]
202    pub fn new(
203        name: impl Into<String>,
204        org_id: impl Into<String>,
205        created_by: impl Into<String>,
206    ) -> Self {
207        let now = std::time::SystemTime::now()
208            .duration_since(std::time::UNIX_EPOCH)
209            .map(|d| d.as_secs())
210            .unwrap_or(0);
211
212        Self {
213            id: GroupId::generate(),
214            org_id: org_id.into(),
215            name: name.into(),
216            description: String::new(),
217            created_at: now,
218            updated_at: now,
219            created_by: created_by.into(),
220        }
221    }
222
223    /// Creates a new group with a description.
224    #[must_use]
225    pub fn with_description(mut self, description: impl Into<String>) -> Self {
226        self.description = description.into();
227        self
228    }
229}
230
231/// A member of a group with an assigned role.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct GroupMember {
234    /// Unique identifier for this membership record.
235    pub id: String,
236
237    /// The group this member belongs to.
238    pub group_id: GroupId,
239
240    /// Email address of the member (identity).
241    pub email: String,
242
243    /// Role within the group.
244    pub role: GroupRole,
245
246    /// When the member joined (Unix timestamp).
247    pub joined_at: u64,
248
249    /// Email of the user who added this member.
250    pub added_by: String,
251}
252
253impl GroupMember {
254    /// Creates a new group member.
255    ///
256    /// # Arguments
257    ///
258    /// * `group_id` - ID of the group
259    /// * `email` - Email address of the member
260    /// * `role` - Role to assign
261    /// * `added_by` - Email of the user adding this member
262    #[must_use]
263    pub fn new(
264        group_id: GroupId,
265        email: impl Into<String>,
266        role: GroupRole,
267        added_by: impl Into<String>,
268    ) -> Self {
269        let now = std::time::SystemTime::now()
270            .duration_since(std::time::UNIX_EPOCH)
271            .map(|d| d.as_secs())
272            .unwrap_or(0);
273
274        Self {
275            // Use v4 for member IDs to ensure uniqueness in rapid succession
276            id: uuid::Uuid::new_v4().simple().to_string()[..12].to_string(),
277            group_id,
278            email: email.into().to_lowercase(),
279            role,
280            joined_at: now,
281            added_by: added_by.into().to_lowercase(),
282        }
283    }
284}
285
286/// An invitation to join a group.
287///
288/// Invites use a token-based system where:
289/// 1. An admin creates an invite with a role
290/// 2. The invite generates a random token
291/// 3. The token is shared out-of-band (email, Slack, etc.)
292/// 4. The recipient joins using the token
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct GroupInvite {
295    /// Unique identifier for this invite.
296    pub id: String,
297
298    /// The group this invite is for.
299    pub group_id: GroupId,
300
301    /// SHA256 hash of the invite token (never store plaintext).
302    pub token_hash: String,
303
304    /// Role to assign when the invite is used.
305    pub role: GroupRole,
306
307    /// Email of the user who created the invite.
308    pub created_by: String,
309
310    /// When the invite was created (Unix timestamp).
311    pub created_at: u64,
312
313    /// When the invite expires (Unix timestamp).
314    pub expires_at: u64,
315
316    /// Maximum number of times this invite can be used.
317    ///
318    /// `None` means unlimited uses.
319    pub max_uses: Option<u32>,
320
321    /// Number of times this invite has been used.
322    pub current_uses: u32,
323
324    /// Whether this invite has been revoked.
325    pub revoked: bool,
326}
327
328impl GroupInvite {
329    /// Default expiration time in seconds (7 days).
330    pub const DEFAULT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60;
331
332    /// Default maximum uses for an invite.
333    pub const DEFAULT_MAX_USES: u32 = 1;
334
335    /// Creates a new invite for a group.
336    ///
337    /// Returns both the invite and the plaintext token.
338    /// The token should be shared with the invitee and never stored.
339    ///
340    /// # Arguments
341    ///
342    /// * `group_id` - ID of the group to invite to
343    /// * `role` - Role to assign when joined
344    /// * `created_by` - Email of the admin creating the invite
345    /// * `expires_in_secs` - How long until the invite expires
346    /// * `max_uses` - Maximum number of uses (None for unlimited)
347    #[must_use]
348    pub fn new(
349        group_id: GroupId,
350        role: GroupRole,
351        created_by: impl Into<String>,
352        expires_in_secs: Option<u64>,
353        max_uses: Option<u32>,
354    ) -> (Self, String) {
355        let now = std::time::SystemTime::now()
356            .duration_since(std::time::UNIX_EPOCH)
357            .map(|d| d.as_secs())
358            .unwrap_or(0);
359
360        // Generate a secure random token
361        let token = uuid::Uuid::now_v7().to_string();
362        let token_hash = Self::hash_token(&token);
363
364        let invite = Self {
365            // Use v4 for invite IDs to ensure uniqueness in rapid succession
366            id: uuid::Uuid::new_v4().simple().to_string()[..12].to_string(),
367            group_id,
368            token_hash,
369            role,
370            created_by: created_by.into().to_lowercase(),
371            created_at: now,
372            expires_at: now + expires_in_secs.unwrap_or(Self::DEFAULT_EXPIRY_SECS),
373            max_uses: Some(max_uses.unwrap_or(Self::DEFAULT_MAX_USES)),
374            current_uses: 0,
375            revoked: false,
376        };
377
378        (invite, token)
379    }
380
381    /// Hashes a token using SHA256.
382    #[must_use]
383    pub fn hash_token(token: &str) -> String {
384        use sha2::{Digest, Sha256};
385        let mut hasher = Sha256::new();
386        hasher.update(token.as_bytes());
387        format!("{:x}", hasher.finalize())
388    }
389
390    /// Checks if this invite is still valid.
391    #[must_use]
392    pub fn is_valid(&self) -> bool {
393        if self.revoked {
394            return false;
395        }
396
397        let now = std::time::SystemTime::now()
398            .duration_since(std::time::UNIX_EPOCH)
399            .map(|d| d.as_secs())
400            .unwrap_or(0);
401
402        if now > self.expires_at {
403            return false;
404        }
405
406        if let Some(max) = self.max_uses
407            && self.current_uses >= max
408        {
409            return false;
410        }
411
412        true
413    }
414
415    /// Verifies a token against this invite's hash.
416    #[must_use]
417    pub fn verify_token(&self, token: &str) -> bool {
418        Self::hash_token(token) == self.token_hash
419    }
420}
421
422/// Summary of a user's membership in a group.
423///
424/// Used for listing accessible groups without full member details.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct GroupMembership {
427    /// The group ID.
428    pub group_id: GroupId,
429
430    /// The group name.
431    pub group_name: String,
432
433    /// The organization ID.
434    pub org_id: String,
435
436    /// User's role in this group.
437    pub role: GroupRole,
438}
439
440/// Request to create a new group.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct CreateGroupRequest {
443    /// Name for the new group.
444    pub name: String,
445
446    /// Optional description.
447    pub description: Option<String>,
448
449    /// Initial members to add (email, role pairs).
450    pub initial_members: Vec<(String, GroupRole)>,
451}
452
453/// Request to add a member to a group.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct AddMemberRequest {
456    /// Group to add the member to.
457    pub group_id: GroupId,
458
459    /// Email of the new member.
460    pub email: String,
461
462    /// Role to assign.
463    pub role: GroupRole,
464}
465
466/// Request to create an invite.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct CreateInviteRequest {
469    /// Group to create the invite for.
470    pub group_id: GroupId,
471
472    /// Role to assign when the invite is used.
473    pub role: GroupRole,
474
475    /// How long the invite should be valid (seconds).
476    pub expires_in_secs: Option<u64>,
477
478    /// Maximum number of uses.
479    pub max_uses: Option<u32>,
480}
481
482/// Email validation helper.
483///
484/// Performs basic RFC 5322 format validation.
485#[must_use]
486pub fn is_valid_email(email: &str) -> bool {
487    // Basic validation: contains @, has local and domain parts
488    let parts: Vec<&str> = email.split('@').collect();
489    if parts.len() != 2 {
490        return false;
491    }
492
493    let local = parts[0];
494    let domain = parts[1];
495
496    // Local part must be non-empty
497    if local.is_empty() {
498        return false;
499    }
500
501    // Domain must have at least one dot and non-empty parts
502    let domain_parts: Vec<&str> = domain.split('.').collect();
503    if domain_parts.len() < 2 {
504        return false;
505    }
506
507    domain_parts.iter().all(|part| !part.is_empty())
508}
509
510/// Normalizes an email address to lowercase.
511#[must_use]
512pub fn normalize_email(email: &str) -> String {
513    email.trim().to_lowercase()
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_group_id_generate() {
522        let id1 = GroupId::generate();
523        let id2 = GroupId::generate();
524        assert_ne!(id1, id2);
525        assert_eq!(id1.as_str().len(), 12);
526    }
527
528    #[test]
529    fn test_group_role_parsing() {
530        assert_eq!(GroupRole::parse("admin"), Some(GroupRole::Admin));
531        assert_eq!(GroupRole::parse("WRITE"), Some(GroupRole::Write));
532        assert_eq!(GroupRole::parse("Read"), Some(GroupRole::Read));
533        assert_eq!(GroupRole::parse("invalid"), None);
534    }
535
536    #[test]
537    fn test_group_role_permissions() {
538        assert!(GroupRole::Admin.can_write());
539        assert!(GroupRole::Admin.can_read());
540        assert!(GroupRole::Admin.can_manage());
541
542        assert!(GroupRole::Write.can_write());
543        assert!(GroupRole::Write.can_read());
544        assert!(!GroupRole::Write.can_manage());
545
546        assert!(!GroupRole::Read.can_write());
547        assert!(GroupRole::Read.can_read());
548        assert!(!GroupRole::Read.can_manage());
549    }
550
551    #[test]
552    fn test_group_creation() {
553        let group = Group::new("test-group", "acme-corp", "admin@example.com");
554        assert_eq!(group.name, "test-group");
555        assert_eq!(group.org_id, "acme-corp");
556        assert_eq!(group.created_by, "admin@example.com");
557        assert!(group.created_at > 0);
558    }
559
560    #[test]
561    fn test_group_member_email_normalization() {
562        let member = GroupMember::new(
563            GroupId::new("group-1"),
564            "Bob@Example.COM",
565            GroupRole::Write,
566            "Admin@Example.COM",
567        );
568        assert_eq!(member.email, "bob@example.com");
569        assert_eq!(member.added_by, "admin@example.com");
570    }
571
572    #[test]
573    fn test_invite_token_hashing() {
574        let (invite, token) = GroupInvite::new(
575            GroupId::new("group-1"),
576            GroupRole::Write,
577            "admin@example.com",
578            None,
579            None,
580        );
581
582        assert!(invite.verify_token(&token));
583        assert!(!invite.verify_token("wrong-token"));
584    }
585
586    #[test]
587    fn test_invite_validity() {
588        let (mut invite, _) = GroupInvite::new(
589            GroupId::new("group-1"),
590            GroupRole::Write,
591            "admin@example.com",
592            Some(3600), // 1 hour
593            Some(2),    // 2 uses max
594        );
595
596        assert!(invite.is_valid());
597
598        // Test max uses
599        invite.current_uses = 2;
600        assert!(!invite.is_valid());
601
602        // Reset and test revocation
603        invite.current_uses = 0;
604        invite.revoked = true;
605        assert!(!invite.is_valid());
606
607        // Reset and test expiration
608        invite.revoked = false;
609        invite.expires_at = 0;
610        assert!(!invite.is_valid());
611    }
612
613    #[test]
614    fn test_email_validation() {
615        assert!(is_valid_email("user@example.com"));
616        assert!(is_valid_email("user.name@example.co.uk"));
617        assert!(is_valid_email("user+tag@example.com"));
618
619        assert!(!is_valid_email("invalid"));
620        assert!(!is_valid_email("@example.com"));
621        assert!(!is_valid_email("user@"));
622        assert!(!is_valid_email("user@example"));
623        assert!(!is_valid_email(""));
624    }
625
626    #[test]
627    fn test_email_normalization() {
628        assert_eq!(normalize_email("User@Example.COM"), "user@example.com");
629        assert_eq!(normalize_email("  user@example.com  "), "user@example.com");
630    }
631}