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    pub async fn create_anonymous(&self, fingerprint: &str) -> Result<User> {
63        let email = format!("{}@anonymous.local", fingerprint);
64
65        // Read first: repeat bot traffic hits an existing row, so avoid the
66        // upsert's per-request write lock and commit on the hot path.
67        if let Some(existing) = sqlx::query_as!(
68            User,
69            r#"
70            SELECT id, name, email, full_name, display_name, status, email_verified,
71                   roles, avatar_url, is_bot, is_scanner, created_at, updated_at
72            FROM users
73            WHERE email = $1
74            "#,
75            email
76        )
77        .fetch_optional(&*self.pool)
78        .await?
79        {
80            return Ok(existing);
81        }
82
83        // ON CONFLICT covers a concurrent first request racing this insert.
84        let user_id = uuid::Uuid::new_v4();
85        let id = UserId::new(user_id.to_string());
86        let name = format!("anonymous_{}", &user_id.to_string()[..8]);
87        let now = Utc::now();
88        let status = UserStatus::Active.as_str();
89        let role = UserRole::Anonymous.as_str();
90
91        let row = sqlx::query_as!(
92            User,
93            r#"
94            INSERT INTO users (
95                id, name, email, status, email_verified, roles,
96                is_bot, created_at, updated_at
97            )
98            VALUES ($1, $2, $3, $4, false, ARRAY[$5]::TEXT[], false, $6, $6)
99            ON CONFLICT (email) DO UPDATE SET updated_at = $6
100            RETURNING id, name, email, full_name, display_name, status, email_verified,
101                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
102            "#,
103            id.as_str(),
104            name,
105            email,
106            status,
107            role,
108            now
109        )
110        .fetch_one(&*self.write_pool)
111        .await?;
112
113        Ok(row)
114    }
115
116    pub async fn update_email(&self, id: &UserId, email: &str) -> Result<User> {
117        let row = sqlx::query_as!(
118            User,
119            r#"
120            UPDATE users
121            SET email = $1, email_verified = false, updated_at = $2
122            WHERE id = $3
123            RETURNING id, name, email, full_name, display_name, status, email_verified,
124                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
125            "#,
126            email,
127            Utc::now(),
128            id.as_str()
129        )
130        .fetch_optional(&*self.write_pool)
131        .await?
132        .ok_or_else(|| UserError::NotFound(id.clone()))?;
133
134        Ok(row)
135    }
136
137    pub async fn update_full_name(&self, id: &UserId, full_name: &str) -> Result<User> {
138        let row = sqlx::query_as!(
139            User,
140            r#"
141            UPDATE users
142            SET full_name = $1, updated_at = $2
143            WHERE id = $3
144            RETURNING id, name, email, full_name, display_name, status, email_verified,
145                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
146            "#,
147            full_name,
148            Utc::now(),
149            id.as_str()
150        )
151        .fetch_optional(&*self.write_pool)
152        .await?
153        .ok_or_else(|| UserError::NotFound(id.clone()))?;
154
155        Ok(row)
156    }
157
158    pub async fn update_status(&self, id: &UserId, status: UserStatus) -> Result<User> {
159        let row = sqlx::query_as!(
160            User,
161            r#"
162            UPDATE users
163            SET status = $1, updated_at = $2
164            WHERE id = $3
165            RETURNING id, name, email, full_name, display_name, status, email_verified,
166                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
167            "#,
168            status.as_str(),
169            Utc::now(),
170            id.as_str()
171        )
172        .fetch_optional(&*self.write_pool)
173        .await?
174        .ok_or_else(|| UserError::NotFound(id.clone()))?;
175
176        Ok(row)
177    }
178
179    pub async fn update_email_verified(&self, id: &UserId, verified: bool) -> Result<User> {
180        let row = sqlx::query_as!(
181            User,
182            r#"
183            UPDATE users
184            SET email_verified = $1, updated_at = $2
185            WHERE id = $3
186            RETURNING id, name, email, full_name, display_name, status, email_verified,
187                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
188            "#,
189            verified,
190            Utc::now(),
191            id.as_str()
192        )
193        .fetch_optional(&*self.write_pool)
194        .await?
195        .ok_or_else(|| UserError::NotFound(id.clone()))?;
196
197        Ok(row)
198    }
199
200    pub async fn update_display_name(&self, id: &UserId, display_name: &str) -> Result<User> {
201        let row = sqlx::query_as!(
202            User,
203            r#"
204            UPDATE users
205            SET display_name = $1, updated_at = $2
206            WHERE id = $3
207            RETURNING id, name, email, full_name, display_name, status, email_verified,
208                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
209            "#,
210            display_name,
211            Utc::now(),
212            id.as_str()
213        )
214        .fetch_optional(&*self.write_pool)
215        .await?
216        .ok_or_else(|| UserError::NotFound(id.clone()))?;
217
218        Ok(row)
219    }
220
221    pub async fn update_all_fields(
222        &self,
223        id: &UserId,
224        params: UpdateUserParams<'_>,
225    ) -> Result<User> {
226        let row = sqlx::query_as!(
227            User,
228            r#"
229            UPDATE users
230            SET email = $1, full_name = $2, display_name = $3, status = $4, updated_at = $5
231            WHERE id = $6
232            RETURNING id, name, email, full_name, display_name, status, email_verified,
233                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
234            "#,
235            params.email,
236            params.full_name,
237            params.display_name,
238            params.status.as_str(),
239            Utc::now(),
240            id.as_str()
241        )
242        .fetch_optional(&*self.write_pool)
243        .await?
244        .ok_or_else(|| UserError::NotFound(id.clone()))?;
245
246        Ok(row)
247    }
248
249    pub async fn assign_roles(&self, id: &UserId, roles: &[String]) -> Result<User> {
250        let row = sqlx::query_as!(
251            User,
252            r#"
253            UPDATE users
254            SET roles = $1, updated_at = $2
255            WHERE id = $3
256            RETURNING id, name, email, full_name, display_name, status, email_verified,
257                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
258            "#,
259            roles,
260            Utc::now(),
261            id.as_str()
262        )
263        .fetch_optional(&*self.write_pool)
264        .await?
265        .ok_or_else(|| UserError::NotFound(id.clone()))?;
266
267        Ok(row)
268    }
269
270    pub async fn delete(&self, id: &UserId) -> Result<()> {
271        let result = sqlx::query!(r#"DELETE FROM users WHERE id = $1"#, id.as_str())
272            .execute(&*self.write_pool)
273            .await?;
274
275        if result.rows_affected() == 0 {
276            return Err(UserError::NotFound(id.clone()));
277        }
278
279        Ok(())
280    }
281
282    pub async fn cleanup_old_anonymous(&self, days: i32) -> Result<u64> {
283        let cutoff = Utc::now() - Duration::days(i64::from(days));
284        let anonymous_role = UserRole::Anonymous.as_str();
285        let result = sqlx::query!(
286            r#"
287            DELETE FROM users u
288            WHERE $1 = ANY(u.roles)
289              AND u.created_at < $2
290              AND NOT EXISTS (
291                  SELECT 1
292                  FROM user_sessions s
293                  WHERE s.user_id = u.id
294                    AND s.ended_at IS NULL
295              )
296            "#,
297            anonymous_role,
298            cutoff
299        )
300        .execute(&*self.write_pool)
301        .await?;
302
303        Ok(result.rows_affected())
304    }
305}