Skip to main content

systemprompt_users/repository/user/
operations.rs

1//! User row creation and update operations.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use chrono::{Duration, Utc};
7use systemprompt_identifiers::UserId;
8
9use crate::error::{Result, UserError};
10use crate::models::{User, UserRole, UserStatus};
11use crate::repository::UserRepository;
12
13#[derive(Debug)]
14pub struct UpdateUserParams<'a> {
15    pub email: &'a str,
16    pub full_name: Option<&'a str>,
17    pub display_name: Option<&'a str>,
18    pub status: UserStatus,
19}
20
21impl UserRepository {
22    pub async fn create(
23        &self,
24        name: &str,
25        email: &str,
26        full_name: Option<&str>,
27        display_name: Option<&str>,
28    ) -> Result<User> {
29        let now = Utc::now();
30        let id = UserId::new(uuid::Uuid::new_v4().to_string());
31        let display_name_val = display_name.or(full_name);
32        let status = UserStatus::Active.as_str();
33        let role = UserRole::User.as_str();
34
35        let row = sqlx::query_as!(
36            User,
37            r#"
38            INSERT INTO users (
39                id, name, email, full_name, display_name,
40                status, email_verified, roles, is_bot,
41                created_at, updated_at
42            )
43            VALUES ($1, $2, $3, $4, $5, $6, false, ARRAY[$7]::TEXT[], false, $8, $8)
44            RETURNING id, name, email, full_name, display_name, status, email_verified,
45                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
46            "#,
47            id.as_str(),
48            name,
49            email,
50            full_name,
51            display_name_val,
52            status,
53            role,
54            now
55        )
56        .fetch_one(&*self.write_pool)
57        .await?;
58
59        Ok(row)
60    }
61
62    /// Inserts the user, yielding `None` when one already holds that name or
63    /// email.
64    ///
65    /// `create` is the right call when the caller owns the identity it is
66    /// minting. This one is for the auto-provisioning paths, where several
67    /// processes resolve the *same* well-known identity — the local-trial
68    /// `admin` — and would otherwise each read "absent" and then race to
69    /// insert it, leaving every loser with a unique-violation the caller can
70    /// only tell apart by matching on the driver's error text.
71    pub async fn create_if_absent(
72        &self,
73        name: &str,
74        email: &str,
75        full_name: Option<&str>,
76        display_name: Option<&str>,
77    ) -> Result<Option<User>> {
78        let now = Utc::now();
79        let id = UserId::new(uuid::Uuid::new_v4().to_string());
80        let display_name_val = display_name.or(full_name);
81        let status = UserStatus::Active.as_str();
82        let role = UserRole::User.as_str();
83
84        let row = sqlx::query_as!(
85            User,
86            r#"
87            INSERT INTO users (
88                id, name, email, full_name, display_name,
89                status, email_verified, roles, is_bot,
90                created_at, updated_at
91            )
92            VALUES ($1, $2, $3, $4, $5, $6, false, ARRAY[$7]::TEXT[], false, $8, $8)
93            ON CONFLICT DO NOTHING
94            RETURNING id, name, email, full_name, display_name, status, email_verified,
95                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
96            "#,
97            id.as_str(),
98            name,
99            email,
100            full_name,
101            display_name_val,
102            status,
103            role,
104            now
105        )
106        .fetch_optional(&*self.write_pool)
107        .await?;
108
109        Ok(row)
110    }
111
112    pub async fn create_anonymous(&self, fingerprint: &str) -> Result<User> {
113        let email = format!("{}@anonymous.local", fingerprint);
114
115        // Why: Read first: repeat bot traffic hits an existing row, so avoid the
116        // upsert's per-request write lock and commit on the hot path.
117        if let Some(existing) = sqlx::query_as!(
118            User,
119            r#"
120            SELECT id, name, email, full_name, display_name, status, email_verified,
121                   roles, avatar_url, is_bot, is_scanner, created_at, updated_at
122            FROM users
123            WHERE email = $1
124            "#,
125            email
126        )
127        .fetch_optional(&*self.pool)
128        .await?
129        {
130            return Ok(existing);
131        }
132
133        // Why: ON CONFLICT covers a concurrent first request racing this insert.
134        let user_id = uuid::Uuid::new_v4();
135        let id = UserId::new(user_id.to_string());
136        let name = format!("anonymous_{}", &user_id.to_string()[..8]);
137        let now = Utc::now();
138        let status = UserStatus::Active.as_str();
139        let role = UserRole::Anonymous.as_str();
140
141        let row = sqlx::query_as!(
142            User,
143            r#"
144            INSERT INTO users (
145                id, name, email, status, email_verified, roles,
146                is_bot, created_at, updated_at
147            )
148            VALUES ($1, $2, $3, $4, false, ARRAY[$5]::TEXT[], false, $6, $6)
149            ON CONFLICT (email) DO UPDATE SET updated_at = $6
150            RETURNING id, name, email, full_name, display_name, status, email_verified,
151                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
152            "#,
153            id.as_str(),
154            name,
155            email,
156            status,
157            role,
158            now
159        )
160        .fetch_one(&*self.write_pool)
161        .await?;
162
163        Ok(row)
164    }
165
166    pub async fn update_email(&self, id: &UserId, email: &str) -> Result<User> {
167        let row = sqlx::query_as!(
168            User,
169            r#"
170            UPDATE users
171            SET email = $1, email_verified = false, updated_at = $2
172            WHERE id = $3
173            RETURNING id, name, email, full_name, display_name, status, email_verified,
174                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
175            "#,
176            email,
177            Utc::now(),
178            id.as_str()
179        )
180        .fetch_optional(&*self.write_pool)
181        .await?
182        .ok_or_else(|| UserError::NotFound(id.clone()))?;
183
184        Ok(row)
185    }
186
187    pub async fn update_full_name(&self, id: &UserId, full_name: &str) -> Result<User> {
188        let row = sqlx::query_as!(
189            User,
190            r#"
191            UPDATE users
192            SET full_name = $1, updated_at = $2
193            WHERE id = $3
194            RETURNING id, name, email, full_name, display_name, status, email_verified,
195                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
196            "#,
197            full_name,
198            Utc::now(),
199            id.as_str()
200        )
201        .fetch_optional(&*self.write_pool)
202        .await?
203        .ok_or_else(|| UserError::NotFound(id.clone()))?;
204
205        Ok(row)
206    }
207
208    pub async fn update_status(&self, id: &UserId, status: UserStatus) -> Result<User> {
209        let row = sqlx::query_as!(
210            User,
211            r#"
212            UPDATE users
213            SET status = $1, updated_at = $2
214            WHERE id = $3
215            RETURNING id, name, email, full_name, display_name, status, email_verified,
216                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
217            "#,
218            status.as_str(),
219            Utc::now(),
220            id.as_str()
221        )
222        .fetch_optional(&*self.write_pool)
223        .await?
224        .ok_or_else(|| UserError::NotFound(id.clone()))?;
225
226        Ok(row)
227    }
228
229    pub async fn update_email_verified(&self, id: &UserId, verified: bool) -> Result<User> {
230        let row = sqlx::query_as!(
231            User,
232            r#"
233            UPDATE users
234            SET email_verified = $1, updated_at = $2
235            WHERE id = $3
236            RETURNING id, name, email, full_name, display_name, status, email_verified,
237                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
238            "#,
239            verified,
240            Utc::now(),
241            id.as_str()
242        )
243        .fetch_optional(&*self.write_pool)
244        .await?
245        .ok_or_else(|| UserError::NotFound(id.clone()))?;
246
247        Ok(row)
248    }
249
250    pub async fn update_display_name(&self, id: &UserId, display_name: &str) -> Result<User> {
251        let row = sqlx::query_as!(
252            User,
253            r#"
254            UPDATE users
255            SET display_name = $1, updated_at = $2
256            WHERE id = $3
257            RETURNING id, name, email, full_name, display_name, status, email_verified,
258                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
259            "#,
260            display_name,
261            Utc::now(),
262            id.as_str()
263        )
264        .fetch_optional(&*self.write_pool)
265        .await?
266        .ok_or_else(|| UserError::NotFound(id.clone()))?;
267
268        Ok(row)
269    }
270
271    pub async fn update_all_fields(
272        &self,
273        id: &UserId,
274        params: UpdateUserParams<'_>,
275    ) -> Result<User> {
276        let row = sqlx::query_as!(
277            User,
278            r#"
279            UPDATE users
280            SET email = $1, full_name = $2, display_name = $3, status = $4, updated_at = $5
281            WHERE id = $6
282            RETURNING id, name, email, full_name, display_name, status, email_verified,
283                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
284            "#,
285            params.email,
286            params.full_name,
287            params.display_name,
288            params.status.as_str(),
289            Utc::now(),
290            id.as_str()
291        )
292        .fetch_optional(&*self.write_pool)
293        .await?
294        .ok_or_else(|| UserError::NotFound(id.clone()))?;
295
296        Ok(row)
297    }
298
299    pub async fn assign_roles(&self, id: &UserId, roles: &[String]) -> Result<User> {
300        let row = sqlx::query_as!(
301            User,
302            r#"
303            UPDATE users
304            SET roles = $1, updated_at = $2
305            WHERE id = $3
306            RETURNING id, name, email, full_name, display_name, status, email_verified,
307                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
308            "#,
309            roles,
310            Utc::now(),
311            id.as_str()
312        )
313        .fetch_optional(&*self.write_pool)
314        .await?
315        .ok_or_else(|| UserError::NotFound(id.clone()))?;
316
317        Ok(row)
318    }
319
320    pub async fn delete(&self, id: &UserId) -> Result<()> {
321        let result = sqlx::query!(r#"DELETE FROM users WHERE id = $1"#, id.as_str())
322            .execute(&*self.write_pool)
323            .await?;
324
325        if result.rows_affected() == 0 {
326            return Err(UserError::NotFound(id.clone()));
327        }
328
329        Ok(())
330    }
331
332    pub async fn cleanup_old_anonymous(&self, days: i32) -> Result<u64> {
333        let cutoff = Utc::now() - Duration::days(i64::from(days));
334        let anonymous_role = UserRole::Anonymous.as_str();
335        let result = sqlx::query!(
336            r#"
337            DELETE FROM users u
338            WHERE $1 = ANY(u.roles)
339              AND u.created_at < $2
340              AND NOT EXISTS (
341                  SELECT 1
342                  FROM user_sessions s
343                  WHERE s.user_id = u.id
344                    AND s.ended_at IS NULL
345              )
346            "#,
347            anonymous_role,
348            cutoff
349        )
350        .execute(&*self.write_pool)
351        .await?;
352
353        Ok(result.rows_affected())
354    }
355}