1use 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 Commissioner, Key3Leaders, Leader, MeetingLocation, OrgProfile, UnitContact, UnitInfo,
23};
24use crate::models::advancement::CounselorInfo;
25
26use super::ApiError;
27
28const AUTH_BASE_URL: &str = "https://auth.scouting.org/api";
34
35const API_BASE_URL: &str = "https://api.scouting.org";
37
38const REQUEST_TIMEOUT_SECS: u64 = 30;
41
42const EVENT_LOOKBACK_DAYS: i64 = 30;
45
46const EVENT_LOOKAHEAD_DAYS: i64 = 180;
49
50const MAX_RATE_LIMIT_RETRIES: u32 = 3;
53
54const 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#[derive(Clone)]
83pub struct ApiClient {
84 client: Client,
85 token: Option<Arc<String>>,
86}
87
88impl ApiClient {
89 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 pub fn set_token(&mut self, token: impl Into<Arc<String>>) {
104 self.token = Some(token.into());
105 }
106
107 pub fn with_token(&self, token: Arc<String>) -> Self {
111 Self {
112 client: self.client.clone(), token: Some(token), }
115 }
116
117 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 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 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 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 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 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 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 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; }
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 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; }
303 }
304 }
305 }
306
307 pub async fn fetch_youth(&self, org_guid: &str) -> Result<Vec<Youth>> {
311 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 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 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 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 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 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 Ok(parsed.iter().map(|p| p.to_parent()).collect())
409 }
410
411 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 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 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 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 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 ranks.sort_by_key(|a| a.sort_order());
468
469 Ok(ranks)
470 }
471
472 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 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 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 pub async fn fetch_rank_requirements(&self, user_id: i64, rank_id: i64) -> Result<Vec<RankRequirement>> {
538 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 if let Ok(requirements) = serde_json::from_str::<Vec<RankRequirement>>(&text) {
557 return Ok(requirements);
558 }
559
560 let rank: RankWithRequirements = serde_json::from_str(&text)
562 .context("Failed to parse rank requirements")?;
563 Ok(rank.requirements)
564 }
565
566 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 pub async fn fetch_badge_requirements(&self, user_id: i64, badge_id: i64) -> Result<(Vec<MeritBadgeRequirement>, Option<String>, Option<CounselorInfo>)> {
598 let mut requirements = Vec::new();
603 let mut version = None;
604 let mut counselor = None;
605
606 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 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 debug!(count = reqs.len(), "Parsed badge requirements as array");
630 requirements = reqs;
631 }
632 }
633
634 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 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 if version.is_none() {
656 version = badge.version;
657 }
658 }
659 }
660
661 Ok((requirements, version, counselor))
662 }
663
664 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 if let Ok(badges) = serde_json::from_str::<Vec<crate::models::MeritBadgeCatalogEntry>>(&text) {
680 return Ok(badges);
681 }
682
683 #[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!("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 pub async fn fetch_events(&self, user_id: i64) -> Result<Vec<Event>> {
702 let url = format!("{}/advancements/events", API_BASE_URL);
703
704 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 if let Ok(events) = serde_json::from_str::<Vec<Event>>(&text) {
732 return Ok(events);
733 }
734
735 #[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 Ok(vec![])
755 }
756
757 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 event.id = event_id;
780 Ok(event)
781 }
782
783 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 if let Ok(guests) = serde_json::from_str::<Vec<EventGuest>>(&text) {
803 return Ok(guests);
804 }
805
806 #[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 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 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 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 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 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 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 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 debug!("Commissioners raw response: {}", &text[..text.len().min(500)]);
982
983 let convert = |api: CommissionerApiItem| Commissioner {
985 first_name: api.first_name,
986 last_name: api.last_name,
987 position: api.position,
988 };
989
990 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 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#[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#[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#[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 assert!(ApiClient::is_valid_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"));
1131 assert!(ApiClient::is_valid_guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); assert!(ApiClient::is_valid_guid("00000000-0000-0000-0000-000000000000"));
1133 assert!(ApiClient::is_valid_guid("12345678-1234-1234-1234-123456789ABC"));
1134
1135 assert!(!ApiClient::is_valid_guid("")); assert!(!ApiClient::is_valid_guid("not-a-guid")); assert!(!ApiClient::is_valid_guid("AAAAAAAABBBBCCCCDDDDEEEEEEEEEEEE")); assert!(!ApiClient::is_valid_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEE")); assert!(!ApiClient::is_valid_guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEEE")); assert!(!ApiClient::is_valid_guid("ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ")); }
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 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 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}