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    RequestEnrolmentListFetch,
37    DismissStudyRegistryConflict,
38    /// Took a registration off the Needs attention queue; `details.attention_reasons` holds what the
39    /// dismissal covers. Not `mark_resolved`, which clears the pipeline's own flag.
40    DismissAttention,
41    /// A note on a registration for whoever handles it next; the note is the `reason`.
42    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    /// `None` only for phase targets, which are keyed by `target_phase`.
87    pub target_id: Option<Uuid>,
88    pub target_phase: Option<String>,
89    pub actor_user_id: Uuid,
90    /// `global_admin` or `course_teacher`.
91    pub actor_role: String,
92    /// The course whose edit permission authorised a teacher action.
93    pub actor_course_id: Option<Uuid>,
94    pub reason: Option<String>,
95    pub before_state: Option<CreditRegistrationState>,
96    pub after_state: Option<CreditRegistrationState>,
97    /// Scrub before passing if this ever carries a Suotar payload.
98    pub details: Option<serde_json::Value>,
99    pub affected_row_count: Option<i32>,
100}
101
102impl NewCreditRegistrationAdminAction {
103    /// The fields every call site names; everything else defaults to `None` and is overridden with
104    /// struct-update syntax where it varies, the same way [`crate::credit_registrations::Transition`]
105    /// is built from [`crate::credit_registrations::Transition::to`].
106    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
129/// Call in the same transaction as the effect it audits.
130pub 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
171/// Backs the per-actor guard on the teacher resend endpoint: one person's actions of one kind in a
172/// window.
173pub 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/// A linking email an admin or a teacher sent by hand: a `resend_link_email` action that queued one.
198#[derive(Debug, Clone, PartialEq)]
199pub struct ManualLinkingEmailSend {
200    pub sent_at: DateTime<Utc>,
201    pub student_number: Option<String>,
202    /// The registration whose student the email was a guess for.
203    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    /// In full. `None` when the action recorded no address and no ledger row matched it.
209    pub emailed_to: Option<String>,
210}
211
212/// The linking emails sent by hand on these courses, newest first.
213///
214/// An action without `details.emailed_to`, as the earliest ones are, is matched to the ledger row its
215/// send claimed: the newest one for the same course and student number claimed at most a minute before
216/// the action. The per person and course quiet period leaves no other row in that window.
217pub 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/// A note an admin left on a registration for whoever handles it next.
263#[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
275/// The notes on these registrations, newest first.
276pub 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
308/// The distinct reasons given for `action`, most recently used first.
309pub 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/// The narrowings the Audit tab applies, all of them in SQL.
334#[derive(Debug, Clone, Default)]
335pub struct CreditRegistrationAdminActionFilters<'a> {
336    pub actions: Option<&'a [CreditRegistrationAdminAction]>,
337    pub actor_user_id: Option<Uuid>,
338    /// `global_admin` or `course_teacher`. An admin and a teacher acting on the same course are
339    /// otherwise indistinguishable.
340    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    /// Every action about this student, whatever its target: their registrations, their student
345    /// number links and tokens, and actions that name them or their student number in `details`.
346    pub user_id: Option<Uuid>,
347    /// Matches the course a teacher's permission authorised and a course-targeted action alike.
348    pub course_id: Option<Uuid>,
349    pub from: Option<DateTime<Utc>>,
350    pub to: Option<DateTime<Utc>>,
351}
352
353/// One action with its actor named and the page's total attached.
354#[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    /// The course the action was about: the one a teacher's permission authorised, the one a
361    /// course-targeted action names, or the one a targeted registration belongs to.
362    pub course_name: Option<String>,
363    /// The student a registration- or link-targeted action was about. `None` for a phase, a course
364    /// or a module, which are about nobody in particular.
365    pub target_user_id: Option<Uuid>,
366    pub target_first_name: Option<String>,
367    pub target_last_name: Option<String>,
368    /// In full, like the actor's.
369    pub target_email: Option<String>,
370    pub total_count: i64,
371}
372
373/// A page of the global action log, newest first, covering both actor kinds.
374pub 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}