1use utoipa::ToSchema;
6
7use crate::credit_registrations::CreditRegistrationState;
8use crate::prelude::*;
9
10pub const GLOBAL_ADMIN_ROLE: &str = "global_admin";
11pub const COURSE_TEACHER_ROLE: &str = "course_teacher";
12
13#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
14#[sqlx(
15 type_name = "credit_registration_admin_action",
16 rename_all = "snake_case"
17)]
18#[serde(rename_all = "snake_case")]
19pub enum CreditRegistrationAdminAction {
20 RetryItem,
21 RetryFailedForCourse,
22 ForceRecheck,
23 MarkResolved,
24 RequeueBatch,
25 TransitionItem,
26 CancelRegistration,
27 PauseCourseModule,
28 ResumeCourseModule,
29 PausePhase,
30 ResumePhase,
31 RunPhaseNow,
32 ResendLinkEmail,
33 UnlinkStudentNumber,
34 ManualLinkStudentNumber,
35 OverrideRateCap,
36 RequestEnrolmentListFetch,
37 DismissStudyRegistryConflict,
38 DismissAttention,
41 AddNote,
43}
44
45#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
46#[sqlx(
47 type_name = "credit_registration_admin_action_target",
48 rename_all = "snake_case"
49)]
50#[serde(rename_all = "snake_case")]
51pub enum CreditRegistrationAdminActionTarget {
52 CreditRegistration,
53 CourseModule,
54 Course,
55 Phase,
56 VerifiedStudentNumber,
57 StudentNumberVerificationToken,
58 RosterSchedule,
59 StudyRegistryStudentNumberConflict,
60}
61
62#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
63pub struct CreditRegistrationAdminActionRecord {
64 pub id: Uuid,
65 pub created_at: DateTime<Utc>,
66 pub updated_at: DateTime<Utc>,
67 pub deleted_at: Option<DateTime<Utc>>,
68 pub action: CreditRegistrationAdminAction,
69 pub target_kind: CreditRegistrationAdminActionTarget,
70 pub target_id: Option<Uuid>,
71 pub target_phase: Option<String>,
72 pub actor_user_id: Uuid,
73 pub actor_role: String,
74 pub actor_course_id: Option<Uuid>,
75 pub reason: Option<String>,
76 pub before_state: Option<CreditRegistrationState>,
77 pub after_state: Option<CreditRegistrationState>,
78 pub details: Option<serde_json::Value>,
79 pub affected_row_count: Option<i32>,
80}
81
82#[derive(Debug, Clone, PartialEq)]
83pub struct NewCreditRegistrationAdminAction {
84 pub action: CreditRegistrationAdminAction,
85 pub target_kind: CreditRegistrationAdminActionTarget,
86 pub target_id: Option<Uuid>,
88 pub target_phase: Option<String>,
89 pub actor_user_id: Uuid,
90 pub actor_role: String,
92 pub actor_course_id: Option<Uuid>,
94 pub reason: Option<String>,
95 pub before_state: Option<CreditRegistrationState>,
96 pub after_state: Option<CreditRegistrationState>,
97 pub details: Option<serde_json::Value>,
99 pub affected_row_count: Option<i32>,
100}
101
102impl NewCreditRegistrationAdminAction {
103 pub fn new(
107 action: CreditRegistrationAdminAction,
108 target_kind: CreditRegistrationAdminActionTarget,
109 actor_user_id: Uuid,
110 actor_role: &str,
111 ) -> Self {
112 Self {
113 action,
114 target_kind,
115 target_id: None,
116 target_phase: None,
117 actor_user_id,
118 actor_role: actor_role.to_string(),
119 actor_course_id: None,
120 reason: None,
121 before_state: None,
122 after_state: None,
123 details: None,
124 affected_row_count: None,
125 }
126 }
127}
128
129pub async fn record(
131 conn: &mut PgConnection,
132 new: &NewCreditRegistrationAdminAction,
133) -> ModelResult<Uuid> {
134 let res = sqlx::query!(
135 r#"
136INSERT INTO credit_registration_admin_actions (
137 action,
138 target_kind,
139 target_id,
140 target_phase,
141 actor_user_id,
142 actor_role,
143 actor_course_id,
144 reason,
145 before_state,
146 after_state,
147 details,
148 affected_row_count
149 )
150VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
151RETURNING id
152 "#,
153 new.action as CreditRegistrationAdminAction,
154 new.target_kind as CreditRegistrationAdminActionTarget,
155 new.target_id,
156 new.target_phase,
157 new.actor_user_id,
158 new.actor_role,
159 new.actor_course_id,
160 new.reason,
161 new.before_state as Option<CreditRegistrationState>,
162 new.after_state as Option<CreditRegistrationState>,
163 new.details,
164 new.affected_row_count,
165 )
166 .fetch_one(conn)
167 .await?;
168 Ok(res.id)
169}
170
171pub async fn count_by_actor_since(
174 conn: &mut PgConnection,
175 actor_user_id: Uuid,
176 action: CreditRegistrationAdminAction,
177 since: DateTime<Utc>,
178) -> ModelResult<i64> {
179 let count = sqlx::query_scalar!(
180 r#"
181SELECT COUNT(*) AS "count!"
182FROM credit_registration_admin_actions
183WHERE actor_user_id = $1
184 AND action = $2
185 AND created_at >= $3
186 AND deleted_at IS NULL
187 "#,
188 actor_user_id,
189 action as CreditRegistrationAdminAction,
190 since,
191 )
192 .fetch_one(conn)
193 .await?;
194 Ok(count)
195}
196
197#[derive(Debug, Clone, PartialEq)]
199pub struct ManualLinkingEmailSend {
200 pub sent_at: DateTime<Utc>,
201 pub student_number: Option<String>,
202 pub credit_registration_id: Option<Uuid>,
204 pub sent_by_user_id: Uuid,
205 pub sent_by_first_name: Option<String>,
206 pub sent_by_last_name: Option<String>,
207 pub sent_by_email: Option<String>,
208 pub emailed_to: Option<String>,
210}
211
212pub async fn get_manual_linking_email_sends(
218 conn: &mut PgConnection,
219 course_ids: &[Uuid],
220) -> ModelResult<Vec<ManualLinkingEmailSend>> {
221 let res = sqlx::query_as!(
222 ManualLinkingEmailSend,
223 r#"
224SELECT a.created_at AS sent_at,
225 a.details->>'student_number' AS "student_number?",
226 CASE
227 WHEN a.details->>'credit_registration_id' ~ '^[0-9a-fA-F-]{36}$' THEN (a.details->>'credit_registration_id')::uuid
228 END AS "credit_registration_id?",
229 a.actor_user_id AS sent_by_user_id,
230 ud.first_name AS "sent_by_first_name?",
231 ud.last_name AS "sent_by_last_name?",
232 ud.email AS "sent_by_email?",
233 COALESCE(a.details->>'emailed_to', claimed.emailed_to) AS "emailed_to?"
234FROM credit_registration_admin_actions a
235 LEFT JOIN user_details ud ON ud.user_id = a.actor_user_id
236 LEFT JOIN LATERAL (
237 SELECT e.emailed_to
238 FROM credit_registration_account_linking_emails e
239 WHERE a.details->>'emailed_to' IS NULL
240 AND e.course_id = a.target_id
241 AND e.student_number = a.details->>'student_number'
242 AND e.sent_at <= a.created_at
243 AND e.sent_at > a.created_at - INTERVAL '1 minute'
244 ORDER BY e.sent_at DESC
245 LIMIT 1
246 ) claimed ON TRUE
247WHERE a.target_kind = 'course'
248 AND a.target_id = ANY($1)
249 AND a.action = 'resend_link_email'
250 AND a.details->>'outcome' = 'queued'
251 AND a.deleted_at IS NULL
252ORDER BY a.created_at DESC,
253 a.id
254 "#,
255 course_ids,
256 )
257 .fetch_all(conn)
258 .await?;
259 Ok(res)
260}
261
262#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
264pub struct CreditRegistrationNote {
265 pub id: Uuid,
266 pub credit_registration_id: Uuid,
267 pub created_at: DateTime<Utc>,
268 pub note: String,
269 pub written_by_user_id: Uuid,
270 pub written_by_first_name: Option<String>,
271 pub written_by_last_name: Option<String>,
272 pub written_by_email: Option<String>,
273}
274
275pub async fn get_notes_by_registrations(
277 conn: &mut PgConnection,
278 credit_registration_ids: &[Uuid],
279) -> ModelResult<Vec<CreditRegistrationNote>> {
280 let res = sqlx::query_as!(
281 CreditRegistrationNote,
282 r#"
283SELECT a.id,
284 a.target_id AS "credit_registration_id!",
285 a.created_at,
286 a.reason AS "note!",
287 a.actor_user_id AS written_by_user_id,
288 ud.first_name AS "written_by_first_name?",
289 ud.last_name AS "written_by_last_name?",
290 ud.email AS "written_by_email?"
291FROM credit_registration_admin_actions a
292 LEFT JOIN user_details ud ON ud.user_id = a.actor_user_id
293WHERE a.target_kind = 'credit_registration'
294 AND a.target_id = ANY($1)
295 AND a.action = 'add_note'
296 AND a.reason IS NOT NULL
297 AND a.deleted_at IS NULL
298ORDER BY a.created_at DESC,
299 a.id
300 "#,
301 credit_registration_ids,
302 )
303 .fetch_all(conn)
304 .await?;
305 Ok(res)
306}
307
308pub async fn get_earlier_reasons(
310 conn: &mut PgConnection,
311 action: CreditRegistrationAdminAction,
312 limit: i64,
313) -> ModelResult<Vec<String>> {
314 let res = sqlx::query_scalar!(
315 r#"
316SELECT TRIM(reason) AS "reason!"
317FROM credit_registration_admin_actions
318WHERE action = $1
319 AND TRIM(reason) <> ''
320 AND deleted_at IS NULL
321GROUP BY TRIM(reason)
322ORDER BY MAX(created_at) DESC
323LIMIT $2
324 "#,
325 action as CreditRegistrationAdminAction,
326 limit,
327 )
328 .fetch_all(conn)
329 .await?;
330 Ok(res)
331}
332
333#[derive(Debug, Clone, Default)]
335pub struct CreditRegistrationAdminActionFilters<'a> {
336 pub actions: Option<&'a [CreditRegistrationAdminAction]>,
337 pub actor_user_id: Option<Uuid>,
338 pub actor_role: Option<&'a str>,
341 pub target_kind: Option<CreditRegistrationAdminActionTarget>,
342 pub target_id: Option<Uuid>,
343 pub target_phase: Option<&'a str>,
344 pub user_id: Option<Uuid>,
347 pub course_id: Option<Uuid>,
349 pub from: Option<DateTime<Utc>>,
350 pub to: Option<DateTime<Utc>>,
351}
352
353#[derive(Debug, Clone, PartialEq)]
355pub struct CreditRegistrationAdminActionListRow {
356 pub action: CreditRegistrationAdminActionRecord,
357 pub actor_first_name: Option<String>,
358 pub actor_last_name: Option<String>,
359 pub actor_email: Option<String>,
360 pub course_name: Option<String>,
363 pub target_user_id: Option<Uuid>,
366 pub target_first_name: Option<String>,
367 pub target_last_name: Option<String>,
368 pub target_email: Option<String>,
370 pub total_count: i64,
371}
372
373pub async fn get_page(
375 conn: &mut PgConnection,
376 filters: &CreditRegistrationAdminActionFilters<'_>,
377 limit: i64,
378 offset: i64,
379) -> ModelResult<Vec<CreditRegistrationAdminActionListRow>> {
380 let rows = sqlx::query!(
381 r#"
382SELECT a.id,
383 a.created_at,
384 a.updated_at,
385 a.deleted_at,
386 a.action AS "action!: CreditRegistrationAdminAction",
387 a.target_kind AS "target_kind!: CreditRegistrationAdminActionTarget",
388 a.target_id,
389 a.target_phase,
390 a.actor_user_id,
391 a.actor_role,
392 a.actor_course_id,
393 a.reason,
394 a.before_state AS "before_state?: CreditRegistrationState",
395 a.after_state AS "after_state?: CreditRegistrationState",
396 a.details,
397 a.affected_row_count,
398 ud.first_name AS "actor_first_name?",
399 ud.last_name AS "actor_last_name?",
400 ud.email AS "actor_email?",
401 c.name AS "course_name?",
402 target_user.user_id AS "target_user_id?",
403 tud.first_name AS "target_first_name?",
404 tud.last_name AS "target_last_name?",
405 tud.email AS "target_email?",
406 COUNT(*) OVER () AS "total_count!"
407FROM credit_registration_admin_actions a
408 LEFT JOIN user_details ud ON ud.user_id = a.actor_user_id
409 LEFT JOIN credit_registrations tcr ON tcr.id = a.target_id
410 AND a.target_kind = 'credit_registration'
411 LEFT JOIN verified_student_numbers tvsn ON tvsn.id = a.target_id
412 AND a.target_kind = 'verified_student_number'
413 LEFT JOIN student_number_verification_tokens ttok ON ttok.id = a.target_id
414 AND a.target_kind = 'student_number_verification_token'
415 LEFT JOIN study_registry_student_number_conflicts tconf ON tconf.id = a.target_id
416 AND a.target_kind = 'study_registry_student_number_conflict'
417 CROSS JOIN LATERAL (
418 SELECT COALESCE(
419 tcr.user_id,
420 tvsn.user_id,
421 ttok.claimed_by_user_id,
422 tconf.user_id,
423 CASE
424 WHEN a.details->>'user_id' ~ '^[0-9a-fA-F-]{36}$' THEN (a.details->>'user_id')::uuid
425 END
426 ) AS user_id
427 ) target_user
428 LEFT JOIN user_details tud ON tud.user_id = target_user.user_id
429 LEFT JOIN courses c ON c.id = COALESCE(
430 a.actor_course_id,
431 tcr.course_id,
432 CASE
433 WHEN a.target_kind = 'course' THEN a.target_id
434 END
435 )
436WHERE a.deleted_at IS NULL
437 AND (
438 $1::credit_registration_admin_action [] IS NULL
439 OR a.action = ANY($1)
440 )
441 AND ($2::uuid IS NULL OR a.actor_user_id = $2)
442 AND ($3::text IS NULL OR a.actor_role = $3)
443 AND (
444 $4::credit_registration_admin_action_target IS NULL
445 OR a.target_kind = $4
446 )
447 AND ($5::uuid IS NULL OR a.target_id = $5)
448 AND ($6::text IS NULL OR a.target_phase = $6)
449 AND (
450 $7::uuid IS NULL
451 OR a.actor_course_id = $7
452 OR (
453 a.target_kind = 'course'
454 AND a.target_id = $7
455 )
456 )
457 AND ($8::timestamptz IS NULL OR a.created_at >= $8)
458 AND ($9::timestamptz IS NULL OR a.created_at <= $9)
459 AND (
460 $12::uuid IS NULL
461 OR target_user.user_id = $12
462 OR a.details->>'student_number' IN (
463 SELECT linked.student_number
464 FROM verified_student_numbers linked
465 WHERE linked.user_id = $12
466 )
467 )
468ORDER BY a.created_at DESC,
469 a.id
470LIMIT $10 OFFSET $11
471 "#,
472 filters.actions as Option<&[CreditRegistrationAdminAction]>,
473 filters.actor_user_id,
474 filters.actor_role,
475 filters.target_kind as Option<CreditRegistrationAdminActionTarget>,
476 filters.target_id,
477 filters.target_phase,
478 filters.course_id,
479 filters.from,
480 filters.to,
481 limit,
482 offset,
483 filters.user_id,
484 )
485 .fetch_all(conn)
486 .await?;
487 Ok(rows
488 .into_iter()
489 .map(|row| CreditRegistrationAdminActionListRow {
490 actor_first_name: row.actor_first_name,
491 actor_last_name: row.actor_last_name,
492 actor_email: row.actor_email,
493 course_name: row.course_name,
494 target_user_id: row.target_user_id,
495 target_first_name: row.target_first_name,
496 target_last_name: row.target_last_name,
497 target_email: row.target_email,
498 total_count: row.total_count,
499 action: CreditRegistrationAdminActionRecord {
500 id: row.id,
501 created_at: row.created_at,
502 updated_at: row.updated_at,
503 deleted_at: row.deleted_at,
504 action: row.action,
505 target_kind: row.target_kind,
506 target_id: row.target_id,
507 target_phase: row.target_phase,
508 actor_user_id: row.actor_user_id,
509 actor_role: row.actor_role,
510 actor_course_id: row.actor_course_id,
511 reason: row.reason,
512 before_state: row.before_state,
513 after_state: row.after_state,
514 details: row.details,
515 affected_row_count: row.affected_row_count,
516 },
517 })
518 .collect())
519}
520
521pub async fn get_by_actor(
522 conn: &mut PgConnection,
523 actor_user_id: Uuid,
524 limit: i64,
525) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
526 let res = sqlx::query_as!(
527 CreditRegistrationAdminActionRecord,
528 r#"
529SELECT *
530FROM credit_registration_admin_actions
531WHERE actor_user_id = $1
532 AND deleted_at IS NULL
533ORDER BY created_at DESC
534LIMIT $2
535 "#,
536 actor_user_id,
537 limit,
538 )
539 .fetch_all(conn)
540 .await?;
541 Ok(res)
542}