headless_lms_models/
user_email_codes.rs1use rand::RngExt;
2use secrecy::ExposeSecret;
3
4use crate::prelude::*;
5
6#[derive(Debug, PartialEq, Eq, Clone, Copy, Type)]
9#[sqlx(type_name = "user_email_code_purpose", rename_all = "snake_case")]
10pub enum UserEmailCodePurpose {
11 AdminLogin,
12 AccountDeletion,
13 EmailOwnershipVerification,
14}
15
16#[derive(sqlx::FromRow, Debug, Clone)]
17pub struct UserEmailCode {
18 pub id: Uuid,
19 pub user_id: Uuid,
20 pub code: DbSecret,
21 pub purpose: UserEmailCodePurpose,
22 pub attempt_count: i32,
23 pub expires_at: DateTime<Utc>,
24 pub used_at: Option<DateTime<Utc>>,
25 pub created_at: DateTime<Utc>,
26 pub updated_at: DateTime<Utc>,
27 pub deleted_at: Option<DateTime<Utc>>,
28}
29
30pub fn generate_code() -> DbSecret {
35 DbSecret::new(format!("{:06}", rand::rng().random_range(0..1_000_000u32)))
36}
37
38pub async fn insert_user_email_code(
43 conn: &mut PgConnection,
44 user_id: Uuid,
45 purpose: UserEmailCodePurpose,
46 code: &DbSecret,
47) -> ModelResult<()> {
48 let mut tx = conn.begin().await?;
49
50 sqlx::query!(
51 r#"
52UPDATE user_email_codes
53SET deleted_at = NOW()
54WHERE user_id = $1
55 AND purpose = $2
56 AND deleted_at IS NULL
57 "#,
58 user_id,
59 purpose as UserEmailCodePurpose,
60 )
61 .execute(&mut *tx)
62 .await?;
63
64 sqlx::query!(
65 r#"
66INSERT INTO user_email_codes (code, user_id, purpose)
67VALUES ($1, $2, $3)
68 "#,
69 code.expose_secret(),
70 user_id,
71 purpose as UserEmailCodePurpose,
72 )
73 .execute(&mut *tx)
74 .await?;
75
76 tx.commit().await?;
77
78 Ok(())
79}
80
81pub async fn get_outstanding_user_email_code(
86 conn: &mut PgConnection,
87 user_id: Uuid,
88 purpose: UserEmailCodePurpose,
89) -> ModelResult<Option<UserEmailCode>> {
90 let record = sqlx::query_as!(
91 UserEmailCode,
92 r#"
93SELECT id,
94 user_id,
95 code,
96 purpose AS "purpose: UserEmailCodePurpose",
97 attempt_count,
98 expires_at,
99 used_at,
100 created_at,
101 updated_at,
102 deleted_at
103FROM user_email_codes
104WHERE user_id = $1
105 AND purpose = $2
106 AND deleted_at IS NULL
107 AND used_at IS NULL
108 "#,
109 user_id,
110 purpose as UserEmailCodePurpose,
111 )
112 .fetch_optional(conn)
113 .await?;
114
115 Ok(record)
116}
117
118pub async fn get_unused_user_email_code_with_user_id(
120 conn: &mut PgConnection,
121 user_id: Uuid,
122 purpose: UserEmailCodePurpose,
123) -> ModelResult<Option<UserEmailCode>> {
124 let code = get_outstanding_user_email_code(conn, user_id, purpose).await?;
125 Ok(code.filter(|code| code.expires_at > Utc::now()))
126}
127
128pub async fn is_reset_user_email_code_valid(
129 conn: &mut PgConnection,
130 user_id: Uuid,
131 purpose: UserEmailCodePurpose,
132 code: &DbSecret,
133) -> ModelResult<bool> {
134 let now = Utc::now();
135 let record = sqlx::query!(
136 r#"
137SELECT id
138FROM user_email_codes
139WHERE user_id = $1
140 AND purpose = $2
141 AND code = $3
142 AND deleted_at IS NULL
143 AND used_at IS NULL
144 AND expires_at > $4
145 "#,
146 user_id,
147 purpose as UserEmailCodePurpose,
148 code.expose_secret(),
149 now
150 )
151 .fetch_optional(conn)
152 .await?;
153
154 Ok(record.is_some())
155}
156
157pub async fn mark_user_email_code_used(
158 conn: &mut PgConnection,
159 user_id: Uuid,
160 purpose: UserEmailCodePurpose,
161 code: &DbSecret,
162) -> ModelResult<bool> {
163 let result = sqlx::query!(
164 r#"
165UPDATE user_email_codes
166SET used_at = NOW(),
167 deleted_at = NOW()
168WHERE user_id = $1
169 AND purpose = $2
170 AND code = $3
171 AND deleted_at IS NULL
172 "#,
173 user_id,
174 purpose as UserEmailCodePurpose,
175 code.expose_secret(),
176 )
177 .execute(conn)
178 .await?;
179
180 Ok(result.rows_affected() > 0)
181}
182
183pub async fn record_failed_attempt(
191 conn: &mut PgConnection,
192 user_id: Uuid,
193 purpose: UserEmailCodePurpose,
194 max_attempts: i32,
195) -> ModelResult<bool> {
196 let record = sqlx::query!(
197 r#"
198UPDATE user_email_codes
199SET attempt_count = attempt_count + 1,
200 deleted_at = CASE
201 WHEN attempt_count + 1 >= $3 THEN NOW()
202 ELSE deleted_at
203 END
204WHERE user_id = $1
205 AND purpose = $2
206 AND deleted_at IS NULL
207 AND used_at IS NULL
208RETURNING deleted_at IS NOT NULL AS "retired!"
209 "#,
210 user_id,
211 purpose as UserEmailCodePurpose,
212 max_attempts,
213 )
214 .fetch_optional(conn)
215 .await?;
216
217 Ok(record.is_some_and(|record| record.retired))
218}