Skip to main content

subcog/storage/group/
sqlite.rs

1//! `SQLite` backend for group storage.
2//!
3//! Stores groups, members, and invites in the organization's `SQLite` database.
4
5use std::path::{Path, PathBuf};
6use std::sync::Mutex;
7
8use rusqlite::{Connection, OptionalExtension, params};
9
10use crate::models::group::{
11    Group, GroupId, GroupInvite, GroupMember, GroupMembership, GroupRole, normalize_email,
12};
13use crate::{Error, Result};
14
15use super::traits::GroupBackend;
16
17/// SQLite-based group storage backend.
18///
19/// Uses the same database as other org-scoped data, with dedicated tables
20/// for groups, members, and invites.
21pub struct SqliteGroupBackend {
22    /// Database connection (mutex for interior mutability).
23    conn: Mutex<Connection>,
24}
25
26impl SqliteGroupBackend {
27    /// Creates a new `SQLite` group backend at the specified path.
28    ///
29    /// # Arguments
30    ///
31    /// * `path` - Path to the `SQLite` database file
32    ///
33    /// # Errors
34    ///
35    /// Returns an error if the database cannot be opened or initialized.
36    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
37        let conn = Connection::open(path.as_ref()).map_err(|e| Error::OperationFailed {
38            operation: "open_group_database".to_string(),
39            cause: e.to_string(),
40        })?;
41
42        let backend = Self {
43            conn: Mutex::new(conn),
44        };
45        backend.initialize_schema()?;
46        Ok(backend)
47    }
48
49    /// Creates an in-memory `SQLite` group backend (for testing).
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if the database cannot be initialized.
54    pub fn in_memory() -> Result<Self> {
55        let conn = Connection::open_in_memory().map_err(|e| Error::OperationFailed {
56            operation: "open_group_database_memory".to_string(),
57            cause: e.to_string(),
58        })?;
59
60        let backend = Self {
61            conn: Mutex::new(conn),
62        };
63        backend.initialize_schema()?;
64        Ok(backend)
65    }
66
67    /// Returns the default path for organization-scoped group storage.
68    ///
69    /// The path is `~/.config/subcog/orgs/{org}/memories.db`.
70    #[must_use]
71    pub fn default_org_path(org: &str) -> Option<PathBuf> {
72        directories::BaseDirs::new().map(|d| {
73            d.home_dir()
74                .join(".config")
75                .join("subcog")
76                .join("orgs")
77                .join(org)
78                .join("memories.db")
79        })
80    }
81
82    /// Initializes the database schema.
83    fn initialize_schema(&self) -> Result<()> {
84        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
85            operation: "lock_connection".to_string(),
86            cause: e.to_string(),
87        })?;
88
89        conn.execute_batch(
90            r"
91            -- Enable foreign keys first
92            PRAGMA foreign_keys = ON;
93
94            -- Groups table
95            CREATE TABLE IF NOT EXISTS groups (
96                id TEXT PRIMARY KEY,
97                org_id TEXT NOT NULL,
98                name TEXT NOT NULL,
99                description TEXT NOT NULL DEFAULT '',
100                created_at INTEGER NOT NULL,
101                updated_at INTEGER NOT NULL,
102                created_by TEXT NOT NULL,
103                UNIQUE(org_id, name)
104            );
105
106            CREATE INDEX IF NOT EXISTS idx_groups_org ON groups(org_id);
107
108            -- Group members table
109            CREATE TABLE IF NOT EXISTS group_members (
110                id TEXT PRIMARY KEY,
111                group_id TEXT NOT NULL,
112                email TEXT NOT NULL,
113                role TEXT NOT NULL,
114                joined_at INTEGER NOT NULL,
115                added_by TEXT NOT NULL,
116                UNIQUE(group_id, email),
117                FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
118            );
119
120            CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
121            CREATE INDEX IF NOT EXISTS idx_group_members_email ON group_members(email);
122
123            -- Group invites table
124            CREATE TABLE IF NOT EXISTS group_invites (
125                id TEXT PRIMARY KEY,
126                group_id TEXT NOT NULL,
127                token_hash TEXT NOT NULL UNIQUE,
128                role TEXT NOT NULL,
129                created_by TEXT NOT NULL,
130                created_at INTEGER NOT NULL,
131                expires_at INTEGER NOT NULL,
132                max_uses INTEGER,
133                current_uses INTEGER NOT NULL DEFAULT 0,
134                revoked INTEGER NOT NULL DEFAULT 0,
135                FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
136            );
137
138            CREATE INDEX IF NOT EXISTS idx_group_invites_group ON group_invites(group_id);
139            CREATE INDEX IF NOT EXISTS idx_group_invites_token_hash ON group_invites(token_hash);
140            CREATE INDEX IF NOT EXISTS idx_group_invites_expires ON group_invites(expires_at);
141            ",
142        )
143        .map_err(|e| Error::OperationFailed {
144            operation: "initialize_group_schema".to_string(),
145            cause: e.to_string(),
146        })?;
147
148        Ok(())
149    }
150
151    /// Gets the current Unix timestamp as i64 (for `SQLite` compatibility).
152    #[allow(clippy::cast_possible_wrap)]
153    fn now() -> i64 {
154        std::time::SystemTime::now()
155            .duration_since(std::time::UNIX_EPOCH)
156            .map(|d| d.as_secs() as i64)
157            .unwrap_or(0)
158    }
159
160    /// Converts u64 timestamp to i64 for `SQLite` storage.
161    #[allow(clippy::cast_possible_wrap)]
162    const fn to_db_timestamp(ts: u64) -> i64 {
163        ts as i64
164    }
165
166    /// Converts i64 from `SQLite` back to u64 timestamp.
167    #[allow(clippy::cast_sign_loss)]
168    const fn from_db_timestamp(ts: i64) -> u64 {
169        ts as u64
170    }
171
172    /// Parses a `GroupRole` from a string stored in the database.
173    fn parse_role(s: &str) -> GroupRole {
174        GroupRole::parse(s).unwrap_or(GroupRole::Read)
175    }
176}
177
178impl GroupBackend for SqliteGroupBackend {
179    fn create_group(
180        &self,
181        org_id: &str,
182        name: &str,
183        description: &str,
184        created_by: &str,
185    ) -> Result<Group> {
186        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
187            operation: "lock_connection".to_string(),
188            cause: e.to_string(),
189        })?;
190
191        let now = Self::now();
192        let group = Group {
193            id: GroupId::generate(),
194            org_id: org_id.to_string(),
195            name: name.to_string(),
196            description: description.to_string(),
197            created_at: Self::from_db_timestamp(now),
198            updated_at: Self::from_db_timestamp(now),
199            created_by: normalize_email(created_by),
200        };
201
202        conn.execute(
203            "INSERT INTO groups (id, org_id, name, description, created_at, updated_at, created_by)
204             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
205            params![
206                group.id.as_str(),
207                group.org_id,
208                group.name,
209                group.description,
210                now,
211                now,
212                group.created_by,
213            ],
214        )
215        .map_err(|e| {
216            if e.to_string().contains("UNIQUE constraint failed") {
217                Error::InvalidInput(format!(
218                    "Group '{name}' already exists in organization '{org_id}'"
219                ))
220            } else {
221                Error::OperationFailed {
222                    operation: "create_group".to_string(),
223                    cause: e.to_string(),
224                }
225            }
226        })?;
227
228        Ok(group)
229    }
230
231    fn get_group(&self, group_id: &GroupId) -> Result<Option<Group>> {
232        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
233            operation: "lock_connection".to_string(),
234            cause: e.to_string(),
235        })?;
236
237        let mut stmt = conn
238            .prepare(
239                "SELECT id, org_id, name, description, created_at, updated_at, created_by
240                 FROM groups WHERE id = ?1",
241            )
242            .map_err(|e| Error::OperationFailed {
243                operation: "prepare_get_group".to_string(),
244                cause: e.to_string(),
245            })?;
246
247        let result = stmt
248            .query_row(params![group_id.as_str()], |row| {
249                Ok(Group {
250                    id: GroupId::new(row.get::<_, String>(0)?),
251                    org_id: row.get(1)?,
252                    name: row.get(2)?,
253                    description: row.get(3)?,
254                    created_at: Self::from_db_timestamp(row.get(4)?),
255                    updated_at: Self::from_db_timestamp(row.get(5)?),
256                    created_by: row.get(6)?,
257                })
258            })
259            .optional()
260            .map_err(|e| Error::OperationFailed {
261                operation: "get_group".to_string(),
262                cause: e.to_string(),
263            })?;
264
265        Ok(result)
266    }
267
268    fn get_group_by_name(&self, org_id: &str, name: &str) -> Result<Option<Group>> {
269        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
270            operation: "lock_connection".to_string(),
271            cause: e.to_string(),
272        })?;
273
274        let mut stmt = conn
275            .prepare(
276                "SELECT id, org_id, name, description, created_at, updated_at, created_by
277                 FROM groups WHERE org_id = ?1 AND name = ?2",
278            )
279            .map_err(|e| Error::OperationFailed {
280                operation: "prepare_get_group_by_name".to_string(),
281                cause: e.to_string(),
282            })?;
283
284        let result = stmt
285            .query_row(params![org_id, name], |row| {
286                Ok(Group {
287                    id: GroupId::new(row.get::<_, String>(0)?),
288                    org_id: row.get(1)?,
289                    name: row.get(2)?,
290                    description: row.get(3)?,
291                    created_at: Self::from_db_timestamp(row.get(4)?),
292                    updated_at: Self::from_db_timestamp(row.get(5)?),
293                    created_by: row.get(6)?,
294                })
295            })
296            .optional()
297            .map_err(|e| Error::OperationFailed {
298                operation: "get_group_by_name".to_string(),
299                cause: e.to_string(),
300            })?;
301
302        Ok(result)
303    }
304
305    fn list_groups(&self, org_id: &str) -> Result<Vec<Group>> {
306        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
307            operation: "lock_connection".to_string(),
308            cause: e.to_string(),
309        })?;
310
311        let mut stmt = conn
312            .prepare(
313                "SELECT id, org_id, name, description, created_at, updated_at, created_by
314                 FROM groups WHERE org_id = ?1 ORDER BY name",
315            )
316            .map_err(|e| Error::OperationFailed {
317                operation: "prepare_list_groups".to_string(),
318                cause: e.to_string(),
319            })?;
320
321        let groups = stmt
322            .query_map(params![org_id], |row| {
323                Ok(Group {
324                    id: GroupId::new(row.get::<_, String>(0)?),
325                    org_id: row.get(1)?,
326                    name: row.get(2)?,
327                    description: row.get(3)?,
328                    created_at: Self::from_db_timestamp(row.get(4)?),
329                    updated_at: Self::from_db_timestamp(row.get(5)?),
330                    created_by: row.get(6)?,
331                })
332            })
333            .map_err(|e| Error::OperationFailed {
334                operation: "list_groups".to_string(),
335                cause: e.to_string(),
336            })?
337            .collect::<std::result::Result<Vec<_>, _>>()
338            .map_err(|e| Error::OperationFailed {
339                operation: "collect_groups".to_string(),
340                cause: e.to_string(),
341            })?;
342
343        Ok(groups)
344    }
345
346    fn delete_group(&self, group_id: &GroupId) -> Result<bool> {
347        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
348            operation: "lock_connection".to_string(),
349            cause: e.to_string(),
350        })?;
351
352        let rows = conn
353            .execute(
354                "DELETE FROM groups WHERE id = ?1",
355                params![group_id.as_str()],
356            )
357            .map_err(|e| Error::OperationFailed {
358                operation: "delete_group".to_string(),
359                cause: e.to_string(),
360            })?;
361
362        Ok(rows > 0)
363    }
364
365    fn add_member(
366        &self,
367        group_id: &GroupId,
368        email: &str,
369        role: GroupRole,
370        added_by: &str,
371    ) -> Result<GroupMember> {
372        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
373            operation: "lock_connection".to_string(),
374            cause: e.to_string(),
375        })?;
376
377        let member = GroupMember::new(group_id.clone(), email, role, added_by);
378
379        // Use INSERT ... ON CONFLICT to handle existing members (update role only)
380        // This preserves the original joined_at timestamp for existing members
381        conn.execute(
382            "INSERT INTO group_members (id, group_id, email, role, joined_at, added_by)
383             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
384             ON CONFLICT (group_id, email) DO UPDATE SET
385                 role = excluded.role,
386                 added_by = excluded.added_by",
387            params![
388                member.id,
389                member.group_id.as_str(),
390                member.email,
391                member.role.as_str(),
392                Self::to_db_timestamp(member.joined_at),
393                member.added_by,
394            ],
395        )
396        .map_err(|e| Error::OperationFailed {
397            operation: "add_member".to_string(),
398            cause: e.to_string(),
399        })?;
400
401        Ok(member)
402    }
403
404    fn get_member(&self, group_id: &GroupId, email: &str) -> Result<Option<GroupMember>> {
405        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
406            operation: "lock_connection".to_string(),
407            cause: e.to_string(),
408        })?;
409
410        let normalized_email = normalize_email(email);
411
412        let mut stmt = conn
413            .prepare(
414                "SELECT id, group_id, email, role, joined_at, added_by
415                 FROM group_members WHERE group_id = ?1 AND email = ?2",
416            )
417            .map_err(|e| Error::OperationFailed {
418                operation: "prepare_get_member".to_string(),
419                cause: e.to_string(),
420            })?;
421
422        let result = stmt
423            .query_row(params![group_id.as_str(), normalized_email], |row| {
424                Ok(GroupMember {
425                    id: row.get(0)?,
426                    group_id: GroupId::new(row.get::<_, String>(1)?),
427                    email: row.get(2)?,
428                    role: Self::parse_role(&row.get::<_, String>(3)?),
429                    joined_at: Self::from_db_timestamp(row.get(4)?),
430                    added_by: row.get(5)?,
431                })
432            })
433            .optional()
434            .map_err(|e| Error::OperationFailed {
435                operation: "get_member".to_string(),
436                cause: e.to_string(),
437            })?;
438
439        Ok(result)
440    }
441
442    fn update_member_role(
443        &self,
444        group_id: &GroupId,
445        email: &str,
446        new_role: GroupRole,
447    ) -> Result<bool> {
448        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
449            operation: "lock_connection".to_string(),
450            cause: e.to_string(),
451        })?;
452
453        let normalized_email = normalize_email(email);
454
455        let rows = conn
456            .execute(
457                "UPDATE group_members SET role = ?1 WHERE group_id = ?2 AND email = ?3",
458                params![new_role.as_str(), group_id.as_str(), normalized_email],
459            )
460            .map_err(|e| Error::OperationFailed {
461                operation: "update_member_role".to_string(),
462                cause: e.to_string(),
463            })?;
464
465        Ok(rows > 0)
466    }
467
468    fn remove_member(&self, group_id: &GroupId, email: &str) -> Result<bool> {
469        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
470            operation: "lock_connection".to_string(),
471            cause: e.to_string(),
472        })?;
473
474        let normalized_email = normalize_email(email);
475
476        let rows = conn
477            .execute(
478                "DELETE FROM group_members WHERE group_id = ?1 AND email = ?2",
479                params![group_id.as_str(), normalized_email],
480            )
481            .map_err(|e| Error::OperationFailed {
482                operation: "remove_member".to_string(),
483                cause: e.to_string(),
484            })?;
485
486        Ok(rows > 0)
487    }
488
489    fn list_members(&self, group_id: &GroupId) -> Result<Vec<GroupMember>> {
490        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
491            operation: "lock_connection".to_string(),
492            cause: e.to_string(),
493        })?;
494
495        let mut stmt = conn
496            .prepare(
497                "SELECT id, group_id, email, role, joined_at, added_by
498                 FROM group_members WHERE group_id = ?1 ORDER BY email",
499            )
500            .map_err(|e| Error::OperationFailed {
501                operation: "prepare_list_members".to_string(),
502                cause: e.to_string(),
503            })?;
504
505        let members = stmt
506            .query_map(params![group_id.as_str()], |row| {
507                Ok(GroupMember {
508                    id: row.get(0)?,
509                    group_id: GroupId::new(row.get::<_, String>(1)?),
510                    email: row.get(2)?,
511                    role: Self::parse_role(&row.get::<_, String>(3)?),
512                    joined_at: Self::from_db_timestamp(row.get(4)?),
513                    added_by: row.get(5)?,
514                })
515            })
516            .map_err(|e| Error::OperationFailed {
517                operation: "list_members".to_string(),
518                cause: e.to_string(),
519            })?
520            .collect::<std::result::Result<Vec<_>, _>>()
521            .map_err(|e| Error::OperationFailed {
522                operation: "collect_members".to_string(),
523                cause: e.to_string(),
524            })?;
525
526        Ok(members)
527    }
528
529    fn get_user_groups(&self, org_id: &str, email: &str) -> Result<Vec<GroupMembership>> {
530        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
531            operation: "lock_connection".to_string(),
532            cause: e.to_string(),
533        })?;
534
535        let normalized_email = normalize_email(email);
536
537        let mut stmt = conn
538            .prepare(
539                "SELECT g.id, g.name, g.org_id, gm.role
540                 FROM groups g
541                 JOIN group_members gm ON g.id = gm.group_id
542                 WHERE g.org_id = ?1 AND gm.email = ?2
543                 ORDER BY g.name",
544            )
545            .map_err(|e| Error::OperationFailed {
546                operation: "prepare_get_user_groups".to_string(),
547                cause: e.to_string(),
548            })?;
549
550        let memberships = stmt
551            .query_map(params![org_id, normalized_email], |row| {
552                Ok(GroupMembership {
553                    group_id: GroupId::new(row.get::<_, String>(0)?),
554                    group_name: row.get(1)?,
555                    org_id: row.get(2)?,
556                    role: Self::parse_role(&row.get::<_, String>(3)?),
557                })
558            })
559            .map_err(|e| Error::OperationFailed {
560                operation: "get_user_groups".to_string(),
561                cause: e.to_string(),
562            })?
563            .collect::<std::result::Result<Vec<_>, _>>()
564            .map_err(|e| Error::OperationFailed {
565                operation: "collect_user_groups".to_string(),
566                cause: e.to_string(),
567            })?;
568
569        Ok(memberships)
570    }
571
572    fn count_admins(&self, group_id: &GroupId) -> Result<u32> {
573        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
574            operation: "lock_connection".to_string(),
575            cause: e.to_string(),
576        })?;
577
578        let count: u32 = conn
579            .query_row(
580                "SELECT COUNT(*) FROM group_members WHERE group_id = ?1 AND role = 'admin'",
581                params![group_id.as_str()],
582                |row| row.get(0),
583            )
584            .map_err(|e| Error::OperationFailed {
585                operation: "count_admins".to_string(),
586                cause: e.to_string(),
587            })?;
588
589        Ok(count)
590    }
591
592    fn create_invite(
593        &self,
594        group_id: &GroupId,
595        role: GroupRole,
596        created_by: &str,
597        expires_in_secs: Option<u64>,
598        max_uses: Option<u32>,
599    ) -> Result<(GroupInvite, String)> {
600        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
601            operation: "lock_connection".to_string(),
602            cause: e.to_string(),
603        })?;
604
605        let (invite, token) = GroupInvite::new(
606            group_id.clone(),
607            role,
608            created_by,
609            expires_in_secs,
610            max_uses,
611        );
612
613        conn.execute(
614            "INSERT INTO group_invites
615             (id, group_id, token_hash, role, created_by, created_at, expires_at, max_uses, current_uses, revoked)
616             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
617            params![
618                invite.id,
619                invite.group_id.as_str(),
620                invite.token_hash,
621                invite.role.as_str(),
622                invite.created_by,
623                Self::to_db_timestamp(invite.created_at),
624                Self::to_db_timestamp(invite.expires_at),
625                invite.max_uses,
626                invite.current_uses,
627                i32::from(invite.revoked),
628            ],
629        )
630        .map_err(|e| Error::OperationFailed {
631            operation: "create_invite".to_string(),
632            cause: e.to_string(),
633        })?;
634
635        Ok((invite, token))
636    }
637
638    fn get_invite_by_token_hash(&self, token_hash: &str) -> Result<Option<GroupInvite>> {
639        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
640            operation: "lock_connection".to_string(),
641            cause: e.to_string(),
642        })?;
643
644        let mut stmt = conn
645            .prepare(
646                "SELECT id, group_id, token_hash, role, created_by, created_at, expires_at,
647                        max_uses, current_uses, revoked
648                 FROM group_invites WHERE token_hash = ?1",
649            )
650            .map_err(|e| Error::OperationFailed {
651                operation: "prepare_get_invite_by_token".to_string(),
652                cause: e.to_string(),
653            })?;
654
655        let result = stmt
656            .query_row(params![token_hash], |row| {
657                Ok(GroupInvite {
658                    id: row.get(0)?,
659                    group_id: GroupId::new(row.get::<_, String>(1)?),
660                    token_hash: row.get(2)?,
661                    role: Self::parse_role(&row.get::<_, String>(3)?),
662                    created_by: row.get(4)?,
663                    created_at: Self::from_db_timestamp(row.get(5)?),
664                    expires_at: Self::from_db_timestamp(row.get(6)?),
665                    max_uses: row.get(7)?,
666                    current_uses: row.get(8)?,
667                    revoked: row.get::<_, i32>(9)? != 0,
668                })
669            })
670            .optional()
671            .map_err(|e| Error::OperationFailed {
672                operation: "get_invite_by_token".to_string(),
673                cause: e.to_string(),
674            })?;
675
676        Ok(result)
677    }
678
679    fn get_invite(&self, invite_id: &str) -> Result<Option<GroupInvite>> {
680        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
681            operation: "lock_connection".to_string(),
682            cause: e.to_string(),
683        })?;
684
685        let mut stmt = conn
686            .prepare(
687                "SELECT id, group_id, token_hash, role, created_by, created_at, expires_at,
688                        max_uses, current_uses, revoked
689                 FROM group_invites WHERE id = ?1",
690            )
691            .map_err(|e| Error::OperationFailed {
692                operation: "prepare_get_invite".to_string(),
693                cause: e.to_string(),
694            })?;
695
696        let result = stmt
697            .query_row(params![invite_id], |row| {
698                Ok(GroupInvite {
699                    id: row.get(0)?,
700                    group_id: GroupId::new(row.get::<_, String>(1)?),
701                    token_hash: row.get(2)?,
702                    role: Self::parse_role(&row.get::<_, String>(3)?),
703                    created_by: row.get(4)?,
704                    created_at: Self::from_db_timestamp(row.get(5)?),
705                    expires_at: Self::from_db_timestamp(row.get(6)?),
706                    max_uses: row.get(7)?,
707                    current_uses: row.get(8)?,
708                    revoked: row.get::<_, i32>(9)? != 0,
709                })
710            })
711            .optional()
712            .map_err(|e| Error::OperationFailed {
713                operation: "get_invite".to_string(),
714                cause: e.to_string(),
715            })?;
716
717        Ok(result)
718    }
719
720    fn list_invites(&self, group_id: &GroupId, include_expired: bool) -> Result<Vec<GroupInvite>> {
721        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
722            operation: "lock_connection".to_string(),
723            cause: e.to_string(),
724        })?;
725
726        let query = if include_expired {
727            "SELECT id, group_id, token_hash, role, created_by, created_at, expires_at,
728                    max_uses, current_uses, revoked
729             FROM group_invites WHERE group_id = ?1 ORDER BY created_at DESC"
730        } else {
731            "SELECT id, group_id, token_hash, role, created_by, created_at, expires_at,
732                    max_uses, current_uses, revoked
733             FROM group_invites
734             WHERE group_id = ?1 AND revoked = 0 AND expires_at > ?2
735             ORDER BY created_at DESC"
736        };
737
738        let mut stmt = conn.prepare(query).map_err(|e| Error::OperationFailed {
739            operation: "prepare_list_invites".to_string(),
740            cause: e.to_string(),
741        })?;
742
743        // Helper to parse invite from row
744        let parse_invite = |row: &rusqlite::Row<'_>| -> rusqlite::Result<GroupInvite> {
745            Ok(GroupInvite {
746                id: row.get(0)?,
747                group_id: GroupId::new(row.get::<_, String>(1)?),
748                token_hash: row.get(2)?,
749                role: Self::parse_role(&row.get::<_, String>(3)?),
750                created_by: row.get(4)?,
751                created_at: Self::from_db_timestamp(row.get(5)?),
752                expires_at: Self::from_db_timestamp(row.get(6)?),
753                max_uses: row.get(7)?,
754                current_uses: row.get(8)?,
755                revoked: row.get::<_, i32>(9)? != 0,
756            })
757        };
758
759        let invites = if include_expired {
760            stmt.query_map(params![group_id.as_str()], parse_invite)
761        } else {
762            stmt.query_map(params![group_id.as_str(), Self::now()], parse_invite)
763        }
764        .map_err(|e| Error::OperationFailed {
765            operation: "list_invites".to_string(),
766            cause: e.to_string(),
767        })?
768        .collect::<std::result::Result<Vec<_>, _>>()
769        .map_err(|e| Error::OperationFailed {
770            operation: "collect_invites".to_string(),
771            cause: e.to_string(),
772        })?;
773
774        Ok(invites)
775    }
776
777    fn increment_invite_uses(&self, invite_id: &str) -> Result<()> {
778        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
779            operation: "lock_connection".to_string(),
780            cause: e.to_string(),
781        })?;
782
783        conn.execute(
784            "UPDATE group_invites SET current_uses = current_uses + 1 WHERE id = ?1",
785            params![invite_id],
786        )
787        .map_err(|e| Error::OperationFailed {
788            operation: "increment_invite_uses".to_string(),
789            cause: e.to_string(),
790        })?;
791
792        Ok(())
793    }
794
795    fn revoke_invite(&self, invite_id: &str) -> Result<bool> {
796        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
797            operation: "lock_connection".to_string(),
798            cause: e.to_string(),
799        })?;
800
801        let rows = conn
802            .execute(
803                "UPDATE group_invites SET revoked = 1 WHERE id = ?1",
804                params![invite_id],
805            )
806            .map_err(|e| Error::OperationFailed {
807                operation: "revoke_invite".to_string(),
808                cause: e.to_string(),
809            })?;
810
811        Ok(rows > 0)
812    }
813
814    fn cleanup_expired_invites(&self) -> Result<u64> {
815        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
816            operation: "lock_connection".to_string(),
817            cause: e.to_string(),
818        })?;
819
820        let rows = conn
821            .execute(
822                "DELETE FROM group_invites WHERE expires_at < ?1 OR revoked = 1",
823                params![Self::now()],
824            )
825            .map_err(|e| Error::OperationFailed {
826                operation: "cleanup_expired_invites".to_string(),
827                cause: e.to_string(),
828            })?;
829
830        Ok(rows as u64)
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    fn create_test_backend() -> SqliteGroupBackend {
839        // Use a unique temp file for each test to ensure isolation
840        let dir = tempfile::TempDir::new().expect("Failed to create temp dir");
841        let path = dir.path().join("test_groups.db");
842        // We leak the TempDir to keep it alive for the duration of the test
843        std::mem::forget(dir);
844        SqliteGroupBackend::new(&path).expect("Failed to create test backend")
845    }
846
847    #[test]
848    fn test_create_and_get_group() {
849        let backend = create_test_backend();
850
851        let group = backend
852            .create_group(
853                "acme-corp",
854                "research",
855                "Research team",
856                "admin@example.com",
857            )
858            .expect("Failed to create group");
859
860        assert_eq!(group.name, "research");
861        assert_eq!(group.org_id, "acme-corp");
862        assert_eq!(group.created_by, "admin@example.com");
863
864        let retrieved = backend
865            .get_group(&group.id)
866            .expect("Failed to get group")
867            .expect("Group not found");
868
869        assert_eq!(retrieved.id, group.id);
870        assert_eq!(retrieved.name, group.name);
871    }
872
873    #[test]
874    fn test_duplicate_group_name() {
875        let backend = create_test_backend();
876
877        backend
878            .create_group("acme-corp", "research", "", "admin@example.com")
879            .expect("Failed to create first group");
880
881        let result = backend.create_group("acme-corp", "research", "", "admin@example.com");
882
883        assert!(result.is_err());
884    }
885
886    #[test]
887    fn test_add_and_list_members() {
888        let backend = create_test_backend();
889
890        let group = backend
891            .create_group("acme-corp", "team", "", "admin@example.com")
892            .expect("Failed to create group");
893
894        backend
895            .add_member(
896                &group.id,
897                "alice@example.com",
898                GroupRole::Admin,
899                "admin@example.com",
900            )
901            .expect("Failed to add alice");
902        backend
903            .add_member(
904                &group.id,
905                "bob@example.com",
906                GroupRole::Write,
907                "admin@example.com",
908            )
909            .expect("Failed to add bob");
910
911        let members = backend
912            .list_members(&group.id)
913            .expect("Failed to list members");
914
915        assert_eq!(members.len(), 2);
916        assert!(members.iter().any(|m| m.email == "alice@example.com"));
917        assert!(members.iter().any(|m| m.email == "bob@example.com"));
918    }
919
920    #[test]
921    fn test_get_user_groups() {
922        let backend = create_test_backend();
923
924        let group1 = backend
925            .create_group("acme-corp", "team-a", "", "admin@example.com")
926            .expect("Failed to create group1");
927        let group2 = backend
928            .create_group("acme-corp", "team-b", "", "admin@example.com")
929            .expect("Failed to create group2");
930
931        backend
932            .add_member(
933                &group1.id,
934                "user@example.com",
935                GroupRole::Write,
936                "admin@example.com",
937            )
938            .expect("Failed to add user to group1");
939        backend
940            .add_member(
941                &group2.id,
942                "user@example.com",
943                GroupRole::Read,
944                "admin@example.com",
945            )
946            .expect("Failed to add user to group2");
947
948        let memberships = backend
949            .get_user_groups("acme-corp", "user@example.com")
950            .expect("Failed to get user groups");
951
952        assert_eq!(memberships.len(), 2);
953    }
954
955    #[test]
956    fn test_invite_workflow() {
957        let backend = create_test_backend();
958
959        let group = backend
960            .create_group("acme-corp", "team", "", "admin@example.com")
961            .expect("Failed to create group");
962
963        let (invite, token) = backend
964            .create_invite(
965                &group.id,
966                GroupRole::Write,
967                "admin@example.com",
968                None,
969                Some(1),
970            )
971            .expect("Failed to create invite");
972
973        assert!(invite.is_valid());
974
975        // Verify token hash lookup
976        let token_hash = GroupInvite::hash_token(&token);
977        let retrieved = backend
978            .get_invite_by_token_hash(&token_hash)
979            .expect("Failed to get invite")
980            .expect("Invite not found");
981
982        assert_eq!(retrieved.id, invite.id);
983
984        // Increment uses
985        backend
986            .increment_invite_uses(&invite.id)
987            .expect("Failed to increment uses");
988
989        let updated = backend
990            .get_invite(&invite.id)
991            .expect("Failed to get invite")
992            .expect("Invite not found");
993
994        assert_eq!(updated.current_uses, 1);
995        assert!(!updated.is_valid()); // max_uses = 1, so now invalid
996    }
997
998    #[test]
999    fn test_count_admins() {
1000        let backend = create_test_backend();
1001
1002        let group = backend
1003            .create_group("acme-corp", "team", "", "admin@example.com")
1004            .expect("Failed to create group");
1005
1006        backend
1007            .add_member(&group.id, "admin1@example.com", GroupRole::Admin, "system")
1008            .expect("Failed to add admin1");
1009        backend
1010            .add_member(&group.id, "admin2@example.com", GroupRole::Admin, "system")
1011            .expect("Failed to add admin2");
1012        backend
1013            .add_member(&group.id, "user@example.com", GroupRole::Write, "system")
1014            .expect("Failed to add user");
1015
1016        let count = backend
1017            .count_admins(&group.id)
1018            .expect("Failed to count admins");
1019        assert_eq!(count, 2);
1020    }
1021
1022    #[test]
1023    fn test_delete_group_cascades() {
1024        let backend = create_test_backend();
1025
1026        let group = backend
1027            .create_group("acme-corp", "team", "", "admin@example.com")
1028            .expect("Failed to create group");
1029
1030        backend
1031            .add_member(
1032                &group.id,
1033                "user@example.com",
1034                GroupRole::Write,
1035                "admin@example.com",
1036            )
1037            .expect("Failed to add member");
1038        backend
1039            .create_invite(&group.id, GroupRole::Read, "admin@example.com", None, None)
1040            .expect("Failed to create invite");
1041
1042        let deleted = backend
1043            .delete_group(&group.id)
1044            .expect("Failed to delete group");
1045        assert!(deleted);
1046
1047        let members = backend
1048            .list_members(&group.id)
1049            .expect("Failed to list members");
1050        assert!(members.is_empty());
1051
1052        let invites = backend
1053            .list_invites(&group.id, true)
1054            .expect("Failed to list invites");
1055        assert!(invites.is_empty());
1056    }
1057}