Skip to main content

headless_lms_models/
credit_registration_admin_actions.rs

1//! Audit of manual actions on the credit registration pipeline.
2//!
3//! Separate from `credit_registration_events` because the targets are often not registrations at
4//! all: a phase, a course module, a student-number link. Item-targeted actions write both tables.
5use 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    /// `None` only for phase targets, which are keyed by `target_phase`.
78    pub target_id: Option<Uuid>,
79    pub target_phase: Option<String>,
80    pub actor_user_id: Uuid,
81    /// `global_admin` or `course_teacher`.
82    pub actor_role: String,
83    /// The course whose edit permission authorised a teacher action.
84    pub actor_course_id: Option<Uuid>,
85    pub reason: Option<String>,
86    pub before_state: Option<CreditRegistrationState>,
87    pub after_state: Option<CreditRegistrationState>,
88    /// Scrub before passing if this ever carries a Suotar payload.
89    pub details: Option<serde_json::Value>,
90    pub affected_row_count: Option<i32>,
91}
92
93impl NewCreditRegistrationAdminAction {
94    /// The fields every call site names; everything else defaults to `None` and is overridden with
95    /// struct-update syntax where it varies, the same way [`crate::credit_registrations::Transition`]
96    /// is built from [`crate::credit_registrations::Transition::to`].
97    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
120/// Call in the same transaction as the effect it audits.
121pub 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
162/// Backs the per-actor guard on the resend endpoints: one person's actions of one kind in a window.
163pub 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
187/// Backs the admin resend endpoint's own quiet period. Unlike `count_by_actor_since`, a refused
188/// attempt (no mail sent) doesn't count: otherwise a refusal would block the immediate
189/// override-and-retry the rate cap's own "send anyway" option exists to offer.
190pub 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/// The narrowings the Audit tab applies, all of them in SQL.
215#[derive(Debug, Clone, Default)]
216pub struct CreditRegistrationAdminActionFilters<'a> {
217    pub actions: Option<&'a [CreditRegistrationAdminAction]>,
218    pub actor_user_id: Option<Uuid>,
219    /// `global_admin` or `course_teacher`. An admin and a teacher acting on the same course are
220    /// otherwise indistinguishable.
221    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    /// Matches the course a teacher's permission authorised and a course-targeted action alike.
226    pub course_id: Option<Uuid>,
227    pub from: Option<DateTime<Utc>>,
228    pub to: Option<DateTime<Utc>>,
229}
230
231/// One action with its actor named and the page's total attached.
232#[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    /// Named for the course-targeted and teacher-authorised rows; `None` where neither applies.
239    pub course_name: Option<String>,
240    pub total_count: i64,
241}
242
243/// A page of the global action log, newest first, covering both actor kinds.
244pub 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}