1use chrono::TimeDelta;
10use utoipa::ToSchema;
11
12use super::metrics::StuckThresholds;
13use super::state::{CreditRegistrationErrorCode, CreditRegistrationState, ResubmissionFacts};
14use crate::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
15use crate::library::credit_registration::PendingPreconditions;
16use crate::library::credit_registration::backoff::{
17 NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER, VERIFY_MAX_AGE,
18};
19use crate::library::credit_registration::classification::{Retryability, retryability};
20use crate::library::credit_registration::timeline::{Engagement, TimelinePosition};
21use crate::prelude::*;
22
23pub const TOO_MANY_SUBMIT_RETRIES: i32 = 5;
26
27pub const STUDENT_NUMBER_STUCK_AFTER_PRESS: TimeDelta = TimeDelta::minutes(60);
31
32#[derive(
34 Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Type, ToSchema,
35)]
36#[sqlx(
37 type_name = "credit_registration_attention_reason",
38 rename_all = "snake_case"
39)]
40#[serde(rename_all = "snake_case")]
41#[schema(as = CreditRegistrationAttentionReason)]
43pub enum AttentionReason {
44 StuckInState,
47 PermanentError,
49 RetryWindowExpired,
50 Misregistered,
51 TooManyAttempts,
53 OutcomeUncertain,
55 PartlyRegisteredOverdue,
56 VerificationGaveUp,
58 RepeatedlyNotRegistered,
60 StudentNumberStuck,
66 FlaggedByPipeline,
68}
69
70impl AttentionReason {
71 pub fn is_person_level(self) -> bool {
73 self != Self::StuckInState
74 }
75}
76
77#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
79#[serde(rename_all = "snake_case")]
80pub enum AttentionStanding {
81 NeedsAttention,
83 RunningLate,
85 Dismissed,
87 ExplainedByProblem,
90}
91
92#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
94#[serde(rename_all = "snake_case")]
95pub enum BlockingProblemKind {
96 ProcessingPhaseStopped,
98 CourseCodeFailing,
100 ModuleMisconfigured,
102}
103
104#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Hash, ToSchema)]
106pub struct BlockingProblem {
107 pub kind: BlockingProblemKind,
108 pub subject: String,
109}
110
111#[derive(Debug, Clone, PartialEq)]
113pub struct StoppedPhase {
114 pub phase: String,
115 pub owned_states: Vec<CreditRegistrationState>,
117 pub sends_linking_emails: bool,
119}
120
121#[derive(Debug, Clone, Default, PartialEq)]
123pub struct BlockingProblems {
124 pub stopped_phases: Vec<StoppedPhase>,
125 pub failing_course_codes: Vec<String>,
126 pub misconfigured_module_ids: Vec<Uuid>,
127}
128
129pub fn needs_course_setup(error_code: Option<CreditRegistrationErrorCode>) -> bool {
132 error_code.is_some_and(|code| retryability(code) == Retryability::PermanentNeedsConfig)
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct AttentionRules {
138 pub thresholds: StuckThresholds,
139 pub account_linking_since: Option<DateTime<Utc>>,
142 pub blocking: BlockingProblems,
143}
144
145#[derive(Debug, Clone)]
147pub struct AttentionRegistration {
148 pub id: Uuid,
149 pub user_id: Uuid,
150 pub first_name: Option<String>,
151 pub last_name: Option<String>,
152 pub email: Option<String>,
153 pub course_id: Uuid,
154 pub course_name: String,
155 pub course_module_id: Uuid,
156 pub course_module_name: Option<String>,
157 pub uh_course_code: Option<String>,
159 pub course_module_completion_id: Uuid,
160 pub completion_date: DateTime<Utc>,
161 pub state: CreditRegistrationState,
162 pub state_changed_at: DateTime<Utc>,
163 pub phase_started_at: DateTime<Utc>,
164 pub error_code: Option<CreditRegistrationErrorCode>,
165 pub attempt_count: i32,
166 pub needs_admin_attention: bool,
168 pub next_attempt_at: DateTime<Utc>,
169 pub submitted_at: Option<DateTime<Utc>>,
170 pub resubmit_not_before: Option<DateTime<Utc>>,
171 pub student_number: Option<DbSecret>,
172 pub completion_eligible: bool,
173 pub has_verified_student_number: bool,
174 pub course_code_allowed: bool,
175 pub enrolment_resolved: bool,
176 pub pressed_at: Option<DateTime<Utc>>,
178 pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
179 pub last_visited_at: Option<DateTime<Utc>>,
180 pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
182 pub is_enrolment_list_empty: bool,
184 pub reasons: Vec<AttentionReason>,
185 pub dismissed_reasons: Option<Vec<AttentionReason>>,
186 pub dismissed_at: Option<DateTime<Utc>>,
187 pub dismissed_by_user_id: Option<Uuid>,
188 pub dismissed_by_first_name: Option<String>,
189 pub dismissed_by_last_name: Option<String>,
190 pub dismissal_reason: Option<String>,
191}
192
193impl AttentionRegistration {
194 pub fn resubmission_facts(&self) -> ResubmissionFacts {
197 ResubmissionFacts {
198 state: self.state,
199 is_superseded: false,
200 error_code: self.error_code,
201 resubmit_not_before: self.resubmit_not_before,
202 submitted_at: self.submitted_at,
203 }
204 }
205
206 pub fn position(&self) -> TimelinePosition {
208 TimelinePosition::of(
209 self.state,
210 PendingPreconditions {
211 completion_eligible: self.completion_eligible,
212 has_verified_student_number: self.has_verified_student_number,
213 course_code_allowed: self.course_code_allowed,
214 },
215 self.enrolment_resolved,
216 Engagement::of(self.pressed_at, self.last_visited_at),
217 )
218 }
219
220 pub fn needs_course_setup(&self) -> bool {
222 needs_course_setup(self.error_code)
223 }
224
225 pub fn blocking_problem(&self, problems: &BlockingProblems) -> Option<BlockingProblem> {
227 let explains_all = |explains: &dyn Fn(AttentionReason) -> bool| {
228 !self.reasons.is_empty() && self.reasons.iter().all(|reason| explains(*reason))
229 };
230 for phase in &problems.stopped_phases {
231 let explains = |reason: AttentionReason| match reason {
232 AttentionReason::StuckInState => phase.owned_states.contains(&self.state),
233 AttentionReason::StudentNumberStuck => phase.sends_linking_emails,
234 _ => false,
235 };
236 if explains_all(&explains) {
237 return Some(BlockingProblem {
238 kind: BlockingProblemKind::ProcessingPhaseStopped,
239 subject: phase.phase.clone(),
240 });
241 }
242 }
243 if let Some(code) = self
244 .uh_course_code
245 .as_ref()
246 .filter(|code| problems.failing_course_codes.contains(code))
247 && explains_all(&|reason| reason == AttentionReason::StudentNumberStuck)
248 {
249 return Some(BlockingProblem {
250 kind: BlockingProblemKind::CourseCodeFailing,
251 subject: code.clone(),
252 });
253 }
254 if problems
255 .misconfigured_module_ids
256 .contains(&self.course_module_id)
257 && self.needs_course_setup()
258 && explains_all(&|reason| {
259 matches!(
260 reason,
261 AttentionReason::PermanentError | AttentionReason::FlaggedByPipeline
262 )
263 })
264 {
265 return Some(BlockingProblem {
266 kind: BlockingProblemKind::ModuleMisconfigured,
267 subject: self.course_module_id.to_string(),
268 });
269 }
270 None
271 }
272
273 pub fn is_dismissed(&self) -> bool {
275 self.dismissed_reasons
276 .as_ref()
277 .is_some_and(|dismissed| self.reasons.iter().all(|reason| dismissed.contains(reason)))
278 }
279
280 pub fn standing(&self, problems: &BlockingProblems) -> AttentionStanding {
282 if self.is_dismissed() {
283 AttentionStanding::Dismissed
284 } else if self.blocking_problem(problems).is_some() {
285 AttentionStanding::ExplainedByProblem
286 } else if self.reasons.iter().any(|reason| reason.is_person_level()) {
287 AttentionStanding::NeedsAttention
288 } else {
289 AttentionStanding::RunningLate
290 }
291 }
292}
293
294pub async fn get_attention_items(
298 conn: &mut PgConnection,
299 rules: &AttentionRules,
300 only_ids: Option<&[Uuid]>,
301) -> ModelResult<Vec<AttentionRegistration>> {
302 let (state_thresholds, threshold_secs) = rules.thresholds.state_seconds_arrays();
303 let res = sqlx::query_as!(
304 AttentionRegistration,
305 r#"
306SELECT cr.id,
307 cr.user_id,
308 ud.first_name AS "first_name?",
309 ud.last_name AS "last_name?",
310 ud.email AS "email?",
311 cr.course_id,
312 c.name AS course_name,
313 cr.course_module_id,
314 cm.name AS course_module_name,
315 NULLIF(TRIM(cm.uh_course_code), '') AS uh_course_code,
316 cr.course_module_completion_id,
317 cmc.completion_date,
318 cr.state,
319 cr.state_changed_at,
320 cr.phase_started_at,
321 cr.error_code AS "error_code?",
322 cr.submit_retry_count + cr.verify_attempt_count AS "attempt_count!",
323 cr.needs_admin_attention,
324 cr.next_attempt_at,
325 cr.submitted_at,
326 cr.resubmit_not_before,
327 cr.student_number,
328 p.completion_eligible AS "completion_eligible!",
329 p.has_verified_student_number AS "has_verified_student_number!",
330 p.course_code_allowed AS "course_code_allowed!",
331 cr.selected_enrolment_id IS NOT NULL AS "enrolment_resolved!",
332 route.enrolment_confirmed_at AS "pressed_at?",
333 route.route AS "enrolment_route?: CreditRegistrationEnrolmentRoute",
334 sig.last_visited_at AS "last_visited_at?",
335 s.last_mailing_fetch_started_at AS "last_mailing_fetch_started_at?",
336 COALESCE(s.last_listed_person_count = 0, FALSE) AS "is_enrolment_list_empty!",
337 ARRAY_REMOVE(
338 ARRAY [
339 CASE WHEN d.stuck_in_state THEN 'stuck_in_state'::credit_registration_attention_reason END,
340 CASE WHEN d.permanent_error THEN 'permanent_error'::credit_registration_attention_reason END,
341 CASE WHEN d.retry_window_expired THEN 'retry_window_expired'::credit_registration_attention_reason END,
342 CASE WHEN d.misregistered THEN 'misregistered'::credit_registration_attention_reason END,
343 CASE WHEN d.too_many_attempts THEN 'too_many_attempts'::credit_registration_attention_reason END,
344 CASE WHEN d.outcome_uncertain THEN 'outcome_uncertain'::credit_registration_attention_reason END,
345 CASE WHEN d.partly_registered_overdue THEN 'partly_registered_overdue'::credit_registration_attention_reason END,
346 CASE WHEN d.verification_gave_up THEN 'verification_gave_up'::credit_registration_attention_reason END,
347 CASE WHEN d.repeatedly_not_registered THEN 'repeatedly_not_registered'::credit_registration_attention_reason END,
348 CASE WHEN d.student_number_stuck THEN 'student_number_stuck'::credit_registration_attention_reason END,
349 CASE WHEN flagged.only_flag THEN 'flagged_by_pipeline'::credit_registration_attention_reason END
350 ],
351 NULL
352 ) AS "reasons!: Vec<AttentionReason>",
353 dis.dismissed_reasons AS "dismissed_reasons?: Vec<AttentionReason>",
354 dis.created_at AS "dismissed_at?",
355 dis.dismissed_by_user_id AS "dismissed_by_user_id?",
356 dismisser.first_name AS "dismissed_by_first_name?",
357 dismisser.last_name AS "dismissed_by_last_name?",
358 dis.reason AS "dismissal_reason?"
359FROM credit_registrations cr
360 JOIN courses c ON c.id = cr.course_id
361 JOIN course_modules cm ON cm.id = cr.course_module_id
362 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
363 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
364 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
365 LEFT JOIN credit_registration_enrolment_routes route ON route.course_module_completion_id = cr.course_module_completion_id
366 AND route.deleted_at IS NULL
367 LEFT JOIN credit_registration_enrolment_check_signals sig ON sig.course_module_completion_id = cr.course_module_completion_id
368 AND sig.deleted_at IS NULL
369 LEFT JOIN credit_registration_roster_schedules s ON s.course_code = TRIM(cm.uh_course_code)
370 LEFT JOIN credit_registration_attention_dismissals dis ON dis.credit_registration_id = cr.id
371 AND dis.deleted_at IS NULL
372 LEFT JOIN user_details dismisser ON dismisser.user_id = dis.dismissed_by_user_id
373 LEFT JOIN LATERAL (
374 SELECT u.threshold_secs
375 FROM UNNEST($1::credit_registration_state [], $2::double precision []) AS u(state, threshold_secs)
376 WHERE u.state = cr.state
377 ) t ON TRUE
378 CROSS JOIN LATERAL (
379 SELECT cr.terminal_at IS NULL
380 AND t.threshold_secs IS NOT NULL
381 AND now() - cr.state_changed_at > MAKE_INTERVAL(secs => t.threshold_secs) AS stuck_in_state,
382 cr.state = 'failed_permanent'
383 AND cr.needs_admin_attention
384 AND cr.error_code IS DISTINCT FROM 'retry_window_expired' AS permanent_error,
385 COALESCE(cr.error_code = 'retry_window_expired', FALSE) AS retry_window_expired,
386 cr.state = 'misregistered' AS misregistered,
387 cr.submit_retry_count >= $3
388 AND cr.terminal_at IS NULL AS too_many_attempts,
389 cr.state = 'submission_uncertain' AS outcome_uncertain,
390 COALESCE(
391 cr.state = 'partially_registered'
392 AND cr.partially_registered_at <= now() - ($4::bigint * INTERVAL '1 second'),
393 FALSE
394 ) AS partly_registered_overdue,
395 COALESCE(
396 cr.state IN ('awaiting_verification', 'partially_registered')
397 AND cr.submitted_at <= now() - ($5::bigint * INTERVAL '1 second'),
398 FALSE
399 ) AS verification_gave_up,
400 cr.not_registered_reimport_count >= $6
401 AND cr.terminal_at IS NULL AS repeatedly_not_registered,
402 COALESCE(
403 cr.state = 'pending'
404 AND p.completion_eligible
405 AND NOT p.has_verified_student_number
406 AND cmc.completion_date >= $7::timestamptz
407 AND s.last_mailing_fetch_started_at >= route.enrolment_confirmed_at + ($8::bigint * INTERVAL '1 second')
408 AND (
409 s.linking_unlinked_enrolled_before_count > 0
410 OR NOT EXISTS (
411 SELECT 1
412 FROM credit_registration_account_linking_emails e
413 JOIN course_modules code_module ON code_module.course_id = e.course_id
414 AND code_module.deleted_at IS NULL
415 WHERE TRIM(code_module.uh_course_code) = s.course_code
416 AND e.sent_at >= route.enrolment_confirmed_at
417 AND e.deleted_at IS NULL
418 )
419 ),
420 FALSE
421 ) AS student_number_stuck
422 ) d
423 CROSS JOIN LATERAL (
424 SELECT cr.needs_admin_attention
425 AND NOT (
426 d.stuck_in_state
427 OR d.permanent_error
428 OR d.retry_window_expired
429 OR d.misregistered
430 OR d.too_many_attempts
431 OR d.outcome_uncertain
432 OR d.partly_registered_overdue
433 OR d.verification_gave_up
434 OR d.repeatedly_not_registered
435 OR d.student_number_stuck
436 ) AS only_flag
437 ) flagged
438WHERE cr.superseded_by_id IS NULL
439 AND cr.deleted_at IS NULL
440 AND (
441 $9::uuid [] IS NULL
442 OR cr.id = ANY($9)
443 )
444 AND (
445 cr.needs_admin_attention
446 OR d.stuck_in_state
447 OR d.permanent_error
448 OR d.retry_window_expired
449 OR d.misregistered
450 OR d.too_many_attempts
451 OR d.outcome_uncertain
452 OR d.partly_registered_overdue
453 OR d.verification_gave_up
454 OR d.repeatedly_not_registered
455 OR d.student_number_stuck
456 )
457ORDER BY cr.phase_started_at,
458 cr.id
459 "#,
460 &state_thresholds as &[CreditRegistrationState],
461 &threshold_secs as &[f64],
462 TOO_MANY_SUBMIT_RETRIES,
463 PARTIAL_REGISTRATION_ADMIN_AFTER.num_seconds(),
464 VERIFY_MAX_AGE.num_seconds(),
465 NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD,
466 rules.account_linking_since,
467 STUDENT_NUMBER_STUCK_AFTER_PRESS.num_seconds(),
468 only_ids as Option<&[Uuid]>,
469 )
470 .fetch_all(conn)
471 .await?;
472 Ok(res)
473}
474
475pub async fn count_needing_attention(
478 conn: &mut PgConnection,
479 rules: &AttentionRules,
480) -> ModelResult<i64> {
481 let rows = get_attention_items(conn, rules, None).await?;
482 Ok(rows
483 .iter()
484 .filter(|row| row.standing(&rules.blocking) == AttentionStanding::NeedsAttention)
485 .count() as i64)
486}
487
488#[derive(Debug, Clone, PartialEq)]
490pub struct AttentionDismissal {
491 pub credit_registration_id: Uuid,
492 pub user_id: Uuid,
493 pub first_name: Option<String>,
494 pub last_name: Option<String>,
495 pub email: Option<String>,
496 pub course_id: Uuid,
497 pub course_name: String,
498 pub dismissed_reasons: Vec<AttentionReason>,
499 pub dismissed_at: DateTime<Utc>,
500 pub dismissed_by_user_id: Uuid,
501 pub dismissed_by_first_name: Option<String>,
502 pub dismissed_by_last_name: Option<String>,
503 pub reason: String,
504}
505
506pub async fn get_dismissals_since(
508 conn: &mut PgConnection,
509 since: DateTime<Utc>,
510 limit: i64,
511) -> ModelResult<Vec<AttentionDismissal>> {
512 let res = sqlx::query_as!(
513 AttentionDismissal,
514 r#"
515SELECT dis.credit_registration_id,
516 cr.user_id,
517 ud.first_name AS "first_name?",
518 ud.last_name AS "last_name?",
519 ud.email AS "email?",
520 cr.course_id,
521 c.name AS course_name,
522 dis.dismissed_reasons AS "dismissed_reasons: Vec<AttentionReason>",
523 dis.created_at AS dismissed_at,
524 dis.dismissed_by_user_id,
525 actor.first_name AS "dismissed_by_first_name?",
526 actor.last_name AS "dismissed_by_last_name?",
527 dis.reason
528FROM credit_registration_attention_dismissals dis
529 JOIN credit_registrations cr ON cr.id = dis.credit_registration_id
530 JOIN courses c ON c.id = cr.course_id
531 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
532 LEFT JOIN user_details actor ON actor.user_id = dis.dismissed_by_user_id
533WHERE dis.deleted_at IS NULL
534 AND dis.created_at >= $1
535ORDER BY dis.created_at DESC
536LIMIT $2
537 "#,
538 since,
539 limit,
540 )
541 .fetch_all(conn)
542 .await?;
543 Ok(res)
544}
545
546pub async fn dismiss_attention(
549 conn: &mut PgConnection,
550 credit_registration_id: Uuid,
551 reasons: &[AttentionReason],
552 dismissed_by_user_id: Uuid,
553 reason: &str,
554) -> ModelResult<Uuid> {
555 sqlx::query!(
556 r#"
557UPDATE credit_registration_attention_dismissals
558SET deleted_at = now()
559WHERE credit_registration_id = $1
560 AND deleted_at IS NULL
561 "#,
562 credit_registration_id,
563 )
564 .execute(&mut *conn)
565 .await?;
566 let id = sqlx::query_scalar!(
567 r#"
568INSERT INTO credit_registration_attention_dismissals (
569 credit_registration_id,
570 dismissed_reasons,
571 dismissed_by_user_id,
572 reason
573 )
574VALUES ($1, $2, $3, $4)
575RETURNING id
576 "#,
577 credit_registration_id,
578 reasons as &[AttentionReason],
579 dismissed_by_user_id,
580 reason,
581 )
582 .fetch_one(conn)
583 .await?;
584 Ok(id)
585}