1#![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#[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#[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 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 #[serde(default)]
111 pub positions: Vec<YouthPosition>,
112 #[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 pub fn to_youth(&self) -> Youth {
148 let primary_pos = self.positions.iter()
150 .find(|p| p.patrol_name.is_some());
151
152 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 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 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, 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 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 #[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 #[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 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 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 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 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#[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 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
792pub 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#[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 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 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 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 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 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
918pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
938pub enum AdultSortColumn {
939 Name,
940 Role,
941 Email,
942}
943
944impl Adult {
945 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}