headless_lms_models/library/credit_registration/
student_notifications.rs1use std::collections::HashMap;
9
10use utoipa::ToSchema;
11
12use crate::credit_registrations::{CreditRegistrationState, RegistrationScope};
13use crate::email_deliveries::{EmailSendStatusReport, get_send_statuses};
14use crate::email_templates::EmailTemplateType;
15use crate::prelude::*;
16
17pub const STUDENT_NOTIFICATION_LIMIT: i64 = 200;
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum CreditRegistrationNotificationKind {
24 ActionNeeded,
26 Registered,
28}
29
30impl CreditRegistrationNotificationKind {
31 pub fn email_template_type(self) -> EmailTemplateType {
32 match self {
33 Self::ActionNeeded => EmailTemplateType::CreditRegistrationActionNeeded,
34 Self::Registered => EmailTemplateType::CreditRegistrationRegistered,
35 }
36 }
37
38 pub fn for_state(state: CreditRegistrationState) -> Option<Self> {
42 match state {
43 CreditRegistrationState::NoUsableEnrolment => Some(Self::ActionNeeded),
44 _ if CreditRegistrationState::SUCCESS_STATES.contains(&state) => Some(Self::Registered),
45 _ => None,
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct StudentNotificationToQueue {
53 pub credit_registration_id: Uuid,
54 pub kind: CreditRegistrationNotificationKind,
55 pub user_id: Uuid,
56 pub course_module_id: Uuid,
57 pub course_name: String,
58 pub course_language_code: String,
59 pub course_module_name: Option<String>,
60 pub first_name: Option<String>,
61 pub credits: Option<f32>,
64}
65
66pub async fn claim_unnotified(
71 conn: &mut PgConnection,
72 scope: &RegistrationScope,
73 limit: i64,
74) -> ModelResult<Vec<StudentNotificationToQueue>> {
75 let res = sqlx::query!(
76 r#"
77SELECT cr.id AS "credit_registration_id!",
78 cr.state AS "state!: CreditRegistrationState",
79 cr.user_id AS "user_id!",
80 cr.course_module_id AS "course_module_id!",
81 c.name AS "course_name!",
82 c.language_code AS "course_language_code!",
83 cm.name AS "course_module_name?",
84 ud.first_name AS "first_name?",
85 COALESCE(cr.credits, cm.ects_credits) AS "credits?"
86FROM credit_registrations cr
87 JOIN courses c ON c.id = cr.course_id AND c.deleted_at IS NULL
88 JOIN course_modules cm ON cm.id = cr.course_module_id AND cm.deleted_at IS NULL
89 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
90WHERE cr.deleted_at IS NULL
91 AND (
92 (
93 cr.state = 'no_usable_enrolment'
94 AND cr.action_needed_email_delivery_id IS NULL
95 )
96 OR (
97 cr.state = ANY($5::credit_registration_state [])
98 AND cr.registered_email_delivery_id IS NULL
99 -- A credit found already recorded is no news to a student told of one for the module.
100 AND NOT (
101 cr.state = ANY($6::credit_registration_state [])
102 AND EXISTS (
103 SELECT 1
104 FROM credit_registrations told
105 WHERE told.user_id = cr.user_id
106 AND told.course_module_id = cr.course_module_id
107 AND told.id <> cr.id
108 AND told.deleted_at IS NULL
109 AND (
110 told.registered_email_delivery_id IS NOT NULL
111 OR told.state = 'registered'
112 )
113 )
114 )
115 )
116 )
117 AND ($2::uuid IS NULL OR cr.course_id = $2)
118 AND ($3::uuid IS NULL OR cr.user_id = $3)
119 AND (
120 cardinality($4::uuid []) = 0
121 OR cr.id = ANY($4::uuid [])
122 )
123ORDER BY cr.state_entered_at
124FOR UPDATE OF cr SKIP LOCKED
125LIMIT $1
126 "#,
127 limit,
128 scope.course_id,
129 scope.user_id,
130 &scope.credit_registration_ids,
131 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
132 &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
133 )
134 .fetch_all(conn)
135 .await?;
136
137 Ok(res
138 .into_iter()
139 .map(|row| StudentNotificationToQueue {
140 kind: CreditRegistrationNotificationKind::for_state(row.state)
141 .unwrap_or(CreditRegistrationNotificationKind::Registered),
142 credit_registration_id: row.credit_registration_id,
143 user_id: row.user_id,
144 course_module_id: row.course_module_id,
145 course_name: row.course_name,
146 course_language_code: row.course_language_code,
147 course_module_name: row.course_module_name,
148 first_name: row.first_name,
149 credits: row.credits,
150 })
151 .collect())
152}
153
154pub async fn set_email_delivery_id(
156 conn: &mut PgConnection,
157 credit_registration_id: Uuid,
158 kind: CreditRegistrationNotificationKind,
159 email_delivery_id: Uuid,
160) -> ModelResult<()> {
161 let action_needed = kind == CreditRegistrationNotificationKind::ActionNeeded;
162 sqlx::query!(
163 r#"
164UPDATE credit_registrations
165SET action_needed_email_delivery_id = CASE
166 WHEN $3 THEN $2
167 ELSE action_needed_email_delivery_id
168 END,
169 registered_email_delivery_id = CASE
170 WHEN $3 THEN registered_email_delivery_id
171 ELSE $2
172 END
173WHERE id = $1
174 AND deleted_at IS NULL
175 "#,
176 credit_registration_id,
177 email_delivery_id,
178 action_needed,
179 )
180 .execute(conn)
181 .await?;
182 Ok(())
183}
184
185#[derive(Debug, Clone, PartialEq)]
187pub struct RegistrationNotificationEmail {
188 pub credit_registration_id: Uuid,
189 pub kind: CreditRegistrationNotificationKind,
190 pub email_delivery_id: Uuid,
193 pub send_status: EmailSendStatusReport,
194}
195
196pub async fn get_for_registrations(
199 conn: &mut PgConnection,
200 credit_registration_ids: &[Uuid],
201) -> ModelResult<Vec<RegistrationNotificationEmail>> {
202 let rows = sqlx::query!(
203 r#"
204SELECT id,
205 action_needed_email_delivery_id,
206 registered_email_delivery_id
207FROM credit_registrations
208WHERE id = ANY($1::uuid [])
209 AND deleted_at IS NULL
210 AND (
211 action_needed_email_delivery_id IS NOT NULL
212 OR registered_email_delivery_id IS NOT NULL
213 )
214 "#,
215 credit_registration_ids
216 )
217 .fetch_all(&mut *conn)
218 .await?;
219
220 let delivery_ids: Vec<Uuid> = rows
221 .iter()
222 .flat_map(|row| {
223 [
224 row.action_needed_email_delivery_id,
225 row.registered_email_delivery_id,
226 ]
227 })
228 .flatten()
229 .collect();
230 let reports: HashMap<Uuid, EmailSendStatusReport> =
231 get_send_statuses(conn, &delivery_ids).await?;
232
233 let mut res = Vec::new();
234 for row in rows {
235 for (kind, delivery_id) in [
236 (
237 CreditRegistrationNotificationKind::ActionNeeded,
238 row.action_needed_email_delivery_id,
239 ),
240 (
241 CreditRegistrationNotificationKind::Registered,
242 row.registered_email_delivery_id,
243 ),
244 ] {
245 let Some((delivery_id, report)) =
246 delivery_id.and_then(|id| reports.get(&id).map(|report| (id, report)))
247 else {
248 continue;
249 };
250 res.push(RegistrationNotificationEmail {
251 credit_registration_id: row.id,
252 kind,
253 email_delivery_id: delivery_id,
254 send_status: report.clone(),
255 });
256 }
257 }
258 Ok(res)
259}