1use std::collections::HashMap;
2
3use headless_lms_utils::secret_string::expose_option;
4use rand::distr::{Alphanumeric, SampleString};
5use secrecy::ExposeSecret;
6
7use crate::prelude::*;
8
9const TOKEN_LENGTH: usize = 128;
11
12#[derive(Debug, Clone)]
13pub struct StudentNumberVerificationToken {
14 pub id: Uuid,
15 pub created_at: DateTime<Utc>,
16 pub updated_at: DateTime<Utc>,
17 pub deleted_at: Option<DateTime<Utc>>,
18 pub token: DbSecret,
19 pub claimed_by_user_id: Option<Uuid>,
20 pub student_number: DbSecret,
21 pub sisu_person_id: DbSecret,
22 pub first_names: Option<DbSecret>,
23 pub last_name: Option<DbSecret>,
24 pub emailed_to: DbSecret,
25 pub course_id: Option<Uuid>,
26 pub expires_at: DateTime<Utc>,
27 pub used_at: Option<DateTime<Utc>>,
28}
29
30#[derive(Debug, Clone)]
31pub struct NewStudentNumberVerificationToken {
32 pub student_number: DbSecret,
33 pub sisu_person_id: DbSecret,
34 pub first_names: Option<DbSecret>,
35 pub last_name: Option<DbSecret>,
36 pub emailed_to: DbSecret,
37 pub course_id: Option<Uuid>,
38}
39
40pub async fn insert(
43 conn: &mut PgConnection,
44 pkey_policy: PKeyPolicy<Uuid>,
45 new: &NewStudentNumberVerificationToken,
46) -> ModelResult<(Uuid, DbSecret)> {
47 let token = DbSecret::new(Alphanumeric.sample_string(&mut rand::rng(), TOKEN_LENGTH));
48 let res = sqlx::query!(
49 r#"
50INSERT INTO student_number_verification_tokens (
51 id,
52 token,
53 student_number,
54 sisu_person_id,
55 first_names,
56 last_name,
57 emailed_to,
58 course_id
59 )
60VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
61RETURNING id
62 "#,
63 pkey_policy.into_uuid(),
64 token.expose_secret(),
65 new.student_number.expose_secret(),
66 new.sisu_person_id.expose_secret(),
67 expose_option(&new.first_names),
68 expose_option(&new.last_name),
69 new.emailed_to.expose_secret(),
70 new.course_id,
71 )
72 .fetch_one(conn)
73 .await?;
74 Ok((res.id, token))
75}
76
77pub async fn insert_batch(
80 conn: &mut PgConnection,
81 ids: &[Uuid],
82 news: &[NewStudentNumberVerificationToken],
83) -> ModelResult<()> {
84 if ids.is_empty() {
85 return Ok(());
86 }
87 let tokens: Vec<String> = (0..news.len())
88 .map(|_| Alphanumeric.sample_string(&mut rand::rng(), TOKEN_LENGTH))
89 .collect();
90 let student_numbers: Vec<String> = news
91 .iter()
92 .map(|n| n.student_number.expose_secret().to_owned())
93 .collect();
94 let sisu_person_ids: Vec<String> = news
95 .iter()
96 .map(|n| n.sisu_person_id.expose_secret().to_owned())
97 .collect();
98 let first_names: Vec<Option<String>> = news
99 .iter()
100 .map(|n| expose_option(&n.first_names).map(str::to_owned))
101 .collect();
102 let last_names: Vec<Option<String>> = news
103 .iter()
104 .map(|n| expose_option(&n.last_name).map(str::to_owned))
105 .collect();
106 let emailed_tos: Vec<String> = news
107 .iter()
108 .map(|n| n.emailed_to.expose_secret().to_owned())
109 .collect();
110 let course_ids: Vec<Option<Uuid>> = news.iter().map(|n| n.course_id).collect();
111
112 sqlx::query!(
113 r#"
114INSERT INTO student_number_verification_tokens (
115 id,
116 token,
117 student_number,
118 sisu_person_id,
119 first_names,
120 last_name,
121 emailed_to,
122 course_id
123 )
124SELECT * FROM UNNEST($1::uuid [], $2::text [], $3::text [], $4::text [], $5::text [], $6::text [], $7::text [], $8::uuid [])
125 "#,
126 ids,
127 &tokens,
128 &student_numbers,
129 &sisu_person_ids,
130 &first_names as &[Option<String>],
131 &last_names as &[Option<String>],
132 &emailed_tos,
133 &course_ids as &[Option<Uuid>],
134 )
135 .execute(conn)
136 .await?;
137 Ok(())
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub struct SeedStudentNumberVerificationToken {
143 pub token: String,
146 pub student_number: String,
147 pub sisu_person_id: String,
148 pub first_names: Option<String>,
149 pub last_name: Option<String>,
150 pub emailed_to: String,
151 pub course_id: Option<Uuid>,
152 pub expires_at: DateTime<Utc>,
153 pub used_at: Option<DateTime<Utc>>,
154 pub claimed_by_user_id: Option<Uuid>,
155}
156
157pub async fn insert_seed_row(
160 conn: &mut PgConnection,
161 pkey_policy: PKeyPolicy<Uuid>,
162 seed: &SeedStudentNumberVerificationToken,
163) -> ModelResult<Uuid> {
164 let res = sqlx::query!(
165 r#"
166INSERT INTO student_number_verification_tokens (
167 id,
168 token,
169 student_number,
170 sisu_person_id,
171 first_names,
172 last_name,
173 emailed_to,
174 course_id,
175 expires_at,
176 used_at,
177 claimed_by_user_id
178 )
179VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
180RETURNING id
181 "#,
182 pkey_policy.into_uuid(),
183 seed.token,
184 seed.student_number,
185 seed.sisu_person_id,
186 seed.first_names,
187 seed.last_name,
188 seed.emailed_to,
189 seed.course_id,
190 seed.expires_at,
191 seed.used_at,
192 seed.claimed_by_user_id,
193 )
194 .fetch_one(conn)
195 .await?;
196 Ok(res.id)
197}
198
199pub async fn get_by_token_any_state(
201 conn: &mut PgConnection,
202 token: &DbSecret,
203) -> ModelResult<Option<StudentNumberVerificationToken>> {
204 let res = sqlx::query_as!(
205 StudentNumberVerificationToken,
206 r#"
207SELECT *
208FROM student_number_verification_tokens
209WHERE token = $1
210 "#,
211 token.expose_secret()
212 )
213 .fetch_optional(conn)
214 .await?;
215 Ok(res)
216}
217
218pub async fn get_by_ids(
220 conn: &mut PgConnection,
221 ids: &[Uuid],
222) -> ModelResult<HashMap<Uuid, StudentNumberVerificationToken>> {
223 let res = sqlx::query_as!(
224 StudentNumberVerificationToken,
225 r#"
226SELECT *
227FROM student_number_verification_tokens
228WHERE id = ANY($1::uuid [])
229 AND deleted_at IS NULL
230 "#,
231 ids
232 )
233 .fetch_all(conn)
234 .await?;
235 Ok(res.into_iter().map(|row| (row.id, row)).collect())
236}
237
238pub async fn claim(
240 conn: &mut PgConnection,
241 token: &DbSecret,
242 claimed_by_user_id: Uuid,
243) -> ModelResult<bool> {
244 let claimed = sqlx::query!(
245 r#"
246UPDATE student_number_verification_tokens
247SET used_at = now(),
248 claimed_by_user_id = $2
249WHERE token = $1
250 AND used_at IS NULL
251 AND deleted_at IS NULL
252 AND expires_at > now()
253RETURNING id
254 "#,
255 token.expose_secret(),
256 claimed_by_user_id,
257 )
258 .fetch_optional(conn)
259 .await?;
260 Ok(claimed.is_some())
261}
262
263pub async fn soft_delete_expired(conn: &mut PgConnection, limit: i64) -> ModelResult<u64> {
268 let res = sqlx::query!(
269 r#"
270UPDATE student_number_verification_tokens
271SET deleted_at = now()
272WHERE id IN (
273 SELECT id
274 FROM student_number_verification_tokens
275 WHERE used_at IS NULL
276 AND deleted_at IS NULL
277 AND expires_at < now()
278 ORDER BY expires_at
279 LIMIT $1
280 )
281 "#,
282 limit
283 )
284 .execute(conn)
285 .await?;
286 Ok(res.rows_affected())
287}
288
289pub async fn soft_delete_unused_for_student_number(
291 conn: &mut PgConnection,
292 student_number: &str,
293) -> ModelResult<u64> {
294 let res = sqlx::query!(
295 r#"
296UPDATE student_number_verification_tokens
297SET deleted_at = now()
298WHERE student_number = $1
299 AND used_at IS NULL
300 AND deleted_at IS NULL
301 "#,
302 student_number
303 )
304 .execute(conn)
305 .await?;
306 Ok(res.rows_affected())
307}