1use utoipa::ToSchema;
2
3use crate::course_modules::CourseModuleSuotarRealisationEdit;
4use crate::credit_registrations::CreditRegistrationErrorCode;
5use crate::prelude::*;
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
8pub struct CourseModuleSuotarRealisation {
9 pub id: Uuid,
10 pub created_at: DateTime<Utc>,
11 pub updated_at: DateTime<Utc>,
12 pub deleted_at: Option<DateTime<Utc>>,
13 pub course_module_id: Uuid,
14 pub course_unit_realisation_id: String,
15 pub label: Option<String>,
16 pub active: bool,
17 pub last_listed_at: Option<DateTime<Utc>>,
18 pub last_listed_person_count: Option<i32>,
19 pub last_already_linked_count: Option<i32>,
20 pub last_mailed_count: Option<i32>,
21 pub last_suppressed_by_dedup_count: Option<i32>,
22 pub last_suppressed_by_rate_cap_count: Option<i32>,
23 pub last_no_address_count: Option<i32>,
24 pub last_fast_tracked_count: Option<i32>,
25 pub last_fast_track_skipped_no_account_count: Option<i32>,
26 pub last_fast_track_skipped_unverified_count: Option<i32>,
27 pub last_fast_track_skipped_stale_verification_count: Option<i32>,
28 pub last_fast_track_skipped_name_mismatch_count: Option<i32>,
29 pub last_fast_track_skipped_account_has_number_count: Option<i32>,
30 pub last_fast_track_skipped_unlinked_before_count: Option<i32>,
31 pub last_listing_attempted_at: Option<DateTime<Utc>>,
32 pub last_listing_error: Option<CreditRegistrationErrorCode>,
33 pub consecutive_listing_failures: i32,
34}
35
36#[derive(Debug, Clone, PartialEq, Default)]
39pub struct RealisationListingOutcome {
40 pub listed_person_count: i32,
41 pub already_linked_count: i32,
42 pub mailed_count: i32,
43 pub suppressed_by_dedup_count: i32,
44 pub suppressed_by_rate_cap_count: i32,
45 pub no_address_count: i32,
46 pub fast_tracked_count: i32,
47 pub fast_track_skipped_no_account_count: i32,
48 pub fast_track_skipped_unverified_count: i32,
49 pub fast_track_skipped_stale_verification_count: i32,
50 pub fast_track_skipped_name_mismatch_count: i32,
51 pub fast_track_skipped_account_has_number_count: i32,
52 pub fast_track_skipped_unlinked_before_count: i32,
53}
54
55pub async fn upsert(
56 conn: &mut PgConnection,
57 course_module_id: Uuid,
58 course_unit_realisation_id: &str,
59 label: Option<&str>,
60 active: bool,
61) -> ModelResult<Uuid> {
62 let res = sqlx::query!(
63 r#"
64INSERT INTO course_module_suotar_realisations (
65 course_module_id,
66 course_unit_realisation_id,
67 label,
68 active
69 )
70VALUES ($1, $2, $3, $4) ON CONFLICT (
71 course_module_id,
72 course_unit_realisation_id,
73 deleted_at
74 ) DO
75UPDATE
76SET label = $3,
77 active = $4
78RETURNING id
79 "#,
80 course_module_id,
81 course_unit_realisation_id,
82 label,
83 active,
84 )
85 .fetch_one(conn)
86 .await?;
87 Ok(res.id)
88}
89
90pub async fn replace_for_course_module(
93 conn: &mut PgConnection,
94 course_module_id: Uuid,
95 wanted: &[CourseModuleSuotarRealisationEdit],
96) -> ModelResult<()> {
97 let existing = get_by_course_module_id(conn, course_module_id).await?;
98 let kept: Vec<&str> = wanted
99 .iter()
100 .map(|row| row.course_unit_realisation_id.trim())
101 .filter(|id| !id.is_empty())
102 .collect();
103 for row in existing {
104 if !kept.contains(&row.course_unit_realisation_id.as_str()) {
105 soft_delete(conn, row.id).await?;
106 }
107 }
108 for row in wanted {
109 let realisation_id = row.course_unit_realisation_id.trim();
110 if realisation_id.is_empty() {
111 continue;
112 }
113 let label = row
114 .label
115 .as_deref()
116 .map(str::trim)
117 .filter(|l| !l.is_empty());
118 upsert(conn, course_module_id, realisation_id, label, row.active).await?;
119 }
120 Ok(())
121}
122
123async fn realisations_for(
126 conn: &mut PgConnection,
127 course_module_id: Option<Uuid>,
128 course_id: Option<Uuid>,
129) -> ModelResult<Vec<CourseModuleSuotarRealisation>> {
130 let res = sqlx::query_as!(
131 CourseModuleSuotarRealisation,
132 r#"
133SELECT cmsr.id,
134 cmsr.created_at,
135 cmsr.updated_at,
136 cmsr.deleted_at,
137 cmsr.course_module_id,
138 cmsr.course_unit_realisation_id,
139 cmsr.label,
140 cmsr.active,
141 cmsr.last_listed_at,
142 cmsr.last_listed_person_count,
143 cmsr.last_already_linked_count,
144 cmsr.last_mailed_count,
145 cmsr.last_suppressed_by_dedup_count,
146 cmsr.last_suppressed_by_rate_cap_count,
147 cmsr.last_no_address_count,
148 cmsr.last_fast_tracked_count,
149 cmsr.last_fast_track_skipped_no_account_count,
150 cmsr.last_fast_track_skipped_unverified_count,
151 cmsr.last_fast_track_skipped_stale_verification_count,
152 cmsr.last_fast_track_skipped_name_mismatch_count,
153 cmsr.last_fast_track_skipped_account_has_number_count,
154 cmsr.last_fast_track_skipped_unlinked_before_count,
155 cmsr.last_listing_attempted_at,
156 cmsr.last_listing_error AS "last_listing_error?",
157 cmsr.consecutive_listing_failures
158FROM course_module_suotar_realisations cmsr
159 JOIN course_modules cm ON cm.id = cmsr.course_module_id
160WHERE ($1::uuid IS NULL OR cmsr.course_module_id = $1)
161 AND ($2::uuid IS NULL OR cm.course_id = $2)
162 AND cm.deleted_at IS NULL
163 AND cmsr.deleted_at IS NULL
164ORDER BY cm.order_number,
165 cmsr.created_at
166 "#,
167 course_module_id,
168 course_id,
169 )
170 .fetch_all(conn)
171 .await?;
172 Ok(res)
173}
174
175pub async fn get_by_course_module_id(
176 conn: &mut PgConnection,
177 course_module_id: Uuid,
178) -> ModelResult<Vec<CourseModuleSuotarRealisation>> {
179 realisations_for(conn, Some(course_module_id), None).await
180}
181
182pub async fn get_by_course_id(
183 conn: &mut PgConnection,
184 course_id: Uuid,
185) -> ModelResult<Vec<CourseModuleSuotarRealisation>> {
186 realisations_for(conn, None, Some(course_id)).await
187}
188
189pub fn listing_request_item_id(realisation_id: Uuid) -> String {
192 format!("cur-{realisation_id}")
193}
194
195#[derive(Debug, Clone, PartialEq)]
197pub struct RealisationToList {
198 pub id: Uuid,
199 pub course_module_id: Uuid,
200 pub course_id: Uuid,
201 pub course_unit_realisation_id: String,
202 pub uh_course_code: Option<String>,
204 pub course_language_code: String,
206}
207
208pub async fn claim_stalest_for_listing(
218 conn: &mut PgConnection,
219 limit: i64,
220 course_id: Option<Uuid>,
221) -> ModelResult<Vec<RealisationToList>> {
222 let res = sqlx::query_as!(
223 RealisationToList,
224 r#"
225SELECT cmsr.id AS "id!",
226 cmsr.course_module_id AS "course_module_id!",
227 cm.course_id AS "course_id!",
228 cmsr.course_unit_realisation_id AS "course_unit_realisation_id!",
229 cm.uh_course_code AS "uh_course_code?",
230 co.language_code AS "course_language_code!"
231FROM course_module_suotar_realisations cmsr
232 JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cmsr.course_module_id
233 JOIN course_modules cm ON cm.id = acm.course_module_id
234 JOIN courses co ON co.id = acm.course_id
235WHERE cmsr.active
236 AND cmsr.deleted_at IS NULL
237 AND ($2::uuid IS NULL OR acm.course_id = $2)
238ORDER BY COALESCE(cmsr.last_listing_attempted_at, cmsr.last_listed_at) ASC NULLS FIRST,
239 cmsr.id
240LIMIT $1
241FOR UPDATE OF cmsr SKIP LOCKED
242 "#,
243 limit,
244 course_id,
245 )
246 .fetch_all(&mut *conn)
247 .await?;
248 let ids: Vec<Uuid> = res.iter().map(|row| row.id).collect();
249 if !ids.is_empty() {
250 sqlx::query!(
251 r#"
252UPDATE course_module_suotar_realisations
253SET last_listing_attempted_at = now()
254WHERE id = ANY($1)
255 "#,
256 &ids,
257 )
258 .execute(conn)
259 .await?;
260 }
261 Ok(res)
262}
263
264pub async fn get_active_for_course(
267 conn: &mut PgConnection,
268 course_id: Uuid,
269) -> ModelResult<Vec<RealisationToList>> {
270 let res = sqlx::query_as!(
271 RealisationToList,
272 r#"
273SELECT cmsr.id AS "id!",
274 cmsr.course_module_id AS "course_module_id!",
275 cm.course_id AS "course_id!",
276 cmsr.course_unit_realisation_id AS "course_unit_realisation_id!",
277 cm.uh_course_code AS "uh_course_code?",
278 co.language_code AS "course_language_code!"
279FROM course_module_suotar_realisations cmsr
280 JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cmsr.course_module_id
281 JOIN course_modules cm ON cm.id = acm.course_module_id
282 JOIN courses co ON co.id = acm.course_id
283WHERE cmsr.active
284 AND cmsr.deleted_at IS NULL
285 AND acm.course_id = $1
286ORDER BY cmsr.id
287 "#,
288 course_id,
289 )
290 .fetch_all(conn)
291 .await?;
292 Ok(res)
293}
294
295#[derive(Debug, Clone, PartialEq)]
299pub struct RealisationDiscoveryReport {
300 pub id: Uuid,
301 pub course_id: Uuid,
302 pub course_name: String,
303 pub course_module_id: Uuid,
304 pub course_module_name: Option<String>,
305 pub course_unit_realisation_id: String,
306 pub label: Option<String>,
307 pub uh_course_code: Option<String>,
308 pub last_listed_at: Option<DateTime<Utc>>,
309 pub last_listed_person_count: Option<i32>,
310 pub last_already_linked_count: Option<i32>,
311 pub last_mailed_count: Option<i32>,
312 pub last_suppressed_by_dedup_count: Option<i32>,
313 pub last_suppressed_by_rate_cap_count: Option<i32>,
314 pub last_no_address_count: Option<i32>,
315 pub last_fast_tracked_count: Option<i32>,
316 pub last_fast_track_skipped_no_account_count: Option<i32>,
317 pub last_fast_track_skipped_unverified_count: Option<i32>,
318 pub last_fast_track_skipped_stale_verification_count: Option<i32>,
319 pub last_fast_track_skipped_name_mismatch_count: Option<i32>,
320 pub last_fast_track_skipped_account_has_number_count: Option<i32>,
321 pub last_fast_track_skipped_unlinked_before_count: Option<i32>,
322 pub last_listing_attempted_at: Option<DateTime<Utc>>,
323 pub last_listing_error: Option<CreditRegistrationErrorCode>,
324 pub consecutive_listing_failures: i32,
325}
326
327pub async fn get_active_discovery_reports(
328 conn: &mut PgConnection,
329) -> ModelResult<Vec<RealisationDiscoveryReport>> {
330 let res = sqlx::query_as!(
331 RealisationDiscoveryReport,
332 r#"
333SELECT cmsr.id,
334 cm.course_id,
335 c.name AS course_name,
336 cmsr.course_module_id,
337 cm.name AS course_module_name,
338 cmsr.course_unit_realisation_id,
339 cmsr.label,
340 cm.uh_course_code,
341 cmsr.last_listed_at,
342 cmsr.last_listed_person_count,
343 cmsr.last_already_linked_count,
344 cmsr.last_mailed_count,
345 cmsr.last_suppressed_by_dedup_count,
346 cmsr.last_suppressed_by_rate_cap_count,
347 cmsr.last_no_address_count,
348 cmsr.last_fast_tracked_count,
349 cmsr.last_fast_track_skipped_no_account_count,
350 cmsr.last_fast_track_skipped_unverified_count,
351 cmsr.last_fast_track_skipped_stale_verification_count,
352 cmsr.last_fast_track_skipped_name_mismatch_count,
353 cmsr.last_fast_track_skipped_account_has_number_count,
354 cmsr.last_fast_track_skipped_unlinked_before_count,
355 cmsr.last_listing_attempted_at,
356 cmsr.last_listing_error AS "last_listing_error?: CreditRegistrationErrorCode",
357 cmsr.consecutive_listing_failures
358FROM course_module_suotar_realisations cmsr
359 JOIN course_modules cm ON cm.id = cmsr.course_module_id
360 JOIN courses c ON c.id = cm.course_id
361WHERE cmsr.active
362 AND cmsr.deleted_at IS NULL
363 AND cm.deleted_at IS NULL
364 AND c.deleted_at IS NULL
365ORDER BY c.name,
366 cmsr.course_unit_realisation_id
367 "#,
368 )
369 .fetch_all(conn)
370 .await?;
371 Ok(res)
372}
373
374pub async fn mark_listing_failed(
378 conn: &mut PgConnection,
379 id: Uuid,
380 error: CreditRegistrationErrorCode,
381) -> ModelResult<()> {
382 sqlx::query!(
383 r#"
384UPDATE course_module_suotar_realisations
385SET last_listing_attempted_at = now(),
386 last_listing_error = $2,
387 consecutive_listing_failures = consecutive_listing_failures + 1
388WHERE id = $1
389 AND deleted_at IS NULL
390 "#,
391 id,
392 error as CreditRegistrationErrorCode,
393 )
394 .execute(conn)
395 .await?;
396 Ok(())
397}
398
399pub async fn record_listing_outcome(
400 conn: &mut PgConnection,
401 id: Uuid,
402 outcome: &RealisationListingOutcome,
403) -> ModelResult<()> {
404 sqlx::query!(
405 r#"
406UPDATE course_module_suotar_realisations
407SET last_listed_at = now(),
408 last_listing_attempted_at = now(),
409 last_listing_error = NULL,
410 consecutive_listing_failures = 0,
411 last_listed_person_count = $2,
412 last_already_linked_count = $3,
413 last_mailed_count = $4,
414 last_suppressed_by_dedup_count = $5,
415 last_suppressed_by_rate_cap_count = $6,
416 last_no_address_count = $7,
417 last_fast_tracked_count = $8,
418 last_fast_track_skipped_no_account_count = $9,
419 last_fast_track_skipped_unverified_count = $10,
420 last_fast_track_skipped_stale_verification_count = $11,
421 last_fast_track_skipped_name_mismatch_count = $12,
422 last_fast_track_skipped_account_has_number_count = $13,
423 last_fast_track_skipped_unlinked_before_count = $14
424WHERE id = $1
425 AND deleted_at IS NULL
426 "#,
427 id,
428 outcome.listed_person_count,
429 outcome.already_linked_count,
430 outcome.mailed_count,
431 outcome.suppressed_by_dedup_count,
432 outcome.suppressed_by_rate_cap_count,
433 outcome.no_address_count,
434 outcome.fast_tracked_count,
435 outcome.fast_track_skipped_no_account_count,
436 outcome.fast_track_skipped_unverified_count,
437 outcome.fast_track_skipped_stale_verification_count,
438 outcome.fast_track_skipped_name_mismatch_count,
439 outcome.fast_track_skipped_account_has_number_count,
440 outcome.fast_track_skipped_unlinked_before_count,
441 )
442 .execute(conn)
443 .await?;
444 Ok(())
445}
446
447pub async fn sum_last_fast_track_name_mismatches(conn: &mut PgConnection) -> ModelResult<i64> {
452 let count = sqlx::query_scalar!(
453 r#"
454SELECT COALESCE(
455 SUM(last_fast_track_skipped_name_mismatch_count),
456 0
457 ) AS "count!"
458FROM course_module_suotar_realisations
459WHERE active
460 AND deleted_at IS NULL
461 "#,
462 )
463 .fetch_one(conn)
464 .await?;
465 Ok(count)
466}
467
468pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
469 sqlx::query!(
470 r#"
471UPDATE course_module_suotar_realisations
472SET deleted_at = now()
473WHERE id = $1
474 AND deleted_at IS NULL
475 "#,
476 id
477 )
478 .execute(conn)
479 .await?;
480 Ok(())
481}