Skip to main content

trailcache_core/models/
person.rs

1// Allow dead code: API response structs have fields for completeness
2#![allow(dead_code)]
3
4use std::cmp::Ordering;
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8use chrono::{NaiveDate, Utc, Datelike};
9
10pub const PROGRAM_SCOUTS_BSA: &str = "Scouts BSA";
11pub const PROGRAM_ID_SCOUTS_BSA: i32 = 2;
12pub const UNIT_TYPE_ID_SCOUTS_BSA: i32 = 2;
13pub const POSITION_SCOUT: &str = "Scout";
14pub const POSITION_PATROL_LEADER: &str = "Patrol Leader";
15pub const POSITION_ASST_PATROL_LEADER: &str = "Assistant Patrol Leader";
16pub const DISPLAY_NOT_TRAINED: &str = "Not Trained";
17pub const DEFAULT_ADULT_ROLE: &str = "Adult Leader";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PersonType {
21    Youth,
22    Adult,
23    Parent,
24}
25
26impl std::fmt::Display for PersonType {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            PersonType::Youth => write!(f, "Youth"),
30            PersonType::Adult => write!(f, "Adult"),
31            PersonType::Parent => write!(f, "Parent"),
32        }
33    }
34}
35
36// API Response wrappers
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct OrganizationInfo {
39    #[serde(rename = "organizationFullName")]
40    pub organization_full_name: Option<String>,
41    #[serde(rename = "organizationGuid")]
42    pub organization_guid: Option<String>,
43    #[serde(rename = "organizationName")]
44    pub organization_name: Option<String>,
45    #[serde(rename = "unitType")]
46    pub unit_type: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct OrgYouthsResponse {
51    #[serde(rename = "organizationInfo")]
52    pub organization_info: Option<OrganizationInfo>,
53    pub members: Vec<Youth>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct OrgAdultsResponse {
58    #[serde(rename = "organizationInfo")]
59    pub organization_info: Option<OrganizationInfo>,
60    pub members: Vec<Adult>,
61}
62
63// Response from /units/{guid}/youths endpoint (has patrol & rank info)
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct UnitYouthsResponse {
66    pub id: Option<i64>,
67    pub number: Option<String>,
68    #[serde(rename = "unitType")]
69    pub unit_type: Option<String>,
70    #[serde(rename = "fullName")]
71    pub full_name: Option<String>,
72    pub users: Vec<UnitYouth>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct UnitYouth {
77    #[serde(rename = "userId")]
78    pub user_id: Option<i64>,
79    #[serde(rename = "memberId")]
80    pub member_id: Option<i64>,
81    #[serde(rename = "personGuid")]
82    pub person_guid: Option<String>,
83    #[serde(rename = "firstName")]
84    pub first_name: String,
85    #[serde(rename = "middleName")]
86    pub middle_name: Option<String>,
87    #[serde(rename = "lastName")]
88    pub last_name: String,
89    #[serde(rename = "nickName")]
90    pub nick_name: Option<String>,
91    #[serde(rename = "personFullName")]
92    pub person_full_name: Option<String>,
93    #[serde(rename = "dateOfBirth")]
94    pub date_of_birth: Option<String>,
95    pub age: Option<i32>,
96    pub grade: Option<i32>,
97    pub gender: Option<String>,
98    // Contact info
99    pub email: Option<String>,
100    pub address1: Option<String>,
101    pub address2: Option<String>,
102    pub city: Option<String>,
103    pub state: Option<String>,
104    pub zip: Option<String>,
105    #[serde(rename = "homePhone")]
106    pub home_phone: Option<String>,
107    #[serde(rename = "mobilePhone")]
108    pub mobile_phone: Option<String>,
109    // Positions (contains patrol info)
110    #[serde(default)]
111    pub positions: Vec<YouthPosition>,
112    // Ranks
113    #[serde(rename = "highestRanksAwarded", default)]
114    pub highest_ranks_awarded: Vec<YouthRank>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct YouthPosition {
119    #[serde(rename = "positionId")]
120    pub position_id: Option<i64>,
121    pub position: Option<String>,
122    #[serde(rename = "patrolId")]
123    pub patrol_id: Option<i64>,
124    #[serde(rename = "patrolName")]
125    pub patrol_name: Option<String>,
126    #[serde(rename = "dateStarted")]
127    pub date_started: Option<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct YouthRank {
132    pub id: Option<i64>,
133    pub rank: Option<String>,
134    pub level: Option<i32>,
135    #[serde(rename = "programId")]
136    pub program_id: Option<i32>,
137    pub program: Option<String>,
138    #[serde(rename = "unitTypeId")]
139    pub unit_type_id: Option<i32>,
140    #[serde(rename = "dateEarned")]
141    pub date_earned: Option<String>,
142    pub awarded: Option<bool>,
143}
144
145impl UnitYouth {
146    /// Convert to Youth struct for compatibility with existing code
147    pub fn to_youth(&self) -> Youth {
148        // Find primary position (first one with a patrol)
149        let primary_pos = self.positions.iter()
150            .find(|p| p.patrol_name.is_some());
151
152        // Find highest Scouts BSA rank (programId 2, unitTypeId 2)
153        let scouts_bsa_rank = self.highest_ranks_awarded.iter()
154            .filter(|r| r.program_id == Some(PROGRAM_ID_SCOUTS_BSA) && r.unit_type_id == Some(UNIT_TYPE_ID_SCOUTS_BSA))
155            .max_by_key(|r| r.level);
156
157        // Find position of responsibility (not "Scouts BSA" which is just member)
158        let por = self.positions.iter()
159            .find(|p| p.position.as_deref().map(|s| s != PROGRAM_SCOUTS_BSA).unwrap_or(false))
160            .and_then(|p| p.position.clone());
161
162        Youth {
163            person_guid: self.person_guid.clone(),
164            member_id: self.member_id.map(|id| id.to_string()),
165            person_full_name: self.person_full_name.clone(),
166            first_name: self.first_name.clone(),
167            middle_name: self.middle_name.clone(),
168            last_name: self.last_name.clone(),
169            nick_name: self.nick_name.clone(),
170            gender: self.gender.clone(),
171            name_suffix: None,
172            ethnicity: None,
173            grade: self.grade,
174            grade_id: None,
175            position: por,
176            position_id: None,
177            program_id: Some(PROGRAM_ID_SCOUTS_BSA),
178            program: Some(PROGRAM_SCOUTS_BSA.to_string()),
179            registrar_info: Some(RegistrarInfo {
180                date_of_birth: self.date_of_birth.clone(),
181                registration_id: None,
182                registration_status_id: None,
183                registration_status: None,
184                registration_effective_dt: None,
185                registration_expire_dt: None,
186                renewal_status: None,
187                is_yearly_membership: None,
188                is_manually_ended: None,
189                is_auto_renewal_opted_out: None,
190            }),
191            primary_email_info: self.email.as_ref().map(|e| PrimaryEmailInfo {
192                email_id: None,
193                email_type: None,
194                email_address: Some(e.clone()),
195            }),
196            primary_phone_info: self.mobile_phone.as_ref().or(self.home_phone.as_ref()).map(|phone| {
197                // Parse phone into components if possible
198                let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
199                if digits.len() == 10 {
200                    PrimaryPhoneInfo {
201                        phone_id: None,
202                        phone_type: None,
203                        phone_area_code: Some(digits[0..3].to_string()),
204                        phone_prefix: Some(digits[3..6].to_string()),
205                        phone_line_number: Some(digits[6..10].to_string()),
206                    }
207                } else {
208                    PrimaryPhoneInfo {
209                        phone_id: None,
210                        phone_type: None,
211                        phone_area_code: None,
212                        phone_prefix: None,
213                        phone_line_number: None,
214                    }
215                }
216            }),
217            primary_address_info: if self.address1.is_some() || self.city.is_some() {
218                Some(PrimaryAddressInfo {
219                    id: None,
220                    address_type: None,
221                    address1: self.address1.clone(),
222                    address2: self.address2.clone(),
223                    city: self.city.clone(),
224                    state: self.state.clone(),
225                    zip_code: self.zip.clone(),
226                })
227            } else {
228                None
229            },
230            user_id: self.user_id,
231            email: self.email.clone(),
232            phone_number: self.mobile_phone.clone().or(self.home_phone.clone()),
233            patrol_name: primary_pos.and_then(|p| p.patrol_name.clone()),
234            patrol_guid: None,
235            is_patrol_leader: None, // Could check for "Patrol Leader" position
236            current_rank: scouts_bsa_rank.and_then(|r| r.rank.clone()),
237        }
238    }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct PrimaryEmailInfo {
243    #[serde(rename = "emailId")]
244    pub email_id: Option<String>,
245    #[serde(rename = "emailType")]
246    pub email_type: Option<String>,
247    #[serde(rename = "emailAddress")]
248    pub email_address: Option<String>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct PrimaryPhoneInfo {
253    #[serde(rename = "phoneId")]
254    pub phone_id: Option<String>,
255    #[serde(rename = "phoneType")]
256    pub phone_type: Option<String>,
257    #[serde(rename = "phoneAreaCode")]
258    pub phone_area_code: Option<String>,
259    #[serde(rename = "phonePrefix")]
260    pub phone_prefix: Option<String>,
261    #[serde(rename = "phoneLineNumber")]
262    pub phone_line_number: Option<String>,
263}
264
265impl PrimaryPhoneInfo {
266    pub fn formatted(&self) -> Option<String> {
267        match (&self.phone_area_code, &self.phone_prefix, &self.phone_line_number) {
268            (Some(area), Some(prefix), Some(line)) if !area.is_empty() && !prefix.is_empty() && !line.is_empty() => {
269                Some(format!("({}) {}-{}", area, prefix, line))
270            }
271            _ => None,
272        }
273    }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct PrimaryAddressInfo {
278    pub id: Option<String>,
279    #[serde(rename = "type")]
280    pub address_type: Option<String>,
281    pub address1: Option<String>,
282    pub address2: Option<String>,
283    pub city: Option<String>,
284    pub state: Option<String>,
285    #[serde(rename = "zipCode")]
286    pub zip_code: Option<String>,
287}
288
289impl PrimaryAddressInfo {
290    pub fn formatted(&self) -> Option<String> {
291        let addr1 = self.address1.as_deref().unwrap_or("").trim();
292        let city = self.city.as_deref().unwrap_or("");
293        let state = self.state.as_deref().unwrap_or("");
294        let zip = self.zip_code.as_deref().unwrap_or("");
295
296        if addr1.is_empty() && city.is_empty() {
297            return None;
298        }
299
300        Some(format!("{}, {}, {} {}", addr1, city, state, zip).trim().to_string())
301    }
302
303    pub fn city_state(&self) -> Option<String> {
304        let city = self.city.as_deref().unwrap_or("");
305        let state = self.state.as_deref().unwrap_or("");
306        if city.is_empty() {
307            return None;
308        }
309        Some(format!("{}, {}", city, state))
310    }
311
312    /// Format city, state, and zip as a single line.
313    pub fn city_state_zip(&self) -> Option<String> {
314        let cs = self.city_state()?;
315        let zip = self.zip_code.as_deref().unwrap_or("");
316        Some(format!("{} {}", cs, zip).trim().to_string())
317    }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct RegistrarInfo {
322    #[serde(rename = "dateOfBirth")]
323    pub date_of_birth: Option<String>,
324    #[serde(rename = "registrationId")]
325    pub registration_id: Option<i64>,
326    #[serde(rename = "registrationStatusId")]
327    pub registration_status_id: Option<i32>,
328    #[serde(rename = "registrationStatus")]
329    pub registration_status: Option<String>,
330    #[serde(rename = "registrationEffectiveDt")]
331    pub registration_effective_dt: Option<String>,
332    #[serde(rename = "registrationExpireDt")]
333    pub registration_expire_dt: Option<String>,
334    #[serde(rename = "renewalStatus")]
335    pub renewal_status: Option<String>,
336    #[serde(rename = "isYearlyMembership")]
337    pub is_yearly_membership: Option<bool>,
338    #[serde(rename = "isManuallyEnded")]
339    pub is_manually_ended: Option<bool>,
340    #[serde(rename = "isAutoRenewalOptedOut")]
341    pub is_auto_renewal_opted_out: Option<bool>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct Youth {
346    #[serde(rename = "personGuid")]
347    pub person_guid: Option<String>,
348    #[serde(rename = "memberId")]
349    pub member_id: Option<String>,
350    #[serde(rename = "personFullName")]
351    pub person_full_name: Option<String>,
352    #[serde(rename = "firstName")]
353    pub first_name: String,
354    #[serde(rename = "middleName")]
355    pub middle_name: Option<String>,
356    #[serde(rename = "lastName")]
357    pub last_name: String,
358    #[serde(rename = "nickName")]
359    pub nick_name: Option<String>,
360    pub gender: Option<String>,
361    #[serde(rename = "nameSuffix")]
362    pub name_suffix: Option<String>,
363    pub ethnicity: Option<String>,
364    pub grade: Option<i32>,
365    #[serde(rename = "gradeId")]
366    pub grade_id: Option<i32>,
367    pub position: Option<String>,
368    #[serde(rename = "positionId")]
369    pub position_id: Option<i64>,
370    #[serde(rename = "programId")]
371    pub program_id: Option<i32>,
372    pub program: Option<String>,
373    #[serde(rename = "registrarInfo")]
374    pub registrar_info: Option<RegistrarInfo>,
375    #[serde(rename = "primaryEmailInfo")]
376    pub primary_email_info: Option<PrimaryEmailInfo>,
377    #[serde(rename = "primaryPhoneInfo")]
378    pub primary_phone_info: Option<PrimaryPhoneInfo>,
379    #[serde(rename = "primaryAddressInfo")]
380    pub primary_address_info: Option<PrimaryAddressInfo>,
381    // Legacy fields for compatibility
382    #[serde(rename = "userId")]
383    pub user_id: Option<i64>,
384    pub email: Option<String>,
385    #[serde(rename = "phoneNumber")]
386    pub phone_number: Option<String>,
387    #[serde(rename = "subUnitName")]
388    pub patrol_name: Option<String>,
389    #[serde(rename = "subUnitGuid")]
390    pub patrol_guid: Option<String>,
391    #[serde(rename = "isPatrolLeader")]
392    pub is_patrol_leader: Option<bool>,
393    #[serde(rename = "currentRankName")]
394    pub current_rank: Option<String>,
395}
396
397impl Youth {
398    pub fn full_name(&self) -> String {
399        if let Some(ref full) = self.person_full_name {
400            full.clone()
401        } else {
402            format!("{} {}", self.first_name, self.last_name)
403        }
404    }
405
406    pub fn display_name(&self) -> String {
407        let nick = self.nick_name.as_deref().filter(|n| !n.is_empty() && *n != self.first_name);
408        match nick {
409            Some(n) => format!("{}, {} ({})", self.last_name, self.first_name, n),
410            None => format!("{}, {}", self.last_name, self.first_name),
411        }
412    }
413
414    pub fn short_name(&self) -> String {
415        let first = self.nick_name.as_deref()
416            .filter(|n| !n.is_empty())
417            .unwrap_or(&self.first_name);
418        format!("{} {}", first, self.last_name)
419    }
420
421    pub fn get_user_id(&self) -> i64 {
422        self.user_id.unwrap_or(0)
423    }
424
425    pub fn date_of_birth(&self) -> Option<NaiveDate> {
426        self.registrar_info.as_ref()
427            .and_then(|r| r.date_of_birth.as_ref())
428            .and_then(|dob| NaiveDate::parse_from_str(dob, "%Y-%m-%d").ok())
429    }
430
431    pub fn age(&self) -> Option<i32> {
432        self.date_of_birth().map(|dob| {
433            let today = Utc::now().date_naive();
434            let mut age = today.year() - dob.year();
435            if (today.month(), today.day()) < (dob.month(), dob.day()) {
436                age -= 1;
437            }
438            age
439        })
440    }
441
442    pub fn age_str(&self) -> String {
443        self.age().map(|a| a.to_string()).unwrap_or_else(|| "-".to_string())
444    }
445
446    pub fn grade_str(&self) -> String {
447        self.grade.map(|g| g.to_string()).unwrap_or_else(|| "-".to_string())
448    }
449
450    pub fn phone(&self) -> Option<String> {
451        self.primary_phone_info.as_ref()
452            .and_then(|p| p.formatted())
453            .or_else(|| self.phone_number.clone())
454    }
455
456    pub fn email(&self) -> Option<String> {
457        self.primary_email_info.as_ref()
458            .and_then(|e| e.email_address.clone())
459            .filter(|e| !e.is_empty())
460            .or_else(|| self.email.clone())
461    }
462
463    pub fn address(&self) -> Option<String> {
464        self.primary_address_info.as_ref().and_then(|a| a.formatted())
465    }
466
467    pub fn city_state(&self) -> Option<String> {
468        self.primary_address_info.as_ref().and_then(|a| a.city_state())
469    }
470
471    pub fn registration_status(&self) -> Option<String> {
472        self.registrar_info.as_ref()
473            .and_then(|r| r.registration_status.clone())
474    }
475
476    pub fn registration_expires(&self) -> Option<String> {
477        self.registrar_info.as_ref()
478            .and_then(|r| r.registration_expire_dt.clone())
479    }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct Adult {
484    #[serde(rename = "personGuid")]
485    pub person_guid: Option<String>,
486    #[serde(rename = "memberId")]
487    pub member_id: Option<String>,
488    #[serde(rename = "personFullName")]
489    pub person_full_name: Option<String>,
490    #[serde(rename = "firstName")]
491    pub first_name: String,
492    #[serde(rename = "middleName")]
493    pub middle_name: Option<String>,
494    #[serde(rename = "lastName")]
495    pub last_name: String,
496    #[serde(rename = "nickName")]
497    pub nick_name: Option<String>,
498    pub position: Option<String>,
499    #[serde(rename = "positionId")]
500    pub position_id: Option<i64>,
501    pub key3: Option<String>,
502    #[serde(rename = "positionTrained")]
503    pub position_trained: Option<String>,
504    #[serde(rename = "yptStatus")]
505    pub ypt_status: Option<String>,
506    #[serde(rename = "yptCompletedDate")]
507    pub ypt_completed_date: Option<String>,
508    #[serde(rename = "yptExpiredDate")]
509    pub ypt_expired_date: Option<String>,
510    #[serde(rename = "registrarInfo")]
511    pub registrar_info: Option<RegistrarInfo>,
512    #[serde(rename = "primaryEmailInfo")]
513    pub primary_email_info: Option<PrimaryEmailInfo>,
514    #[serde(rename = "primaryPhoneInfo")]
515    pub primary_phone_info: Option<PrimaryPhoneInfo>,
516    #[serde(rename = "primaryAddressInfo")]
517    pub primary_address_info: Option<PrimaryAddressInfo>,
518    // Legacy fields
519    #[serde(rename = "userId")]
520    pub user_id: Option<i64>,
521    pub email: Option<String>,
522    #[serde(rename = "phoneNumber")]
523    pub phone_number: Option<String>,
524}
525
526impl Adult {
527    /// Classify position_trained field into a boolean.
528    /// "Trained"/"Y"/"Yes"/"true" → Some(true), "Not Trained"/"N"/"No"/"false" → Some(false), else None
529    pub fn is_position_trained(&self) -> Option<bool> {
530        match self.position_trained.as_deref() {
531            Some("Trained") | Some("Y") | Some("Yes") | Some("true") => Some(true),
532            Some("Not Trained") | Some("N") | Some("No") | Some("false") => Some(false),
533            _ => None,
534        }
535    }
536
537    /// Display string for position training status.
538    pub fn position_trained_display(&self) -> &str {
539        match self.is_position_trained() {
540            Some(true) => "Trained",
541            Some(false) => DISPLAY_NOT_TRAINED,
542            None => "-",
543        }
544    }
545
546    /// Check if this adult matches a search query (case-insensitive).
547    /// Query should already be lowercased.
548    pub fn matches_search(&self, query_lowercase: &str) -> bool {
549        self.first_name.to_lowercase().contains(query_lowercase)
550            || self.last_name.to_lowercase().contains(query_lowercase)
551            || self.position.as_ref().map(|s| s.to_lowercase().contains(query_lowercase)).unwrap_or(false)
552            || self.email().as_ref().map(|s| s.to_lowercase().contains(query_lowercase)).unwrap_or(false)
553    }
554
555    pub fn full_name(&self) -> String {
556        if let Some(ref full) = self.person_full_name {
557            full.clone()
558        } else {
559            format!("{} {}", self.first_name, self.last_name)
560        }
561    }
562
563    pub fn display_name(&self) -> String {
564        format!("{}, {}", self.last_name, self.first_name)
565    }
566
567    pub fn display_name_full(&self) -> String {
568        match &self.middle_name {
569            Some(middle) if !middle.is_empty() => {
570                format!("{}, {} {}", self.last_name, self.first_name, middle)
571            }
572            _ => format!("{}, {}", self.last_name, self.first_name)
573        }
574    }
575
576    pub fn role(&self) -> String {
577        self.position
578            .clone()
579            .unwrap_or_else(|| DEFAULT_ADULT_ROLE.to_string())
580    }
581
582    pub fn get_user_id(&self) -> i64 {
583        self.user_id.unwrap_or(0)
584    }
585
586    pub fn phone(&self) -> Option<String> {
587        self.primary_phone_info.as_ref()
588            .and_then(|p| p.formatted())
589            .or_else(|| self.phone_number.clone())
590    }
591
592    pub fn email(&self) -> Option<String> {
593        self.primary_email_info.as_ref()
594            .and_then(|e| e.email_address.clone())
595            .filter(|e| !e.is_empty())
596            .or_else(|| self.email.clone())
597    }
598
599    /// Deduplicate adults by person_guid, combining multiple positions.
600    ///
601    /// The BSA API returns duplicate entries for adults who hold multiple
602    /// positions (e.g., both "Assistant Scoutmaster" and "Committee Member").
603    /// This merges duplicates into single entries with combined position strings
604    /// (e.g., "Assistant Scoutmaster, Committee Member").
605    ///
606    /// Adults without a person_guid are kept as separate entries.
607    /// Result is sorted by last_name, first_name.
608    pub fn deduplicate(adults: Vec<Adult>) -> Vec<Adult> {
609        let mut by_guid: HashMap<String, Adult> = HashMap::new();
610        let mut no_guid_counter: usize = 0;
611
612        for adult in adults {
613            let guid = adult.person_guid.clone().unwrap_or_default();
614            if guid.is_empty() {
615                by_guid.insert(format!("_no_guid_{}", no_guid_counter), adult);
616                no_guid_counter += 1;
617                continue;
618            }
619
620            if let Some(existing) = by_guid.get_mut(&guid) {
621                if let Some(new_pos) = &adult.position {
622                    if let Some(existing_pos) = &existing.position {
623                        let existing_positions: Vec<&str> = existing_pos
624                            .split(", ")
625                            .map(|s| s.trim())
626                            .collect();
627                        if !existing_positions.contains(&new_pos.as_str()) {
628                            existing.position = Some(format!("{}, {}", existing_pos, new_pos));
629                        }
630                    } else {
631                        existing.position = Some(new_pos.clone());
632                    }
633                }
634            } else {
635                by_guid.insert(guid, adult);
636            }
637        }
638
639        let mut result: Vec<Adult> = by_guid.into_values().collect();
640        result.sort_by(|a, b| a.last_name.cmp(&b.last_name).then(a.first_name.cmp(&b.first_name)));
641        result
642    }
643}
644
645// API response format for parents endpoint
646#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct ParentResponse {
648    #[serde(rename = "youthUserId")]
649    pub youth_user_id: i64,
650    #[serde(rename = "parentUserId")]
651    pub parent_user_id: i64,
652    #[serde(rename = "parentInformation")]
653    pub parent_information: ParentInformation,
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct ParentInformation {
658    #[serde(rename = "memberId")]
659    pub member_id: Option<i64>,
660    #[serde(rename = "personGuid")]
661    pub person_guid: Option<String>,
662    #[serde(rename = "firstName")]
663    pub first_name: String,
664    #[serde(rename = "middleName")]
665    pub middle_name: Option<String>,
666    #[serde(rename = "lastName")]
667    pub last_name: String,
668    #[serde(rename = "nickName")]
669    pub nick_name: Option<String>,
670    #[serde(rename = "personFullName")]
671    pub person_full_name: Option<String>,
672    pub email: Option<String>,
673    #[serde(rename = "homePhone")]
674    pub home_phone: Option<String>,
675    #[serde(rename = "mobilePhone")]
676    pub mobile_phone: Option<String>,
677    #[serde(rename = "workPhone")]
678    pub work_phone: Option<String>,
679    pub address1: Option<String>,
680    pub address2: Option<String>,
681    pub city: Option<String>,
682    pub state: Option<String>,
683    pub zip: Option<String>,
684}
685
686impl ParentResponse {
687    pub fn to_parent(&self) -> Parent {
688        let info = &self.parent_information;
689        Parent {
690            user_id: Some(self.parent_user_id),
691            person_guid: info.person_guid.clone(),
692            first_name: info.first_name.clone(),
693            last_name: info.last_name.clone(),
694            email: info.email.clone(),
695            mobile_phone: info.mobile_phone.clone(),
696            home_phone: info.home_phone.clone(),
697            address1: info.address1.clone(),
698            address2: info.address2.clone(),
699            city: info.city.clone(),
700            state: info.state.clone(),
701            zip: info.zip.clone(),
702            youth_user_id: Some(self.youth_user_id),
703            youth_first_name: None,
704            youth_last_name: None,
705            relationship: None,
706        }
707    }
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize)]
711pub struct Parent {
712    #[serde(rename = "userId")]
713    pub user_id: Option<i64>,
714    #[serde(rename = "personGuid")]
715    pub person_guid: Option<String>,
716    #[serde(rename = "firstName")]
717    pub first_name: String,
718    #[serde(rename = "lastName")]
719    pub last_name: String,
720    pub email: Option<String>,
721    #[serde(rename = "mobilePhone", alias = "phoneNumber")]
722    pub mobile_phone: Option<String>,
723    #[serde(rename = "homePhone", default)]
724    pub home_phone: Option<String>,
725    #[serde(default)]
726    pub address1: Option<String>,
727    #[serde(default)]
728    pub address2: Option<String>,
729    #[serde(default)]
730    pub city: Option<String>,
731    #[serde(default)]
732    pub state: Option<String>,
733    #[serde(default)]
734    pub zip: Option<String>,
735    #[serde(rename = "youthUserId")]
736    pub youth_user_id: Option<i64>,
737    #[serde(rename = "youthFirstName")]
738    pub youth_first_name: Option<String>,
739    #[serde(rename = "youthLastName")]
740    pub youth_last_name: Option<String>,
741    pub relationship: Option<String>,
742}
743
744impl Parent {
745    pub fn full_name(&self) -> String {
746        format!("{} {}", self.first_name, self.last_name)
747    }
748
749    pub fn display_name(&self) -> String {
750        format!("{}, {}", self.last_name, self.first_name)
751    }
752
753    pub fn phone(&self) -> Option<String> {
754        self.mobile_phone.as_ref()
755            .or(self.home_phone.as_ref())
756            .map(|p| crate::utils::format_phone(p))
757    }
758
759    pub fn address_line(&self) -> Option<String> {
760        let addr = self.address1.as_deref().filter(|a| !a.trim().is_empty())?;
761        let city = self.city.as_deref().unwrap_or("");
762        let state = self.state.as_deref().unwrap_or("");
763        let zip = self.zip.as_deref().unwrap_or("");
764        Some(format!("{}, {}, {} {}", addr, city, state, zip))
765    }
766
767    /// Format city, state, and zip as a single line.
768    pub fn city_state_zip(&self) -> Option<String> {
769        let city = self.city.as_deref().unwrap_or("");
770        let state = self.state.as_deref().unwrap_or("");
771        let zip = self.zip.as_deref().unwrap_or("");
772        if city.is_empty() && state.is_empty() {
773            return None;
774        }
775        let cs = if !city.is_empty() {
776            format!("{}, {}", city, state)
777        } else {
778            state.to_string()
779        };
780        let line = format!("{} {}", cs, zip).trim().to_string();
781        if line == "," || line.is_empty() { None } else { Some(line) }
782    }
783
784    pub fn youth_name(&self) -> Option<String> {
785        match (&self.youth_first_name, &self.youth_last_name) {
786            (Some(first), Some(last)) => Some(format!("{} {}", first, last)),
787            _ => None,
788        }
789    }
790}
791
792/// Priority order for youth positions of responsibility.
793pub const YOUTH_POSITION_PRIORITY: &[&str] = &[
794    "Senior Patrol Leader",
795    "Assistant Senior Patrol Leader",
796    "Troop Guide",
797    "Patrol Leader",
798    "Assistant Patrol Leader",
799    "Quartermaster",
800    "Scribe",
801    "Historian",
802    "Librarian",
803    "Chaplain Aide",
804    "Outdoor Ethics Guide",
805    "Den Chief",
806    "Instructor",
807    "Junior Assistant Scoutmaster",
808    "Bugler",
809];
810
811// Sorting options for scouts table
812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
813pub enum ScoutSortColumn {
814    Name,
815    Patrol,
816    Rank,
817    Grade,
818    Age,
819}
820
821impl ScoutSortColumn {
822    pub fn next(&self) -> Self {
823        match self {
824            ScoutSortColumn::Name => ScoutSortColumn::Patrol,
825            ScoutSortColumn::Patrol => ScoutSortColumn::Rank,
826            ScoutSortColumn::Rank => ScoutSortColumn::Grade,
827            ScoutSortColumn::Grade => ScoutSortColumn::Age,
828            ScoutSortColumn::Age => ScoutSortColumn::Name,
829        }
830    }
831}
832
833impl Youth {
834    pub fn patrol(&self) -> String {
835        self.patrol_name.clone().unwrap_or_else(|| "-".to_string())
836    }
837
838    pub fn rank(&self) -> String {
839        self.current_rank.clone().unwrap_or_else(|| "Crossover".to_string())
840    }
841
842    pub fn rank_short(&self) -> String {
843        use super::advancement::ScoutRank;
844        ScoutRank::parse(self.current_rank.as_deref()).abbreviation().to_string()
845    }
846
847    pub fn position_display(&self) -> Option<String> {
848        self.position.clone().filter(|p| !p.is_empty() && p != POSITION_SCOUT)
849    }
850
851    /// Sort key based on YOUTH_POSITION_PRIORITY. Returns 999 for unknown/none.
852    pub fn position_sort_key(&self) -> usize {
853        match self.position.as_deref() {
854            Some(pos) if !pos.is_empty() && pos != POSITION_SCOUT && pos != PROGRAM_SCOUTS_BSA => {
855                YOUTH_POSITION_PRIORITY.iter().position(|&p| p == pos).unwrap_or(999)
856            }
857            _ => 999,
858        }
859    }
860
861    /// Display position with patrol name for PL/APL.
862    /// Returns None for "Scout", "Scouts BSA", or empty positions.
863    pub fn position_display_with_patrol(&self) -> Option<String> {
864        let pos = self.position.as_deref()?;
865        if pos.is_empty() || pos == POSITION_SCOUT || pos == PROGRAM_SCOUTS_BSA {
866            return None;
867        }
868        if (pos == POSITION_PATROL_LEADER || pos == POSITION_ASST_PATROL_LEADER)
869            && self.patrol_name.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
870        {
871            Some(format!("{} ({})", pos, self.patrol_name.as_ref().unwrap()))
872        } else {
873            Some(pos.to_string())
874        }
875    }
876
877    /// Check if this youth matches a search query (case-insensitive).
878    /// Query should already be lowercased.
879    pub fn matches_search(&self, query_lowercase: &str) -> bool {
880        self.first_name.to_lowercase().contains(query_lowercase)
881            || self.last_name.to_lowercase().contains(query_lowercase)
882            || self.patrol_name.as_ref().map(|s| s.to_lowercase().contains(query_lowercase)).unwrap_or(false)
883            || self.current_rank.as_ref().map(|s| s.to_lowercase().contains(query_lowercase)).unwrap_or(false)
884            || self.email().as_ref().map(|s| s.to_lowercase().contains(query_lowercase)).unwrap_or(false)
885    }
886
887    /// Compare two youth by the given column, with name as tiebreaker.
888    pub fn cmp_by_column(a: &Youth, b: &Youth, column: ScoutSortColumn) -> Ordering {
889        use crate::utils::cmp_ignore_case;
890        use super::advancement::ScoutRank;
891
892        let name_cmp = || {
893            cmp_ignore_case(&a.last_name, &b.last_name)
894                .then_with(|| cmp_ignore_case(&a.first_name, &b.first_name))
895        };
896
897        match column {
898            ScoutSortColumn::Name => name_cmp(),
899            ScoutSortColumn::Rank => {
900                let rank_a = ScoutRank::parse(a.current_rank.as_deref());
901                let rank_b = ScoutRank::parse(b.current_rank.as_deref());
902                // Reversed so ascending shows highest rank first
903                rank_b.cmp(&rank_a).then_with(name_cmp)
904            }
905            ScoutSortColumn::Grade => a.grade.cmp(&b.grade).then_with(name_cmp),
906            ScoutSortColumn::Age => a.age().cmp(&b.age()).then_with(name_cmp),
907            ScoutSortColumn::Patrol => {
908                cmp_ignore_case(
909                    a.patrol_name.as_deref().unwrap_or(""),
910                    b.patrol_name.as_deref().unwrap_or(""),
911                )
912                .then_with(name_cmp)
913            }
914        }
915    }
916}
917
918/// Build a sorted list of youth positions of responsibility.
919/// Filters out youth without positions, sorts by priority order.
920/// Returns (position_display, holder_display_name) pairs.
921pub fn youth_position_list(youth: &[Youth]) -> Vec<(String, String)> {
922    let mut positions: Vec<(usize, String, String)> = Vec::new();
923    for y in youth {
924        if let Some(display_pos) = y.position_display_with_patrol() {
925            positions.push((y.position_sort_key(), display_pos, y.display_name()));
926        }
927    }
928    positions.sort_by(|a, b| {
929        a.0.cmp(&b.0)
930            .then_with(|| a.1.cmp(&b.1))
931            .then_with(|| a.2.cmp(&b.2))
932    });
933    positions.into_iter().map(|(_, display, name)| (display, name)).collect()
934}
935
936/// Sorting options for adult table columns.
937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
938pub enum AdultSortColumn {
939    Name,
940    Role,
941    Email,
942}
943
944impl Adult {
945    /// Compare two adults by the given column, with name as tiebreaker.
946    pub fn cmp_by_column(a: &Adult, b: &Adult, column: AdultSortColumn) -> Ordering {
947        use crate::utils::cmp_ignore_case;
948        let name_cmp = || {
949            cmp_ignore_case(&a.last_name, &b.last_name)
950                .then_with(|| cmp_ignore_case(&a.first_name, &b.first_name))
951        };
952        match column {
953            AdultSortColumn::Name => name_cmp(),
954            AdultSortColumn::Role => {
955                cmp_ignore_case(&a.role(), &b.role()).then_with(name_cmp)
956            }
957            AdultSortColumn::Email => {
958                let ea = a.email().unwrap_or_default();
959                let eb = b.email().unwrap_or_default();
960                cmp_ignore_case(&ea, &eb).then_with(name_cmp)
961            }
962        }
963    }
964}
965
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    fn make_adult(position_trained: Option<&str>) -> Adult {
972        Adult {
973            person_guid: None, member_id: None, person_full_name: None,
974            first_name: "Jane".to_string(), middle_name: None, last_name: "Doe".to_string(),
975            nick_name: None, position: Some("Scoutmaster".to_string()), position_id: None,
976            key3: None, position_trained: position_trained.map(|s| s.to_string()),
977            ypt_status: None, ypt_completed_date: None, ypt_expired_date: None,
978            registrar_info: None, primary_email_info: None, primary_phone_info: None,
979            primary_address_info: None, user_id: None, email: None, phone_number: None,
980        }
981    }
982
983    fn make_youth(position: Option<&str>, patrol: Option<&str>) -> Youth {
984        Youth {
985            person_guid: None, member_id: None, person_full_name: None,
986            first_name: "John".to_string(), middle_name: None, last_name: "Smith".to_string(),
987            nick_name: None, gender: None, name_suffix: None, ethnicity: None,
988            grade: None, grade_id: None, position: position.map(|s| s.to_string()),
989            position_id: None, program_id: None, program: None, registrar_info: None,
990            primary_email_info: None, primary_phone_info: None, primary_address_info: None,
991            user_id: None, email: None, phone_number: None,
992            patrol_name: patrol.map(|s| s.to_string()), patrol_guid: None,
993            is_patrol_leader: None, current_rank: Some("First Class".to_string()),
994        }
995    }
996
997    #[test]
998    fn test_is_position_trained() {
999        assert_eq!(make_adult(Some("Trained")).is_position_trained(), Some(true));
1000        assert_eq!(make_adult(Some("Y")).is_position_trained(), Some(true));
1001        assert_eq!(make_adult(Some("Yes")).is_position_trained(), Some(true));
1002        assert_eq!(make_adult(Some("Not Trained")).is_position_trained(), Some(false));
1003        assert_eq!(make_adult(Some("N")).is_position_trained(), Some(false));
1004        assert_eq!(make_adult(None).is_position_trained(), None);
1005        assert_eq!(make_adult(Some("unknown")).is_position_trained(), None);
1006    }
1007
1008    #[test]
1009    fn test_position_trained_display() {
1010        assert_eq!(make_adult(Some("Trained")).position_trained_display(), "Trained");
1011        assert_eq!(make_adult(Some("Y")).position_trained_display(), "Trained");
1012        assert_eq!(make_adult(Some("Not Trained")).position_trained_display(), "Not Trained");
1013        assert_eq!(make_adult(None).position_trained_display(), "-");
1014    }
1015
1016    #[test]
1017    fn test_position_sort_key() {
1018        let spl = make_youth(Some("Senior Patrol Leader"), None);
1019        let pl = make_youth(Some("Patrol Leader"), None);
1020        let scout = make_youth(Some("Scout"), None);
1021        let none = make_youth(None, None);
1022
1023        assert_eq!(spl.position_sort_key(), 0);
1024        assert_eq!(pl.position_sort_key(), 3);
1025        assert_eq!(scout.position_sort_key(), 999);
1026        assert_eq!(none.position_sort_key(), 999);
1027    }
1028
1029    #[test]
1030    fn test_position_display_with_patrol() {
1031        let pl = make_youth(Some("Patrol Leader"), Some("Eagle"));
1032        assert_eq!(pl.position_display_with_patrol(), Some("Patrol Leader (Eagle)".to_string()));
1033
1034        let spl = make_youth(Some("Senior Patrol Leader"), Some("Eagle"));
1035        assert_eq!(spl.position_display_with_patrol(), Some("Senior Patrol Leader".to_string()));
1036
1037        let scout = make_youth(Some("Scout"), None);
1038        assert_eq!(scout.position_display_with_patrol(), None);
1039
1040        let none = make_youth(None, None);
1041        assert_eq!(none.position_display_with_patrol(), None);
1042    }
1043
1044    #[test]
1045    fn test_youth_matches_search() {
1046        let youth = make_youth(Some("Patrol Leader"), Some("Eagle"));
1047        assert!(youth.matches_search("john"));
1048        assert!(youth.matches_search("smith"));
1049        assert!(youth.matches_search("eagle"));
1050        assert!(youth.matches_search("first class"));
1051        assert!(!youth.matches_search("bob"));
1052    }
1053
1054    #[test]
1055    fn test_adult_matches_search() {
1056        let adult = make_adult(Some("Trained"));
1057        assert!(adult.matches_search("jane"));
1058        assert!(adult.matches_search("doe"));
1059        assert!(adult.matches_search("scoutmaster"));
1060        assert!(!adult.matches_search("bob"));
1061    }
1062}