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