1use serde::{Deserialize, Serialize};
24use std::fmt;
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(transparent)]
29pub struct GroupId(String);
30
31impl GroupId {
32 #[must_use]
34 pub fn new(id: impl Into<String>) -> Self {
35 Self(id.into())
36 }
37
38 #[must_use]
43 pub fn generate() -> Self {
44 Self(uuid::Uuid::new_v4().simple().to_string()[..12].to_string())
45 }
46
47 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum GroupRole {
81 Admin,
89
90 Write,
96
97 Read,
102}
103
104impl GroupRole {
105 #[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 #[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 #[must_use]
136 pub const fn can_write(&self) -> bool {
137 matches!(self, Self::Admin | Self::Write)
138 }
139
140 #[must_use]
142 pub const fn can_read(&self) -> bool {
143 true
145 }
146
147 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct Group {
171 pub id: GroupId,
173
174 pub org_id: String,
176
177 pub name: String,
179
180 pub description: String,
182
183 pub created_at: u64,
185
186 pub updated_at: u64,
188
189 pub created_by: String,
191}
192
193impl Group {
194 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct GroupMember {
234 pub id: String,
236
237 pub group_id: GroupId,
239
240 pub email: String,
242
243 pub role: GroupRole,
245
246 pub joined_at: u64,
248
249 pub added_by: String,
251}
252
253impl GroupMember {
254 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct GroupInvite {
295 pub id: String,
297
298 pub group_id: GroupId,
300
301 pub token_hash: String,
303
304 pub role: GroupRole,
306
307 pub created_by: String,
309
310 pub created_at: u64,
312
313 pub expires_at: u64,
315
316 pub max_uses: Option<u32>,
320
321 pub current_uses: u32,
323
324 pub revoked: bool,
326}
327
328impl GroupInvite {
329 pub const DEFAULT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60;
331
332 pub const DEFAULT_MAX_USES: u32 = 1;
334
335 #[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 let token = uuid::Uuid::now_v7().to_string();
362 let token_hash = Self::hash_token(&token);
363
364 let invite = Self {
365 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 #[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 #[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 #[must_use]
417 pub fn verify_token(&self, token: &str) -> bool {
418 Self::hash_token(token) == self.token_hash
419 }
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct GroupMembership {
427 pub group_id: GroupId,
429
430 pub group_name: String,
432
433 pub org_id: String,
435
436 pub role: GroupRole,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct CreateGroupRequest {
443 pub name: String,
445
446 pub description: Option<String>,
448
449 pub initial_members: Vec<(String, GroupRole)>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct AddMemberRequest {
456 pub group_id: GroupId,
458
459 pub email: String,
461
462 pub role: GroupRole,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct CreateInviteRequest {
469 pub group_id: GroupId,
471
472 pub role: GroupRole,
474
475 pub expires_in_secs: Option<u64>,
477
478 pub max_uses: Option<u32>,
480}
481
482#[must_use]
486pub fn is_valid_email(email: &str) -> bool {
487 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 if local.is_empty() {
498 return false;
499 }
500
501 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#[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), Some(2), );
595
596 assert!(invite.is_valid());
597
598 invite.current_uses = 2;
600 assert!(!invite.is_valid());
601
602 invite.current_uses = 0;
604 invite.revoked = true;
605 assert!(!invite.is_valid());
606
607 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}