Skip to main content

headless_lms_server/programs/mailchimp_syncer/
mod.rs

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
88/// These fields are excluded from removing all fields that are not in the schema
89const 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;
98/// Bulk archive of every operation's result, so it can be far larger and slower than the JSON
99/// calls the shared client's default timeout is sized for.
100const 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
114/// The main function that initializes environment variables, config, and sync process.
115pub 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    // Iterate through access tokens and ensure Mailchimp schema is set up
133    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        // Check and sync tags for this access token once every hour
171        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                // this usually happens if the database is reset while running bin/dev etc.
197                info!("syncer may have lost its connection to the db, trying to reconnect");
198                conn = db_pool.acquire().await?;
199            }
200        }
201    }
202}
203
204/// Initializes environment variables, logging, and tracing setup.
205fn initialize_environment() -> anyhow::Result<()> {
206    // TODO: Audit that the environment access only happens in single-threaded code.
207    unsafe { env::set_var("RUST_LOG", "info,actix_web=info,sqlx=warn") };
208    dotenv().ok();
209    setup_tracing()?;
210    Ok(())
211}
212
213/// Structure to hold the configuration settings, such as the database URL.
214struct SyncerConfig {
215    database_url: String,
216}
217
218/// Initializes and returns configuration settings (database URL).
219async fn initialize_configuration() -> anyhow::Result<SyncerConfig> {
220    let database_url = ProgramConfig::database_url_with_default();
221
222    Ok(SyncerConfig { database_url })
223}
224
225/// Initializes the PostgreSQL connection pool from the provided database URL.
226async 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
236/// Ensures the Mailchimp schema is up to date, adding required fields and removing any extra ones.
237async 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        // Remove extra fields not in REQUIRED_FIELDS or FIELDS_EXCLUDED_FROM_REMOVING
247        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    // Add any required fields that are missing
273    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
304/// Fetches the current merge fields from the Mailchimp list schema.
305async 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
354/// Adds a new merge field to the Mailchimp list.
355async 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
399/// Removes a merge field from the Mailchimp list by with a field ID.
400async 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
436/// Fetch tags from mailchimp and sync the changes to the database
437pub 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    // Extract tags from the response
460    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    // Fetch the current tags from the database
486    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    // Check if any tags from Mailchimp are renamed
494    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    // Check if any tags in the database have been removed via Mailchimp
516    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        // Check if this tag exists in the Mailchimp tags
524        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
544/// Synchronizes the user contacts with Mailchimp.
545/// Added a boolean flag to determine whether to process unsubscribes.
546async 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    // Iterate through tokens and fetch and send user details to Mailchimp
560    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        // Fetch all users from Mailchimp and sync possible changes locally
581        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        // Fetch unsynced emails and update them in Mailchimp
602        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        // Fetch unsynced user consents and update them in Mailchimp
687        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            // Store the successfully synced user IDs from syncing user consents
731            successfully_synced_user_ids.extend(consent_synced_user_ids);
732        }
733    }
734
735    // If there are any successfully synced users, update the database to mark them as synced
736    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
762/// Sends a batch of users to Mailchimp for synchronization.
763pub 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    // Prepare each user's data for Mailchimp
775    for user in users_details {
776        // Check user has given permission to send data to mailchimp
777        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
1025/// Sets or removes the "{slug}-completed" tag on Mailchimp members based on course completion. Skips users without user_mailchimp_id.
1026async 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
1113/// Updates the email addresses of multiple users in a Mailchimp mailing list.
1114async 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; // Skip this user if they are not subscribed because Mailchimp only updates emails that are subscribed
1129            }
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            // Prepare the body for the PUT request
1137            let body = serde_json::json!({
1138                "email_address": &user.email,
1139                "status": &user.email_subscription_in_mailchimp,
1140            });
1141
1142            // Update the email
1143            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
1176/// Fetches data from Mailchimp in chunks.
1177async 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            // Process the member, but only if necessary fields are present and valid
1211            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                // Ensure both USERID and LANGGRPID are present and valid
1217                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                    // Avoid adding data if any field is missing or empty
1222                    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        // Check the pagination info from the response
1235        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}