Skip to main content

offline_intelligence/memory_db/
users_store.rs

1//! Users Store - User authentication and management
2//!
3//! Stores user accounts with hashed passwords (email/password users) or Google OAuth.
4//! Google users have password_hash = "google-oauth-user" sentinel and email_verified = 1.
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rusqlite::{params, OptionalExtension};
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13use tracing::info;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct User {
17    pub id: i64,
18    pub email: String,
19    pub name: String,
20    pub password_hash: String,
21    pub email_verified: bool,
22    pub verification_token: Option<String>,
23    pub created_at: DateTime<Utc>,
24    pub verified_at: Option<DateTime<Utc>>,
25    pub google_id: Option<String>,
26    pub avatar_url: Option<String>,
27}
28
29pub struct UsersStore {
30    pool: Arc<Pool<SqliteConnectionManager>>,
31}
32
33impl Clone for UsersStore {
34    fn clone(&self) -> Self {
35        Self {
36            pool: Arc::clone(&self.pool),
37        }
38    }
39}
40
41impl UsersStore {
42    pub fn new(pool: Arc<Pool<SqliteConnectionManager>>) -> Self {
43        Self { pool }
44    }
45
46    pub fn initialize_schema(&self) -> Result<()> {
47        let conn = self.pool.get()?;
48        conn.execute(
49            "CREATE TABLE IF NOT EXISTS users (
50                id INTEGER PRIMARY KEY AUTOINCREMENT,
51                email TEXT NOT NULL UNIQUE,
52                name TEXT NOT NULL,
53                password_hash TEXT NOT NULL,
54                email_verified INTEGER DEFAULT 0,
55                verification_token TEXT,
56                created_at TEXT NOT NULL,
57                verified_at TEXT
58            )",
59            [],
60        )?;
61
62        // Idempotent migrations: ADD COLUMN silently fails if column already exists
63        let _ = conn.execute("ALTER TABLE users ADD COLUMN google_id TEXT", []);
64        let _ = conn.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT", []);
65
66        info!("Users table initialized");
67        Ok(())
68    }
69
70    pub fn create_user(
71        &self,
72        email: &str,
73        name: &str,
74        password_hash: &str,
75    ) -> Result<i64> {
76        let conn = self.pool.get()?;
77        let now = Utc::now().to_rfc3339();
78
79        conn.execute(
80            "INSERT INTO users (email, name, password_hash, email_verified, verification_token, created_at)
81             VALUES (?1, ?2, ?3, 1, '', ?4)",
82            params![email, name, password_hash, now],
83        )?;
84
85        let id = conn.last_insert_rowid();
86        info!("User created with id: {}", id);
87        Ok(id)
88    }
89
90    /// Create or update a Google OAuth user.
91    ///
92    /// Logic:
93    /// 1. Look up by `google_id` → update name/avatar, return existing user
94    /// 2. Look up by `email` → link Google account to existing user
95    /// 3. Otherwise → create a new Google user (no password)
96    /// Returns `(User, is_new_user)` where `is_new_user` is `true` only when a
97    /// brand-new account was created (Step 3 — never seen this Google ID or email).
98    pub fn upsert_google_user(
99        &self,
100        email: &str,
101        name: &str,
102        google_id: &str,
103        avatar_url: Option<&str>,
104    ) -> Result<(User, bool)> {
105        let conn = self.pool.get()?;
106
107        let mut is_new_user = false;
108
109        // Step 1: find by google_id
110        let by_google_id = conn
111            .query_row(
112                "SELECT id FROM users WHERE google_id = ?1",
113                params![google_id],
114                |row| row.get::<_, i64>(0),
115            )
116            .optional()?;
117
118        if let Some(user_id) = by_google_id {
119            // Update name and avatar in case they changed in Google profile
120            conn.execute(
121                "UPDATE users SET name = ?1, avatar_url = ?2 WHERE id = ?3",
122                params![name, avatar_url, user_id],
123            )?;
124        } else {
125            // Step 2: find by email (account linking)
126            let by_email = conn
127                .query_row(
128                    "SELECT id FROM users WHERE email = ?1",
129                    params![email],
130                    |row| row.get::<_, i64>(0),
131                )
132                .optional()?;
133
134            if let Some(user_id) = by_email {
135                // Link the Google account to the existing email/password account
136                conn.execute(
137                    "UPDATE users SET google_id = ?1, avatar_url = ?2, email_verified = 1 WHERE id = ?3",
138                    params![google_id, avatar_url, user_id],
139                )?;
140            } else {
141                // Step 3: brand-new Google user
142                let now = Utc::now().to_rfc3339();
143                conn.execute(
144                    "INSERT INTO users (email, name, password_hash, email_verified, google_id, avatar_url, created_at)
145                     VALUES (?1, ?2, 'google-oauth-user', 1, ?3, ?4, ?5)",
146                    params![email, name, google_id, avatar_url, now],
147                )?;
148                is_new_user = true;
149            }
150        }
151
152        // Fetch and return the final user record
153        let user = conn.query_row(
154            "SELECT id, email, name, password_hash, email_verified, verification_token,
155                    created_at, verified_at, google_id, avatar_url
156             FROM users WHERE email = ?1",
157            params![email],
158            |row| {
159                let created_str: String = row.get(6)?;
160                let verified_str: Option<String> = row.get(7)?;
161                Ok(User {
162                    id: row.get(0)?,
163                    email: row.get(1)?,
164                    name: row.get(2)?,
165                    password_hash: row.get(3)?,
166                    email_verified: row.get::<_, i32>(4)? != 0,
167                    verification_token: row.get(5)?,
168                    created_at: DateTime::parse_from_rfc3339(&created_str)
169                        .map(|dt| dt.with_timezone(&Utc))
170                        .unwrap_or_else(|_| Utc::now()),
171                    verified_at: verified_str.and_then(|s| {
172                        DateTime::parse_from_rfc3339(&s)
173                            .ok()
174                            .map(|dt| dt.with_timezone(&Utc))
175                    }),
176                    google_id: row.get(8)?,
177                    avatar_url: row.get(9)?,
178                })
179            },
180        )?;
181
182        info!("Google user upserted: {} (new={})", email, is_new_user);
183        Ok((user, is_new_user))
184    }
185
186    pub fn get_user_by_email(&self, email: &str) -> Result<Option<User>> {
187        let conn = self.pool.get()?;
188
189        let result = conn
190            .query_row(
191                "SELECT id, email, name, password_hash, email_verified, verification_token,
192                        created_at, verified_at, google_id, avatar_url
193                 FROM users WHERE email = ?1",
194                params![email],
195                |row| {
196                    let created_str: String = row.get(6)?;
197                    let verified_str: Option<String> = row.get(7)?;
198
199                    Ok(User {
200                        id: row.get(0)?,
201                        email: row.get(1)?,
202                        name: row.get(2)?,
203                        password_hash: row.get(3)?,
204                        email_verified: row.get::<_, i32>(4)? != 0,
205                        verification_token: row.get(5)?,
206                        created_at: DateTime::parse_from_rfc3339(&created_str)
207                            .map(|dt| dt.with_timezone(&Utc))
208                            .unwrap_or_else(|_| Utc::now()),
209                        verified_at: verified_str.and_then(|s| {
210                            DateTime::parse_from_rfc3339(&s)
211                                .ok()
212                                .map(|dt| dt.with_timezone(&Utc))
213                        }),
214                        google_id: row.get(8)?,
215                        avatar_url: row.get(9)?,
216                    })
217                },
218            )
219            .optional()?;
220
221        Ok(result)
222    }
223
224    pub fn get_user_by_id(&self, id: i64) -> Result<Option<User>> {
225        let conn = self.pool.get()?;
226
227        let result = conn
228            .query_row(
229                "SELECT id, email, name, password_hash, email_verified, verification_token,
230                        created_at, verified_at, google_id, avatar_url
231                 FROM users WHERE id = ?1",
232                params![id],
233                |row| {
234                    let created_str: String = row.get(6)?;
235                    let verified_str: Option<String> = row.get(7)?;
236
237                    Ok(User {
238                        id: row.get(0)?,
239                        email: row.get(1)?,
240                        name: row.get(2)?,
241                        password_hash: row.get(3)?,
242                        email_verified: row.get::<_, i32>(4)? != 0,
243                        verification_token: row.get(5)?,
244                        created_at: DateTime::parse_from_rfc3339(&created_str)
245                            .map(|dt| dt.with_timezone(&Utc))
246                            .unwrap_or_else(|_| Utc::now()),
247                        verified_at: verified_str.and_then(|s| {
248                            DateTime::parse_from_rfc3339(&s)
249                                .ok()
250                                .map(|dt| dt.with_timezone(&Utc))
251                        }),
252                        google_id: row.get(8)?,
253                        avatar_url: row.get(9)?,
254                    })
255                },
256            )
257            .optional()?;
258
259        Ok(result)
260    }
261
262    pub fn verify_email(&self, token: &str) -> Result<Option<User>> {
263        let conn = self.pool.get()?;
264        let now = Utc::now().to_rfc3339();
265
266        let rows_affected = conn.execute(
267            "UPDATE users SET email_verified = 1, verified_at = ?1, verification_token = NULL
268             WHERE verification_token = ?2 AND email_verified = 0",
269            params![now, token],
270        )?;
271
272        if rows_affected > 0 {
273            let user = conn
274                .query_row(
275                    "SELECT id, email, name, password_hash, email_verified, verification_token,
276                            created_at, verified_at, google_id, avatar_url
277                     FROM users WHERE verification_token IS NULL AND email_verified = 1
278                     ORDER BY id DESC LIMIT 1",
279                    [],
280                    |row| {
281                        let created_str: String = row.get(6)?;
282                        let verified_str: Option<String> = row.get(7)?;
283
284                        Ok(User {
285                            id: row.get(0)?,
286                            email: row.get(1)?,
287                            name: row.get(2)?,
288                            password_hash: row.get(3)?,
289                            email_verified: true,
290                            verification_token: None,
291                            created_at: DateTime::parse_from_rfc3339(&created_str)
292                                .map(|dt| dt.with_timezone(&Utc))
293                                .unwrap_or_else(|_| Utc::now()),
294                            verified_at: verified_str.and_then(|s| {
295                                DateTime::parse_from_rfc3339(&s)
296                                    .ok()
297                                    .map(|dt| dt.with_timezone(&Utc))
298                            }),
299                            google_id: row.get(8)?,
300                            avatar_url: row.get(9)?,
301                        })
302                    },
303                )
304                .optional()?;
305
306            Ok(user)
307        } else {
308            Ok(None)
309        }
310    }
311
312    pub fn email_exists(&self, email: &str) -> Result<bool> {
313        let conn = self.pool.get()?;
314        let count: i64 = conn.query_row(
315            "SELECT COUNT(*) FROM users WHERE email = ?1",
316            params![email],
317            |row| row.get(0),
318        )?;
319        Ok(count > 0)
320    }
321}