1use rand::RngExt;
2use rand::distr::{Alphanumeric, SampleString};
3use secrecy::ExposeSecret;
4
5use crate::prelude::*;
6
7const TOKEN_LENGTH: usize = 128;
9
10#[derive(Debug, Clone)]
11pub struct StudentNumberVerificationToken {
12 pub id: Uuid,
13 pub created_at: DateTime<Utc>,
14 pub updated_at: DateTime<Utc>,
15 pub deleted_at: Option<DateTime<Utc>>,
16 pub token: DbSecret,
17 pub claimed_by_user_id: Option<Uuid>,
18 pub student_number: String,
19 pub sisu_person_id: String,
20 pub first_names: Option<String>,
21 pub last_name: Option<String>,
22 pub emailed_to: String,
23 pub course_id: Option<Uuid>,
24 pub expires_at: DateTime<Utc>,
25 pub used_at: Option<DateTime<Utc>>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct NewStudentNumberVerificationToken {
30 pub student_number: String,
31 pub sisu_person_id: String,
32 pub first_names: Option<String>,
33 pub last_name: Option<String>,
34 pub emailed_to: String,
35 pub course_id: Option<Uuid>,
36}
37
38pub fn is_valid(token: &StudentNumberVerificationToken) -> bool {
39 let now = Utc::now();
40 token.expires_at > now && token.used_at.is_none() && token.deleted_at.is_none()
41}
42
43pub async fn maybe_cleanup_expired(conn: &mut PgConnection) -> ModelResult<()> {
49 let random_num = rand::rng().random_range(1..=10);
50 if random_num == 1 {
51 info!("Cleaning up expired student number verification tokens");
52 sqlx::query!(
53 r#"
54UPDATE student_number_verification_tokens
55SET deleted_at = now()
56WHERE expires_at < now()
57 AND used_at IS NULL
58 AND deleted_at IS NULL
59 "#,
60 )
61 .execute(conn)
62 .await?;
63 }
64 Ok(())
65}
66
67pub async fn insert(
70 conn: &mut PgConnection,
71 pkey_policy: PKeyPolicy<Uuid>,
72 new: &NewStudentNumberVerificationToken,
73) -> ModelResult<(Uuid, DbSecret)> {
74 let token = DbSecret::new(Alphanumeric.sample_string(&mut rand::rng(), TOKEN_LENGTH));
75 let res = sqlx::query!(
76 r#"
77INSERT INTO student_number_verification_tokens (
78 id,
79 token,
80 student_number,
81 sisu_person_id,
82 first_names,
83 last_name,
84 emailed_to,
85 course_id
86 )
87VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
88RETURNING id
89 "#,
90 pkey_policy.into_uuid(),
91 token.expose_secret(),
92 new.student_number,
93 new.sisu_person_id,
94 new.first_names,
95 new.last_name,
96 new.emailed_to,
97 new.course_id,
98 )
99 .fetch_one(conn)
100 .await?;
101 Ok((res.id, token))
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct SeedStudentNumberVerificationToken {
107 pub token: String,
110 pub student_number: String,
111 pub sisu_person_id: String,
112 pub first_names: Option<String>,
113 pub last_name: Option<String>,
114 pub emailed_to: String,
115 pub course_id: Option<Uuid>,
116 pub expires_at: DateTime<Utc>,
117 pub used_at: Option<DateTime<Utc>>,
118 pub claimed_by_user_id: Option<Uuid>,
119}
120
121pub async fn insert_seed_row(
124 conn: &mut PgConnection,
125 pkey_policy: PKeyPolicy<Uuid>,
126 seed: &SeedStudentNumberVerificationToken,
127) -> ModelResult<Uuid> {
128 let res = sqlx::query!(
129 r#"
130INSERT INTO student_number_verification_tokens (
131 id,
132 token,
133 student_number,
134 sisu_person_id,
135 first_names,
136 last_name,
137 emailed_to,
138 course_id,
139 expires_at,
140 used_at,
141 claimed_by_user_id
142 )
143VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
144RETURNING id
145 "#,
146 pkey_policy.into_uuid(),
147 seed.token,
148 seed.student_number,
149 seed.sisu_person_id,
150 seed.first_names,
151 seed.last_name,
152 seed.emailed_to,
153 seed.course_id,
154 seed.expires_at,
155 seed.used_at,
156 seed.claimed_by_user_id,
157 )
158 .fetch_one(conn)
159 .await?;
160 Ok(res.id)
161}
162
163pub async fn get_unclaimed_by_token(
165 conn: &mut PgConnection,
166 token: &DbSecret,
167) -> ModelResult<Option<StudentNumberVerificationToken>> {
168 maybe_cleanup_expired(conn).await?;
169
170 let res = sqlx::query_as!(
171 StudentNumberVerificationToken,
172 r#"
173SELECT *
174FROM student_number_verification_tokens
175WHERE token = $1
176 AND expires_at > now()
177 AND used_at IS NULL
178 AND deleted_at IS NULL
179 "#,
180 token.expose_secret()
181 )
182 .fetch_optional(conn)
183 .await?;
184 Ok(res)
185}
186
187pub async fn get_by_id(
188 conn: &mut PgConnection,
189 id: Uuid,
190) -> ModelResult<StudentNumberVerificationToken> {
191 let res = sqlx::query_as!(
192 StudentNumberVerificationToken,
193 r#"
194SELECT *
195FROM student_number_verification_tokens
196WHERE id = $1
197 AND deleted_at IS NULL
198 "#,
199 id
200 )
201 .fetch_one(conn)
202 .await?;
203 Ok(res)
204}
205
206pub async fn claim(
208 conn: &mut PgConnection,
209 token: &DbSecret,
210 claimed_by_user_id: Uuid,
211) -> ModelResult<bool> {
212 let claimed = sqlx::query!(
213 r#"
214UPDATE student_number_verification_tokens
215SET used_at = now(),
216 claimed_by_user_id = $2
217WHERE token = $1
218 AND used_at IS NULL
219 AND deleted_at IS NULL
220 AND expires_at > now()
221RETURNING id
222 "#,
223 token.expose_secret(),
224 claimed_by_user_id,
225 )
226 .fetch_optional(conn)
227 .await?;
228 Ok(claimed.is_some())
229}
230
231pub async fn soft_delete_unused_for_student_number(
233 conn: &mut PgConnection,
234 student_number: &str,
235) -> ModelResult<u64> {
236 let res = sqlx::query!(
237 r#"
238UPDATE student_number_verification_tokens
239SET deleted_at = now()
240WHERE student_number = $1
241 AND used_at IS NULL
242 AND deleted_at IS NULL
243 "#,
244 student_number
245 )
246 .execute(conn)
247 .await?;
248 Ok(res.rows_affected())
249}