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
10#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
11#[sqlx(
12    type_name = "credit_registration_admin_action",
13    rename_all = "snake_case"
14)]
15#[serde(rename_all = "snake_case")]
16pub enum CreditRegistrationAdminAction {
17    RetryItem,
18    RetryFailedForCourse,
19    ForceRecheck,
20    MarkResolved,
21    RequeueBatch,
22    TransitionItem,
23    CancelRegistration,
24    PauseCourseModule,
25    ResumeCourseModule,
26    PausePhase,
27    ResumePhase,
28    RunPhaseNow,
29    ResendLinkEmail,
30    UnlinkStudentNumber,
31    ManualLinkStudentNumber,
32    OverrideRateCap,
33}
34
35#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
36#[sqlx(
37    type_name = "credit_registration_admin_action_target",
38    rename_all = "snake_case"
39)]
40#[serde(rename_all = "snake_case")]
41pub enum CreditRegistrationAdminActionTarget {
42    CreditRegistration,
43    CourseModule,
44    Course,
45    Phase,
46    VerifiedStudentNumber,
47    StudentNumberVerificationToken,
48}
49
50#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
51pub struct CreditRegistrationAdminActionRecord {
52    pub id: Uuid,
53    pub created_at: DateTime<Utc>,
54    pub updated_at: DateTime<Utc>,
55    pub deleted_at: Option<DateTime<Utc>>,
56    pub action: CreditRegistrationAdminAction,
57    pub target_kind: CreditRegistrationAdminActionTarget,
58    pub target_id: Option<Uuid>,
59    pub target_phase: Option<String>,
60    pub actor_user_id: Uuid,
61    pub actor_role: String,
62    pub actor_course_id: Option<Uuid>,
63    pub reason: Option<String>,
64    pub before_state: Option<CreditRegistrationState>,
65    pub after_state: Option<CreditRegistrationState>,
66    pub details: Option<serde_json::Value>,
67    pub affected_row_count: Option<i32>,
68}
69
70#[derive(Debug, Clone, PartialEq)]
71pub struct NewCreditRegistrationAdminAction {
72    pub action: CreditRegistrationAdminAction,
73    pub target_kind: CreditRegistrationAdminActionTarget,
74    /// `None` only for phase targets, which are keyed by `target_phase`.
75    pub target_id: Option<Uuid>,
76    pub target_phase: Option<String>,
77    pub actor_user_id: Uuid,
78    /// `global_admin` or `course_teacher`.
79    pub actor_role: String,
80    /// The course whose edit permission authorised a teacher action.
81    pub actor_course_id: Option<Uuid>,
82    pub reason: Option<String>,
83    pub before_state: Option<CreditRegistrationState>,
84    pub after_state: Option<CreditRegistrationState>,
85    /// Scrub before passing if this ever carries a Suotar payload.
86    pub details: Option<serde_json::Value>,
87    pub affected_row_count: Option<i32>,
88}
89
90/// Call in the same transaction as the effect it audits.
91pub async fn record(
92    conn: &mut PgConnection,
93    new: &NewCreditRegistrationAdminAction,
94) -> ModelResult<Uuid> {
95    let res = sqlx::query!(
96        r#"
97INSERT INTO credit_registration_admin_actions (
98    action,
99    target_kind,
100    target_id,
101    target_phase,
102    actor_user_id,
103    actor_role,
104    actor_course_id,
105    reason,
106    before_state,
107    after_state,
108    details,
109    affected_row_count
110  )
111VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
112RETURNING id
113        "#,
114        new.action as CreditRegistrationAdminAction,
115        new.target_kind as CreditRegistrationAdminActionTarget,
116        new.target_id,
117        new.target_phase,
118        new.actor_user_id,
119        new.actor_role,
120        new.actor_course_id,
121        new.reason,
122        new.before_state as Option<CreditRegistrationState>,
123        new.after_state as Option<CreditRegistrationState>,
124        new.details,
125        new.affected_row_count,
126    )
127    .fetch_one(conn)
128    .await?;
129    Ok(res.id)
130}
131
132pub async fn get_recent(
133    conn: &mut PgConnection,
134    limit: i64,
135) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
136    let res = sqlx::query_as!(
137        CreditRegistrationAdminActionRecord,
138        r#"
139SELECT *
140FROM credit_registration_admin_actions
141WHERE deleted_at IS NULL
142ORDER BY created_at DESC
143LIMIT $1
144        "#,
145        limit
146    )
147    .fetch_all(conn)
148    .await?;
149    Ok(res)
150}
151
152pub async fn get_by_target(
153    conn: &mut PgConnection,
154    target_kind: CreditRegistrationAdminActionTarget,
155    target_id: Uuid,
156) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
157    let res = sqlx::query_as!(
158        CreditRegistrationAdminActionRecord,
159        r#"
160SELECT *
161FROM credit_registration_admin_actions
162WHERE target_kind = $1
163  AND target_id = $2
164  AND deleted_at IS NULL
165ORDER BY created_at DESC
166        "#,
167        target_kind as CreditRegistrationAdminActionTarget,
168        target_id,
169    )
170    .fetch_all(conn)
171    .await?;
172    Ok(res)
173}
174
175pub async fn get_by_actor(
176    conn: &mut PgConnection,
177    actor_user_id: Uuid,
178    limit: i64,
179) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
180    let res = sqlx::query_as!(
181        CreditRegistrationAdminActionRecord,
182        r#"
183SELECT *
184FROM credit_registration_admin_actions
185WHERE actor_user_id = $1
186  AND deleted_at IS NULL
187ORDER BY created_at DESC
188LIMIT $2
189        "#,
190        actor_user_id,
191        limit,
192    )
193    .fetch_all(conn)
194    .await?;
195    Ok(res)
196}
197
198/// Actions authorised by a course's teacher permission, not actions targeting the course.
199pub async fn get_by_actor_course(
200    conn: &mut PgConnection,
201    actor_course_id: Uuid,
202    limit: i64,
203) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
204    let res = sqlx::query_as!(
205        CreditRegistrationAdminActionRecord,
206        r#"
207SELECT *
208FROM credit_registration_admin_actions
209WHERE actor_course_id = $1
210  AND deleted_at IS NULL
211ORDER BY created_at DESC
212LIMIT $2
213        "#,
214        actor_course_id,
215        limit,
216    )
217    .fetch_all(conn)
218    .await?;
219    Ok(res)
220}
221
222pub async fn get_by_phase(
223    conn: &mut PgConnection,
224    target_phase: &str,
225    limit: i64,
226) -> ModelResult<Vec<CreditRegistrationAdminActionRecord>> {
227    let res = sqlx::query_as!(
228        CreditRegistrationAdminActionRecord,
229        r#"
230SELECT *
231FROM credit_registration_admin_actions
232WHERE target_phase = $1
233  AND deleted_at IS NULL
234ORDER BY created_at DESC
235LIMIT $2
236        "#,
237        target_phase,
238        limit,
239    )
240    .fetch_all(conn)
241    .await?;
242    Ok(res)
243}