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,
27 Registered,
29}
30
31impl CreditRegistrationNotificationKind {
32 pub fn email_template_type(self) -> EmailTemplateType {
33 match self {
34 Self::ActionNeeded => EmailTemplateType::CreditRegistrationActionNeeded,
35 Self::Registered => EmailTemplateType::CreditRegistrationRegistered,
36 }
37 }
38
39 pub fn for_state(state: CreditRegistrationState) -> Option<Self> {
43 match state {
44 CreditRegistrationState::NoUsableEnrolment | CreditRegistrationState::Pending => {
45 Some(Self::ActionNeeded)
46 }
47 _ if CreditRegistrationState::SUCCESS_STATES.contains(&state) => Some(Self::Registered),
48 _ => None,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct StudentNotificationToQueue {
56 pub credit_registration_id: Uuid,
57 pub kind: CreditRegistrationNotificationKind,
58 pub user_id: Uuid,
59 pub course_module_id: Uuid,
60 pub course_name: String,
61 pub course_language_code: String,
62 pub course_module_name: Option<String>,
63 pub first_name: Option<String>,
64 pub credits: Option<f32>,
67 pub enrolment_link: Option<String>,
68}
69
70pub async fn claim_unnotified(
77 conn: &mut PgConnection,
78 scope: &RegistrationScope,
79 limit: i64,
80 is_account_linking_enabled: bool,
81) -> ModelResult<Vec<StudentNotificationToQueue>> {
82 let res = sqlx::query!(
83 r#"
84SELECT cr.id AS "credit_registration_id!",
85 cr.state AS "state!: CreditRegistrationState",
86 cr.user_id AS "user_id!",
87 cr.course_module_id AS "course_module_id!",
88 c.name AS "course_name!",
89 c.language_code AS "course_language_code!",
90 cm.name AS "course_module_name?",
91 ud.first_name AS "first_name?",
92 COALESCE(cr.credits, cm.ects_credits) AS "credits?",
93 NULLIF(TRIM(cm.completion_registration_link_override), '') AS "enrolment_link?"
94FROM credit_registrations cr
95 JOIN courses c ON c.id = cr.course_id AND c.deleted_at IS NULL
96 JOIN course_modules cm ON cm.id = cr.course_module_id AND cm.deleted_at IS NULL
97 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
98WHERE cr.deleted_at IS NULL
99 AND (
100 (
101 cr.state = 'no_usable_enrolment'
102 AND cr.action_needed_email_delivery_id IS NULL
103 )
104 OR (
105 cr.state = 'pending'
106 AND $7
107 AND cr.action_needed_email_delivery_id IS NULL
108 AND NOT EXISTS (
109 SELECT 1
110 FROM verified_student_numbers vsn
111 WHERE vsn.user_id = cr.user_id
112 AND vsn.deleted_at IS NULL
113 )
114 )
115 OR (
116 cr.state = ANY($5::credit_registration_state [])
117 AND cr.registered_email_delivery_id IS NULL
118 -- A credit found already recorded is no news to a student told of one for the module.
119 AND NOT (
120 cr.state = ANY($6::credit_registration_state [])
121 AND EXISTS (
122 SELECT 1
123 FROM credit_registrations told
124 WHERE told.user_id = cr.user_id
125 AND told.course_module_id = cr.course_module_id
126 AND told.id <> cr.id
127 AND told.deleted_at IS NULL
128 AND (
129 told.registered_email_delivery_id IS NOT NULL
130 OR told.state = 'registered'
131 )
132 )
133 )
134 )
135 )
136 AND ($2::uuid IS NULL OR cr.course_id = $2)
137 AND ($3::uuid IS NULL OR cr.user_id = $3)
138 AND (
139 cardinality($4::uuid []) = 0
140 OR cr.id = ANY($4::uuid [])
141 )
142ORDER BY cr.state_entered_at
143FOR UPDATE OF cr SKIP LOCKED
144LIMIT $1
145 "#,
146 limit,
147 scope.course_id,
148 scope.user_id,
149 &scope.credit_registration_ids,
150 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
151 &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
152 is_account_linking_enabled,
153 )
154 .fetch_all(conn)
155 .await?;
156
157 Ok(res
158 .into_iter()
159 .map(|row| StudentNotificationToQueue {
160 kind: CreditRegistrationNotificationKind::for_state(row.state)
161 .unwrap_or(CreditRegistrationNotificationKind::Registered),
162 credit_registration_id: row.credit_registration_id,
163 user_id: row.user_id,
164 course_module_id: row.course_module_id,
165 course_name: row.course_name,
166 course_language_code: row.course_language_code,
167 course_module_name: row.course_module_name,
168 first_name: row.first_name,
169 credits: row.credits,
170 enrolment_link: row.enrolment_link,
171 })
172 .collect())
173}
174
175pub async fn set_email_delivery_id(
177 conn: &mut PgConnection,
178 credit_registration_id: Uuid,
179 kind: CreditRegistrationNotificationKind,
180 email_delivery_id: Uuid,
181) -> ModelResult<()> {
182 let action_needed = kind == CreditRegistrationNotificationKind::ActionNeeded;
183 sqlx::query!(
184 r#"
185UPDATE credit_registrations
186SET action_needed_email_delivery_id = CASE
187 WHEN $3 THEN $2
188 ELSE action_needed_email_delivery_id
189 END,
190 registered_email_delivery_id = CASE
191 WHEN $3 THEN registered_email_delivery_id
192 ELSE $2
193 END
194WHERE id = $1
195 AND deleted_at IS NULL
196 "#,
197 credit_registration_id,
198 email_delivery_id,
199 action_needed,
200 )
201 .execute(conn)
202 .await?;
203 Ok(())
204}
205
206#[derive(Debug, Clone, PartialEq)]
208pub struct RegistrationNotificationEmail {
209 pub credit_registration_id: Uuid,
210 pub kind: CreditRegistrationNotificationKind,
211 pub email_delivery_id: Uuid,
214 pub send_status: EmailSendStatusReport,
215}
216
217pub async fn get_for_registrations(
220 conn: &mut PgConnection,
221 credit_registration_ids: &[Uuid],
222) -> ModelResult<Vec<RegistrationNotificationEmail>> {
223 let rows = sqlx::query!(
224 r#"
225SELECT id,
226 action_needed_email_delivery_id,
227 registered_email_delivery_id
228FROM credit_registrations
229WHERE id = ANY($1::uuid [])
230 AND deleted_at IS NULL
231 AND (
232 action_needed_email_delivery_id IS NOT NULL
233 OR registered_email_delivery_id IS NOT NULL
234 )
235 "#,
236 credit_registration_ids
237 )
238 .fetch_all(&mut *conn)
239 .await?;
240
241 let delivery_ids: Vec<Uuid> = rows
242 .iter()
243 .flat_map(|row| {
244 [
245 row.action_needed_email_delivery_id,
246 row.registered_email_delivery_id,
247 ]
248 })
249 .flatten()
250 .collect();
251 let reports: HashMap<Uuid, EmailSendStatusReport> =
252 get_send_statuses(conn, &delivery_ids).await?;
253
254 let mut res = Vec::new();
255 for row in rows {
256 for (kind, delivery_id) in [
257 (
258 CreditRegistrationNotificationKind::ActionNeeded,
259 row.action_needed_email_delivery_id,
260 ),
261 (
262 CreditRegistrationNotificationKind::Registered,
263 row.registered_email_delivery_id,
264 ),
265 ] {
266 let Some((delivery_id, report)) =
267 delivery_id.and_then(|id| reports.get(&id).map(|report| (id, report)))
268 else {
269 continue;
270 };
271 res.push(RegistrationNotificationEmail {
272 credit_registration_id: row.id,
273 kind,
274 email_delivery_id: delivery_id,
275 send_status: report.clone(),
276 });
277 }
278 }
279 Ok(res)
280}