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_unused_user_email_code_with_user_id(
82 conn: &mut PgConnection,
83 user_id: Uuid,
84 purpose: UserEmailCodePurpose,
85) -> ModelResult<Option<UserEmailCode>> {
86 let now = Utc::now();
87 let record = sqlx::query_as!(
88 UserEmailCode,
89 r#"
90SELECT id,
91 user_id,
92 code,
93 purpose AS "purpose: UserEmailCodePurpose",
94 attempt_count,
95 expires_at,
96 used_at,
97 created_at,
98 updated_at,
99 deleted_at
100FROM user_email_codes
101WHERE user_id = $1
102 AND purpose = $2
103 AND deleted_at IS NULL
104 AND used_at IS NULL
105 AND expires_at > $3
106 "#,
107 user_id,
108 purpose as UserEmailCodePurpose,
109 now
110 )
111 .fetch_optional(conn)
112 .await?;
113
114 Ok(record)
115}
116
117pub async fn is_reset_user_email_code_valid(
118 conn: &mut PgConnection,
119 user_id: Uuid,
120 purpose: UserEmailCodePurpose,
121 code: &DbSecret,
122) -> ModelResult<bool> {
123 let now = Utc::now();
124 let record = sqlx::query!(
125 r#"
126SELECT id
127FROM user_email_codes
128WHERE user_id = $1
129 AND purpose = $2
130 AND code = $3
131 AND deleted_at IS NULL
132 AND used_at IS NULL
133 AND expires_at > $4
134 "#,
135 user_id,
136 purpose as UserEmailCodePurpose,
137 code.expose_secret(),
138 now
139 )
140 .fetch_optional(conn)
141 .await?;
142
143 Ok(record.is_some())
144}
145
146pub async fn mark_user_email_code_used(
147 conn: &mut PgConnection,
148 user_id: Uuid,
149 purpose: UserEmailCodePurpose,
150 code: &DbSecret,
151) -> ModelResult<bool> {
152 let result = sqlx::query!(
153 r#"
154UPDATE user_email_codes
155SET used_at = NOW(),
156 deleted_at = NOW()
157WHERE user_id = $1
158 AND purpose = $2
159 AND code = $3
160 AND deleted_at IS NULL
161 "#,
162 user_id,
163 purpose as UserEmailCodePurpose,
164 code.expose_secret(),
165 )
166 .execute(conn)
167 .await?;
168
169 Ok(result.rows_affected() > 0)
170}
171
172pub async fn record_failed_attempt(
177 conn: &mut PgConnection,
178 user_id: Uuid,
179 purpose: UserEmailCodePurpose,
180 max_attempts: i32,
181) -> ModelResult<()> {
182 sqlx::query!(
183 r#"
184UPDATE user_email_codes
185SET attempt_count = attempt_count + 1,
186 deleted_at = CASE
187 WHEN attempt_count + 1 >= $3 THEN NOW()
188 ELSE deleted_at
189 END
190WHERE user_id = $1
191 AND purpose = $2
192 AND deleted_at IS NULL
193 AND used_at IS NULL
194 "#,
195 user_id,
196 purpose as UserEmailCodePurpose,
197 max_attempts,
198 )
199 .execute(conn)
200 .await?;
201
202 Ok(())
203}