Skip to main content

pylon_runtime/
org_backend.rs

1//! Persistent organization stores. SQLite + Postgres backends behind
2//! the [`pylon_auth::org::OrgBackend`] trait so orgs / memberships /
3//! invites survive a server restart.
4//!
5//! Schema: three tables — orgs (id, name, created_by, created_at),
6//! org_memberships (org_id, user_id, role, joined_at) with composite
7//! PK, and org_invites (id, org_id, email, role, invited_by,
8//! token_hash, token_prefix, created_at, expires_at, accepted_at).
9//! `token_prefix` is indexed so accept-by-token is fast.
10
11use std::sync::{Arc, Mutex};
12
13use pylon_auth::org::{Invite, Membership, Org, OrgBackend, OrgRole};
14use rusqlite::Connection;
15
16const ORGS_TABLE: &str = "_pylon_orgs";
17const MEMBERS_TABLE: &str = "_pylon_org_members";
18const INVITES_TABLE: &str = "_pylon_org_invites";
19
20// ---------------------------------------------------------------------------
21// SQLite backend
22// ---------------------------------------------------------------------------
23
24pub struct SqliteOrgBackend {
25    conn: Arc<Mutex<Connection>>,
26}
27
28impl SqliteOrgBackend {
29    pub fn open(path: &str) -> Result<Self, String> {
30        let conn = Connection::open(path).map_err(|e| format!("open: {e}"))?;
31        Self::from_connection(conn)
32    }
33    pub fn in_memory() -> Result<Self, String> {
34        let conn = Connection::open_in_memory().map_err(|e| format!("open: {e}"))?;
35        Self::from_connection(conn)
36    }
37    fn from_connection(conn: Connection) -> Result<Self, String> {
38        conn.execute_batch(&format!(
39            "CREATE TABLE IF NOT EXISTS {ORGS_TABLE} (
40                id TEXT PRIMARY KEY,
41                name TEXT NOT NULL,
42                created_by TEXT NOT NULL,
43                created_at INTEGER NOT NULL
44            );
45            CREATE TABLE IF NOT EXISTS {MEMBERS_TABLE} (
46                org_id TEXT NOT NULL,
47                user_id TEXT NOT NULL,
48                role TEXT NOT NULL,
49                joined_at INTEGER NOT NULL,
50                PRIMARY KEY (org_id, user_id)
51            );
52            CREATE INDEX IF NOT EXISTS {MEMBERS_TABLE}_user_idx ON {MEMBERS_TABLE}(user_id);
53            CREATE TABLE IF NOT EXISTS {INVITES_TABLE} (
54                id TEXT PRIMARY KEY,
55                org_id TEXT NOT NULL,
56                email TEXT NOT NULL,
57                role TEXT NOT NULL,
58                invited_by TEXT NOT NULL,
59                token_hash TEXT NOT NULL,
60                token_prefix TEXT NOT NULL,
61                created_at INTEGER NOT NULL,
62                expires_at INTEGER NOT NULL,
63                accepted_at INTEGER
64            );
65            CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_prefix_idx ON {INVITES_TABLE}(token_prefix);
66            CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_org_idx ON {INVITES_TABLE}(org_id);"
67        ))
68        .map_err(|e| format!("init schema: {e}"))?;
69        Ok(Self {
70            conn: Arc::new(Mutex::new(conn)),
71        })
72    }
73}
74
75fn role_to_str(r: OrgRole) -> &'static str {
76    r.as_str()
77}
78
79fn role_from_str(s: &str) -> OrgRole {
80    OrgRole::from_str(s).unwrap_or(OrgRole::Member)
81}
82
83impl OrgBackend for SqliteOrgBackend {
84    fn put_org(&self, org: &Org) {
85        if let Ok(c) = self.conn.lock() {
86            let _ = c.execute(
87                &format!(
88                    "INSERT INTO {ORGS_TABLE} (id, name, created_by, created_at)
89                     VALUES (?1, ?2, ?3, ?4)
90                     ON CONFLICT(id) DO UPDATE SET
91                       name = excluded.name"
92                ),
93                rusqlite::params![org.id, org.name, org.created_by, org.created_at as i64],
94            );
95        }
96    }
97
98    fn get_org(&self, id: &str) -> Option<Org> {
99        let c = self.conn.lock().ok()?;
100        c.query_row(
101            &format!("SELECT id, name, created_by, created_at FROM {ORGS_TABLE} WHERE id = ?1"),
102            rusqlite::params![id],
103            |r| {
104                Ok(Org {
105                    id: r.get(0)?,
106                    name: r.get(1)?,
107                    created_by: r.get(2)?,
108                    created_at: r.get::<_, i64>(3)? as u64,
109                })
110            },
111        )
112        .ok()
113    }
114
115    fn delete_org(&self, id: &str) -> bool {
116        let Ok(c) = self.conn.lock() else {
117            return false;
118        };
119        // Cascade memberships + invites — host schema doesn't have FKs
120        // since pylon owns these tables.
121        let _ = c.execute(
122            &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = ?1"),
123            rusqlite::params![id],
124        );
125        let _ = c.execute(
126            &format!("DELETE FROM {INVITES_TABLE} WHERE org_id = ?1"),
127            rusqlite::params![id],
128        );
129        c.execute(
130            &format!("DELETE FROM {ORGS_TABLE} WHERE id = ?1"),
131            rusqlite::params![id],
132        )
133        .map(|n| n > 0)
134        .unwrap_or(false)
135    }
136
137    fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
138        let Ok(c) = self.conn.lock() else {
139            return vec![];
140        };
141        let mut stmt = match c.prepare(&format!(
142            "SELECT o.id, o.name, o.created_by, o.created_at, m.role
143             FROM {ORGS_TABLE} o JOIN {MEMBERS_TABLE} m ON o.id = m.org_id
144             WHERE m.user_id = ?1
145             ORDER BY o.created_at DESC"
146        )) {
147            Ok(s) => s,
148            Err(_) => return vec![],
149        };
150        let iter = match stmt.query_map(rusqlite::params![user_id], |r| {
151            let role: String = r.get(4)?;
152            Ok((
153                Org {
154                    id: r.get(0)?,
155                    name: r.get(1)?,
156                    created_by: r.get(2)?,
157                    created_at: r.get::<_, i64>(3)? as u64,
158                },
159                role_from_str(&role),
160            ))
161        }) {
162            Ok(it) => it,
163            Err(_) => return vec![],
164        };
165        iter.filter_map(|r| r.ok()).collect()
166    }
167
168    fn put_membership(&self, m: &Membership) {
169        if let Ok(c) = self.conn.lock() {
170            let _ = c.execute(
171                &format!(
172                    "INSERT INTO {MEMBERS_TABLE} (org_id, user_id, role, joined_at)
173                     VALUES (?1, ?2, ?3, ?4)
174                     ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role"
175                ),
176                rusqlite::params![m.org_id, m.user_id, role_to_str(m.role), m.joined_at as i64],
177            );
178        }
179    }
180
181    fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
182        let c = self.conn.lock().ok()?;
183        c.query_row(
184            &format!(
185                "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
186                 WHERE org_id = ?1 AND user_id = ?2"
187            ),
188            rusqlite::params![org_id, user_id],
189            |r| {
190                let role: String = r.get(2)?;
191                Ok(Membership {
192                    org_id: r.get(0)?,
193                    user_id: r.get(1)?,
194                    role: role_from_str(&role),
195                    joined_at: r.get::<_, i64>(3)? as u64,
196                })
197            },
198        )
199        .ok()
200    }
201
202    fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
203        let Ok(c) = self.conn.lock() else {
204            return false;
205        };
206        c.execute(
207            &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = ?1 AND user_id = ?2"),
208            rusqlite::params![org_id, user_id],
209        )
210        .map(|n| n > 0)
211        .unwrap_or(false)
212    }
213
214    fn list_members(&self, org_id: &str) -> Vec<Membership> {
215        let Ok(c) = self.conn.lock() else {
216            return vec![];
217        };
218        let mut stmt = match c.prepare(&format!(
219            "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
220             WHERE org_id = ?1 ORDER BY joined_at"
221        )) {
222            Ok(s) => s,
223            Err(_) => return vec![],
224        };
225        let iter = match stmt.query_map(rusqlite::params![org_id], |r| {
226            let role: String = r.get(2)?;
227            Ok(Membership {
228                org_id: r.get(0)?,
229                user_id: r.get(1)?,
230                role: role_from_str(&role),
231                joined_at: r.get::<_, i64>(3)? as u64,
232            })
233        }) {
234            Ok(it) => it,
235            Err(_) => return vec![],
236        };
237        iter.filter_map(|r| r.ok()).collect()
238    }
239
240    fn put_invite(&self, inv: &Invite) {
241        if let Ok(c) = self.conn.lock() {
242            let _ = c.execute(
243                &format!(
244                    "INSERT INTO {INVITES_TABLE}
245                       (id, org_id, email, role, invited_by, token_hash, token_prefix,
246                        created_at, expires_at, accepted_at)
247                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
248                     ON CONFLICT(id) DO UPDATE SET
249                       accepted_at = excluded.accepted_at"
250                ),
251                rusqlite::params![
252                    inv.id,
253                    inv.org_id,
254                    inv.email,
255                    role_to_str(inv.role),
256                    inv.invited_by,
257                    inv.token_hash,
258                    inv.token_prefix,
259                    inv.created_at as i64,
260                    inv.expires_at as i64,
261                    inv.accepted_at.map(|v| v as i64),
262                ],
263            );
264        }
265    }
266
267    fn get_invite(&self, id: &str) -> Option<Invite> {
268        let c = self.conn.lock().ok()?;
269        c.query_row(
270            &format!(
271                "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
272                        created_at, expires_at, accepted_at
273                 FROM {INVITES_TABLE} WHERE id = ?1"
274            ),
275            rusqlite::params![id],
276            row_to_invite,
277        )
278        .ok()
279    }
280
281    fn list_invites(&self, org_id: &str) -> Vec<Invite> {
282        let Ok(c) = self.conn.lock() else {
283            return vec![];
284        };
285        let mut stmt = match c.prepare(&format!(
286            "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
287                    created_at, expires_at, accepted_at
288             FROM {INVITES_TABLE}
289             WHERE org_id = ?1 AND accepted_at IS NULL
290             ORDER BY created_at DESC"
291        )) {
292            Ok(s) => s,
293            Err(_) => return vec![],
294        };
295        let iter = match stmt.query_map(rusqlite::params![org_id], row_to_invite) {
296            Ok(it) => it,
297            Err(_) => return vec![],
298        };
299        iter.filter_map(|r| r.ok()).collect()
300    }
301
302    fn delete_invite(&self, id: &str) -> bool {
303        let Ok(c) = self.conn.lock() else {
304            return false;
305        };
306        c.execute(
307            &format!("DELETE FROM {INVITES_TABLE} WHERE id = ?1"),
308            rusqlite::params![id],
309        )
310        .map(|n| n > 0)
311        .unwrap_or(false)
312    }
313
314    fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
315        let Ok(c) = self.conn.lock() else {
316            return vec![];
317        };
318        // Include accepted invites — accept_invite returns
319        // AlreadyAccepted by checking the field, not by their absence.
320        let mut stmt = match c.prepare(&format!(
321            "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
322                    created_at, expires_at, accepted_at
323             FROM {INVITES_TABLE} WHERE token_prefix = ?1"
324        )) {
325            Ok(s) => s,
326            Err(_) => return vec![],
327        };
328        let iter = match stmt.query_map(rusqlite::params![prefix], row_to_invite) {
329            Ok(it) => it,
330            Err(_) => return vec![],
331        };
332        iter.filter_map(|r| r.ok()).collect()
333    }
334
335    fn mark_invite_accepted(&self, id: &str, now: u64) -> bool {
336        let Ok(c) = self.conn.lock() else {
337            return false;
338        };
339        // CAS via SQL: only stamp when accepted_at IS NULL. The
340        // affected-row count tells us whether we won the race.
341        c.execute(
342            &format!(
343                "UPDATE {INVITES_TABLE} SET accepted_at = ?2
344                 WHERE id = ?1 AND accepted_at IS NULL"
345            ),
346            rusqlite::params![id, now as i64],
347        )
348        .map(|n| n > 0)
349        .unwrap_or(false)
350    }
351}
352
353fn row_to_invite(row: &rusqlite::Row<'_>) -> rusqlite::Result<Invite> {
354    let role: String = row.get(3)?;
355    Ok(Invite {
356        id: row.get(0)?,
357        org_id: row.get(1)?,
358        email: row.get(2)?,
359        role: role_from_str(&role),
360        invited_by: row.get(4)?,
361        token_hash: row.get(5)?,
362        token_prefix: row.get(6)?,
363        created_at: row.get::<_, i64>(7)? as u64,
364        expires_at: row.get::<_, i64>(8)? as u64,
365        accepted_at: row.get::<_, Option<i64>>(9)?.map(|v| v as u64),
366    })
367}
368
369// ---------------------------------------------------------------------------
370// Postgres backend
371// ---------------------------------------------------------------------------
372
373pub use pg::PostgresOrgBackend;
374
375mod pg {
376    use super::*;
377    use postgres::Client;
378
379    pub struct PostgresOrgBackend {
380        client: Mutex<Client>,
381    }
382
383    impl PostgresOrgBackend {
384        pub fn connect(url: &str) -> Result<Self, String> {
385            let mut client = pylon_storage::postgres::live::connect_pg(url)?;
386            client
387                .batch_execute(&format!(
388                    "CREATE TABLE IF NOT EXISTS {ORGS_TABLE} (
389                        id TEXT PRIMARY KEY,
390                        name TEXT NOT NULL,
391                        created_by TEXT NOT NULL,
392                        created_at BIGINT NOT NULL
393                    );
394                    CREATE TABLE IF NOT EXISTS {MEMBERS_TABLE} (
395                        org_id TEXT NOT NULL,
396                        user_id TEXT NOT NULL,
397                        role TEXT NOT NULL,
398                        joined_at BIGINT NOT NULL,
399                        PRIMARY KEY (org_id, user_id)
400                    );
401                    CREATE INDEX IF NOT EXISTS {MEMBERS_TABLE}_user_idx ON {MEMBERS_TABLE}(user_id);
402                    CREATE TABLE IF NOT EXISTS {INVITES_TABLE} (
403                        id TEXT PRIMARY KEY,
404                        org_id TEXT NOT NULL,
405                        email TEXT NOT NULL,
406                        role TEXT NOT NULL,
407                        invited_by TEXT NOT NULL,
408                        token_hash TEXT NOT NULL,
409                        token_prefix TEXT NOT NULL,
410                        created_at BIGINT NOT NULL,
411                        expires_at BIGINT NOT NULL,
412                        accepted_at BIGINT
413                    );
414                    CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_prefix_idx ON {INVITES_TABLE}(token_prefix);
415                    CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_org_idx ON {INVITES_TABLE}(org_id);"
416                ))
417                .map_err(|e| format!("PG init schema: {e}"))?;
418            Ok(Self {
419                client: Mutex::new(client),
420            })
421        }
422    }
423
424    impl OrgBackend for PostgresOrgBackend {
425        fn put_org(&self, org: &Org) {
426            if let Ok(mut c) = self.client.lock() {
427                let _ = c.execute(
428                    &format!(
429                        "INSERT INTO {ORGS_TABLE} (id, name, created_by, created_at)
430                         VALUES ($1, $2, $3, $4)
431                         ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"
432                    ),
433                    &[
434                        &org.id,
435                        &org.name,
436                        &org.created_by,
437                        &(org.created_at as i64),
438                    ],
439                );
440            }
441        }
442
443        fn get_org(&self, id: &str) -> Option<Org> {
444            let mut c = self.client.lock().ok()?;
445            let row = c
446                .query_opt(
447                    &format!(
448                        "SELECT id, name, created_by, created_at FROM {ORGS_TABLE} WHERE id = $1"
449                    ),
450                    &[&id],
451                )
452                .ok()??;
453            Some(Org {
454                id: row.get(0),
455                name: row.get(1),
456                created_by: row.get(2),
457                created_at: row.get::<_, i64>(3) as u64,
458            })
459        }
460
461        fn delete_org(&self, id: &str) -> bool {
462            let Ok(mut c) = self.client.lock() else {
463                return false;
464            };
465            let _ = c.execute(
466                &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = $1"),
467                &[&id],
468            );
469            let _ = c.execute(
470                &format!("DELETE FROM {INVITES_TABLE} WHERE org_id = $1"),
471                &[&id],
472            );
473            c.execute(&format!("DELETE FROM {ORGS_TABLE} WHERE id = $1"), &[&id])
474                .map(|n| n > 0)
475                .unwrap_or(false)
476        }
477
478        fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
479            let Ok(mut c) = self.client.lock() else {
480                return vec![];
481            };
482            let rows = match c.query(
483                &format!(
484                    "SELECT o.id, o.name, o.created_by, o.created_at, m.role
485                     FROM {ORGS_TABLE} o JOIN {MEMBERS_TABLE} m ON o.id = m.org_id
486                     WHERE m.user_id = $1
487                     ORDER BY o.created_at DESC"
488                ),
489                &[&user_id],
490            ) {
491                Ok(r) => r,
492                Err(_) => return vec![],
493            };
494            rows.iter()
495                .map(|row| {
496                    let role: String = row.get(4);
497                    (
498                        Org {
499                            id: row.get(0),
500                            name: row.get(1),
501                            created_by: row.get(2),
502                            created_at: row.get::<_, i64>(3) as u64,
503                        },
504                        role_from_str(&role),
505                    )
506                })
507                .collect()
508        }
509
510        fn put_membership(&self, m: &Membership) {
511            if let Ok(mut c) = self.client.lock() {
512                let _ = c.execute(
513                    &format!(
514                        "INSERT INTO {MEMBERS_TABLE} (org_id, user_id, role, joined_at)
515                         VALUES ($1, $2, $3, $4)
516                         ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role"
517                    ),
518                    &[
519                        &m.org_id,
520                        &m.user_id,
521                        &role_to_str(m.role),
522                        &(m.joined_at as i64),
523                    ],
524                );
525            }
526        }
527
528        fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
529            let mut c = self.client.lock().ok()?;
530            let row = c
531                .query_opt(
532                    &format!(
533                        "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
534                         WHERE org_id = $1 AND user_id = $2"
535                    ),
536                    &[&org_id, &user_id],
537                )
538                .ok()??;
539            let role: String = row.get(2);
540            Some(Membership {
541                org_id: row.get(0),
542                user_id: row.get(1),
543                role: role_from_str(&role),
544                joined_at: row.get::<_, i64>(3) as u64,
545            })
546        }
547
548        fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
549            let Ok(mut c) = self.client.lock() else {
550                return false;
551            };
552            c.execute(
553                &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = $1 AND user_id = $2"),
554                &[&org_id, &user_id],
555            )
556            .map(|n| n > 0)
557            .unwrap_or(false)
558        }
559
560        fn list_members(&self, org_id: &str) -> Vec<Membership> {
561            let Ok(mut c) = self.client.lock() else {
562                return vec![];
563            };
564            let rows = match c.query(
565                &format!(
566                    "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
567                     WHERE org_id = $1 ORDER BY joined_at"
568                ),
569                &[&org_id],
570            ) {
571                Ok(r) => r,
572                Err(_) => return vec![],
573            };
574            rows.iter()
575                .map(|row| {
576                    let role: String = row.get(2);
577                    Membership {
578                        org_id: row.get(0),
579                        user_id: row.get(1),
580                        role: role_from_str(&role),
581                        joined_at: row.get::<_, i64>(3) as u64,
582                    }
583                })
584                .collect()
585        }
586
587        fn put_invite(&self, inv: &Invite) {
588            if let Ok(mut c) = self.client.lock() {
589                let _ = c.execute(
590                    &format!(
591                        "INSERT INTO {INVITES_TABLE}
592                           (id, org_id, email, role, invited_by, token_hash, token_prefix,
593                            created_at, expires_at, accepted_at)
594                         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
595                         ON CONFLICT (id) DO UPDATE SET accepted_at = EXCLUDED.accepted_at"
596                    ),
597                    &[
598                        &inv.id,
599                        &inv.org_id,
600                        &inv.email,
601                        &role_to_str(inv.role),
602                        &inv.invited_by,
603                        &inv.token_hash,
604                        &inv.token_prefix,
605                        &(inv.created_at as i64),
606                        &(inv.expires_at as i64),
607                        &inv.accepted_at.map(|v| v as i64),
608                    ],
609                );
610            }
611        }
612
613        fn get_invite(&self, id: &str) -> Option<Invite> {
614            let mut c = self.client.lock().ok()?;
615            let row = c
616                .query_opt(
617                    &format!(
618                        "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
619                                created_at, expires_at, accepted_at
620                         FROM {INVITES_TABLE} WHERE id = $1"
621                    ),
622                    &[&id],
623                )
624                .ok()??;
625            Some(pg_row_to_invite(&row))
626        }
627
628        fn list_invites(&self, org_id: &str) -> Vec<Invite> {
629            let Ok(mut c) = self.client.lock() else {
630                return vec![];
631            };
632            let rows = match c.query(
633                &format!(
634                    "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
635                            created_at, expires_at, accepted_at
636                     FROM {INVITES_TABLE}
637                     WHERE org_id = $1 AND accepted_at IS NULL
638                     ORDER BY created_at DESC"
639                ),
640                &[&org_id],
641            ) {
642                Ok(r) => r,
643                Err(_) => return vec![],
644            };
645            rows.iter().map(pg_row_to_invite).collect()
646        }
647
648        fn delete_invite(&self, id: &str) -> bool {
649            let Ok(mut c) = self.client.lock() else {
650                return false;
651            };
652            c.execute(
653                &format!("DELETE FROM {INVITES_TABLE} WHERE id = $1"),
654                &[&id],
655            )
656            .map(|n| n > 0)
657            .unwrap_or(false)
658        }
659
660        fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
661            let Ok(mut c) = self.client.lock() else {
662                return vec![];
663            };
664            let rows = match c.query(
665                &format!(
666                    "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
667                            created_at, expires_at, accepted_at
668                     FROM {INVITES_TABLE} WHERE token_prefix = $1"
669                ),
670                &[&prefix],
671            ) {
672                Ok(r) => r,
673                Err(_) => return vec![],
674            };
675            rows.iter().map(pg_row_to_invite).collect()
676        }
677
678        fn mark_invite_accepted(&self, id: &str, now: u64) -> bool {
679            let Ok(mut c) = self.client.lock() else {
680                return false;
681            };
682            c.execute(
683                &format!(
684                    "UPDATE {INVITES_TABLE} SET accepted_at = $2
685                     WHERE id = $1 AND accepted_at IS NULL"
686                ),
687                &[&id, &(now as i64)],
688            )
689            .map(|n| n > 0)
690            .unwrap_or(false)
691        }
692    }
693
694    fn pg_row_to_invite(row: &postgres::Row) -> Invite {
695        let role: String = row.get(3);
696        Invite {
697            id: row.get(0),
698            org_id: row.get(1),
699            email: row.get(2),
700            role: role_from_str(&role),
701            invited_by: row.get(4),
702            token_hash: row.get(5),
703            token_prefix: row.get(6),
704            created_at: row.get::<_, i64>(7) as u64,
705            expires_at: row.get::<_, i64>(8) as u64,
706            accepted_at: row.get::<_, Option<i64>>(9).map(|v| v as u64),
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use pylon_auth::org::{Invite, Membership, Org, OrgRole};
715
716    #[test]
717    fn sqlite_org_round_trip() {
718        let b = SqliteOrgBackend::in_memory().unwrap();
719        let org = Org {
720            id: "o1".into(),
721            name: "Acme".into(),
722            created_by: "u1".into(),
723            created_at: 100,
724        };
725        b.put_org(&org);
726        assert_eq!(b.get_org("o1").unwrap().name, "Acme");
727        assert!(b.delete_org("o1"));
728        assert!(b.get_org("o1").is_none());
729    }
730
731    #[test]
732    fn sqlite_membership_and_list_for_user() {
733        let b = SqliteOrgBackend::in_memory().unwrap();
734        b.put_org(&Org {
735            id: "o1".into(),
736            name: "A".into(),
737            created_by: "u1".into(),
738            created_at: 100,
739        });
740        b.put_org(&Org {
741            id: "o2".into(),
742            name: "B".into(),
743            created_by: "u2".into(),
744            created_at: 200,
745        });
746        b.put_membership(&Membership {
747            org_id: "o1".into(),
748            user_id: "u1".into(),
749            role: OrgRole::Owner,
750            joined_at: 100,
751        });
752        b.put_membership(&Membership {
753            org_id: "o2".into(),
754            user_id: "u1".into(),
755            role: OrgRole::Member,
756            joined_at: 200,
757        });
758        let list = b.list_orgs_for_user("u1");
759        assert_eq!(list.len(), 2);
760        // Newest first.
761        assert_eq!(list[0].0.id, "o2");
762        assert_eq!(list[0].1, OrgRole::Member);
763    }
764
765    #[test]
766    fn sqlite_invite_prefix_index_used() {
767        let b = SqliteOrgBackend::in_memory().unwrap();
768        b.put_org(&Org {
769            id: "o1".into(),
770            name: "A".into(),
771            created_by: "u1".into(),
772            created_at: 100,
773        });
774        b.put_invite(&Invite {
775            id: "i1".into(),
776            org_id: "o1".into(),
777            email: "x@y.com".into(),
778            role: OrgRole::Member,
779            invited_by: "u1".into(),
780            token_hash: "h".into(),
781            token_prefix: "abcd1234".into(),
782            created_at: 100,
783            expires_at: 9_999_999_999,
784            accepted_at: None,
785        });
786        let hits = b.invites_by_prefix("abcd1234");
787        assert_eq!(hits.len(), 1);
788        let misses = b.invites_by_prefix("nomatch1");
789        assert_eq!(misses.len(), 0);
790    }
791
792    #[test]
793    fn sqlite_delete_org_cascades() {
794        let b = SqliteOrgBackend::in_memory().unwrap();
795        b.put_org(&Org {
796            id: "o1".into(),
797            name: "A".into(),
798            created_by: "u1".into(),
799            created_at: 100,
800        });
801        b.put_membership(&Membership {
802            org_id: "o1".into(),
803            user_id: "u1".into(),
804            role: OrgRole::Owner,
805            joined_at: 100,
806        });
807        b.put_invite(&Invite {
808            id: "i1".into(),
809            org_id: "o1".into(),
810            email: "x@y.com".into(),
811            role: OrgRole::Member,
812            invited_by: "u1".into(),
813            token_hash: "h".into(),
814            token_prefix: "p".into(),
815            created_at: 100,
816            expires_at: 9_999_999_999,
817            accepted_at: None,
818        });
819        assert!(b.delete_org("o1"));
820        assert!(b.list_members("o1").is_empty());
821        assert!(b.list_invites("o1").is_empty());
822    }
823}