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>,
240 pub total_count: i64,
241}
242
243pub async fn get_page(
245 conn: &mut PgConnection,
246 filters: &CreditRegistrationAdminActionFilters<'_>,
247 limit: i64,
248 offset: i64,
249) -> ModelResult<Vec<CreditRegistrationAdminActionListRow>> {
250 let rows = sqlx::query!(
251 r#"
252SELECT a.id,
253 a.created_at,
254 a.updated_at,
255 a.deleted_at,
256 a.action AS "action!: CreditRegistrationAdminAction",
257 a.target_kind AS "target_kind!: CreditRegistrationAdminActionTarget",
258 a.target_id,
259 a.target_phase,
260 a.actor_user_id,
261 a.actor_role,
262 a.actor_course_id,
263 a.reason,
264 a.before_state AS "before_state?: CreditRegistrationState",
265 a.after_state AS "after_state?: CreditRegistrationState",
266 a.details,
267 a.affected_row_count,
268 ud.first_name AS "actor_first_name?",
269 ud.last_name AS "actor_last_name?",
270 ud.email AS "actor_email?",
271 c.name AS "course_name?",
272 COUNT(*) OVER () AS "total_count!"
273FROM credit_registration_admin_actions a
274 LEFT JOIN user_details ud ON ud.user_id = a.actor_user_id
275 LEFT JOIN courses c ON c.id = COALESCE(
276 a.actor_course_id,
277 CASE
278 WHEN a.target_kind = 'course' THEN a.target_id
279 END
280 )
281WHERE a.deleted_at IS NULL
282 AND (
283 $1::credit_registration_admin_action [] IS NULL
284 OR a.action = ANY($1)
285 )
286 AND ($2::uuid IS NULL OR a.actor_user_id = $2)
287 AND ($3::text IS NULL OR a.actor_role = $3)
288 AND (
289 $4::credit_registration_admin_action_target IS NULL
290 OR a.target_kind = $4
291 )
292 AND ($5::uuid IS NULL OR a.target_id = $5)
293 AND ($6::text IS NULL OR a.target_phase = $6)
294 AND (
295 $7::uuid IS NULL
296 OR a.actor_course_id = $7
297 OR (
298 a.target_kind = 'course'
299 AND a.target_id = $7
300 )
301 )
302 AND ($8::timestamptz IS NULL OR a.created_at >= $8)
303 AND ($9::timestamptz IS NULL OR a.created_at <= $9)
304ORDER BY a.created_at DESC,
305 a.id
306LIMIT $10 OFFSET $11
307 "#,
308 filters.actions as Option<&[CreditRegistrationAdminAction]>,
309 filters.actor_user_id,
310 filters.actor_role,
311 filters.target_kind as Option<CreditRegistrationAdminActionTarget>,
312 filters.target_id,
313 filters.target_phase,
314 filters.course_id,
315 filters.from,
316 filters.to,
317 limit,
318 offset,
319 )
320 .fetch_all(conn)
321 .await?;
322 Ok(rows
323 .into_iter()
324 .map(|row| CreditRegistrationAdminActionListRow {
325 actor_first_name: row.actor_first_name,
326 actor_last_name: row.actor_last_name,
327 actor_email: row.actor_email,
328 course_name: row.course_name,
329 total_count: row.total_count,
330 action: CreditRegistrationAdminActionRecord {
331 id: row.id,
332 created_at: row.created_at,
333 updated_at: row.updated_at,
334 deleted_at: row.deleted_at,
335 action: row.action,
336 target_kind: row.target_kind,
337 target_id: row.target_id,
338 target_phase: row.target_phase,
339 actor_user_id: row.actor_user_id,
340 actor_role: row.actor_role,
341 actor_course_id: row.actor_course_id,
342 reason: row.reason,
343 before_state: row.before_state,
344 after_state: row.after_state,
345 details: row.details,
346 affected_row_count: row.affected_row_count,
347 },
348 })
349 .collect())
350}
351
352pub async fn get_by_actor(
353 conn: &mut PgConnection,
354 actor_user_id: Uuid,
355 limit: i64,
356) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
357 let res = sqlx::query_as!(
358 CreditRegistrationAdminActionRecord,
359 r#"
360SELECT *
361FROM credit_registration_admin_actions
362WHERE actor_user_id = $1
363 AND deleted_at IS NULL
364ORDER BY created_at DESC
365LIMIT $2
366 "#,
367 actor_user_id,
368 limit,
369 )
370 .fetch_all(conn)
371 .await?;
372 Ok(res)
373}