1use headless_lms_base::config::ApplicationConfiguration;
4use headless_lms_models::credit_registration_admin_actions::{
5 self, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
6 CreditRegistrationNote, GLOBAL_ADMIN_ROLE, NewCreditRegistrationAdminAction,
7};
8use headless_lms_models::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
9use headless_lms_models::credit_registration_events::{self, ErrorCodeWindowCounts};
10use headless_lms_models::credit_registrations::{
11 self, AttentionDismissal, AttentionReason, AttentionRegistration, AttentionStanding,
12 BlockingProblem, BlockingProblems, CreditRegistrationErrorCode, CreditRegistrationState,
13 HandActionAvailability, ResubmissionStrictness, StuckThresholds,
14};
15use headless_lms_models::library::credit_registration::classification::{
16 Retryability, retryability,
17};
18use headless_lms_models::library::credit_registration::timeline::{
19 Engagement, TimelinePhase, TimelineStep, WaitsOn,
20};
21use headless_lms_models::suotar_api_calls::SuotarEndpoint;
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23use utoipa::ToSchema;
24
25use crate::domain::credit_registration::health::stuck_thresholds;
26use crate::prelude::*;
27use headless_lms_utils::secret_string::expose_option;
28
29use super::{
30 AdminManualLinkingEmailSend, attention_rules, authorize_credit_registration_admin,
31 insert_admin_action_event, manual_linking_email_sends_by_registration, required_reason,
32};
33
34const RECENT_DISMISSAL_DAYS: i64 = 14;
35const RECENT_DISMISSAL_LIMIT: i64 = 200;
36const DEFAULT_ERROR_WINDOW_SECS: i64 = 24 * 60 * 60;
37const MAX_ERROR_WINDOW_SECS: i64 = 90 * 24 * 60 * 60;
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40pub struct CreditRegistrationAttentionItem {
41 pub credit_registration_id: Uuid,
42 pub user_id: Uuid,
43 pub first_name: Option<String>,
44 pub last_name: Option<String>,
45 pub email: Option<String>,
47 pub course_id: Uuid,
48 pub course_name: String,
49 pub course_module_id: Uuid,
50 pub course_module_name: Option<String>,
51 pub uh_course_code: Option<String>,
52 pub state: CreditRegistrationState,
53 pub state_changed_at: DateTime<Utc>,
55 pub phase_started_at: DateTime<Utc>,
56 pub timeline_step: TimelineStep,
57 pub phase: TimelinePhase,
58 pub waits_on: WaitsOn,
59 pub engagement: Option<Engagement>,
61 pub error_code: Option<CreditRegistrationErrorCode>,
62 pub attempt_count: i32,
63 pub next_attempt_at: DateTime<Utc>,
64 pub student_number: Option<String>,
65 pub reasons: Vec<AttentionReason>,
67 pub standing: AttentionStanding,
68 pub blocking_problem: Option<BlockingProblem>,
70 pub pressed_at: Option<DateTime<Utc>>,
72 pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
73 pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
75 pub is_enrolment_list_empty: bool,
76 pub needs_admin_attention: bool,
78 pub hand_actions: HandActionAvailability,
80 pub manual_linking_email_sends: Vec<AdminManualLinkingEmailSend>,
82 pub notes: Vec<CreditRegistrationNote>,
84}
85
86impl CreditRegistrationAttentionItem {
87 fn is_reacted_to(&self) -> bool {
90 self.notes
91 .iter()
92 .any(|note| note.created_at >= self.phase_started_at)
93 || self
94 .manual_linking_email_sends
95 .iter()
96 .any(|send| send.sent_at >= self.phase_started_at)
97 }
98}
99
100#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
103pub struct CreditRegistrationAttentionReactionCounts {
104 pub reacted_count: i64,
105 pub not_reacted_count: i64,
106}
107
108#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
109pub struct CreditRegistrationAttentionReasonCount {
110 pub reason: AttentionReason,
111 pub count: i64,
112}
113
114#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
116pub struct CreditRegistrationBlockingProblemRows {
117 pub problem: BlockingProblem,
118 pub items: Vec<CreditRegistrationAttentionItem>,
120}
121
122#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
124pub struct CreditRegistrationAttentionPhaseRows {
125 pub phase: TimelinePhase,
126 pub items: Vec<CreditRegistrationAttentionItem>,
128}
129
130#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
132pub struct CreditRegistrationAttentionDismissal {
133 pub credit_registration_id: Uuid,
134 pub user_id: Uuid,
135 pub first_name: Option<String>,
136 pub last_name: Option<String>,
137 pub email: Option<String>,
138 pub course_id: Uuid,
139 pub course_name: String,
140 pub dismissed_reasons: Vec<AttentionReason>,
141 pub dismissed_at: DateTime<Utc>,
142 pub dismissed_by_user_id: Uuid,
143 pub dismissed_by_first_name: Option<String>,
144 pub dismissed_by_last_name: Option<String>,
145 pub reason: String,
146}
147
148#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
149pub struct CreditRegistrationAttentionItems {
150 pub total_count: i64,
153 pub counts_by_reason: Vec<CreditRegistrationAttentionReasonCount>,
155 pub phases: Vec<CreditRegistrationAttentionPhaseRows>,
158 pub running_late: Vec<CreditRegistrationAttentionItem>,
160 pub explained_by_problem: Vec<CreditRegistrationBlockingProblemRows>,
162 pub dismissed_recently: Vec<CreditRegistrationAttentionDismissal>,
164 pub reaction_counts: CreditRegistrationAttentionReactionCounts,
166}
167
168#[derive(Debug, Deserialize, ToSchema)]
169pub struct AdminDismissAttentionPayload {
170 pub reason: String,
171}
172
173#[derive(Debug, Deserialize, ToSchema)]
175pub struct AdminAddCreditRegistrationNotePayload {
176 pub note: String,
177}
178
179#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
180pub struct AdminDismissAttentionResult {
181 pub dismissed_reasons: Vec<AttentionReason>,
183}
184
185#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
187pub struct CreditRegistrationErrorCodeWindow {
188 pub error_code: CreditRegistrationErrorCode,
189 pub retryability: Retryability,
191 pub live_count: i64,
194 pub current_count: i64,
195 pub previous_count: i64,
196 pub user_count: i64,
197 pub course_count: i64,
198 pub first_seen_at: Option<DateTime<Utc>>,
199 pub last_seen_at: Option<DateTime<Utc>>,
200 pub endpoints: Vec<SuotarEndpoint>,
201}
202
203#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
206pub struct CreditRegistrationTerminalVerdicts {
207 pub registered_count: i64,
208 pub duplicate_and_not_improved_count: i64,
209 pub failed_permanent_count: i64,
210 pub cancelled_count: i64,
211 pub total_count: i64,
213}
214
215#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
216pub struct CreditRegistrationErrorsByCode {
217 pub window_secs: i64,
218 pub codes: Vec<CreditRegistrationErrorCodeWindow>,
219 pub verdicts: CreditRegistrationTerminalVerdicts,
220}
221
222#[derive(Debug, Deserialize)]
223pub struct ErrorWindowQuery {
224 window_secs: Option<i64>,
225}
226
227#[instrument(skip(pool))]
235#[utoipa::path(
236 get,
237 path = "/thresholds",
238 operation_id = "getCreditRegistrationThresholds",
239 tag = "credit-registration-admin",
240 responses(
241 (status = 200, description = "The thresholds every rule and detector shares", body = StuckThresholds)
242 )
243)]
244pub async fn get_credit_registration_thresholds(
245 user: AuthUser,
246 pool: web::Data<PgPool>,
247) -> ControllerResult<web::Json<StuckThresholds>> {
248 let mut conn = pool.acquire().await?;
249 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
250 token.authorized_ok(web::Json(stuck_thresholds()))
251}
252
253#[derive(Debug, Deserialize)]
254pub struct AttentionQuery {
255 reason: Option<Vec<AttentionReason>>,
256 sort: Option<String>,
257 reacted: Option<bool>,
258}
259
260#[instrument(skip(pool, app_conf))]
270#[utoipa::path(
271 get,
272 path = "/attention",
273 operation_id = "getCreditRegistrationAttentionItems",
274 tag = "credit-registration-admin",
275 params(
276 ("reason" = Option<Vec<AttentionReason>>, Query, description = "Only rows carrying one of these reasons; repeat the parameter for several"),
277 ("sort" = Option<String>, Query, description = "time_in_phase, next_attempt or course"),
278 ("reacted" = Option<bool>, Query, description = "Only rows someone has, or has not, left a note on or sent a linking email for by hand since the row entered its phase; the counts cover every row")
279 ),
280 responses(
281 (status = 200, description = "The Needs attention sections", body = CreditRegistrationAttentionItems)
282 )
283)]
284pub async fn get_credit_registration_attention_items(
285 user: AuthUser,
286 pool: web::Data<PgPool>,
287 query: MultiQuery<AttentionQuery>,
288 app_conf: web::Data<ApplicationConfiguration>,
289) -> ControllerResult<web::Json<CreditRegistrationAttentionItems>> {
290 let mut conn = pool.acquire().await?;
291 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
292
293 let reasons: &[AttentionReason] = query.reason.as_deref().unwrap_or_default();
294 let rules = attention_rules(&mut conn, &app_conf).await?;
295 let rows = credit_registrations::get_attention_items(&mut conn, &rules, None).await?;
296 let course_ids: Vec<Uuid> = rows
297 .iter()
298 .map(|row| row.course_id)
299 .collect::<BTreeSet<_>>()
300 .into_iter()
301 .collect();
302 let mut sends = manual_linking_email_sends_by_registration(&mut conn, &course_ids).await?;
303 let registration_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
304 let mut notes: HashMap<Uuid, Vec<CreditRegistrationNote>> = HashMap::new();
305 for note in
306 credit_registration_admin_actions::get_notes_by_registrations(&mut conn, ®istration_ids)
307 .await?
308 {
309 notes
310 .entry(note.credit_registration_id)
311 .or_default()
312 .push(note);
313 }
314 let dismissed_recently = credit_registrations::get_dismissals_since(
315 &mut conn,
316 Utc::now() - chrono::Duration::days(RECENT_DISMISSAL_DAYS),
317 RECENT_DISMISSAL_LIMIT,
318 )
319 .await?
320 .into_iter()
321 .map(to_dismissal)
322 .collect();
323
324 let mut needing: Vec<CreditRegistrationAttentionItem> = Vec::new();
325 let mut running_late = Vec::new();
326 let mut explained_by_problem: Vec<CreditRegistrationBlockingProblemRows> = Vec::new();
327 let matches_reacted = |item: &CreditRegistrationAttentionItem| {
328 query
329 .reacted
330 .is_none_or(|reacted| reacted == item.is_reacted_to())
331 };
332 let mut reaction_counts = CreditRegistrationAttentionReactionCounts {
333 reacted_count: 0,
334 not_reacted_count: 0,
335 };
336 for item in rows.into_iter().map(|row| {
337 let row_sends = sends.remove(&row.id).unwrap_or_default();
338 let row_notes = notes.remove(&row.id).unwrap_or_default();
339 to_attention_item(row, &rules.blocking, row_sends, row_notes)
340 }) {
341 if item.standing != AttentionStanding::Dismissed {
342 if item.is_reacted_to() {
343 reaction_counts.reacted_count += 1;
344 } else {
345 reaction_counts.not_reacted_count += 1;
346 }
347 }
348 if item.standing != AttentionStanding::NeedsAttention && !matches_reacted(&item) {
351 continue;
352 }
353 match item.standing {
354 AttentionStanding::NeedsAttention => needing.push(item),
355 AttentionStanding::RunningLate => running_late.push(item),
356 AttentionStanding::ExplainedByProblem => {
357 let Some(problem) = item.blocking_problem.clone() else {
358 continue;
359 };
360 match explained_by_problem
361 .iter_mut()
362 .find(|group| group.problem == problem)
363 {
364 Some(group) => group.items.push(item),
365 None => explained_by_problem.push(CreditRegistrationBlockingProblemRows {
366 problem,
367 items: vec![item],
368 }),
369 }
370 }
371 AttentionStanding::Dismissed => {}
372 }
373 }
374 let mut reason_counts: BTreeMap<AttentionReason, i64> = BTreeMap::new();
375 for reason in needing.iter().flat_map(|item| item.reasons.iter()) {
376 *reason_counts.entry(*reason).or_insert(0) += 1;
377 }
378 let counts_by_reason = reason_counts
379 .into_iter()
380 .map(|(reason, count)| CreditRegistrationAttentionReasonCount { reason, count })
381 .collect();
382 let total_count = needing.len() as i64;
383
384 let mut by_phase: BTreeMap<TimelinePhase, Vec<CreditRegistrationAttentionItem>> =
385 BTreeMap::new();
386 for item in needing.into_iter().filter(|item| {
387 (reasons.is_empty() || item.reasons.iter().any(|reason| reasons.contains(reason)))
388 && matches_reacted(item)
389 }) {
390 by_phase.entry(item.phase).or_default().push(item);
391 }
392 let phases = by_phase
393 .into_iter()
394 .map(|(phase, mut items)| {
395 match query.sort.as_deref() {
396 Some("next_attempt") => items.sort_by_key(|item| item.next_attempt_at),
397 Some("course") => items.sort_by(|a, b| a.course_name.cmp(&b.course_name)),
398 _ => items.sort_by_key(|item| item.phase_started_at),
399 }
400 CreditRegistrationAttentionPhaseRows { phase, items }
401 })
402 .collect();
403
404 token.authorized_ok(web::Json(CreditRegistrationAttentionItems {
405 total_count,
406 counts_by_reason,
407 phases,
408 running_late,
409 explained_by_problem,
410 dismissed_recently,
411 reaction_counts,
412 }))
413}
414
415#[instrument(skip(pool, payload, app_conf))]
423#[utoipa::path(
424 post,
425 path = "/registrations/{credit_registration_id}/dismiss-attention",
426 operation_id = "adminDismissCreditRegistrationAttention",
427 tag = "credit-registration-admin",
428 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
429 request_body = AdminDismissAttentionPayload,
430 responses(
431 (status = 200, description = "The reasons dismissed", body = AdminDismissAttentionResult),
432 (status = 400, description = "No reason given, or nothing picks the row"),
433 )
434)]
435pub async fn admin_dismiss_credit_registration_attention(
436 user: AuthUser,
437 pool: web::Data<PgPool>,
438 credit_registration_id: web::Path<Uuid>,
439 payload: web::Json<AdminDismissAttentionPayload>,
440 app_conf: web::Data<ApplicationConfiguration>,
441) -> ControllerResult<web::Json<AdminDismissAttentionResult>> {
442 let mut conn = pool.acquire().await?;
443 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
444
445 let reason = required_reason(&payload.reason)?;
446 let id = *credit_registration_id;
447 let rules = attention_rules(&mut conn, &app_conf).await?;
448 let reasons = credit_registrations::get_attention_items(&mut conn, &rules, Some(&[id]))
449 .await?
450 .into_iter()
451 .next()
452 .map(|row| row.reasons)
453 .unwrap_or_default();
454 if reasons.is_empty() {
455 return Err(controller_err!(
456 BadRequest,
457 "Nothing puts this registration on the Needs attention queue.".to_string()
458 ));
459 }
460
461 let mut tx = conn.begin().await?;
462 credit_registrations::dismiss_attention(&mut tx, id, &reasons, user.id, reason).await?;
463 insert_admin_action_event(&mut tx, id, user.id, reason).await?;
464 models::credit_registration_admin_actions::record(
465 &mut tx,
466 &NewCreditRegistrationAdminAction {
467 target_id: Some(id),
468 reason: Some(reason.to_string()),
469 details: Some(serde_json::json!({ "attention_reasons": reasons })),
470 affected_row_count: Some(1),
471 ..NewCreditRegistrationAdminAction::new(
472 CreditRegistrationAdminAction::DismissAttention,
473 CreditRegistrationAdminActionTarget::CreditRegistration,
474 user.id,
475 GLOBAL_ADMIN_ROLE,
476 )
477 },
478 )
479 .await?;
480 tx.commit().await?;
481
482 token.authorized_ok(web::Json(AdminDismissAttentionResult {
483 dismissed_reasons: reasons,
484 }))
485}
486
487#[instrument(skip(pool, payload))]
494#[utoipa::path(
495 post,
496 path = "/registrations/{credit_registration_id}/notes",
497 operation_id = "adminAddCreditRegistrationNote",
498 tag = "credit-registration-admin",
499 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
500 request_body = AdminAddCreditRegistrationNotePayload,
501 responses(
502 (status = 200, description = "The note was added"),
503 (status = 400, description = "The note is empty"),
504 )
505)]
506pub async fn admin_add_credit_registration_note(
507 user: AuthUser,
508 pool: web::Data<PgPool>,
509 credit_registration_id: web::Path<Uuid>,
510 payload: web::Json<AdminAddCreditRegistrationNotePayload>,
511) -> ControllerResult<web::Json<()>> {
512 let mut conn = pool.acquire().await?;
513 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
514
515 let note = required_reason(&payload.note)?;
516 let id = *credit_registration_id;
517 credit_registrations::get_by_id(&mut conn, id).await?;
519
520 let mut tx = conn.begin().await?;
521 insert_admin_action_event(&mut tx, id, user.id, note).await?;
522 models::credit_registration_admin_actions::record(
523 &mut tx,
524 &NewCreditRegistrationAdminAction {
525 target_id: Some(id),
526 reason: Some(note.to_string()),
527 affected_row_count: Some(1),
528 ..NewCreditRegistrationAdminAction::new(
529 CreditRegistrationAdminAction::AddNote,
530 CreditRegistrationAdminActionTarget::CreditRegistration,
531 user.id,
532 GLOBAL_ADMIN_ROLE,
533 )
534 },
535 )
536 .await?;
537 tx.commit().await?;
538
539 token.authorized_ok(web::Json(()))
540}
541
542#[instrument(skip(pool))]
550#[utoipa::path(
551 get,
552 path = "/errors/by-code",
553 operation_id = "getCreditRegistrationErrorsByCode",
554 tag = "credit-registration-admin",
555 params(("window_secs" = Option<i64>, Query, description = "Window length in seconds; the same length before it is the comparison")),
556 responses(
557 (status = 200, description = "Per-code counts and the window's verdicts", body = CreditRegistrationErrorsByCode)
558 )
559)]
560pub async fn get_credit_registration_errors_by_code(
561 user: AuthUser,
562 pool: web::Data<PgPool>,
563 query: web::Query<ErrorWindowQuery>,
564) -> ControllerResult<web::Json<CreditRegistrationErrorsByCode>> {
565 let mut conn = pool.acquire().await?;
566 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
567
568 let window_secs = query
569 .window_secs
570 .unwrap_or(DEFAULT_ERROR_WINDOW_SECS)
571 .clamp(60, MAX_ERROR_WINDOW_SECS);
572 let live_counts = credit_registrations::count_by_error_code(&mut conn).await?;
573 let codes =
574 credit_registration_events::get_error_code_counts_for_window(&mut conn, window_secs)
575 .await?
576 .into_iter()
577 .map(|row| {
578 let live_count = live_counts
579 .iter()
580 .find(|live| live.error_code == row.error_code)
581 .map_or(0, |live| live.live_count);
582 to_error_code_window(row, live_count)
583 })
584 .collect();
585 let totals = credit_registrations::count_terminal_outcomes_since(
586 &mut conn,
587 Utc::now() - chrono::Duration::seconds(window_secs),
588 )
589 .await?;
590
591 token.authorized_ok(web::Json(CreditRegistrationErrorsByCode {
592 window_secs,
593 codes,
594 verdicts: CreditRegistrationTerminalVerdicts {
595 registered_count: totals.registered_count,
596 duplicate_and_not_improved_count: totals.success_count - totals.registered_count,
597 failed_permanent_count: totals.failed_permanent_count,
598 cancelled_count: totals.cancelled_count,
599 total_count: totals.total_count,
600 },
601 }))
602}
603
604pub(super) fn to_attention_item(
605 row: AttentionRegistration,
606 problems: &BlockingProblems,
607 manual_linking_email_sends: Vec<AdminManualLinkingEmailSend>,
608 notes: Vec<CreditRegistrationNote>,
609) -> CreditRegistrationAttentionItem {
610 let position = row.position();
611 let standing = row.standing(problems);
612 let waits_on = WaitsOn::of(
613 position.step,
614 position.engagement,
615 standing == AttentionStanding::NeedsAttention,
616 row.needs_course_setup(),
617 );
618 CreditRegistrationAttentionItem {
619 blocking_problem: (standing == AttentionStanding::ExplainedByProblem)
620 .then(|| row.blocking_problem(problems))
621 .flatten(),
622 hand_actions: row
623 .resubmission_facts()
624 .hand_actions(ResubmissionStrictness::AnyExceptSubmissionUncertain),
625 timeline_step: position.step,
626 phase: position.step.phase(),
627 engagement: position.engagement,
628 waits_on,
629 standing,
630 credit_registration_id: row.id,
631 user_id: row.user_id,
632 first_name: row.first_name,
633 last_name: row.last_name,
634 email: row.email,
635 course_id: row.course_id,
636 course_name: row.course_name,
637 course_module_id: row.course_module_id,
638 course_module_name: row.course_module_name,
639 uh_course_code: row.uh_course_code,
640 state: row.state,
641 state_changed_at: row.state_changed_at,
642 phase_started_at: row.phase_started_at,
643 error_code: row.error_code,
644 attempt_count: row.attempt_count,
645 next_attempt_at: row.next_attempt_at,
646 student_number: expose_option(&row.student_number).map(str::to_owned),
647 reasons: row.reasons,
648 pressed_at: row.pressed_at,
649 enrolment_route: row.enrolment_route,
650 last_mailing_fetch_started_at: row.last_mailing_fetch_started_at,
651 is_enrolment_list_empty: row.is_enrolment_list_empty,
652 needs_admin_attention: row.needs_admin_attention,
653 manual_linking_email_sends,
654 notes,
655 }
656}
657
658fn to_dismissal(row: AttentionDismissal) -> CreditRegistrationAttentionDismissal {
659 CreditRegistrationAttentionDismissal {
660 credit_registration_id: row.credit_registration_id,
661 user_id: row.user_id,
662 first_name: row.first_name,
663 last_name: row.last_name,
664 email: row.email,
665 course_id: row.course_id,
666 course_name: row.course_name,
667 dismissed_reasons: row.dismissed_reasons,
668 dismissed_at: row.dismissed_at,
669 dismissed_by_user_id: row.dismissed_by_user_id,
670 dismissed_by_first_name: row.dismissed_by_first_name,
671 dismissed_by_last_name: row.dismissed_by_last_name,
672 reason: row.reason,
673 }
674}
675
676fn to_error_code_window(
677 row: ErrorCodeWindowCounts,
678 live_count: i64,
679) -> CreditRegistrationErrorCodeWindow {
680 CreditRegistrationErrorCodeWindow {
681 live_count,
682 retryability: retryability(row.error_code),
683 error_code: row.error_code,
684 current_count: row.current_count,
685 previous_count: row.previous_count,
686 user_count: row.user_count,
687 course_count: row.course_count,
688 first_seen_at: row.first_seen_at,
689 last_seen_at: row.last_seen_at,
690 endpoints: row.endpoints,
691 }
692}
693
694pub fn _add_routes(cfg: &mut ServiceConfig) {
695 cfg.route(
696 "/thresholds",
697 web::get().to(get_credit_registration_thresholds),
698 )
699 .route(
700 "/attention",
701 web::get().to(get_credit_registration_attention_items),
702 )
703 .route(
704 "/errors/by-code",
705 web::get().to(get_credit_registration_errors_by_code),
706 )
707 .route(
708 "/registrations/{credit_registration_id}/dismiss-attention",
709 web::post().to(admin_dismiss_credit_registration_attention),
710 )
711 .route(
712 "/registrations/{credit_registration_id}/notes",
713 web::post().to(admin_add_credit_registration_note),
714 );
715}