1use crate::config::program_config::ProgramConfig;
2use crate::prelude::*;
3use crate::setup_tracing;
4use dotenvy::dotenv;
5use headless_lms_models::marketing_consents::MarketingMailingListAccessToken;
6use headless_lms_models::marketing_consents::UserEmailSubscription;
7use headless_lms_models::marketing_consents::UserMarketingConsentWithDetails;
8use headless_lms_utils::http::REQWEST_CLIENT;
9use secrecy::ExposeSecret;
10use serde_json::json;
11use sqlx::{PgConnection, PgPool};
12use std::{
13 env,
14 time::{Duration, Instant},
15};
16use uuid::Uuid;
17
18mod batch_client;
19mod mailchimp_ops;
20mod policy_tags;
21
22use batch_client::MAX_MAILCHIMP_BATCH_SIZE;
23use mailchimp_ops::{MailchimpExecutor, MailchimpOperation};
24use policy_tags::sync_policy_tags_for_users;
25use reqwest::Method;
26
27#[derive(Debug, Deserialize)]
28struct MailchimpField {
29 field_id: String,
30 field_name: String,
31}
32
33#[derive(Debug)]
34struct FieldSchema {
35 tag: &'static str,
36 name: &'static str,
37 default_value: &'static str,
38}
39
40const REQUIRED_FIELDS: &[FieldSchema] = &[
41 FieldSchema {
42 tag: "FNAME",
43 name: "First Name",
44 default_value: "",
45 },
46 FieldSchema {
47 tag: "LNAME",
48 name: "Last Name",
49 default_value: "",
50 },
51 FieldSchema {
52 tag: "MARKETING",
53 name: "Accepts Marketing",
54 default_value: "disallowed",
55 },
56 FieldSchema {
57 tag: "LOCALE",
58 name: "Locale",
59 default_value: "en",
60 },
61 FieldSchema {
62 tag: "GRADUATED",
63 name: "Graduated",
64 default_value: "",
65 },
66 FieldSchema {
67 tag: "COURSEID",
68 name: "Course ID",
69 default_value: "",
70 },
71 FieldSchema {
72 tag: "LANGGRPID",
73 name: "Course language Group ID",
74 default_value: "",
75 },
76 FieldSchema {
77 tag: "USERID",
78 name: "User ID",
79 default_value: "",
80 },
81 FieldSchema {
82 tag: "RESEARCH",
83 name: "Research consent",
84 default_value: "false",
85 },
86];
87
88const FIELDS_EXCLUDED_FROM_REMOVING: &[&str] = &["PHONE", "PACE", "COUNTRY", "MMERGE9"];
90const REMOVE_UNSUPPORTED_FIELDS: bool = false;
91const PROCESS_UNSUBSCRIBES_INTERVAL_SECS: u64 = 10_800;
92
93const SYNC_INTERVAL_SECS: u64 = 10;
94const PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD: u32 = 60;
95
96const BATCH_POLL_INTERVAL_SECS: u64 = 10;
97const BATCH_POLL_TIMEOUT_SECS: u64 = 300;
98const BATCH_RESULT_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
101
102#[derive(Debug)]
103struct SyncUser {
104 details: UserMarketingConsentWithDetails,
105 payload: serde_json::Value,
106}
107
108#[derive(Debug)]
109struct EmailSyncResult {
110 user_id: Uuid,
111 user_mailchimp_id: String,
112}
113
114pub async fn main() -> anyhow::Result<()> {
116 initialize_environment()?;
117
118 let config = initialize_configuration().await?;
119
120 let db_pool = initialize_database_pool(&config.database_url).await?;
121 let mut conn = db_pool.acquire().await?;
122
123 let mut interval = tokio::time::interval(Duration::from_secs(SYNC_INTERVAL_SECS));
124 let mut ticks = 0;
125
126 let access_tokens =
127 headless_lms_models::marketing_consents::fetch_all_marketing_mailing_list_access_tokens(
128 &mut conn,
129 )
130 .await?;
131
132 for token in &access_tokens {
134 if let Err(e) = ensure_mailchimp_schema(
135 &token.mailchimp_mailing_list_id,
136 &token.server_prefix,
137 &token.access_token,
138 )
139 .await
140 {
141 error!(
142 "Failed to set up Mailchimp schema for list '{}': {:?}",
143 token.mailchimp_mailing_list_id, e
144 );
145 return Err(e);
146 }
147 }
148
149 info!("Starting mailchimp syncer (periodic reconciliation loop).");
150
151 let mut last_time_unsubscribes_processed = Instant::now();
152 let mut last_time_tags_synced = Instant::now();
153
154 loop {
155 interval.tick().await;
156 ticks += 1;
157
158 if ticks >= PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD {
159 ticks = 0;
160 info!("Still syncing.");
161 }
162 let mut process_unsubscribes = false;
163 if last_time_unsubscribes_processed.elapsed().as_secs()
164 >= PROCESS_UNSUBSCRIBES_INTERVAL_SECS
165 {
166 process_unsubscribes = true;
167 last_time_unsubscribes_processed = Instant::now();
168 };
169
170 if last_time_tags_synced.elapsed().as_secs() >= 3600 {
172 info!("Stage: tag catalog sync (keep local tag metadata aligned with Mailchimp).");
173 for token in &access_tokens {
174 if let Err(e) = sync_tags_from_mailchimp(
175 &mut conn,
176 &token.mailchimp_mailing_list_id,
177 &token.access_token,
178 &token.server_prefix,
179 token.id,
180 token.course_language_group_id,
181 )
182 .await
183 {
184 error!(
185 "Failed to sync tags for list '{}': {:?}",
186 token.mailchimp_mailing_list_id, e
187 );
188 }
189 }
190 last_time_tags_synced = Instant::now();
191 }
192
193 if let Err(e) = sync_contacts(&mut conn, &config, process_unsubscribes).await {
194 error!("Error during synchronization: {:?}", e);
195 if let Ok(sqlx::Error::Io(..)) = e.downcast::<sqlx::Error>() {
196 info!("syncer may have lost its connection to the db, trying to reconnect");
198 conn = db_pool.acquire().await?;
199 }
200 }
201 }
202}
203
204fn initialize_environment() -> anyhow::Result<()> {
206 unsafe { env::set_var("RUST_LOG", "info,actix_web=info,sqlx=warn") };
208 dotenv().ok();
209 setup_tracing()?;
210 Ok(())
211}
212
213struct SyncerConfig {
215 database_url: String,
216}
217
218async fn initialize_configuration() -> anyhow::Result<SyncerConfig> {
220 let database_url = ProgramConfig::database_url_with_default();
221
222 Ok(SyncerConfig { database_url })
223}
224
225async fn initialize_database_pool(database_url: &str) -> anyhow::Result<PgPool> {
227 PgPool::connect(database_url).await.map_err(|e| {
228 anyhow::anyhow!(
229 "Failed to connect to the database at {}: {:?}",
230 database_url,
231 e
232 )
233 })
234}
235
236async fn ensure_mailchimp_schema(
238 list_id: &str,
239 server_prefix: &str,
240 access_token: &DbSecret,
241) -> anyhow::Result<()> {
242 let existing_fields =
243 fetch_current_mailchimp_fields(list_id, server_prefix, access_token).await?;
244
245 if REMOVE_UNSUPPORTED_FIELDS {
246 for field in existing_fields.iter() {
248 if !REQUIRED_FIELDS
249 .iter()
250 .any(|r| r.tag == field.field_name.as_str())
251 && !FIELDS_EXCLUDED_FROM_REMOVING.contains(&field.field_name.as_str())
252 {
253 match remove_field_from_mailchimp(
254 list_id,
255 &field.field_id,
256 server_prefix,
257 access_token,
258 )
259 .await
260 {
261 Err(e) => {
262 warn!("Could not remove field '{}': {}", field.field_name, e);
263 }
264 _ => {
265 info!("Removed field '{}'", field.field_name);
266 }
267 }
268 }
269 }
270 }
271
272 for required_field in REQUIRED_FIELDS.iter() {
274 if !existing_fields
275 .iter()
276 .any(|f| f.field_name == required_field.tag)
277 {
278 match add_field_to_mailchimp(list_id, required_field, server_prefix, access_token).await
279 {
280 Err(e) => {
281 warn!(
282 "Failed to add required field '{}': {}",
283 required_field.name, e
284 );
285 }
286 _ => {
287 info!(
288 "Successfully added required field '{}'",
289 required_field.name
290 );
291 }
292 }
293 } else {
294 info!(
295 "Field '{}' already exists, skipping addition.",
296 required_field.name
297 );
298 }
299 }
300
301 Ok(())
302}
303
304async fn fetch_current_mailchimp_fields(
306 list_id: &str,
307 server_prefix: &str,
308 access_token: &DbSecret,
309) -> Result<Vec<MailchimpField>, anyhow::Error> {
310 let url = format!(
311 "https://{}.api.mailchimp.com/3.0/lists/{}/merge-fields",
312 server_prefix, list_id
313 );
314
315 let response = REQWEST_CLIENT
316 .get(&url)
317 .header(
318 "Authorization",
319 format!("apikey {}", access_token.expose_secret()),
320 )
321 .send()
322 .await?;
323
324 if response.status().is_success() {
325 let json = response.json::<serde_json::Value>().await?;
326
327 let fields: Vec<MailchimpField> = json["merge_fields"]
328 .as_array()
329 .unwrap_or(&vec![])
330 .iter()
331 .filter_map(|field| {
332 let field_id = field["merge_id"].as_u64();
333 let field_name = field["tag"].as_str();
334
335 if let (Some(field_id), Some(field_name)) = (field_id, field_name) {
336 Some(MailchimpField {
337 field_id: field_id.to_string(),
338 field_name: field_name.to_string(),
339 })
340 } else {
341 None
342 }
343 })
344 .collect();
345
346 Ok(fields)
347 } else {
348 let error_text = response.text().await?;
349 error!("Error fetching merge fields: {}", error_text);
350 Err(anyhow::anyhow!("Failed to fetch current Mailchimp fields."))
351 }
352}
353
354async fn add_field_to_mailchimp(
356 list_id: &str,
357 field_schema: &FieldSchema,
358 server_prefix: &str,
359 access_token: &DbSecret,
360) -> anyhow::Result<()> {
361 let url = format!(
362 "https://{}.api.mailchimp.com/3.0/lists/{}/merge-fields",
363 server_prefix, list_id
364 );
365
366 let body = json!({
367 "tag": field_schema.tag,
368 "name": field_schema.name,
369 "type": "text",
370 "default_value": field_schema.default_value,
371 });
372
373 let response = REQWEST_CLIENT
374 .post(&url)
375 .header(
376 "Authorization",
377 format!("apikey {}", access_token.expose_secret()),
378 )
379 .json(&body)
380 .send()
381 .await?;
382
383 if response.status().is_success() {
384 Ok(())
385 } else {
386 let status = response.status();
387 let error_text = response
388 .text()
389 .await
390 .unwrap_or_else(|_| "No additional error info.".to_string());
391 Err(anyhow::anyhow!(
392 "Failed to add field to Mailchimp. Status: {}. Error: {}",
393 status,
394 error_text
395 ))
396 }
397}
398
399async fn remove_field_from_mailchimp(
401 list_id: &str,
402 field_id: &str,
403 server_prefix: &str,
404 access_token: &DbSecret,
405) -> anyhow::Result<()> {
406 let url = format!(
407 "https://{}.api.mailchimp.com/3.0/lists/{}/merge-fields/{}",
408 server_prefix, list_id, field_id
409 );
410
411 let response = REQWEST_CLIENT
412 .delete(&url)
413 .header(
414 "Authorization",
415 format!("apikey {}", access_token.expose_secret()),
416 )
417 .send()
418 .await?;
419
420 if response.status().is_success() {
421 Ok(())
422 } else {
423 let status = response.status();
424 let error_text = response
425 .text()
426 .await
427 .unwrap_or_else(|_| "No additional error info.".to_string());
428 Err(anyhow::anyhow!(
429 "Failed to remove field from Mailchimp. Status: {}. Error: {}",
430 status,
431 error_text
432 ))
433 }
434}
435
436pub async fn sync_tags_from_mailchimp(
438 conn: &mut PgConnection,
439 list_id: &str,
440 access_token: &DbSecret,
441 server_prefix: &str,
442 marketing_mailing_list_access_token_id: Uuid,
443 course_language_group_id: Uuid,
444) -> anyhow::Result<()> {
445 let url = format!(
446 "https://{}.api.mailchimp.com/3.0/lists/{}/tag-search",
447 server_prefix, list_id
448 );
449
450 let response = REQWEST_CLIENT
451 .get(&url)
452 .header(
453 "Authorization",
454 format!("apikey {}", access_token.expose_secret()),
455 )
456 .send()
457 .await?;
458
459 let response_json = response.json::<serde_json::Value>().await?;
461
462 let mailchimp_tags = match response_json.get("tags") {
463 Some(tags) if tags.is_array() => tags
464 .as_array()
465 .ok_or_else(|| anyhow::anyhow!("tags field is not an array despite is_array() check"))?
466 .iter()
467 .filter_map(|tag| {
468 let name = tag.get("name")?.as_str()?.to_string();
469
470 let id = match tag.get("id") {
471 Some(serde_json::Value::Number(num)) => num.to_string(),
472 Some(serde_json::Value::String(str_id)) => str_id.clone(),
473 _ => return None,
474 };
475
476 Some((id, name))
477 })
478 .collect::<Vec<(String, String)>>(),
479 _ => {
480 warn!("No tags found for list '{}', skipping sync.", list_id);
481 return Ok(());
482 }
483 };
484
485 let db_tags = headless_lms_models::marketing_consents::fetch_tags_with_course_language_group_id_and_marketing_mailing_list_access_token_id(
487 conn,
488 course_language_group_id,
489 marketing_mailing_list_access_token_id,
490 )
491 .await?;
492
493 for (tag_id, tag_name) in &mailchimp_tags {
495 if let Some(db_tag) = db_tags.iter().find(|db_tag| {
496 db_tag.get("id").and_then(|v| v.as_str()).map(|v| v.trim()) == Some(tag_id.trim())
497 }) {
498 let db_tag_name = db_tag
499 .get("tag_name")
500 .and_then(|v| v.as_str())
501 .unwrap_or_default();
502 if db_tag_name != tag_name {
503 headless_lms_models::marketing_consents::upsert_tag(
504 conn,
505 course_language_group_id,
506 marketing_mailing_list_access_token_id,
507 tag_id.clone(),
508 tag_name.clone(),
509 )
510 .await?;
511 }
512 }
513 }
514
515 for db_tag in db_tags.iter() {
517 let db_tag_id = db_tag
518 .get("id")
519 .and_then(|v| v.as_str())
520 .map(|v| v.trim().to_string())
521 .unwrap_or_default();
522
523 if !mailchimp_tags
525 .iter()
526 .any(|(tag_id, _)| tag_id.trim() == db_tag_id.trim())
527 {
528 if db_tag_id.is_empty() {
529 warn!("Skipping tag deletion due to missing ID: {:?}", db_tag);
530 continue;
531 }
532 headless_lms_models::marketing_consents::delete_tag(
533 conn,
534 db_tag_id.clone(),
535 course_language_group_id,
536 )
537 .await?;
538 }
539 }
540
541 Ok(())
542}
543
544async fn sync_contacts(
547 conn: &mut PgConnection,
548 _config: &SyncerConfig,
549 process_unsubscribes: bool,
550) -> anyhow::Result<()> {
551 let access_tokens =
552 headless_lms_models::marketing_consents::fetch_all_marketing_mailing_list_access_tokens(
553 conn,
554 )
555 .await?;
556
557 let mut successfully_synced_user_ids = Vec::new();
558
559 for token in access_tokens {
561 let course_language_group_slug =
562 match headless_lms_models::course_language_groups::get_slug_by_id(
563 conn,
564 token.course_language_group_id,
565 )
566 .await
567 {
568 Ok(Some(s)) => Some(s),
569 Ok(None) => None,
570 Err(e) => {
571 error!(
572 course_language_group_id = %token.course_language_group_id,
573 "Failed to get course language group slug: {:?}",
574 e
575 );
576 return Err(e.into());
577 }
578 };
579
580 if process_unsubscribes {
582 info!(
583 "Stage: unsubscribe sync (apply Mailchimp compliance/unsubscribe changes locally)."
584 );
585 let mailchimp_data = fetch_unsubscribed_users_from_mailchimp_in_chunks(
586 &token.mailchimp_mailing_list_id,
587 &token.server_prefix,
588 &token.access_token,
589 1000,
590 )
591 .await?;
592
593 info!(
594 "Processing Mailchimp data for list: {}",
595 token.mailchimp_mailing_list_id
596 );
597
598 process_unsubscribed_users_from_mailchimp(conn, mailchimp_data).await?;
599 }
600
601 let users_with_unsynced_emails =
603 headless_lms_models::marketing_consents::fetch_all_unsynced_updated_emails(
604 conn,
605 token.course_language_group_id,
606 )
607 .await?;
608
609 info!(
610 "Stage: email updates (ensure member identifiers stay correct). Found {} unsynced user email(s) for course language group: {}",
611 users_with_unsynced_emails.len(),
612 token.course_language_group_id
613 );
614
615 if !users_with_unsynced_emails.is_empty() {
616 let email_sync_results = update_emails_in_mailchimp(
617 users_with_unsynced_emails,
618 &token.mailchimp_mailing_list_id,
619 &token.server_prefix,
620 &token.access_token,
621 )
622 .await?;
623
624 let email_synced_user_ids: Vec<Uuid> =
625 email_sync_results.iter().map(|r| r.user_id).collect();
626 successfully_synced_user_ids.extend(email_synced_user_ids.clone());
627
628 if !email_sync_results.is_empty() {
629 let mailchimp_id_by_user: std::collections::HashMap<Uuid, String> =
630 email_sync_results
631 .iter()
632 .map(|r| (r.user_id, r.user_mailchimp_id.clone()))
633 .collect();
634 let user_details =
635 headless_lms_models::marketing_consents::fetch_user_marketing_consents_with_details_by_user_ids(
636 conn,
637 token.course_language_group_id,
638 &email_synced_user_ids,
639 )
640 .await?;
641 match sync_policy_tags_for_users(
642 &token,
643 &user_details,
644 &mailchimp_id_by_user,
645 Duration::from_secs(BATCH_POLL_TIMEOUT_SECS),
646 Duration::from_secs(BATCH_POLL_INTERVAL_SECS),
647 )
648 .await
649 {
650 Ok(results) => {
651 let (ok, failed): (Vec<_>, Vec<_>) =
652 results.into_iter().partition(|r| r.success);
653 if !failed.is_empty() {
654 let sample: Vec<String> = failed
655 .iter()
656 .take(5)
657 .map(|r| {
658 format!(
659 "{}: {}",
660 r.user_id,
661 r.error.as_deref().unwrap_or("unknown error")
662 )
663 })
664 .collect();
665 warn!(
666 "Policy tag sync after email updates for list '{}' had {} failure(s) out of {}. Sample: {:?}",
667 token.mailchimp_mailing_list_id,
668 failed.len(),
669 ok.len() + failed.len(),
670 sample
671 );
672 }
673 }
674 Err(e) => {
675 error!(
676 "Failed to sync policy tags after email updates for list '{}': {:?}",
677 token.mailchimp_mailing_list_id, e
678 );
679 }
680 }
681 }
682 }
683
684 let tag_objects = headless_lms_models::marketing_consents::fetch_tags_with_course_language_group_id_and_marketing_mailing_list_access_token_id(conn, token.course_language_group_id, token.id).await?;
685
686 let unsynced_users_details =
688 headless_lms_models::marketing_consents::fetch_all_unsynced_user_marketing_consents_by_course_language_group_id(
689 conn,
690 token.course_language_group_id,
691 )
692 .await?;
693
694 info!(
695 "Stage: member upsert (merge fields + consent). Found {} unsynced user consent(s) for course language group: {}",
696 unsynced_users_details.len(),
697 token.course_language_group_id
698 );
699
700 if !unsynced_users_details.is_empty() {
701 let consent_synced_user_ids =
702 send_users_to_mailchimp(conn, &token, &unsynced_users_details, tag_objects).await?;
703
704 if let Some(ref slug) = course_language_group_slug
705 && !consent_synced_user_ids.is_empty()
706 {
707 let mailchimp_id_mapping =
708 headless_lms_models::marketing_consents::fetch_user_mailchimp_id_mapping(
709 conn,
710 token.course_language_group_id,
711 &consent_synced_user_ids,
712 )
713 .await?;
714 if let Err(e) = sync_completed_tag_for_members(
715 &unsynced_users_details,
716 &consent_synced_user_ids,
717 &mailchimp_id_mapping,
718 slug,
719 &token,
720 )
721 .await
722 {
723 error!(
724 "Failed to sync completed tag for list '{}': {:?}",
725 token.mailchimp_mailing_list_id, e
726 );
727 }
728 }
729
730 successfully_synced_user_ids.extend(consent_synced_user_ids);
732 }
733 }
734
735 if !successfully_synced_user_ids.is_empty() {
737 match headless_lms_models::marketing_consents::update_synced_to_mailchimp_at_to_all_synced_users(
738 conn,
739 &successfully_synced_user_ids,
740 )
741 .await
742 {
743 Ok(_) => {
744 info!(
745 "Stage: mark synced (avoid repeat work). Successfully updated synced status for {} users.",
746 successfully_synced_user_ids.len()
747 );
748 }
749 Err(e) => {
750 error!(
751 "Failed to update synced status for {} users: {:?}",
752 successfully_synced_user_ids.len(),
753 e
754 );
755 }
756 }
757 }
758
759 Ok(())
760}
761
762pub async fn send_users_to_mailchimp(
764 conn: &mut PgConnection,
765 token: &MarketingMailingListAccessToken,
766 users_details: &[UserMarketingConsentWithDetails],
767 tag_objects: Vec<serde_json::Value>,
768) -> anyhow::Result<Vec<Uuid>> {
769 let mut users_to_sync = vec![];
770 let mut sent_user_ids = Vec::new();
771 let mut successfully_synced_user_ids = Vec::new();
772 let mut user_id_contact_id_pairs = Vec::new();
773
774 for user in users_details {
776 if let Some(ref subscription) = user.email_subscription_in_mailchimp
778 && subscription == "subscribed"
779 {
780 sent_user_ids.push(user.user_id);
781 let user_details = json!({
782 "email_address": user.email,
783 "status": user.email_subscription_in_mailchimp,
784 "merge_fields": {
785 "FNAME": user.first_name.clone().unwrap_or("".to_string()),
786 "LNAME": user.last_name.clone().unwrap_or("".to_string()),
787 "MARKETING": if user.consent { "allowed" } else { "disallowed" },
788 "LOCALE": user.locale,
789 "GRADUATED": user.completed_course_at.map(|cca| cca.to_rfc3339()).unwrap_or("".to_string()),
790 "USERID": user.user_id,
791 "COURSEID": user.course_id,
792 "LANGGRPID": user.course_language_group_id,
793 "RESEARCH" : if user.research_consent.unwrap_or(false) { "allowed" } else { "disallowed" },
794 "COUNTRY" : user.country.clone().unwrap_or("".to_string()),
795 },
796 "tags": tag_objects.iter().map(|tag| tag["name"].clone()).collect::<Vec<_>>()
797 });
798 users_to_sync.push(SyncUser {
799 details: user.clone(),
800 payload: user_details,
801 });
802 }
803 }
804
805 if users_to_sync.is_empty() {
806 info!("No new users to sync.");
807 return Ok(vec![]);
808 }
809
810 let url = format!(
811 "https://{}.api.mailchimp.com/3.0/lists/{}",
812 token.server_prefix, token.mailchimp_mailing_list_id
813 );
814
815 let total_chunks = users_to_sync.len().div_ceil(MAX_MAILCHIMP_BATCH_SIZE);
816 info!(
817 "Syncing {} members to list '{}' in {} chunk(s)",
818 users_to_sync.len(),
819 token.mailchimp_mailing_list_id,
820 total_chunks
821 );
822
823 for (chunk_index, chunk) in users_to_sync.chunks(MAX_MAILCHIMP_BATCH_SIZE).enumerate() {
824 info!(
825 "Syncing users chunk {}/{} ({} members)",
826 chunk_index + 1,
827 total_chunks,
828 chunk.len()
829 );
830 let chunk_members: Vec<serde_json::Value> =
831 chunk.iter().map(|user| user.payload.clone()).collect();
832 let batch_request = json!({
833 "members": chunk_members,
834 "update_existing": true
835 });
836
837 let response = REQWEST_CLIENT
838 .post(&url)
839 .header("Content-Type", "application/json")
840 .header(
841 "Authorization",
842 format!("apikey {}", token.access_token.expose_secret()),
843 )
844 .json(&batch_request)
845 .send()
846 .await?;
847
848 if !response.status().is_success() {
849 let status = response.status();
850 let error_text = response.text().await?;
851 return Err(anyhow::anyhow!(
852 "Error syncing users to Mailchimp. Status: {}. Error: {}",
853 status,
854 error_text
855 ));
856 }
857
858 let response_data: serde_json::Value = response.json().await?;
859 let mut chunk_contact_count = 0;
860 let mut chunk_mailchimp_id_by_user: std::collections::HashMap<Uuid, String> =
861 std::collections::HashMap::new();
862 for user in chunk {
863 if let Some(ref mailchimp_id) = user.details.user_mailchimp_id {
864 chunk_mailchimp_id_by_user
865 .entry(user.details.user_id)
866 .or_insert_with(|| mailchimp_id.clone());
867 }
868 }
869 for key in &["new_members", "updated_members"] {
870 if let Some(members) = response_data[key].as_array() {
871 for member in members {
872 if let Some(user_id) = member["merge_fields"]["USERID"].as_str() {
873 if let Ok(uuid) = uuid::Uuid::parse_str(user_id) {
874 successfully_synced_user_ids.push(uuid);
875 }
876 if let Some(contact_id) = member["contact_id"].as_str() {
877 user_id_contact_id_pairs
878 .push((user_id.to_string(), contact_id.to_string()));
879 if let Ok(uuid) = uuid::Uuid::parse_str(user_id) {
880 chunk_mailchimp_id_by_user.insert(uuid, contact_id.to_string());
881 }
882 chunk_contact_count += 1;
883 }
884 }
885 }
886 }
887 }
888 if let Some(errors) = response_data["errors"].as_array()
889 && !errors.is_empty()
890 {
891 let sample: Vec<String> = errors
892 .iter()
893 .take(5)
894 .map(|e| {
895 let email = e
896 .get("email_address")
897 .and_then(|v| v.as_str())
898 .unwrap_or("?");
899 let msg = e.get("error").and_then(|v| v.as_str()).unwrap_or_else(|| {
900 e.get("message").and_then(|v| v.as_str()).unwrap_or("?")
901 });
902 format!("{}: {}", email, msg)
903 })
904 .collect();
905 warn!(
906 "Mailchimp batch subscribe chunk {}/{} returned {} error(s) (e.g. unsubscribed/rejected). Sample: {:?}",
907 chunk_index + 1,
908 total_chunks,
909 errors.len(),
910 sample
911 );
912 }
913 info!(
914 "Chunk {}/{}: {} contact_id(s) from new_members/updated_members",
915 chunk_index + 1,
916 total_chunks,
917 chunk_contact_count
918 );
919
920 let chunk_users: Vec<UserMarketingConsentWithDetails> =
921 chunk.iter().map(|user| user.details.clone()).collect();
922 match sync_policy_tags_for_users(
923 token,
924 &chunk_users,
925 &chunk_mailchimp_id_by_user,
926 Duration::from_secs(BATCH_POLL_TIMEOUT_SECS),
927 Duration::from_secs(BATCH_POLL_INTERVAL_SECS),
928 )
929 .await
930 {
931 Ok(results) => {
932 let (ok, failed): (Vec<_>, Vec<_>) = results.into_iter().partition(|r| r.success);
933 if !failed.is_empty() {
934 let sample: Vec<String> = failed
935 .iter()
936 .take(5)
937 .map(|r| {
938 format!(
939 "{}: {}",
940 r.user_id,
941 r.error.as_deref().unwrap_or("unknown error")
942 )
943 })
944 .collect();
945 warn!(
946 "Policy tag sync for list '{}' chunk {}/{} had {} failure(s) out of {}. Sample: {:?}",
947 token.mailchimp_mailing_list_id,
948 chunk_index + 1,
949 total_chunks,
950 failed.len(),
951 ok.len() + failed.len(),
952 sample
953 );
954 }
955 }
956 Err(e) => {
957 error!(
958 "Failed to sync policy tags for list '{}' chunk {}/{}: {:?}",
959 token.mailchimp_mailing_list_id,
960 chunk_index + 1,
961 total_chunks,
962 e
963 );
964 }
965 }
966 }
967
968 let got_contact_id_set: std::collections::HashSet<Uuid> = user_id_contact_id_pairs
969 .iter()
970 .filter_map(|(uid, _)| Uuid::parse_str(uid).ok())
971 .collect();
972 let no_contact_id_user_ids: Vec<Uuid> = sent_user_ids
973 .iter()
974 .filter(|id| !got_contact_id_set.contains(id))
975 .copied()
976 .collect();
977
978 if !no_contact_id_user_ids.is_empty() {
979 let sample_len = no_contact_id_user_ids.len().min(10);
980 warn!(
981 "Mailchimp did not return contact_id for {} member(s) (likely unsubscribed, removed, or rejected). Marking synced_to_mailchimp_at to stop retry. First {} user_id(s): {:?}",
982 no_contact_id_user_ids.len(),
983 sample_len,
984 &no_contact_id_user_ids[..sample_len]
985 );
986 if let Err(e) = headless_lms_models::marketing_consents::update_synced_to_mailchimp_at_to_all_synced_users(
987 conn,
988 &no_contact_id_user_ids,
989 )
990 .await
991 {
992 error!(
993 "Failed to update synced_to_mailchimp_at for no-contact_id users: {:?}",
994 e
995 );
996 }
997 }
998
999 info!(
1000 "Batch subscribe list '{}': sent {} member(s), got {} contact_id(s){}",
1001 token.mailchimp_mailing_list_id,
1002 sent_user_ids.len(),
1003 user_id_contact_id_pairs.len(),
1004 if no_contact_id_user_ids.is_empty() {
1005 String::new()
1006 } else {
1007 format!(
1008 ", {} without contact_id (marked synced to stop retry)",
1009 no_contact_id_user_ids.len()
1010 )
1011 }
1012 );
1013
1014 if !user_id_contact_id_pairs.is_empty() {
1015 headless_lms_models::marketing_consents::update_user_mailchimp_id_at_to_all_synced_users(
1016 conn,
1017 user_id_contact_id_pairs,
1018 )
1019 .await?;
1020 }
1021
1022 Ok(successfully_synced_user_ids)
1023}
1024
1025async fn sync_completed_tag_for_members(
1027 users_details: &[UserMarketingConsentWithDetails],
1028 successfully_synced_user_ids: &[Uuid],
1029 mailchimp_id_by_user: &std::collections::HashMap<Uuid, String>,
1030 slug: &str,
1031 token: &MarketingMailingListAccessToken,
1032) -> anyhow::Result<()> {
1033 let tag_name = format!("{}-completed", slug);
1034 let success_set: std::collections::HashSet<_> = successfully_synced_user_ids.iter().collect();
1035
1036 let mut operations = Vec::new();
1037 for user in users_details {
1038 if !success_set.contains(&user.user_id) {
1039 continue;
1040 }
1041 let Some(user_mailchimp_id) = mailchimp_id_by_user.get(&user.user_id) else {
1042 continue;
1043 };
1044 let status = if user.completed_course_at.is_some() {
1045 "active"
1046 } else {
1047 "inactive"
1048 };
1049 operations.push(MailchimpOperation {
1050 method: Method::POST,
1051 path: format!(
1052 "/lists/{}/members/{}/tags",
1053 token.mailchimp_mailing_list_id, user_mailchimp_id
1054 ),
1055 body: Some(json!({
1056 "tags": [
1057 { "name": tag_name, "status": status }
1058 ]
1059 })),
1060 operation_id: Some(user.user_id.to_string()),
1061 });
1062 }
1063
1064 if operations.is_empty() {
1065 return Ok(());
1066 }
1067
1068 let ops_count = operations.len();
1069 info!(
1070 "Stage: completion tags (mark course completion). Preparing {} operation(s) for list '{}'",
1071 ops_count, token.mailchimp_mailing_list_id
1072 );
1073
1074 let timeout = Duration::from_secs(BATCH_POLL_TIMEOUT_SECS);
1075 let poll_interval = Duration::from_secs(BATCH_POLL_INTERVAL_SECS);
1076 let executor = MailchimpExecutor::new(timeout, poll_interval);
1077
1078 let start_time = Instant::now();
1079 let results = executor.execute(token, operations).await?;
1080 let failures: Vec<_> = results.iter().filter(|r| !r.is_success()).collect();
1081 if !failures.is_empty() {
1082 let sample: Vec<String> = failures
1083 .iter()
1084 .take(5)
1085 .map(|r| {
1086 format!(
1087 "{}: {}",
1088 r.operation_id.as_deref().unwrap_or("?"),
1089 r.error.as_deref().unwrap_or("unknown error")
1090 )
1091 })
1092 .collect();
1093 warn!(
1094 "Completion tag sync for list '{}' had {} failure(s) out of {}. Sample: {:?}",
1095 token.mailchimp_mailing_list_id,
1096 failures.len(),
1097 results.len(),
1098 sample
1099 );
1100 return Err(anyhow::anyhow!("completion tag sync failed"));
1101 }
1102
1103 info!(
1104 "Completed sync of {} completion tags for list '{}' in {:.2}s",
1105 ops_count,
1106 token.mailchimp_mailing_list_id,
1107 start_time.elapsed().as_secs_f64()
1108 );
1109
1110 Ok(())
1111}
1112
1113async fn update_emails_in_mailchimp(
1115 users: Vec<UserEmailSubscription>,
1116 list_id: &str,
1117 server_prefix: &str,
1118 access_token: &DbSecret,
1119) -> anyhow::Result<Vec<EmailSyncResult>> {
1120 let mut successfully_synced_users = Vec::new();
1121 let mut failed_user_ids = Vec::new();
1122
1123 for user in users {
1124 if let Some(ref user_mailchimp_id) = user.user_mailchimp_id {
1125 if let Some(ref status) = user.email_subscription_in_mailchimp
1126 && status != "subscribed"
1127 {
1128 continue; }
1130
1131 let url = format!(
1132 "https://{}.api.mailchimp.com/3.0/lists/{}/members/{}",
1133 server_prefix, list_id, user_mailchimp_id
1134 );
1135
1136 let body = serde_json::json!({
1138 "email_address": &user.email,
1139 "status": &user.email_subscription_in_mailchimp,
1140 });
1141
1142 let update_response = REQWEST_CLIENT
1144 .put(&url)
1145 .header(
1146 "Authorization",
1147 format!("apikey {}", access_token.expose_secret()),
1148 )
1149 .json(&body)
1150 .send()
1151 .await?;
1152
1153 if update_response.status().is_success() {
1154 successfully_synced_users.push(EmailSyncResult {
1155 user_id: user.user_id,
1156 user_mailchimp_id: user_mailchimp_id.clone(),
1157 });
1158 } else {
1159 failed_user_ids.push(user.user_id);
1160 }
1161 } else {
1162 continue;
1163 }
1164 }
1165
1166 if !failed_user_ids.is_empty() {
1167 info!("Failed to update the following users:");
1168 for user_id in &failed_user_ids {
1169 error!("User ID: {}", user_id);
1170 }
1171 }
1172
1173 Ok(successfully_synced_users)
1174}
1175
1176async fn fetch_unsubscribed_users_from_mailchimp_in_chunks(
1178 list_id: &str,
1179 server_prefix: &str,
1180 access_token: &DbSecret,
1181 chunk_size: usize,
1182) -> anyhow::Result<Vec<(String, String, String, String)>> {
1183 let mut all_data = Vec::new();
1184 let mut offset = 0;
1185
1186 loop {
1187 let url = format!(
1188 "https://{}.api.mailchimp.com/3.0/lists/{}/members?offset={}&count={}&fields=members.merge_fields,members.status,members.last_changed&status=unsubscribed,non-subscribed",
1189 server_prefix, list_id, offset, chunk_size
1190 );
1191
1192 let response = REQWEST_CLIENT
1193 .get(&url)
1194 .header(
1195 "Authorization",
1196 format!("apikey {}", access_token.expose_secret()),
1197 )
1198 .send()
1199 .await?
1200 .json::<serde_json::Value>()
1201 .await?;
1202
1203 let empty_vec = vec![];
1204 let members = response["members"].as_array().unwrap_or(&empty_vec);
1205 if members.is_empty() {
1206 break;
1207 }
1208
1209 for member in members {
1210 if let (Some(status), Some(last_changed), Some(merge_fields)) = (
1212 member["status"].as_str(),
1213 member["last_changed"].as_str(),
1214 member["merge_fields"].as_object(),
1215 ) {
1216 if let (Some(user_id), Some(language_group_id)) = (
1218 merge_fields.get("USERID").and_then(|v| v.as_str()),
1219 merge_fields.get("LANGGRPID").and_then(|v| v.as_str()),
1220 ) {
1221 if !user_id.is_empty() && !language_group_id.is_empty() {
1223 all_data.push((
1224 user_id.to_string(),
1225 last_changed.to_string(),
1226 language_group_id.to_string(),
1227 status.to_string(),
1228 ));
1229 }
1230 }
1231 }
1232 }
1233
1234 let total_items = response["total_items"].as_u64().unwrap_or(0) as usize;
1236 if offset + chunk_size >= total_items {
1237 break;
1238 }
1239
1240 offset += chunk_size;
1241 }
1242
1243 Ok(all_data)
1244}
1245
1246const BATCH_SIZE: usize = 1000;
1247
1248async fn process_unsubscribed_users_from_mailchimp(
1249 conn: &mut PgConnection,
1250 mailchimp_data: Vec<(String, String, String, String)>,
1251) -> anyhow::Result<()> {
1252 let total_records = mailchimp_data.len();
1253 let total_chunks = total_records.div_ceil(BATCH_SIZE);
1254
1255 for (chunk_num, chunk) in mailchimp_data.chunks(BATCH_SIZE).enumerate() {
1256 if chunk.is_empty() {
1257 continue;
1258 }
1259
1260 if let Err(e) = headless_lms_models::marketing_consents::update_unsubscribed_users_from_mailchimp_in_bulk(
1261 conn,
1262 chunk.to_vec(),
1263 )
1264 .await
1265 {
1266 error!(
1267 "Error while processing chunk {}/{}: {}",
1268 chunk_num + 1,
1269 total_chunks,
1270 e
1271 );
1272 }
1273 }
1274
1275 Ok(())
1276}