Skip to main content

sova_scope/
invites.rs

1//! Scope invitation store.
2
3#[cfg(feature = "invites")]
4mod inner {
5    use crate::cache::MutateOpts;
6    use crate::entity::{scope_invitation, ScopeInvitation};
7    use crate::membership::MembershipStore;
8    use crate::types::{
9        normalize_role, ScopeRef, INVITE_ACCEPTED, INVITE_PENDING, INVITE_REVOKED, ROLE_VIEWER,
10    };
11    use chrono::Utc;
12    use rand::RngCore;
13    use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
14    use sova_core::Result;
15    use sova_db::{ActiveModelTrait, DbError, DbHandle, Set};
16
17    pub struct InviteStore;
18
19    fn invite_token() -> String {
20        let mut buf = [0u8; 16];
21        rand::thread_rng().fill_bytes(&mut buf);
22        buf.iter().map(|b| format!("{b:02x}")).collect()
23    }
24
25    impl InviteStore {
26        pub async fn list_pending(
27            db: &DbHandle,
28            scope: ScopeRef,
29        ) -> Result<Vec<scope_invitation::Model>> {
30            Ok(ScopeInvitation::find()
31                .filter(scope_invitation::Column::ScopeKind.eq(scope.kind))
32                .filter(scope_invitation::Column::ScopeId.eq(scope.id))
33                .filter(scope_invitation::Column::Status.eq(INVITE_PENDING))
34                .all(db)
35                .await
36                .map_err(DbError::from)?)
37        }
38
39        pub async fn find_pending_by_email(
40            db: &DbHandle,
41            scope: ScopeRef,
42            email: &str,
43        ) -> Result<Option<scope_invitation::Model>> {
44            Ok(ScopeInvitation::find()
45                .filter(scope_invitation::Column::ScopeKind.eq(scope.kind))
46                .filter(scope_invitation::Column::ScopeId.eq(scope.id))
47                .filter(scope_invitation::Column::Email.eq(email.trim().to_lowercase()))
48                .filter(scope_invitation::Column::Status.eq(INVITE_PENDING))
49                .one(db)
50                .await
51                .map_err(DbError::from)?)
52        }
53
54        pub async fn create(
55            db: &DbHandle,
56            scope: ScopeRef,
57            email: &str,
58            role: &str,
59            invited_by: i64,
60        ) -> Result<scope_invitation::Model> {
61            let role = normalize_role(role).unwrap_or(ROLE_VIEWER);
62            Ok(scope_invitation::ActiveModel {
63                scope_kind: Set(scope.kind),
64                scope_id: Set(scope.id),
65                email: Set(email.trim().to_lowercase()),
66                role: Set(role.into()),
67                invited_by: Set(invited_by),
68                token: Set(invite_token()),
69                status: Set(INVITE_PENDING.into()),
70                created_at: Set(Utc::now()),
71                ..Default::default()
72            }
73            .insert(db)
74            .await
75            .map_err(DbError::from)?)
76        }
77
78        pub async fn revoke(db: &DbHandle, id: i64) -> Result<()> {
79            let row = ScopeInvitation::find_by_id(id)
80                .one(db)
81                .await
82                .map_err(DbError::from)?
83                .ok_or(sova_core::Error::NotFound)?;
84            let mut am: scope_invitation::ActiveModel = row.into();
85            am.status = Set(INVITE_REVOKED.into());
86            am.update(db).await.map_err(DbError::from)?;
87            Ok(())
88        }
89
90        pub async fn accept_pending_for_email(
91            db: &DbHandle,
92            user_id: i64,
93            email: &str,
94        ) -> Result<u32> {
95            Self::accept_pending_for_email_with(db, user_id, email, MutateOpts::default()).await
96        }
97
98        pub async fn accept_pending_for_email_with(
99            db: &DbHandle,
100            user_id: i64,
101            email: &str,
102            opts: MutateOpts<'_>,
103        ) -> Result<u32> {
104            let email = email.trim().to_lowercase();
105            let pending = ScopeInvitation::find()
106                .filter(scope_invitation::Column::Email.eq(&email))
107                .filter(scope_invitation::Column::Status.eq(INVITE_PENDING))
108                .all(db)
109                .await
110                .map_err(DbError::from)?;
111            let mut count = 0u32;
112            for inv in pending {
113                let scope = ScopeRef::new(inv.scope_kind.clone(), inv.scope_id);
114                if MembershipStore::find(db, scope.clone(), user_id)
115                    .await?
116                    .is_some()
117                {
118                    continue;
119                }
120                let mut add_opts = MutateOpts {
121                    actor_id: opts.actor_id.or(Some(inv.invited_by)),
122                    cache: opts.cache,
123                };
124                if add_opts.actor_id.is_none() {
125                    add_opts.actor_id = Some(user_id);
126                }
127                MembershipStore::add_with(db, scope, user_id, &inv.role, add_opts).await?;
128                let mut am: scope_invitation::ActiveModel = inv.into();
129                am.status = Set(INVITE_ACCEPTED.into());
130                am.update(db).await.map_err(DbError::from)?;
131                count += 1;
132            }
133            Ok(count)
134        }
135
136        pub async fn find_by_token(
137            db: &DbHandle,
138            token: &str,
139        ) -> Result<Option<scope_invitation::Model>> {
140            let token = token.trim();
141            if token.is_empty() {
142                return Ok(None);
143            }
144            Ok(ScopeInvitation::find()
145                .filter(scope_invitation::Column::Token.eq(token))
146                .one(db)
147                .await
148                .map_err(DbError::from)?)
149        }
150
151        /// Accept a pending invite by token. Email on the invite must match `user_email`.
152        pub async fn accept_by_token(
153            db: &DbHandle,
154            user_id: i64,
155            user_email: &str,
156            token: &str,
157        ) -> Result<scope_invitation::Model> {
158            Self::accept_by_token_with(db, user_id, user_email, token, MutateOpts::default()).await
159        }
160
161        pub async fn accept_by_token_with(
162            db: &DbHandle,
163            user_id: i64,
164            user_email: &str,
165            token: &str,
166            opts: MutateOpts<'_>,
167        ) -> Result<scope_invitation::Model> {
168            let inv = Self::find_by_token(db, token)
169                .await?
170                .ok_or(sova_core::Error::NotFound)?;
171            if inv.status != INVITE_PENDING {
172                return Err(sova_core::Error::custom(409, "invitation is not pending"));
173            }
174            let email = user_email.trim().to_lowercase();
175            if inv.email != email {
176                return Err(sova_core::Error::custom(
177                    403,
178                    "invitation email does not match your account",
179                ));
180            }
181            let scope = ScopeRef::new(inv.scope_kind.clone(), inv.scope_id);
182            if MembershipStore::find(db, scope.clone(), user_id)
183                .await?
184                .is_none()
185            {
186                let add_opts = MutateOpts {
187                    actor_id: opts.actor_id.or(Some(inv.invited_by)),
188                    cache: opts.cache,
189                };
190                MembershipStore::add_with(db, scope, user_id, &inv.role, add_opts).await?;
191            } else if let Some(cache) = opts.cache {
192                cache.invalidate(
193                    &ScopeRef::new(inv.scope_kind.clone(), inv.scope_id),
194                    user_id,
195                );
196            }
197            let mut am: scope_invitation::ActiveModel = inv.into();
198            am.status = Set(INVITE_ACCEPTED.into());
199            Ok(am.update(db).await.map_err(DbError::from)?)
200        }
201    }
202}
203
204#[cfg(feature = "invites")]
205pub use inner::InviteStore;