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}
37
38#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
39#[sqlx(
40 type_name = "credit_registration_admin_action_target",
41 rename_all = "snake_case"
42)]
43#[serde(rename_all = "snake_case")]
44pub enum CreditRegistrationAdminActionTarget {
45 CreditRegistration,
46 CourseModule,
47 Course,
48 Phase,
49 VerifiedStudentNumber,
50 StudentNumberVerificationToken,
51}
52
53#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
54pub struct CreditRegistrationAdminActionRecord {
55 pub id: Uuid,
56 pub created_at: DateTime<Utc>,
57 pub updated_at: DateTime<Utc>,
58 pub deleted_at: Option<DateTime<Utc>>,
59 pub action: CreditRegistrationAdminAction,
60 pub target_kind: CreditRegistrationAdminActionTarget,
61 pub target_id: Option<Uuid>,
62 pub target_phase: Option<String>,
63 pub actor_user_id: Uuid,
64 pub actor_role: String,
65 pub actor_course_id: Option<Uuid>,
66 pub reason: Option<String>,
67 pub before_state: Option<CreditRegistrationState>,
68 pub after_state: Option<CreditRegistrationState>,
69 pub details: Option<serde_json::Value>,
70 pub affected_row_count: Option<i32>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub struct NewCreditRegistrationAdminAction {
75 pub action: CreditRegistrationAdminAction,
76 pub target_kind: CreditRegistrationAdminActionTarget,
77 pub target_id: Option<Uuid>,
79 pub target_phase: Option<String>,
80 pub actor_user_id: Uuid,
81 pub actor_role: String,
83 pub actor_course_id: Option<Uuid>,
85 pub reason: Option<String>,
86 pub before_state: Option<CreditRegistrationState>,
87 pub after_state: Option<CreditRegistrationState>,
88 pub details: Option<serde_json::Value>,
90 pub affected_row_count: Option<i32>,
91}
92
93impl NewCreditRegistrationAdminAction {
94 pub fn new(
98 action: CreditRegistrationAdminAction,
99 target_kind: CreditRegistrationAdminActionTarget,
100 actor_user_id: Uuid,
101 actor_role: &str,
102 ) -> Self {
103 Self {
104 action,
105 target_kind,
106 target_id: None,
107 target_phase: None,
108 actor_user_id,
109 actor_role: actor_role.to_string(),
110 actor_course_id: None,
111 reason: None,
112 before_state: None,
113 after_state: None,
114 details: None,
115 affected_row_count: None,
116 }
117 }
118}
119
120pub async fn record(
122 conn: &mut PgConnection,
123 new: &NewCreditRegistrationAdminAction,
124) -> ModelResult<Uuid> {
125 let res = sqlx::query!(
126 r#"
127INSERT INTO credit_registration_admin_actions (
128 action,
129 target_kind,
130 target_id,
131 target_phase,
132 actor_user_id,
133 actor_role,
134 actor_course_id,
135 reason,
136 before_state,
137 after_state,
138 details,
139 affected_row_count
140 )
141VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
142RETURNING id
143 "#,
144 new.action as CreditRegistrationAdminAction,
145 new.target_kind as CreditRegistrationAdminActionTarget,
146 new.target_id,
147 new.target_phase,
148 new.actor_user_id,
149 new.actor_role,
150 new.actor_course_id,
151 new.reason,
152 new.before_state as Option<CreditRegistrationState>,
153 new.after_state as Option<CreditRegistrationState>,
154 new.details,
155 new.affected_row_count,
156 )
157 .fetch_one(conn)
158 .await?;
159 Ok(res.id)
160}
161
162pub async fn count_by_actor_since(
164 conn: &mut PgConnection,
165 actor_user_id: Uuid,
166 action: CreditRegistrationAdminAction,
167 since: DateTime<Utc>,
168) -> ModelResult<i64> {
169 let count = sqlx::query_scalar!(
170 r#"
171SELECT COUNT(*) AS "count!"
172FROM credit_registration_admin_actions
173WHERE actor_user_id = $1
174 AND action = $2
175 AND created_at >= $3
176 AND deleted_at IS NULL
177 "#,
178 actor_user_id,
179 action as CreditRegistrationAdminAction,
180 since,
181 )
182 .fetch_one(conn)
183 .await?;
184 Ok(count)
185}
186
187pub async fn count_queued_resends_by_actor_since(
191 conn: &mut PgConnection,
192 actor_user_id: Uuid,
193 since: DateTime<Utc>,
194) -> ModelResult<i64> {
195 let count = sqlx::query_scalar!(
196 r#"
197SELECT COUNT(*) AS "count!"
198FROM credit_registration_admin_actions
199WHERE actor_user_id = $1
200 AND action = $2
201 AND details ->> 'outcome' = 'queued'
202 AND created_at >= $3
203 AND deleted_at IS NULL
204 "#,
205 actor_user_id,
206 CreditRegistrationAdminAction::ResendLinkEmail as CreditRegistrationAdminAction,
207 since,
208 )
209 .fetch_one(conn)
210 .await?;
211 Ok(count)
212}
213
214#[derive(Debug, Clone, Default)]
216pub struct CreditRegistrationAdminActionFilters<'a> {
217 pub actions: Option<&'a [CreditRegistrationAdminAction]>,
218 pub actor_user_id: Option<Uuid>,
219 pub actor_role: Option<&'a str>,
222 pub target_kind: Option<CreditRegistrationAdminActionTarget>,
223 pub target_id: Option<Uuid>,
224 pub target_phase: Option<&'a str>,
225 pub course_id: Option<Uuid>,
227 pub from: Option<DateTime<Utc>>,
228 pub to: Option<DateTime<Utc>>,
229}
230
231#[derive(Debug, Clone, PartialEq)]
233pub struct CreditRegistrationAdminActionListRow {
234 pub action: CreditRegistrationAdminActionRecord,
235 pub actor_first_name: Option<String>,
236 pub actor_last_name: Option<String>,
237 pub actor_email: Option<String>,
238 pub course_name: Option<String>,
241 pub target_user_id: Option<Uuid>,
244 pub target_first_name: Option<String>,
245 pub target_last_name: Option<String>,
246 pub target_email: Option<String>,
248 pub total_count: i64,
249}
250
251pub async fn get_page(
253 conn: &mut PgConnection,
254 filters: &CreditRegistrationAdminActionFilters<'_>,
255 limit: i64,
256 offset: i64,
257) -> ModelResult<Vec<CreditRegistrationAdminActionListRow>> {
258 let rows = sqlx::query!(
259 r#"
260SELECT a.id,
261 a.created_at,
262 a.updated_at,
263 a.deleted_at,
264 a.action AS "action!: CreditRegistrationAdminAction",
265 a.target_kind AS "target_kind!: CreditRegistrationAdminActionTarget",
266 a.target_id,
267 a.target_phase,
268 a.actor_user_id,
269 a.actor_role,
270 a.actor_course_id,
271 a.reason,
272 a.before_state AS "before_state?: CreditRegistrationState",
273 a.after_state AS "after_state?: CreditRegistrationState",
274 a.details,
275 a.affected_row_count,
276 ud.first_name AS "actor_first_name?",
277 ud.last_name AS "actor_last_name?",
278 ud.email AS "actor_email?",
279 c.name AS "course_name?",
280 target_user.user_id AS "target_user_id?",
281 tud.first_name AS "target_first_name?",
282 tud.last_name AS "target_last_name?",
283 tud.email AS "target_email?",
284 COUNT(*) OVER () AS "total_count!"
285FROM credit_registration_admin_actions a
286 LEFT JOIN user_details ud ON ud.user_id = a.actor_user_id
287 LEFT JOIN credit_registrations tcr ON tcr.id = a.target_id
288 AND a.target_kind = 'credit_registration'
289 LEFT JOIN verified_student_numbers tvsn ON tvsn.id = a.target_id
290 AND a.target_kind = 'verified_student_number'
291 CROSS JOIN LATERAL (
292 SELECT COALESCE(tcr.user_id, tvsn.user_id) AS user_id
293 ) target_user
294 LEFT JOIN user_details tud ON tud.user_id = target_user.user_id
295 LEFT JOIN courses c ON c.id = COALESCE(
296 a.actor_course_id,
297 tcr.course_id,
298 CASE
299 WHEN a.target_kind = 'course' THEN a.target_id
300 END
301 )
302WHERE a.deleted_at IS NULL
303 AND (
304 $1::credit_registration_admin_action [] IS NULL
305 OR a.action = ANY($1)
306 )
307 AND ($2::uuid IS NULL OR a.actor_user_id = $2)
308 AND ($3::text IS NULL OR a.actor_role = $3)
309 AND (
310 $4::credit_registration_admin_action_target IS NULL
311 OR a.target_kind = $4
312 )
313 AND ($5::uuid IS NULL OR a.target_id = $5)
314 AND ($6::text IS NULL OR a.target_phase = $6)
315 AND (
316 $7::uuid IS NULL
317 OR a.actor_course_id = $7
318 OR (
319 a.target_kind = 'course'
320 AND a.target_id = $7
321 )
322 )
323 AND ($8::timestamptz IS NULL OR a.created_at >= $8)
324 AND ($9::timestamptz IS NULL OR a.created_at <= $9)
325ORDER BY a.created_at DESC,
326 a.id
327LIMIT $10 OFFSET $11
328 "#,
329 filters.actions as Option<&[CreditRegistrationAdminAction]>,
330 filters.actor_user_id,
331 filters.actor_role,
332 filters.target_kind as Option<CreditRegistrationAdminActionTarget>,
333 filters.target_id,
334 filters.target_phase,
335 filters.course_id,
336 filters.from,
337 filters.to,
338 limit,
339 offset,
340 )
341 .fetch_all(conn)
342 .await?;
343 Ok(rows
344 .into_iter()
345 .map(|row| CreditRegistrationAdminActionListRow {
346 actor_first_name: row.actor_first_name,
347 actor_last_name: row.actor_last_name,
348 actor_email: row.actor_email,
349 course_name: row.course_name,
350 target_user_id: row.target_user_id,
351 target_first_name: row.target_first_name,
352 target_last_name: row.target_last_name,
353 target_email: row.target_email,
354 total_count: row.total_count,
355 action: CreditRegistrationAdminActionRecord {
356 id: row.id,
357 created_at: row.created_at,
358 updated_at: row.updated_at,
359 deleted_at: row.deleted_at,
360 action: row.action,
361 target_kind: row.target_kind,
362 target_id: row.target_id,
363 target_phase: row.target_phase,
364 actor_user_id: row.actor_user_id,
365 actor_role: row.actor_role,
366 actor_course_id: row.actor_course_id,
367 reason: row.reason,
368 before_state: row.before_state,
369 after_state: row.after_state,
370 details: row.details,
371 affected_row_count: row.affected_row_count,
372 },
373 })
374 .collect())
375}
376
377pub async fn get_by_actor(
378 conn: &mut PgConnection,
379 actor_user_id: Uuid,
380 limit: i64,
381) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
382 let res = sqlx::query_as!(
383 CreditRegistrationAdminActionRecord,
384 r#"
385SELECT *
386FROM credit_registration_admin_actions
387WHERE actor_user_id = $1
388 AND deleted_at IS NULL
389ORDER BY created_at DESC
390LIMIT $2
391 "#,
392 actor_user_id,
393 limit,
394 )
395 .fetch_all(conn)
396 .await?;
397 Ok(res)
398}