1use async_trait::async_trait;
10use chrono::Utc;
11use sbol_db_core::{DomainError, NewUser, User, UserId};
12use sbol_db_storage::{UserListQuery, UserPage, UserStore};
13use sqlx::Row;
14
15use crate::pool::db_err;
16use crate::SqlitePool;
17
18const USER_COLS: &str = "id, username, name, email, affiliation, password_hash, \
19 graph_uri, is_admin, is_curator, is_member, reset_password_link, created_at, updated_at";
20
21#[derive(Clone)]
22pub struct SqliteUserStore {
23 pool: SqlitePool,
24}
25
26impl SqliteUserStore {
27 pub fn new(pool: SqlitePool) -> Self {
28 Self { pool }
29 }
30}
31
32#[async_trait]
33impl UserStore for SqliteUserStore {
34 async fn create_user(&self, new_user: NewUser) -> Result<User, DomainError> {
35 let id = UserId::new();
36 let now = Utc::now();
37 let row = sqlx::query(&format!(
38 "INSERT INTO sbh_user \
39 (id, username, name, email, affiliation, password_hash, \
40 graph_uri, is_admin, is_curator, is_member, created_at, updated_at) \
41 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
42 RETURNING {USER_COLS}"
43 ))
44 .bind(id.as_uuid().to_string())
45 .bind(&new_user.username)
46 .bind(&new_user.name)
47 .bind(&new_user.email)
48 .bind(new_user.affiliation.as_deref())
49 .bind(&new_user.password_hash)
50 .bind(&new_user.graph_uri)
51 .bind(new_user.is_admin as i64)
52 .bind(new_user.is_curator as i64)
53 .bind(new_user.is_member as i64)
54 .bind(now)
55 .bind(now)
56 .fetch_one(&self.pool)
57 .await
58 .map_err(db_err)?;
59 row_to_user(row)
60 }
61
62 async fn find_by_email_or_username(
63 &self,
64 identifier: &str,
65 ) -> Result<Option<User>, DomainError> {
66 let username = sqlx::query(&format!(
67 "SELECT {USER_COLS} FROM sbh_user WHERE username = ?"
68 ))
69 .bind(identifier)
70 .fetch_optional(&self.pool)
71 .await
72 .map_err(db_err)?;
73 if let Some(row) = username {
74 return row_to_user(row).map(Some);
75 }
76 let rows = sqlx::query(&format!(
77 "SELECT {USER_COLS} FROM sbh_user WHERE email = ? ORDER BY username LIMIT 2"
78 ))
79 .bind(identifier)
80 .fetch_all(&self.pool)
81 .await
82 .map_err(db_err)?;
83 match rows.len() {
84 0 => Ok(None),
85 1 => row_to_user(rows.into_iter().next().expect("one row")).map(Some),
86 _ => Err(DomainError::Validation(
87 "multiple accounts use this email; log in with your username".to_owned(),
88 )),
89 }
90 }
91
92 async fn get_by_id(&self, id: UserId) -> Result<Option<User>, DomainError> {
93 let row = sqlx::query(&format!("SELECT {USER_COLS} FROM sbh_user WHERE id = ?"))
94 .bind(id.as_uuid().to_string())
95 .fetch_optional(&self.pool)
96 .await
97 .map_err(db_err)?;
98 row.map(row_to_user).transpose()
99 }
100
101 async fn list_users(&self) -> Result<Vec<User>, DomainError> {
102 let rows = sqlx::query(&format!(
103 "SELECT {USER_COLS} FROM sbh_user ORDER BY username"
104 ))
105 .fetch_all(&self.pool)
106 .await
107 .map_err(db_err)?;
108 rows.into_iter().map(row_to_user).collect()
109 }
110
111 async fn page_users(&self, query: &UserListQuery) -> Result<UserPage, DomainError> {
112 let needle = query
113 .text
114 .as_deref()
115 .map(str::trim)
116 .filter(|value| !value.is_empty())
117 .map(str::to_lowercase);
118 let predicate = "(?1 IS NULL OR instr(lower(username), ?1) > 0 OR \
119 instr(lower(name), ?1) > 0 OR instr(lower(email), ?1) > 0 OR \
120 instr(lower(coalesce(affiliation, '')), ?1) > 0)";
121 let total: i64 =
122 sqlx::query_scalar(&format!("SELECT count(*) FROM sbh_user WHERE {predicate}"))
123 .bind(needle.as_deref())
124 .fetch_one(&self.pool)
125 .await
126 .map_err(db_err)?;
127 let rows = sqlx::query(&format!(
128 "SELECT {USER_COLS} FROM sbh_user WHERE {predicate} \
129 ORDER BY lower(username), username LIMIT ?2 OFFSET ?3"
130 ))
131 .bind(needle.as_deref())
132 .bind(i64::from(query.limit))
133 .bind(i64::try_from(query.offset).unwrap_or(i64::MAX))
134 .fetch_all(&self.pool)
135 .await
136 .map_err(db_err)?;
137 Ok(UserPage {
138 total: total.max(0) as u64,
139 items: rows
140 .into_iter()
141 .map(row_to_user)
142 .collect::<Result<_, _>>()?,
143 })
144 }
145
146 async fn update_user(&self, user: &User) -> Result<User, DomainError> {
147 let row = sqlx::query(&format!(
148 "UPDATE sbh_user \
149 SET name = ?, email = ?, affiliation = ?, \
150 is_admin = ?, is_curator = ?, is_member = ?, updated_at = ? \
151 WHERE id = ? \
152 RETURNING {USER_COLS}"
153 ))
154 .bind(&user.name)
155 .bind(&user.email)
156 .bind(user.affiliation.as_deref())
157 .bind(user.is_admin as i64)
158 .bind(user.is_curator as i64)
159 .bind(user.is_member as i64)
160 .bind(Utc::now())
161 .bind(user.id.as_uuid().to_string())
162 .fetch_optional(&self.pool)
163 .await
164 .map_err(db_err)?
165 .ok_or_else(|| DomainError::NotFound(format!("user {}", user.id)))?;
166 row_to_user(row)
167 }
168
169 async fn set_sole_admin(&self, id: UserId) -> Result<(), DomainError> {
170 let mut tx = self.pool.begin().await.map_err(db_err)?;
171 let id = id.as_uuid().to_string();
172 let exists: i64 = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM sbh_user WHERE id = ?)")
173 .bind(&id)
174 .fetch_one(&mut *tx)
175 .await
176 .map_err(db_err)?;
177 if exists == 0 {
178 return Err(DomainError::NotFound(format!("user {id}")));
179 }
180 let now = Utc::now();
181 sqlx::query(
182 "UPDATE sbh_user \
183 SET updated_at = CASE \
184 WHEN is_admin != CASE WHEN id = ? THEN 1 ELSE 0 END THEN ? \
185 ELSE updated_at \
186 END, \
187 is_admin = CASE WHEN id = ? THEN 1 ELSE 0 END",
188 )
189 .bind(&id)
190 .bind(now)
191 .bind(&id)
192 .execute(&mut *tx)
193 .await
194 .map_err(db_err)?;
195 tx.commit().await.map_err(db_err)?;
196 Ok(())
197 }
198
199 async fn set_password_hash(&self, id: UserId, password_hash: &str) -> Result<(), DomainError> {
200 sqlx::query("UPDATE sbh_user SET password_hash = ?, updated_at = ? WHERE id = ?")
201 .bind(password_hash)
202 .bind(Utc::now())
203 .bind(id.as_uuid().to_string())
204 .execute(&self.pool)
205 .await
206 .map_err(db_err)?;
207 Ok(())
208 }
209
210 async fn set_reset_link(&self, id: UserId, link: Option<&str>) -> Result<(), DomainError> {
211 sqlx::query("UPDATE sbh_user SET reset_password_link = ? WHERE id = ?")
212 .bind(link)
213 .bind(id.as_uuid().to_string())
214 .execute(&self.pool)
215 .await
216 .map_err(db_err)?;
217 Ok(())
218 }
219
220 async fn consume_reset_link(&self, link: &str) -> Result<Option<User>, DomainError> {
221 let row = sqlx::query(&format!(
222 "UPDATE sbh_user SET reset_password_link = NULL \
223 WHERE reset_password_link = ? \
224 RETURNING {USER_COLS}"
225 ))
226 .bind(link)
227 .fetch_optional(&self.pool)
228 .await
229 .map_err(db_err)?;
230 row.map(row_to_user).transpose()
231 }
232
233 async fn delete_user(&self, id: UserId) -> Result<bool, DomainError> {
234 let result = sqlx::query("DELETE FROM sbh_user WHERE id = ?")
235 .bind(id.as_uuid().to_string())
236 .execute(&self.pool)
237 .await
238 .map_err(db_err)?;
239 Ok(result.rows_affected() > 0)
240 }
241
242 async fn any_admin(&self) -> Result<bool, DomainError> {
243 let exists: bool =
244 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM sbh_user WHERE is_admin = 1)")
245 .fetch_one(&self.pool)
246 .await
247 .map_err(db_err)?;
248 Ok(exists)
249 }
250}
251
252fn row_to_user(row: sqlx::sqlite::SqliteRow) -> Result<User, DomainError> {
253 let id: String = row.try_get("id").map_err(db_err)?;
254 Ok(User {
255 id: UserId(uuid::Uuid::parse_str(&id).map_err(db_err)?),
256 username: row.try_get("username").map_err(db_err)?,
257 name: row.try_get("name").map_err(db_err)?,
258 email: row.try_get("email").map_err(db_err)?,
259 affiliation: row.try_get("affiliation").map_err(db_err)?,
260 password_hash: row.try_get("password_hash").map_err(db_err)?,
261 graph_uri: row.try_get("graph_uri").map_err(db_err)?,
262 is_admin: row.try_get::<i64, _>("is_admin").map_err(db_err)? != 0,
263 is_curator: row.try_get::<i64, _>("is_curator").map_err(db_err)? != 0,
264 is_member: row.try_get::<i64, _>("is_member").map_err(db_err)? != 0,
265 reset_password_link: row.try_get("reset_password_link").map_err(db_err)?,
266 created_at: row.try_get("created_at").map_err(db_err)?,
267 updated_at: row.try_get("updated_at").map_err(db_err)?,
268 })
269}