Skip to main content

headless_lms_models/
course_module_completion_registered_to_study_registries.rs

1use crate::{course_module_completions, prelude::*};
2use rand::RngExt;
3
4#[derive(Clone, PartialEq, Deserialize, Serialize)]
5pub struct CourseModuleCompletionRegisteredToStudyRegistry {
6    pub id: Uuid,
7    pub created_at: DateTime<Utc>,
8    pub updated_at: DateTime<Utc>,
9    pub deleted_at: Option<DateTime<Utc>>,
10    pub course_id: Uuid,
11    pub course_module_completion_id: Uuid,
12    pub course_module_id: Uuid,
13    pub study_registry_registrar_id: Uuid,
14    pub user_id: Uuid,
15    pub real_student_number: String,
16}
17
18#[derive(Clone, PartialEq, Deserialize, Serialize)]
19pub struct NewCourseModuleCompletionRegisteredToStudyRegistry {
20    pub course_id: Uuid,
21    pub course_module_completion_id: Uuid,
22    pub course_module_id: Uuid,
23    pub study_registry_registrar_id: Uuid,
24    pub user_id: Uuid,
25    pub real_student_number: String,
26}
27
28pub async fn insert(
29    conn: &mut PgConnection,
30    pkey_policy: PKeyPolicy<Uuid>,
31    new_completion_registration: &NewCourseModuleCompletionRegisteredToStudyRegistry,
32) -> ModelResult<Uuid> {
33    let res = sqlx::query!(
34        "
35INSERT INTO course_module_completion_registered_to_study_registries (
36    id,
37    course_id,
38    course_module_completion_id,
39    course_module_id,
40    study_registry_registrar_id,
41    user_id,
42    real_student_number
43  )
44VALUES (
45    $1,
46    $2,
47    $3,
48    $4,
49    $5,
50    $6,
51    $7
52)
53RETURNING *
54        ",
55        pkey_policy.into_uuid(),
56        new_completion_registration.course_id,
57        new_completion_registration.course_module_completion_id,
58        new_completion_registration.course_module_id,
59        new_completion_registration.study_registry_registrar_id,
60        new_completion_registration.user_id,
61        new_completion_registration.real_student_number,
62    )
63    .fetch_one(conn)
64    .await?;
65    Ok(res.id)
66}
67
68pub async fn insert_bulk(
69    conn: &mut PgConnection,
70    new_completion_registrations: Vec<NewCourseModuleCompletionRegisteredToStudyRegistry>,
71) -> ModelResult<Vec<Uuid>> {
72    if new_completion_registrations.is_empty() {
73        return Ok(vec![]);
74    }
75
76    // Create separate vectors for each column
77    let ids: Vec<Uuid> = (0..new_completion_registrations.len())
78        .map(|_| Uuid::new_v4())
79        .collect();
80    let course_ids: Vec<Uuid> = new_completion_registrations
81        .iter()
82        .map(|r| r.course_id)
83        .collect();
84    let completion_ids: Vec<Uuid> = new_completion_registrations
85        .iter()
86        .map(|r| r.course_module_completion_id)
87        .collect();
88    let module_ids: Vec<Uuid> = new_completion_registrations
89        .iter()
90        .map(|r| r.course_module_id)
91        .collect();
92    let registrar_ids: Vec<Uuid> = new_completion_registrations
93        .iter()
94        .map(|r| r.study_registry_registrar_id)
95        .collect();
96    let user_ids: Vec<Uuid> = new_completion_registrations
97        .iter()
98        .map(|r| r.user_id)
99        .collect();
100    let student_numbers: Vec<String> = new_completion_registrations
101        .iter()
102        .map(|r| r.real_student_number.clone())
103        .collect();
104
105    let res = sqlx::query!(
106        r#"
107INSERT INTO course_module_completion_registered_to_study_registries (
108    id,
109    course_id,
110    course_module_completion_id,
111    course_module_id,
112    study_registry_registrar_id,
113    user_id,
114    real_student_number
115)
116SELECT * FROM UNNEST(
117    $1::uuid[],
118    $2::uuid[],
119    $3::uuid[],
120    $4::uuid[],
121    $5::uuid[],
122    $6::uuid[],
123    $7::text[]
124)
125RETURNING *
126        "#,
127        &ids[..],
128        &course_ids[..],
129        &completion_ids[..],
130        &module_ids[..],
131        &registrar_ids[..],
132        &user_ids[..],
133        &student_numbers[..],
134    )
135    .fetch_all(conn)
136    .await?;
137
138    Ok(res.into_iter().map(|r| r.id).collect())
139}
140
141#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, Debug)]
142/// An object representing that a completion has been registered to a study registry.
143pub struct RegisteredCompletion {
144    /// Id of the completion that was registered to the study registry.
145    pub completion_id: Uuid,
146    /// The student number the completion was registed to.
147    pub student_number: String,
148    /// The registration date that is visible in the study registry for the user.
149    pub registration_date: DateTime<Utc>,
150}
151
152pub async fn mark_completions_as_registered_to_study_registry(
153    conn: &mut PgConnection,
154    completions: Vec<RegisteredCompletion>,
155    study_registry_registrar_id: Uuid,
156) -> ModelResult<()> {
157    if completions.is_empty() {
158        return Ok(());
159    }
160
161    let ids: Vec<Uuid> = completions.iter().map(|x| x.completion_id).collect();
162    let completions_by_id = course_module_completions::get_by_ids_as_map(conn, &ids).await?;
163
164    // Validate all completions exist before proceeding
165    for completion in &completions {
166        if !completions_by_id.contains_key(&completion.completion_id) {
167            return Err(ModelError::new(
168                ModelErrorType::PreconditionFailed,
169                format!(
170                    "Cannot find completion with id: {}. This completion does not exist in the database.",
171                    completion.completion_id
172                ),
173                None,
174            ));
175        }
176    }
177
178    let new_registrations = completions
179        .into_iter()
180        .map(|completion| {
181            let module_completion = completions_by_id
182                .get(&completion.completion_id)
183                .ok_or_else(|| {
184                    ModelError::new(
185                        ModelErrorType::PreconditionFailed,
186                        format!(
187                            "Completion with id {} not found after validation - this should never happen",
188                            completion.completion_id
189                        ),
190                        None,
191                    )
192                })?;
193            Ok(NewCourseModuleCompletionRegisteredToStudyRegistry {
194                course_id: module_completion.course_id,
195                course_module_completion_id: completion.completion_id,
196                course_module_id: module_completion.course_module_id,
197                study_registry_registrar_id,
198                user_id: module_completion.user_id,
199                real_student_number: completion.student_number,
200            })
201        })
202        .collect::<ModelResult<Vec<_>>>()?;
203
204    let mut tx = conn.begin().await?;
205
206    insert_bulk(&mut tx, new_registrations).await?;
207
208    // Opportunistically clean up duplicate registrations only inside the
209    // authenticated study registry registrar's namespace. We keep the cleanup
210    // probabilistic to limit write amplification on the hot path while still
211    // gradually converging duplicate rows.
212    let mut rng = rand::rng();
213    let should_cleanup = rng.random_range(0..50) == 0;
214    if should_cleanup {
215        cleanup_duplicates_by_registrar_id_and_completion_ids(
216            &mut tx,
217            study_registry_registrar_id,
218            &ids,
219        )
220        .await?;
221    }
222
223    tx.commit().await?;
224
225    Ok(())
226}
227
228pub async fn get_by_id(
229    conn: &mut PgConnection,
230    id: Uuid,
231) -> ModelResult<CourseModuleCompletionRegisteredToStudyRegistry> {
232    let res = sqlx::query_as!(
233        CourseModuleCompletionRegisteredToStudyRegistry,
234        "
235SELECT *
236FROM course_module_completion_registered_to_study_registries
237WHERE id = $1
238  AND deleted_at IS NULL
239        ",
240        id,
241    )
242    .fetch_one(conn)
243    .await?;
244    Ok(res)
245}
246
247pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
248    sqlx::query!(
249        "
250UPDATE course_module_completion_registered_to_study_registries
251SET deleted_at = now()
252WHERE id = $1
253AND deleted_at IS NULL
254        ",
255        id
256    )
257    .execute(conn)
258    .await?;
259    Ok(())
260}
261
262/// Get the number of students that have completed the course
263pub async fn get_count_of_distinct_users_with_registrations_by_course_id(
264    conn: &mut PgConnection,
265    course_id: Uuid,
266) -> ModelResult<i64> {
267    let res = sqlx::query!(
268        "
269SELECT COUNT(DISTINCT user_id) as count
270FROM course_module_completion_registered_to_study_registries
271WHERE course_id = $1
272  AND deleted_at IS NULL
273",
274        course_id,
275    )
276    .fetch_one(conn)
277    .await?;
278    Ok(res.count.unwrap_or(0))
279}
280
281pub async fn get_by_completion_id_and_registrar_id(
282    conn: &mut PgConnection,
283    completion_id: Uuid,
284    study_registry_registrar_id: Uuid,
285) -> ModelResult<Vec<CourseModuleCompletionRegisteredToStudyRegistry>> {
286    let registrations = sqlx::query_as!(
287        CourseModuleCompletionRegisteredToStudyRegistry,
288        r#"
289        SELECT *
290        FROM course_module_completion_registered_to_study_registries
291        WHERE course_module_completion_id = $1 AND study_registry_registrar_id = $2
292        AND deleted_at IS NULL
293        "#,
294        completion_id,
295        study_registry_registrar_id
296    )
297    .fetch_all(conn)
298    .await?;
299
300    Ok(registrations)
301}
302
303/// Returns non-deleted registrations for a registrar scoped to the given completion ids.
304pub async fn get_by_registrar_id_and_completion_ids(
305    conn: &mut PgConnection,
306    study_registry_registrar_id: Uuid,
307    completion_ids: &[Uuid],
308) -> ModelResult<Vec<CourseModuleCompletionRegisteredToStudyRegistry>> {
309    let registrations = sqlx::query_as!(
310        CourseModuleCompletionRegisteredToStudyRegistry,
311        r#"
312SELECT *
313FROM course_module_completion_registered_to_study_registries
314WHERE study_registry_registrar_id = $1
315  AND course_module_completion_id = ANY($2)
316  AND deleted_at IS NULL
317        "#,
318        study_registry_registrar_id,
319        completion_ids
320    )
321    .fetch_all(conn)
322    .await?;
323
324    Ok(registrations)
325}
326
327/// Soft-deletes duplicate non-deleted registrations for a registrar and completion ids.
328///
329/// Keeps the earliest row (stable by `created_at`, then `id`) and marks later rows deleted.
330pub async fn cleanup_duplicates_by_registrar_id_and_completion_ids(
331    conn: &mut PgConnection,
332    study_registry_registrar_id: Uuid,
333    completion_ids: &[Uuid],
334) -> ModelResult<i64> {
335    let res = sqlx::query!(
336        r#"
337WITH duplicate_rows AS (
338  SELECT id,
339    ROW_NUMBER() OVER (
340      PARTITION BY study_registry_registrar_id, course_module_completion_id
341      ORDER BY created_at ASC, id ASC
342    ) AS rn
343  FROM course_module_completion_registered_to_study_registries
344  WHERE study_registry_registrar_id = $1
345    AND course_module_completion_id = ANY($2)
346    AND deleted_at IS NULL
347)
348UPDATE course_module_completion_registered_to_study_registries
349SET deleted_at = NOW()
350WHERE id IN (
351    SELECT id
352    FROM duplicate_rows
353    WHERE rn > 1
354  )
355  AND deleted_at IS NULL
356        "#,
357        study_registry_registrar_id,
358        completion_ids
359    )
360    .execute(conn)
361    .await?;
362
363    Ok(res.rows_affected() as i64)
364}
365
366pub async fn insert_record(
367    conn: &mut PgConnection,
368    course_id: Uuid,
369    completion_id: Uuid,
370    module_id: Uuid,
371    registrar_id: Uuid,
372    user_id: Uuid,
373    real_student_number: &str,
374) -> ModelResult<()> {
375    sqlx::query!(
376        r#"
377        INSERT INTO course_module_completion_registered_to_study_registries (
378            course_id,
379            course_module_completion_id,
380            course_module_id,
381            study_registry_registrar_id,
382            user_id,
383            real_student_number
384        )
385        VALUES ($1,$2,$3,$4,$5,$6)
386        ON CONFLICT DO NOTHING
387        "#,
388        course_id,
389        completion_id,
390        module_id,
391        registrar_id,
392        user_id,
393        real_student_number
394    )
395    .execute(conn)
396    .await?;
397
398    Ok(())
399}
400
401#[cfg(test)]
402mod test {
403    use super::*;
404    use crate::{course_module_completions::CourseModuleCompletionGranter, test_helper::*};
405
406    #[tokio::test]
407    async fn bulk_insert_works() {
408        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
409
410        let registrar_id = crate::study_registry_registrars::insert(
411            tx.as_mut(),
412            PKeyPolicy::Generate,
413            "Test Registrar",
414            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
415        )
416        .await
417        .unwrap();
418
419        let new_completion_id = crate::course_module_completions::insert(
420            tx.as_mut(),
421            PKeyPolicy::Generate,
422            &crate::course_module_completions::NewCourseModuleCompletion {
423                course_id: course,
424                course_module_id: course_module.id,
425                user_id: user,
426                completion_date: Utc::now(),
427                completion_registration_attempt_date: None,
428                completion_language: "en-US".to_string(),
429                eligible_for_ects: true,
430                email: "test@example.com".to_string(),
431                grade: Some(10),
432                passed: true,
433            },
434            CourseModuleCompletionGranter::User(user),
435        )
436        .await
437        .unwrap();
438
439        let registrations = vec![
440            NewCourseModuleCompletionRegisteredToStudyRegistry {
441                course_id: course,
442                course_module_completion_id: new_completion_id.id,
443                course_module_id: course_module.id,
444                study_registry_registrar_id: registrar_id,
445                user_id: user,
446                real_student_number: "12345".to_string(),
447            },
448            NewCourseModuleCompletionRegisteredToStudyRegistry {
449                course_id: course,
450                course_module_completion_id: new_completion_id.id,
451                course_module_id: course_module.id,
452                study_registry_registrar_id: registrar_id,
453                user_id: user,
454                real_student_number: "67890".to_string(),
455            },
456        ];
457
458        let inserted_ids = insert_bulk(tx.as_mut(), registrations).await.unwrap();
459        assert_eq!(inserted_ids.len(), 2);
460
461        // Verify both records were inserted correctly
462        for id in inserted_ids {
463            let registration = get_by_id(tx.as_mut(), id).await.unwrap();
464            assert_eq!(registration.course_id, course);
465            assert_eq!(
466                registration.course_module_completion_id,
467                new_completion_id.id
468            );
469            assert_eq!(registration.course_module_id, course_module.id);
470            assert_eq!(registration.study_registry_registrar_id, registrar_id);
471            assert_eq!(registration.user_id, user);
472        }
473    }
474
475    #[tokio::test]
476    async fn bulk_insert_empty_vec_works() {
477        insert_data!(:tx);
478
479        let empty_vec = vec![];
480        let result = insert_bulk(tx.as_mut(), empty_vec).await.unwrap();
481        assert!(result.is_empty());
482    }
483
484    #[tokio::test]
485    async fn insert_completions_works() {
486        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
487
488        let registrar_id = crate::study_registry_registrars::insert(
489            tx.as_mut(),
490            PKeyPolicy::Generate,
491            "Test Registrar",
492            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
493        )
494        .await
495        .unwrap();
496
497        let completion = crate::course_module_completions::insert(
498            tx.as_mut(),
499            PKeyPolicy::Generate,
500            &crate::course_module_completions::NewCourseModuleCompletion {
501                course_id: course,
502                course_module_id: course_module.id,
503                user_id: user,
504                completion_date: Utc::now(),
505                completion_registration_attempt_date: None,
506                completion_language: "en-US".to_string(),
507                eligible_for_ects: true,
508                email: "test@example.com".to_string(),
509                grade: Some(5),
510                passed: true,
511            },
512            CourseModuleCompletionGranter::User(user),
513        )
514        .await
515        .unwrap();
516
517        let registered_completions = vec![RegisteredCompletion {
518            completion_id: completion.id,
519            student_number: "12345".to_string(),
520            registration_date: Utc::now(),
521        }];
522
523        mark_completions_as_registered_to_study_registry(
524            tx.as_mut(),
525            registered_completions,
526            registrar_id,
527        )
528        .await
529        .unwrap();
530
531        let registrations =
532            get_by_completion_id_and_registrar_id(tx.as_mut(), completion.id, registrar_id)
533                .await
534                .unwrap();
535
536        assert_eq!(registrations.len(), 1);
537        assert_eq!(registrations[0].course_id, course);
538        assert_eq!(registrations[0].course_module_id, course_module.id);
539        assert_eq!(registrations[0].user_id, user);
540        assert_eq!(registrations[0].real_student_number, "12345");
541    }
542
543    #[tokio::test]
544    async fn insert_completions_with_invalid_completion_id_fails() {
545        insert_data!(:tx);
546
547        let registrar_id = crate::study_registry_registrars::insert(
548            tx.as_mut(),
549            PKeyPolicy::Generate,
550            "Test Registrar",
551            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
552        )
553        .await
554        .unwrap();
555
556        let invalid_uuid = Uuid::new_v4(); // This UUID doesn't correspond to any completion
557        let registered_completions = vec![RegisteredCompletion {
558            completion_id: invalid_uuid,
559            student_number: "12345".to_string(),
560            registration_date: Utc::now(),
561        }];
562
563        // Attempt to insert the completions should fail
564        let result = mark_completions_as_registered_to_study_registry(
565            tx.as_mut(),
566            registered_completions,
567            registrar_id,
568        )
569        .await;
570
571        assert!(result.is_err());
572        let error = result.unwrap_err();
573        assert_eq!(*error.error_type(), ModelErrorType::PreconditionFailed);
574        assert!(error.message().contains("Cannot find completion with id"));
575        assert!(error.message().contains(&invalid_uuid.to_string()));
576    }
577
578    #[tokio::test]
579    async fn delete_duplicate_registrations_works() {
580        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
581
582        let registrar_id = crate::study_registry_registrars::insert(
583            tx.as_mut(),
584            PKeyPolicy::Generate,
585            "Test Registrar",
586            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
587        )
588        .await
589        .unwrap();
590
591        let completion = crate::course_module_completions::insert(
592            tx.as_mut(),
593            PKeyPolicy::Generate,
594            &crate::course_module_completions::NewCourseModuleCompletion {
595                course_id: course,
596                course_module_id: course_module.id,
597                user_id: user,
598                completion_date: Utc::now(),
599                completion_registration_attempt_date: None,
600                completion_language: "en-US".to_string(),
601                eligible_for_ects: true,
602                email: "test@example.com".to_string(),
603                grade: Some(5),
604                passed: true,
605            },
606            CourseModuleCompletionGranter::User(user),
607        )
608        .await
609        .unwrap();
610
611        // Create first registration
612        let first_registration = NewCourseModuleCompletionRegisteredToStudyRegistry {
613            course_id: course,
614            course_module_completion_id: completion.id,
615            course_module_id: course_module.id,
616            study_registry_registrar_id: registrar_id,
617            user_id: user,
618            real_student_number: "12345".to_string(),
619        };
620        insert(tx.as_mut(), PKeyPolicy::Generate, &first_registration)
621            .await
622            .unwrap();
623
624        // Create additional registrations in a separate bulk insert so that we get a different created_at timestamp
625        let later_registrations = vec![
626            NewCourseModuleCompletionRegisteredToStudyRegistry {
627                course_id: course,
628                course_module_completion_id: completion.id,
629                course_module_id: course_module.id,
630                study_registry_registrar_id: registrar_id,
631                user_id: user,
632                real_student_number: "67890".to_string(),
633            },
634            NewCourseModuleCompletionRegisteredToStudyRegistry {
635                course_id: course,
636                course_module_completion_id: completion.id,
637                course_module_id: course_module.id,
638                study_registry_registrar_id: registrar_id,
639                user_id: user,
640                real_student_number: "54321".to_string(),
641            },
642        ];
643        insert_bulk(tx.as_mut(), later_registrations).await.unwrap();
644
645        let before_registrations =
646            get_by_completion_id_and_registrar_id(tx.as_mut(), completion.id, registrar_id)
647                .await
648                .unwrap();
649        assert_eq!(before_registrations.len(), 3);
650
651        let deleted_count = cleanup_duplicates_by_registrar_id_and_completion_ids(
652            tx.as_mut(),
653            registrar_id,
654            &[completion.id],
655        )
656        .await
657        .unwrap();
658        assert_eq!(deleted_count, 2); // Should delete 2 out of 3 registrations
659
660        let after_registrations =
661            get_by_completion_id_and_registrar_id(tx.as_mut(), completion.id, registrar_id)
662                .await
663                .unwrap();
664        assert_eq!(after_registrations.len(), 1);
665
666        // The remaining registration should be one of the previously present registrations.
667        assert!(
668            before_registrations
669                .iter()
670                .any(|registration| registration.id == after_registrations[0].id)
671        );
672    }
673
674    #[tokio::test]
675    async fn delete_duplicate_registrations_with_no_duplicates() {
676        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
677
678        let registrar_id = crate::study_registry_registrars::insert(
679            tx.as_mut(),
680            PKeyPolicy::Generate,
681            "Test Registrar",
682            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
683        )
684        .await
685        .unwrap();
686
687        let completion1 = crate::course_module_completions::insert(
688            tx.as_mut(),
689            PKeyPolicy::Generate,
690            &crate::course_module_completions::NewCourseModuleCompletion {
691                course_id: course,
692                course_module_id: course_module.id,
693                user_id: user,
694                completion_date: Utc::now(),
695                completion_registration_attempt_date: None,
696                completion_language: "en-US".to_string(),
697                eligible_for_ects: true,
698                email: "test1@example.com".to_string(),
699                grade: Some(5),
700                passed: true,
701            },
702            CourseModuleCompletionGranter::User(user),
703        )
704        .await
705        .unwrap();
706
707        let completion2 = crate::course_module_completions::insert(
708            tx.as_mut(),
709            PKeyPolicy::Generate,
710            &crate::course_module_completions::NewCourseModuleCompletion {
711                course_id: course,
712                course_module_id: course_module.id,
713                user_id: user,
714                completion_date: Utc::now(),
715                completion_registration_attempt_date: None,
716                completion_language: "en-US".to_string(),
717                eligible_for_ects: true,
718                email: "test2@example.com".to_string(),
719                grade: Some(4),
720                passed: true,
721            },
722            CourseModuleCompletionGranter::User(user),
723        )
724        .await
725        .unwrap();
726
727        // Create one registration for each completion (no duplicates)
728        let registrations = vec![
729            NewCourseModuleCompletionRegisteredToStudyRegistry {
730                course_id: course,
731                course_module_completion_id: completion1.id,
732                course_module_id: course_module.id,
733                study_registry_registrar_id: registrar_id,
734                user_id: user,
735                real_student_number: "12345".to_string(),
736            },
737            NewCourseModuleCompletionRegisteredToStudyRegistry {
738                course_id: course,
739                course_module_completion_id: completion2.id,
740                course_module_id: course_module.id,
741                study_registry_registrar_id: registrar_id,
742                user_id: user,
743                real_student_number: "67890".to_string(),
744            },
745        ];
746        insert_bulk(tx.as_mut(), registrations).await.unwrap();
747
748        // Verify we have 1 registration for each completion
749        let before_reg1 =
750            get_by_completion_id_and_registrar_id(tx.as_mut(), completion1.id, registrar_id)
751                .await
752                .unwrap();
753        let before_reg2 =
754            get_by_completion_id_and_registrar_id(tx.as_mut(), completion2.id, registrar_id)
755                .await
756                .unwrap();
757        assert_eq!(before_reg1.len(), 1);
758        assert_eq!(before_reg2.len(), 1);
759
760        // Delete duplicate registrations
761        let deleted_count = cleanup_duplicates_by_registrar_id_and_completion_ids(
762            tx.as_mut(),
763            registrar_id,
764            &[completion1.id, completion2.id],
765        )
766        .await
767        .unwrap();
768        assert_eq!(deleted_count, 0); // Should delete 0 registrations as there are no duplicates
769
770        // Verify both registrations still exist
771        let after_reg1 =
772            get_by_completion_id_and_registrar_id(tx.as_mut(), completion1.id, registrar_id)
773                .await
774                .unwrap();
775        let after_reg2 =
776            get_by_completion_id_and_registrar_id(tx.as_mut(), completion2.id, registrar_id)
777                .await
778                .unwrap();
779        assert_eq!(after_reg1.len(), 1);
780        assert_eq!(after_reg2.len(), 1);
781    }
782
783    #[tokio::test]
784    async fn delete_duplicate_registrations_filters_by_completion_id() {
785        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
786
787        let registrar_id = crate::study_registry_registrars::insert(
788            tx.as_mut(),
789            PKeyPolicy::Generate,
790            "Test Registrar",
791            "test_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
792        )
793        .await
794        .unwrap();
795
796        // Create two completions
797        let completion1 = crate::course_module_completions::insert(
798            tx.as_mut(),
799            PKeyPolicy::Generate,
800            &crate::course_module_completions::NewCourseModuleCompletion {
801                course_id: course,
802                course_module_id: course_module.id,
803                user_id: user,
804                completion_date: Utc::now(),
805                completion_registration_attempt_date: None,
806                completion_language: "en-US".to_string(),
807                eligible_for_ects: true,
808                email: "test1@example.com".to_string(),
809                grade: Some(5),
810                passed: true,
811            },
812            CourseModuleCompletionGranter::User(user),
813        )
814        .await
815        .unwrap();
816
817        let completion2 = crate::course_module_completions::insert(
818            tx.as_mut(),
819            PKeyPolicy::Generate,
820            &crate::course_module_completions::NewCourseModuleCompletion {
821                course_id: course,
822                course_module_id: course_module.id,
823                user_id: user,
824                completion_date: Utc::now(),
825                completion_registration_attempt_date: None,
826                completion_language: "en-US".to_string(),
827                eligible_for_ects: true,
828                email: "test2@example.com".to_string(),
829                grade: Some(5),
830                passed: true,
831            },
832            CourseModuleCompletionGranter::User(user),
833        )
834        .await
835        .unwrap();
836
837        // Create first registrations (these should be kept)
838        let first_registrations = vec![
839            NewCourseModuleCompletionRegisteredToStudyRegistry {
840                course_id: course,
841                course_module_completion_id: completion1.id,
842                course_module_id: course_module.id,
843                study_registry_registrar_id: registrar_id,
844                user_id: user,
845                real_student_number: "12345-1".to_string(),
846            },
847            NewCourseModuleCompletionRegisteredToStudyRegistry {
848                course_id: course,
849                course_module_completion_id: completion2.id,
850                course_module_id: course_module.id,
851                study_registry_registrar_id: registrar_id,
852                user_id: user,
853                real_student_number: "12345-2".to_string(),
854            },
855        ];
856        insert_bulk(tx.as_mut(), first_registrations).await.unwrap();
857
858        // Create second registrations (these should be deleted as duplicates)
859        let second_registrations = vec![
860            NewCourseModuleCompletionRegisteredToStudyRegistry {
861                course_id: course,
862                course_module_completion_id: completion1.id,
863                course_module_id: course_module.id,
864                study_registry_registrar_id: registrar_id,
865                user_id: user,
866                real_student_number: "67890-1".to_string(),
867            },
868            NewCourseModuleCompletionRegisteredToStudyRegistry {
869                course_id: course,
870                course_module_completion_id: completion2.id,
871                course_module_id: course_module.id,
872                study_registry_registrar_id: registrar_id,
873                user_id: user,
874                real_student_number: "67890-2".to_string(),
875            },
876        ];
877        insert_bulk(tx.as_mut(), second_registrations)
878            .await
879            .unwrap();
880
881        // Verify we have 2 registrations for each completion
882        let before_reg1 =
883            get_by_completion_id_and_registrar_id(tx.as_mut(), completion1.id, registrar_id)
884                .await
885                .unwrap();
886        let before_reg2 =
887            get_by_completion_id_and_registrar_id(tx.as_mut(), completion2.id, registrar_id)
888                .await
889                .unwrap();
890        assert_eq!(before_reg1.len(), 2);
891        assert_eq!(before_reg2.len(), 2);
892
893        // Delete duplicate registrations only for completion1
894        let deleted_count = cleanup_duplicates_by_registrar_id_and_completion_ids(
895            tx.as_mut(),
896            registrar_id,
897            &[completion1.id],
898        )
899        .await
900        .unwrap();
901        assert_eq!(deleted_count, 1); // Should delete 1 duplicate for completion1
902
903        // Verify completion1 now has 1 registration and completion2 still has 2
904        let after_reg1 =
905            get_by_completion_id_and_registrar_id(tx.as_mut(), completion1.id, registrar_id)
906                .await
907                .unwrap();
908        let after_reg2 =
909            get_by_completion_id_and_registrar_id(tx.as_mut(), completion2.id, registrar_id)
910                .await
911                .unwrap();
912        assert_eq!(after_reg1.len(), 1);
913        assert_eq!(after_reg2.len(), 2);
914
915        // Either completion1 duplicate can remain depending on stable tie-break ordering.
916        assert!(
917            after_reg1[0].real_student_number == "12345-1"
918                || after_reg1[0].real_student_number == "67890-1"
919        );
920    }
921
922    #[tokio::test]
923    async fn cleanup_duplicates_is_scoped_to_registrar() {
924        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
925
926        let registrar_a = crate::study_registry_registrars::insert(
927            tx.as_mut(),
928            PKeyPolicy::Generate,
929            "Registrar A",
930            "registrar_a_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
931        )
932        .await
933        .unwrap();
934        let registrar_b = crate::study_registry_registrars::insert(
935            tx.as_mut(),
936            PKeyPolicy::Generate,
937            "Registrar B",
938            "registrar_b_123131231231231231231231231231238971283718927389172893718923712893129837189273891278317892378193971289",
939        )
940        .await
941        .unwrap();
942
943        let completion1 = crate::course_module_completions::insert(
944            tx.as_mut(),
945            PKeyPolicy::Generate,
946            &crate::course_module_completions::NewCourseModuleCompletion {
947                course_id: course,
948                course_module_id: course_module.id,
949                user_id: user,
950                completion_date: Utc::now(),
951                completion_registration_attempt_date: None,
952                completion_language: "en-US".to_string(),
953                eligible_for_ects: true,
954                email: "registrar-scope@example.com".to_string(),
955                grade: Some(5),
956                passed: true,
957            },
958            CourseModuleCompletionGranter::User(user),
959        )
960        .await
961        .unwrap();
962
963        insert_bulk(
964            tx.as_mut(),
965            vec![
966                NewCourseModuleCompletionRegisteredToStudyRegistry {
967                    course_id: course,
968                    course_module_completion_id: completion1.id,
969                    course_module_id: course_module.id,
970                    study_registry_registrar_id: registrar_a,
971                    user_id: user,
972                    real_student_number: "a-1".to_string(),
973                },
974                NewCourseModuleCompletionRegisteredToStudyRegistry {
975                    course_id: course,
976                    course_module_completion_id: completion1.id,
977                    course_module_id: course_module.id,
978                    study_registry_registrar_id: registrar_a,
979                    user_id: user,
980                    real_student_number: "a-2".to_string(),
981                },
982                NewCourseModuleCompletionRegisteredToStudyRegistry {
983                    course_id: course,
984                    course_module_completion_id: completion1.id,
985                    course_module_id: course_module.id,
986                    study_registry_registrar_id: registrar_b,
987                    user_id: user,
988                    real_student_number: "b-1".to_string(),
989                },
990                NewCourseModuleCompletionRegisteredToStudyRegistry {
991                    course_id: course,
992                    course_module_completion_id: completion1.id,
993                    course_module_id: course_module.id,
994                    study_registry_registrar_id: registrar_b,
995                    user_id: user,
996                    real_student_number: "b-2".to_string(),
997                },
998            ],
999        )
1000        .await
1001        .unwrap();
1002
1003        let deleted_count = cleanup_duplicates_by_registrar_id_and_completion_ids(
1004            tx.as_mut(),
1005            registrar_a,
1006            &[completion1.id],
1007        )
1008        .await
1009        .unwrap();
1010        assert_eq!(deleted_count, 1);
1011
1012        let remaining_a =
1013            get_by_completion_id_and_registrar_id(tx.as_mut(), completion1.id, registrar_a)
1014                .await
1015                .unwrap();
1016        let remaining_b =
1017            get_by_completion_id_and_registrar_id(tx.as_mut(), completion1.id, registrar_b)
1018                .await
1019                .unwrap();
1020
1021        assert_eq!(remaining_a.len(), 1);
1022        assert_eq!(remaining_b.len(), 2);
1023    }
1024}