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    /// The course the action was about: the one a teacher's permission authorised, the one a
239    /// course-targeted action names, or the one a targeted registration belongs to.
240    pub course_name: Option<String>,
241    /// The student a registration- or link-targeted action was about. `None` for a phase, a course
242    /// or a module, which are about nobody in particular.
243    pub target_user_id: Option<Uuid>,
244    pub target_first_name: Option<String>,
245    pub target_last_name: Option<String>,
246    /// In full, like the actor's.
247    pub target_email: Option<String>,
248    pub total_count: i64,
249}
250
251/// A page of the global action log, newest first, covering both actor kinds.
252pub 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}