Skip to main content

trailcache_core/api/
client.rs

1//! API client for communicating with the Scouting.org REST API.
2//!
3//! This module provides the `ApiClient` struct for making authenticated
4//! API requests to fetch scout, event, and advancement data.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::{Context, Result};
10use chrono::Utc;
11use reqwest::{header, Client};
12use serde::{de::DeserializeOwned, Deserialize, Serialize};
13use tracing::{debug, warn};
14
15use crate::auth::SessionData;
16use crate::models::{
17    Adult, AdvancementDashboard, Award, Event, EventGuest, LeadershipPosition, MeritBadgeProgress,
18    MeritBadgeRequirement, MeritBadgeWithRequirements, OrgAdultsResponse, OrgYouthsResponse,
19    Parent, ParentResponse, Patrol, RankProgress, RankRequirement, RankWithRequirements,
20    RanksResponse, ReadyToAward, UnitYouthsResponse, Youth,
21    // Domain types for unit info
22    Commissioner, Key3Leaders, Leader, MeetingLocation, OrgProfile, UnitContact, UnitInfo,
23};
24use crate::models::advancement::CounselorInfo;
25
26use super::ApiError;
27
28// ============================================================================
29// Constants
30// ============================================================================
31
32/// Base URL for authentication endpoints
33const AUTH_BASE_URL: &str = "https://auth.scouting.org/api";
34
35/// Base URL for main API endpoints (api.scouting.org handles data)
36const API_BASE_URL: &str = "https://api.scouting.org";
37
38/// HTTP request timeout in seconds.
39/// 30s allows for slow API responses while failing fast enough for good UX.
40const REQUEST_TIMEOUT_SECS: u64 = 30;
41
42/// Number of days to look back for events.
43/// 30 days captures recent events without overwhelming the list.
44const EVENT_LOOKBACK_DAYS: i64 = 30;
45
46/// Number of days to look ahead for events.
47/// 6 months captures upcoming events including summer camp planning.
48const EVENT_LOOKAHEAD_DAYS: i64 = 180;
49
50/// Maximum number of retries for rate-limited (429) requests.
51/// 3 retries with exponential backoff usually succeeds without excessive delay.
52const MAX_RATE_LIMIT_RETRIES: u32 = 3;
53
54/// Initial backoff delay in milliseconds for rate limiting.
55/// 1 second is polite to the server while not making users wait too long.
56const INITIAL_BACKOFF_MS: u64 = 1000;
57
58#[derive(Debug, Deserialize)]
59struct AuthResponse {
60    token: String,
61    #[serde(rename = "personGuid")]
62    person_guid: String,
63    account: AuthAccount,
64}
65
66#[derive(Debug, Deserialize)]
67struct AuthAccount {
68    #[serde(rename = "userId")]
69    user_id: i64,
70}
71
72#[derive(Debug, Deserialize)]
73struct RenewalRelationship {
74    #[serde(rename = "organizationGuid")]
75    organization_guid: Option<String>,
76    #[serde(rename = "relationshipTypeId")]
77    relationship_type_id: Option<i64>,
78}
79
80/// API client for Scouting.org.
81/// Clone is cheap - both reqwest::Client and token use Arc internally.
82#[derive(Clone)]
83pub struct ApiClient {
84    client: Client,
85    token: Option<Arc<String>>,
86}
87
88impl ApiClient {
89    /// Create a new API client
90    pub fn new() -> Result<Self> {
91        let client = Client::builder()
92            .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
93            .build()?;
94
95        Ok(Self {
96            client,
97            token: None,
98        })
99    }
100
101    /// Set the bearer token for authenticated requests.
102    /// Accepts any type that can be converted to Arc<String> for efficient sharing.
103    pub fn set_token(&mut self, token: impl Into<Arc<String>>) {
104        self.token = Some(token.into());
105    }
106
107    /// Create a new ApiClient with the given token, sharing the connection pool.
108    /// This is very efficient - both the client and token are Arc-wrapped,
109    /// so cloning is just incrementing reference counts.
110    pub fn with_token(&self, token: Arc<String>) -> Self {
111        Self {
112            client: self.client.clone(), // Cheap clone, shares connection pool
113            token: Some(token),          // Cheap clone, just Arc pointer copy
114        }
115    }
116
117    /// Authenticate with the API and return session data
118    pub async fn authenticate(&self, username: &str, password: &str) -> Result<SessionData> {
119        let url = format!("{}/users/{}/authenticate", AUTH_BASE_URL, username);
120
121        let response = self
122            .client
123            .post(&url)
124            .header(header::ACCEPT, "application/json; version=2")
125            .form(&[("password", password)])
126            .send()
127            .await
128            .context("Failed to send authentication request")?;
129
130        let response = Self::check_response(response).await?;
131
132        let auth: AuthResponse = response.json().await.context("Failed to parse auth response")?;
133
134        // Fetch organization GUID from renewal relationships
135        let org_guid = self
136            .fetch_organization_guid(&auth.token, &auth.person_guid)
137            .await?;
138
139        Ok(SessionData {
140            token: auth.token,
141            user_id: auth.account.user_id,
142            person_guid: auth.person_guid,
143            organization_guid: org_guid,
144            username: username.to_string(),
145            created_at: Utc::now(),
146        })
147    }
148
149    /// Validate that a string looks like a valid GUID (UUID format).
150    /// GUIDs should be 36 characters with dashes: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
151    fn is_valid_guid(s: &str) -> bool {
152        if s.len() != 36 {
153            return false;
154        }
155        s.chars().enumerate().all(|(i, c)| {
156            if i == 8 || i == 13 || i == 18 || i == 23 {
157                c == '-'
158            } else {
159                c.is_ascii_hexdigit()
160            }
161        })
162    }
163
164    async fn fetch_organization_guid(&self, token: &str, person_guid: &str) -> Result<String> {
165        let url = format!(
166            "{}/persons/{}/renewalRelationships",
167            API_BASE_URL, person_guid
168        );
169
170        let response = self
171            .client
172            .get(&url)
173            .bearer_auth(token)
174            .send()
175            .await
176            .context("Failed to fetch renewal relationships")?;
177
178        let response = Self::check_response(response).await?;
179
180        let relationships: Vec<RenewalRelationship> = response
181            .json()
182            .await
183            .context("Failed to parse renewal relationships")?;
184
185        // Find the entry where relationshipTypeId is null and validate GUID format
186        for rel in relationships {
187            if rel.relationship_type_id.is_none() {
188                if let Some(org_guid) = rel.organization_guid {
189                    if Self::is_valid_guid(&org_guid) {
190                        return Ok(org_guid);
191                    }
192                    warn!(guid = %org_guid, "Invalid organization GUID format");
193                }
194            }
195        }
196
197        Err(anyhow::anyhow!(
198            "Could not find valid organization GUID in renewal relationships"
199        ))
200    }
201
202    fn auth_headers(&self) -> Result<header::HeaderMap> {
203        let mut headers = header::HeaderMap::new();
204        if let Some(ref token) = self.token {
205            headers.insert(
206                header::AUTHORIZATION,
207                header::HeaderValue::from_str(&format!("Bearer {}", token.as_str()))?,
208            );
209        }
210        Ok(headers)
211    }
212
213    /// Check if response is successful, returning an error with body if not.
214    /// Returns Ok(Some(response)) for success, Ok(None) for rate limit (should retry),
215    /// or Err for other errors.
216    async fn check_response_for_retry(response: reqwest::Response) -> Result<Option<reqwest::Response>> {
217        if response.status().is_success() {
218            Ok(Some(response))
219        } else if response.status().as_u16() == 429 {
220            // Rate limited - signal to retry
221            Ok(None)
222        } else {
223            let status = response.status();
224            let body = response.text().await.unwrap_or_default();
225            Err(ApiError::from_status(status, &body).into())
226        }
227    }
228
229    /// Check if response is successful, returning an error with body if not.
230    async fn check_response(response: reqwest::Response) -> Result<reqwest::Response> {
231        if response.status().is_success() {
232            Ok(response)
233        } else {
234            let status = response.status();
235            let url = response.url().to_string();
236            let body = response.text().await.unwrap_or_default();
237            warn!(status = %status, url = %url, body_len = body.len(), "API request failed");
238            Err(ApiError::from_status(status, &body).into())
239        }
240    }
241
242    async fn get<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
243        let mut retries = 0;
244        let mut backoff_ms = INITIAL_BACKOFF_MS;
245
246        loop {
247            let response = self
248                .client
249                .get(url)
250                .headers(self.auth_headers()?)
251                .send()
252                .await
253                .with_context(|| format!("Failed to send GET request to {}", url))?;
254
255            match Self::check_response_for_retry(response).await? {
256                Some(response) => {
257                    return response.json().await
258                        .with_context(|| format!("Failed to parse JSON response from {}", url));
259                }
260                None => {
261                    // Rate limited
262                    retries += 1;
263                    if retries > MAX_RATE_LIMIT_RETRIES {
264                        return Err(ApiError::RateLimited.into());
265                    }
266                    warn!(url = url, retry = retries, backoff_ms = backoff_ms, "Rate limited, backing off");
267                    tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
268                    backoff_ms *= 2; // Exponential backoff
269                }
270            }
271        }
272    }
273
274    async fn post<T: DeserializeOwned, B: Serialize>(&self, url: &str, body: &B) -> Result<T> {
275        let mut retries = 0;
276        let mut backoff_ms = INITIAL_BACKOFF_MS;
277
278        loop {
279            let response = self
280                .client
281                .post(url)
282                .headers(self.auth_headers()?)
283                .json(body)
284                .send()
285                .await
286                .with_context(|| format!("Failed to send POST request to {}", url))?;
287
288            match Self::check_response_for_retry(response).await? {
289                Some(response) => {
290                    return response.json().await
291                        .with_context(|| format!("Failed to parse JSON response from {}", url));
292                }
293                None => {
294                    // Rate limited
295                    retries += 1;
296                    if retries > MAX_RATE_LIMIT_RETRIES {
297                        return Err(ApiError::RateLimited.into());
298                    }
299                    warn!(url = url, retry = retries, backoff_ms = backoff_ms, "Rate limited, backing off");
300                    tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
301                    backoff_ms *= 2; // Exponential backoff
302                }
303            }
304        }
305    }
306
307    // ===== Data Fetching Methods =====
308
309    /// Fetch all youth members for the organization
310    pub async fn fetch_youth(&self, org_guid: &str) -> Result<Vec<Youth>> {
311        // Fetch from GET endpoint for patrol/rank data
312        let url1 = format!("{}/organizations/v2/units/{}/youths", API_BASE_URL, org_guid);
313        let response1 = self
314            .client
315            .get(&url1)
316            .headers(self.auth_headers()?)
317            .send()
318            .await
319            .context("Failed to fetch youth list")?;
320
321        let response1 = Self::check_response(response1).await?;
322
323        let text1 = response1.text().await.context("Failed to read youth response body")?;
324        let parsed1: UnitYouthsResponse = serde_json::from_str(&text1)
325            .context("Failed to parse units youth response")?;
326
327        let mut youth_list: Vec<Youth> = parsed1.users.iter().map(|u| u.to_youth()).collect();
328
329        // Fetch from POST endpoint for registration details
330        let url2 = format!("{}/organizations/v2/{}/orgYouths", API_BASE_URL, org_guid);
331        let body = serde_json::json!({
332            "includeRegistrationDetails": true,
333            "includeAddressPhoneEmail": false,
334            "includeExpired": false
335        });
336
337        let response2 = self
338            .client
339            .post(&url2)
340            .headers(self.auth_headers()?)
341            .json(&body)
342            .send()
343            .await?;
344
345        if response2.status().is_success() {
346            let text2 = response2.text().await?;
347            debug!("Youth POST response received");
348
349            if let Ok(parsed2) = serde_json::from_str::<OrgYouthsResponse>(&text2) {
350                debug!("Parsed {} members from orgYouths", parsed2.members.len());
351                // Merge registration data and grade by personGuid
352                for youth in &mut youth_list {
353                    if let Some(ref person_guid) = youth.person_guid {
354                        if let Some(detailed) = parsed2.members.iter().find(|m| m.person_guid.as_ref() == Some(person_guid)) {
355                            // Copy over registrarInfo and grade from orgYouths (more accurate)
356                            youth.registrar_info = detailed.registrar_info.clone();
357                            let old_grade = youth.grade;
358                            youth.grade = detailed.grade;
359                            youth.grade_id = detailed.grade_id;
360                            if old_grade != youth.grade {
361                                debug!("Updated grade for {}: {:?} -> {:?}", youth.last_name, old_grade, youth.grade);
362                            }
363                        }
364                    }
365                }
366            } else {
367                warn!("Failed to parse orgYouths response");
368            }
369        }
370
371        Ok(youth_list)
372    }
373
374    /// Fetch all adult leaders for the organization
375    pub async fn fetch_adults(&self, org_guid: &str) -> Result<Vec<Adult>> {
376        let url = format!("{}/organizations/v2/{}/orgAdults", API_BASE_URL, org_guid);
377        let body = serde_json::json!({
378            "includeRegistrationDetails": true,
379            "includeAddressPhoneEmail": true,
380        });
381        let response: OrgAdultsResponse = self.post(&url, &body).await?;
382        Ok(response.members)
383    }
384
385    /// Fetch all parents of youth members in the organization
386    pub async fn fetch_parents(&self, org_guid: &str) -> Result<Vec<Parent>> {
387        let url = format!(
388            "{}/organizations/v2/units/{}/parents",
389            API_BASE_URL, org_guid
390        );
391
392        let response = self
393            .client
394            .get(&url)
395            .headers(self.auth_headers()?)
396            .send()
397            .await?;
398
399        let response = Self::check_response(response).await?;
400
401        let text = response.text().await?;
402        debug!("Parents response received");
403
404        let parsed: Vec<ParentResponse> = serde_json::from_str(&text)
405            .context("Failed to parse parents response")?;
406
407        // Convert to Parent structs
408        Ok(parsed.iter().map(|p| p.to_parent()).collect())
409    }
410
411    /// Fetch all patrols (sub-units) in the organization
412    pub async fn fetch_patrols(&self, org_guid: &str) -> Result<Vec<Patrol>> {
413        let url = format!(
414            "{}/organizations/v2/units/{}/subUnits",
415            API_BASE_URL, org_guid
416        );
417        self.get(&url).await
418    }
419
420    /// Fetch advancement dashboard summary for the organization
421    pub async fn fetch_advancement_dashboard(&self, org_guid: &str) -> Result<AdvancementDashboard> {
422        let url = format!(
423            "{}/organizations/v2/{}/advancementDashboard",
424            API_BASE_URL, org_guid
425        );
426        self.get(&url).await
427    }
428
429    /// Fetch list of advancements ready to be awarded
430    pub async fn fetch_ready_to_award(&self, org_guid: &str) -> Result<Vec<ReadyToAward>> {
431        let url = format!(
432            "{}/organizations/v2/{}/advancementsReadyToBeAwarded",
433            API_BASE_URL, org_guid
434        );
435        self.post(&url, &serde_json::json!({})).await
436    }
437
438    /// Fetch rank progress for a specific youth member
439    pub async fn fetch_youth_ranks(&self, user_id: i64) -> Result<Vec<RankProgress>> {
440        let url = format!("{}/advancements/v2/youth/{}/ranks", API_BASE_URL, user_id);
441        let response = self
442            .client
443            .get(&url)
444            .headers(self.auth_headers()?)
445            .send()
446            .await?;
447
448        let response = Self::check_response(response).await?;
449
450        let text = response.text().await?;
451        debug!("Ranks response received");
452
453        // Parse the nested response and extract Scouts BSA ranks (programId 2)
454        let parsed: RanksResponse = serde_json::from_str(&text)
455            .context("Failed to parse ranks response")?;
456
457        let mut ranks: Vec<RankProgress> = Vec::new();
458        for program in &parsed.program {
459            if program.program_id == crate::models::PROGRAM_ID_SCOUTS_BSA {
460                for rank in &program.ranks {
461                    ranks.push(RankProgress::from_api(rank));
462                }
463            }
464        }
465
466        // Sort by rank order ascending (Scout first, Eagle last) - reversed at display time
467        ranks.sort_by_key(|a| a.sort_order());
468
469        Ok(ranks)
470    }
471
472    /// Fetch merit badge progress for a specific youth member
473    pub async fn fetch_youth_merit_badges(&self, user_id: i64) -> Result<Vec<MeritBadgeProgress>> {
474        let url = format!(
475            "{}/advancements/v2/youth/{}/meritBadges",
476            API_BASE_URL, user_id
477        );
478        let response = self
479            .client
480            .get(&url)
481            .headers(self.auth_headers()?)
482            .send()
483            .await?;
484
485        let response = Self::check_response(response).await?;
486
487        let text = response.text().await?;
488        debug!("Merit badges response received");
489        Ok(serde_json::from_str(&text)?)
490    }
491
492    /// Fetch leadership position history for a specific youth member
493    pub async fn fetch_youth_leadership(&self, user_id: i64) -> Result<Vec<LeadershipPosition>> {
494        let url = format!(
495            "{}/advancements/youth/{}/leadershipPositionHistory?summary=true",
496            API_BASE_URL, user_id
497        );
498        let response = self
499            .client
500            .get(&url)
501            .headers(self.auth_headers()?)
502            .send()
503            .await?;
504
505        let response = Self::check_response(response).await?;
506
507        let text = response.text().await?;
508        debug!("Leadership history response received");
509        Ok(serde_json::from_str(&text)?)
510    }
511
512    /// Fetch awards for a specific youth member
513    pub async fn fetch_youth_awards(&self, user_id: i64) -> Result<Vec<Award>> {
514        let url = format!(
515            "{}/advancements/v2/youth/{}/awards",
516            API_BASE_URL, user_id
517        );
518        debug!("Fetching awards from: {}", url);
519        let response = self
520            .client
521            .get(&url)
522            .headers(self.auth_headers()?)
523            .send()
524            .await?;
525
526        let response = Self::check_response(response).await?;
527
528        let text = response.text().await?;
529        debug!("Awards response received: {} bytes", text.len());
530        let awards: Vec<Award> = serde_json::from_str(&text)
531            .context("Failed to parse awards response")?;
532        debug!("Parsed {} awards", awards.len());
533        Ok(awards)
534    }
535
536    /// Fetch requirements for a specific rank for a youth member
537    pub async fn fetch_rank_requirements(&self, user_id: i64, rank_id: i64) -> Result<Vec<RankRequirement>> {
538        // Try the requirements endpoint first
539        let url = format!(
540            "{}/advancements/v2/youth/{}/ranks/{}/requirements",
541            API_BASE_URL, user_id, rank_id
542        );
543        let response = self
544            .client
545            .get(&url)
546            .headers(self.auth_headers()?)
547            .send()
548            .await?;
549
550        let response = Self::check_response(response).await?;
551
552        let text = response.text().await?;
553        debug!("Rank requirements response received");
554
555        // Try parsing as direct array first, then as rank wrapper
556        if let Ok(requirements) = serde_json::from_str::<Vec<RankRequirement>>(&text) {
557            return Ok(requirements);
558        }
559
560        // Fall back to parsing as rank object with embedded requirements
561        let rank: RankWithRequirements = serde_json::from_str(&text)
562            .context("Failed to parse rank requirements")?;
563        Ok(rank.requirements)
564    }
565
566    /// Fetch badge requirements only (no counselor info). Single API call.
567    /// Use this for bulk/offline caching where counselor data isn't needed.
568    pub async fn fetch_badge_requirements_only(&self, user_id: i64, badge_id: i64) -> Result<(Vec<MeritBadgeRequirement>, Option<String>)> {
569        let req_url = format!(
570            "{}/advancements/v2/youth/{}/meritBadges/{}/requirements",
571            API_BASE_URL, user_id, badge_id
572        );
573        let response = self
574            .client
575            .get(&req_url)
576            .headers(self.auth_headers()?)
577            .send()
578            .await?;
579
580        if !response.status().is_success() {
581            anyhow::bail!("Badge requirements request failed: {}", response.status());
582        }
583
584        let text = response.text().await?;
585
586        if let Ok(badge) = serde_json::from_str::<MeritBadgeWithRequirements>(&text) {
587            return Ok((badge.requirements, badge.version));
588        }
589        if let Ok(reqs) = serde_json::from_str::<Vec<MeritBadgeRequirement>>(&text) {
590            return Ok((reqs, None));
591        }
592
593        Ok((vec![], None))
594    }
595
596    /// Fetch badge requirements, returns (requirements, version, counselor)
597    pub async fn fetch_badge_requirements(&self, user_id: i64, badge_id: i64) -> Result<(Vec<MeritBadgeRequirement>, Option<String>, Option<CounselorInfo>)> {
598        // We need to call TWO endpoints:
599        // 1. /requirements - has the requirements list but NO counselor
600        // 2. /meritBadges/{id} - has counselor info but NO requirements
601
602        let mut requirements = Vec::new();
603        let mut version = None;
604        let mut counselor = None;
605
606        // First, fetch requirements from the requirements endpoint
607        let req_url = format!(
608            "{}/advancements/v2/youth/{}/meritBadges/{}/requirements",
609            API_BASE_URL, user_id, badge_id
610        );
611        let response = self
612            .client
613            .get(&req_url)
614            .headers(self.auth_headers()?)
615            .send()
616            .await?;
617
618        if response.status().is_success() {
619            let text = response.text().await?;
620            debug!("Badge requirements response received");
621
622            // Try parsing as badge object with embedded requirements
623            if let Ok(badge) = serde_json::from_str::<MeritBadgeWithRequirements>(&text) {
624                debug!(count = badge.requirements.len(), version = ?badge.version, "Parsed badge with requirements");
625                requirements = badge.requirements;
626                version = badge.version;
627            } else if let Ok(reqs) = serde_json::from_str::<Vec<MeritBadgeRequirement>>(&text) {
628                // Direct array of requirements
629                debug!(count = reqs.len(), "Parsed badge requirements as array");
630                requirements = reqs;
631            }
632        }
633
634        // Second, fetch counselor info from the detail endpoint
635        let detail_url = format!(
636            "{}/advancements/v2/youth/{}/meritBadges/{}",
637            API_BASE_URL, user_id, badge_id
638        );
639        let response2 = self
640            .client
641            .get(&detail_url)
642            .headers(self.auth_headers()?)
643            .send()
644            .await?;
645
646        if response2.status().is_success() {
647            let text = response2.text().await?;
648            debug!("Badge detail response received");
649
650            // Parse to extract counselor info
651            if let Ok(badge) = serde_json::from_str::<MeritBadgeWithRequirements>(&text) {
652                debug!(has_counselor = badge.assigned_counselor.is_some(), "Parsed badge detail for counselor");
653                counselor = badge.assigned_counselor;
654                // Also get version from here if we didn't get it from requirements
655                if version.is_none() {
656                    version = badge.version;
657                }
658            }
659        }
660
661        Ok((requirements, version, counselor))
662    }
663
664    /// Fetch all merit badges from the catalog (not youth-specific)
665    pub async fn fetch_merit_badge_catalog(&self) -> Result<Vec<crate::models::MeritBadgeCatalogEntry>> {
666        let url = format!("{}/advancements/meritBadges", API_BASE_URL);
667
668        let response = self
669            .client
670            .get(&url)
671            .headers(self.auth_headers()?)
672            .send()
673            .await?;
674
675        let response = Self::check_response(response).await?;
676        let text = response.text().await?;
677
678        // Try parsing as direct array first
679        if let Ok(badges) = serde_json::from_str::<Vec<crate::models::MeritBadgeCatalogEntry>>(&text) {
680            return Ok(badges);
681        }
682
683        // Try as wrapped object with common field names
684        #[derive(Deserialize)]
685        struct Wrapper {
686            #[serde(default, alias = "meritBadges", alias = "data", alias = "badges")]
687            merit_badges: Vec<crate::models::MeritBadgeCatalogEntry>,
688        }
689
690        if let Ok(wrapper) = serde_json::from_str::<Wrapper>(&text) {
691            return Ok(wrapper.merit_badges);
692        }
693
694        // Debug: print first 500 chars to help diagnose
695        debug!("Merit badge catalog response (first 500 chars): {}", &text[..text.len().min(500)]);
696
697        Err(anyhow::anyhow!("Failed to parse merit badge catalog. Response starts with: {}", &text[..text.len().min(200)]))
698    }
699
700    /// Fetch events for a date range around the current date
701    pub async fn fetch_events(&self, user_id: i64) -> Result<Vec<Event>> {
702        let url = format!("{}/advancements/events", API_BASE_URL);
703
704        // Calculate date range
705        let now = chrono::Utc::now();
706        let from_date = (now - chrono::Duration::days(EVENT_LOOKBACK_DAYS)).format("%Y-%m-%d").to_string();
707        let to_date = (now + chrono::Duration::days(EVENT_LOOKAHEAD_DAYS)).format("%Y-%m-%d").to_string();
708
709        debug!(from = %from_date, to = %to_date, "Fetching events");
710
711        let body = serde_json::json!({
712            "fromDate": from_date,
713            "toDate": to_date,
714            "invitedUserId": user_id
715        });
716
717        let response = self
718            .client
719            .post(&url)
720            .headers(self.auth_headers()?)
721            .json(&body)
722            .send()
723            .await?;
724
725        let response = Self::check_response(response).await?;
726
727        let text = response.text().await?;
728        debug!("Events response received");
729
730        // Try to parse as array directly first, then as wrapped object
731        if let Ok(events) = serde_json::from_str::<Vec<Event>>(&text) {
732            return Ok(events);
733        }
734
735        // Try common wrapper formats
736        #[derive(Deserialize)]
737        struct EventsWrapper {
738            #[serde(default)]
739            events: Vec<Event>,
740            #[serde(default)]
741            data: Vec<Event>,
742        }
743
744        if let Ok(wrapper) = serde_json::from_str::<EventsWrapper>(&text) {
745            if !wrapper.events.is_empty() {
746                return Ok(wrapper.events);
747            }
748            if !wrapper.data.is_empty() {
749                return Ok(wrapper.data);
750            }
751        }
752
753        // Return empty if we can't parse
754        Ok(vec![])
755    }
756
757    /// Fetch detailed event info including full invited_users with RSVP data
758    pub async fn fetch_event_detail(&self, event_id: i64) -> Result<Event> {
759        let url = format!("{}/advancements/events/{}", API_BASE_URL, event_id);
760
761        let response = self
762            .client
763            .get(&url)
764            .headers(self.auth_headers()?)
765            .send()
766            .await?;
767
768        let status = response.status();
769        let text = response.text().await?;
770        debug!(event_id, status = %status, "Event detail response received");
771
772        if !status.is_success() {
773            return Err(ApiError::from_status(status, &text).into());
774        }
775
776        let mut event: Event = serde_json::from_str(&text)
777            .context("Failed to parse event detail")?;
778        // GET response doesn't include id, so set it from the URL
779        event.id = event_id;
780        Ok(event)
781    }
782
783    /// Fetch guest list for a specific event
784    pub async fn fetch_event_guests(&self, event_id: i64) -> Result<Vec<EventGuest>> {
785        let url = format!(
786            "{}/advancements/v2/events/{}/guests",
787            API_BASE_URL, event_id
788        );
789
790        let response = self
791            .client
792            .get(&url)
793            .headers(self.auth_headers()?)
794            .send()
795            .await?;
796
797        let status = response.status();
798        let text = response.text().await?;
799        debug!(event_id, status = %status, "Event guests response received");
800
801        // Try to parse as array first
802        if let Ok(guests) = serde_json::from_str::<Vec<EventGuest>>(&text) {
803            return Ok(guests);
804        }
805
806        // Try as wrapper object
807        #[derive(serde::Deserialize)]
808        struct GuestsWrapper {
809            #[serde(default)]
810            guests: Vec<EventGuest>,
811            #[serde(default)]
812            data: Vec<EventGuest>,
813        }
814
815        if let Ok(wrapper) = serde_json::from_str::<GuestsWrapper>(&text) {
816            if !wrapper.guests.is_empty() {
817                return Ok(wrapper.guests);
818            }
819            if !wrapper.data.is_empty() {
820                return Ok(wrapper.data);
821            }
822        }
823
824        Ok(vec![])
825    }
826
827    /// Fetch Key 3 leaders for the organization
828    pub async fn fetch_key3(&self, org_guid: &str) -> Result<Key3Leaders> {
829        let url = format!("{}/organizations/v2/{}/key3", API_BASE_URL, org_guid);
830
831        let response = self
832            .client
833            .get(&url)
834            .headers(self.auth_headers()?)
835            .send()
836            .await?;
837
838        let status = response.status();
839        let text = response.text().await?;
840        debug!(status = %status, "Key3 response received");
841
842        if !status.is_success() {
843            return Err(ApiError::from_status(status, &text).into());
844        }
845
846        // Parse array of Key3 items
847        let items: Vec<Key3ApiItem> = serde_json::from_str(&text)
848            .context("Failed to parse key3 response")?;
849
850        let mut result = Key3Leaders::default();
851
852        for item in items {
853            let k3 = &item.organization_key3;
854            let position = k3.position_long.as_deref().unwrap_or("");
855
856            let person = Leader {
857                first_name: k3.first_name.clone().unwrap_or_default(),
858                last_name: k3.last_name.clone().unwrap_or_default(),
859            };
860
861            if position.contains("Scoutmaster") {
862                result.scoutmaster = Some(person);
863            } else if position.contains("Committee Chair") {
864                result.committee_chair = Some(person);
865            } else if position.contains("Chartered Organization Rep") || position.contains("Charter Org") {
866                result.charter_org_rep = Some(person);
867            }
868        }
869
870        Ok(result)
871    }
872
873    /// Fetch unit registration PIN info (includes website and charter info)
874    pub async fn fetch_unit_pin(&self, org_guid: &str) -> Result<UnitInfo> {
875        let url = format!("{}/organizations/{}/pin", API_BASE_URL, org_guid);
876
877        let response = self
878            .client
879            .get(&url)
880            .headers(self.auth_headers()?)
881            .send()
882            .await?;
883
884        let status = response.status();
885        let text = response.text().await?;
886        debug!(status = %status, "Unit PIN response received");
887
888        if !status.is_success() {
889            return Err(ApiError::from_status(status, &text).into());
890        }
891
892        let api_response: PinApiResponse = serde_json::from_str(&text)
893            .context("Failed to parse PIN response")?;
894
895        // Convert API response to domain type
896        let pin = &api_response.pin_information;
897        let unit = &api_response.unit_information;
898        let council = &api_response.council_information;
899
900        Ok(UnitInfo {
901            name: unit.name.clone(),
902            website: pin.unit_website.clone(),
903            registration_url: unit.tiny_url.clone(),
904            district_name: unit.district_name.clone(),
905            council_name: council.name.clone(),
906            charter_org_name: unit.charter_information.community_organization_name.clone(),
907            charter_expiry: unit.charter_information.expiry_dt.clone(),
908            meeting_location: Some(MeetingLocation {
909                address_line1: pin.meeting_address_line1.clone(),
910                address_line2: pin.meeting_address_line2.clone(),
911                city: pin.meeting_city.clone(),
912                state: pin.meeting_state.clone(),
913                zip: pin.meeting_zip.clone(),
914            }),
915            contacts: pin.contact_persons.iter().map(|c| UnitContact {
916                first_name: c.first_name.clone(),
917                last_name: c.last_name.clone(),
918                email: c.email.clone(),
919                phone: c.phone.clone(),
920            }).collect(),
921            charter_status_display: None,
922            charter_expired: None,
923        })
924    }
925
926    /// Fetch organization profile
927    pub async fn fetch_org_profile(&self, org_guid: &str) -> Result<OrgProfile> {
928        let url = format!("{}/organizations/v2/{}/profile", API_BASE_URL, org_guid);
929
930        let response = self
931            .client
932            .get(&url)
933            .headers(self.auth_headers()?)
934            .send()
935            .await?;
936
937        let status = response.status();
938        let text = response.text().await?;
939        debug!(status = %status, "Org profile response received");
940
941        if !status.is_success() {
942            return Err(ApiError::from_status(status, &text).into());
943        }
944
945        let api_profile: OrgProfileApiResponse = serde_json::from_str(&text)
946            .context("Failed to parse org profile response")?;
947
948        // Convert to domain type
949        Ok(OrgProfile {
950            name: api_profile.organization_name,
951            full_name: api_profile.organization_full_name,
952            charter_org_name: api_profile.chartered_org_name,
953            charter_exp_date: api_profile.charter_exp_date,
954            charter_status: api_profile.charter_status,
955        })
956    }
957
958    /// Fetch assigned commissioners for a unit
959    pub async fn fetch_commissioners(&self, org_guid: &str) -> Result<Vec<Commissioner>> {
960        let url = format!(
961            "{}/commissioners/v2/organizations/{}/units/assignedCommissioners",
962            API_BASE_URL, org_guid
963        );
964
965        let response = self
966            .client
967            .get(&url)
968            .headers(self.auth_headers()?)
969            .send()
970            .await?;
971
972        let status = response.status();
973        let text = response.text().await?;
974        debug!(status = %status, "Commissioners response received");
975
976        if !status.is_success() {
977            return Err(ApiError::from_status(status, &text).into());
978        }
979
980        // Log the raw response for debugging
981        debug!("Commissioners raw response: {}", &text[..text.len().min(500)]);
982
983        // Helper to convert API item to domain type
984        let convert = |api: CommissionerApiItem| Commissioner {
985            first_name: api.first_name,
986            last_name: api.last_name,
987            position: api.position,
988        };
989
990        // Try parsing as CommissionersResponse first, then as direct array
991        if let Ok(resp) = serde_json::from_str::<CommissionersResponse>(&text) {
992            debug!("Parsed as CommissionersResponse with {} commissioners", resp.commissioners.len());
993            return Ok(resp.commissioners.into_iter().map(convert).collect());
994        }
995
996        // Try as direct array
997        if let Ok(items) = serde_json::from_str::<Vec<CommissionerApiItem>>(&text) {
998            debug!("Parsed as direct array with {} commissioners", items.len());
999            return Ok(items.into_iter().map(convert).collect());
1000        }
1001
1002        warn!("Failed to parse commissioners response");
1003        Ok(vec![])
1004    }
1005}
1006
1007// Internal API response types for parsing
1008
1009#[derive(Debug, Clone, Deserialize)]
1010struct Key3ApiItem {
1011    #[serde(rename = "organizationKey3")]
1012    organization_key3: Key3PersonRaw,
1013}
1014
1015#[derive(Debug, Clone, Deserialize)]
1016struct Key3PersonRaw {
1017    #[serde(rename = "positionLong")]
1018    position_long: Option<String>,
1019    #[serde(rename = "firstName")]
1020    first_name: Option<String>,
1021    #[serde(rename = "lastName")]
1022    last_name: Option<String>,
1023}
1024
1025#[derive(Debug, Clone, Deserialize)]
1026struct PinApiResponse {
1027    #[serde(rename = "pinInformation")]
1028    pin_information: PinInformation,
1029    #[serde(rename = "unitInformation")]
1030    unit_information: UnitInformationApi,
1031    #[serde(rename = "councilInformation")]
1032    council_information: CouncilInformation,
1033}
1034
1035#[derive(Debug, Clone, Deserialize)]
1036struct PinInformation {
1037    #[serde(rename = "unitWebsite")]
1038    unit_website: Option<String>,
1039    #[serde(rename = "meetingAddressLine1")]
1040    meeting_address_line1: Option<String>,
1041    #[serde(rename = "meetingAddressLine2")]
1042    meeting_address_line2: Option<String>,
1043    #[serde(rename = "meetingCity")]
1044    meeting_city: Option<String>,
1045    #[serde(rename = "meetingState")]
1046    meeting_state: Option<String>,
1047    #[serde(rename = "meetingZip")]
1048    meeting_zip: Option<String>,
1049    #[serde(rename = "contactPersons", default)]
1050    contact_persons: Vec<ContactPersonApi>,
1051}
1052
1053#[derive(Debug, Clone, Deserialize)]
1054struct ContactPersonApi {
1055    #[serde(rename = "firstName")]
1056    first_name: Option<String>,
1057    #[serde(rename = "lastName")]
1058    last_name: Option<String>,
1059    email: Option<String>,
1060    phone: Option<String>,
1061}
1062
1063#[derive(Debug, Clone, Deserialize)]
1064struct UnitInformationApi {
1065    name: Option<String>,
1066    #[serde(rename = "districtName")]
1067    district_name: Option<String>,
1068    #[serde(rename = "tinyUrl")]
1069    tiny_url: Option<String>,
1070    #[serde(rename = "charterInformation")]
1071    charter_information: CharterInformation,
1072}
1073
1074#[derive(Debug, Clone, Deserialize)]
1075struct CharterInformation {
1076    #[serde(rename = "communityOrganizationName")]
1077    community_organization_name: Option<String>,
1078    #[serde(rename = "expiryDt")]
1079    expiry_dt: Option<String>,
1080}
1081
1082#[derive(Debug, Clone, Deserialize)]
1083struct CouncilInformation {
1084    name: Option<String>,
1085}
1086
1087// Commissioner API response types - internal only
1088#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1089struct CommissionersResponse {
1090    #[serde(rename = "assignedCommissioners", default)]
1091    commissioners: Vec<CommissionerApiItem>,
1092}
1093
1094#[derive(Debug, Clone, Serialize, Deserialize)]
1095struct CommissionerApiItem {
1096    #[serde(rename = "firstName")]
1097    first_name: Option<String>,
1098    #[serde(rename = "lastName")]
1099    last_name: Option<String>,
1100    #[serde(default)]
1101    position: Option<String>,
1102}
1103
1104/// Internal API response type - use OrgProfile from models for domain code
1105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1106struct OrgProfileApiResponse {
1107    #[serde(rename = "organizationName")]
1108    organization_name: Option<String>,
1109    #[serde(rename = "organizationFullName")]
1110    organization_full_name: Option<String>,
1111    #[serde(rename = "charteredOrgName")]
1112    chartered_org_name: Option<String>,
1113    #[serde(rename = "charterExpDate")]
1114    charter_exp_date: Option<String>,
1115    #[serde(rename = "charterStatus")]
1116    charter_status: Option<String>,
1117    #[serde(rename = "unitNumber")]
1118    unit_number: Option<String>,
1119    #[serde(rename = "unitType")]
1120    unit_type: Option<String>,
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126
1127    #[test]
1128    fn test_is_valid_guid() {
1129        // Valid GUIDs (synthetic test data)
1130        assert!(ApiClient::is_valid_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"));
1131        assert!(ApiClient::is_valid_guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); // lowercase
1132        assert!(ApiClient::is_valid_guid("00000000-0000-0000-0000-000000000000"));
1133        assert!(ApiClient::is_valid_guid("12345678-1234-1234-1234-123456789ABC"));
1134
1135        // Invalid GUIDs
1136        assert!(!ApiClient::is_valid_guid("")); // empty
1137        assert!(!ApiClient::is_valid_guid("not-a-guid")); // too short
1138        assert!(!ApiClient::is_valid_guid("AAAAAAAABBBBCCCCDDDDEEEEEEEEEEEE")); // no dashes
1139        assert!(!ApiClient::is_valid_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEE")); // too short
1140        assert!(!ApiClient::is_valid_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEEE")); // too long
1141        assert!(!ApiClient::is_valid_guid("ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ")); // invalid chars
1142    }
1143
1144    #[test]
1145    fn test_parse_commissioners_response() {
1146        let json = r#"{"organizationGuid": "00000000-0000-0000-0000-000000000001","organizationType": "Troop","organizationNumber": "0123","organizationCharterName": "Example Charter Organization","assignedCommissioners": [{"personGuid": "00000000-0000-0000-0000-000000000002","memberId": 1234567,"personId": 7654321,"personfullName": "Jane Marie Doe","firstName": "Jane","middleName": "Marie","lastName": "Doe","nameSuffix": "","positionId": 422,"position": "District Commissioner"}]}"#;
1147
1148        let resp: CommissionersResponse = serde_json::from_str(json)
1149            .expect("Failed to parse commissioners test JSON");
1150        assert_eq!(resp.commissioners.len(), 1);
1151
1152        // Verify API response parses correctly
1153        let c = &resp.commissioners[0];
1154        assert_eq!(c.first_name.as_deref(), Some("Jane"));
1155        assert_eq!(c.last_name.as_deref(), Some("Doe"));
1156        assert_eq!(c.position.as_deref(), Some("District Commissioner"));
1157
1158        // Test conversion to domain type
1159        let domain_commissioner = Commissioner {
1160            first_name: c.first_name.clone(),
1161            last_name: c.last_name.clone(),
1162            position: c.position.clone(),
1163        };
1164        assert_eq!(domain_commissioner.full_name(), "Jane Doe");
1165        assert_eq!(domain_commissioner.position_display(), "District Commissioner");
1166    }
1167}