1#[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 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;