1use crate::author::Author;
34use crate::config::CoreConfig;
35use crate::constants::{DEFAULT_GITIGNORE, DEMOGRAPHICS_DIR_NAME};
36use crate::error::{PatientError, PatientResult};
37use crate::paths::common::GitIgnoreFile;
38use crate::paths::demographics::patient::PatientFile;
39use crate::versioned_files::{
40 DemographicsDomain::Record, FileToWrite, VersionedFileService, VprCommitAction,
41 VprCommitDomain, VprCommitMessage,
42};
43use crate::NonEmptyText;
44use crate::ShardableUuid;
45use api_shared::pb;
46use chrono::Utc;
47use fhir::{NameUse, Patient, PatientData};
48use std::fs;
49use std::path::Path;
50use std::sync::Arc;
51
52#[derive(Clone, Copy, Debug)]
61pub struct Uninitialised;
62
63#[derive(Clone, Debug)]
68pub struct Initialised {
69 demographics_id: ShardableUuid,
70}
71
72#[derive(Clone, Debug)]
85pub struct DemographicsService<S> {
86 cfg: Arc<CoreConfig>,
87 state: S,
88}
89
90impl DemographicsService<Uninitialised> {
91 pub fn new(cfg: Arc<CoreConfig>) -> Self {
97 Self {
98 cfg,
99 state: Uninitialised,
100 }
101 }
102}
103
104impl DemographicsService<Uninitialised> {
105 pub fn initialise(
139 self,
140 author: Author,
141 care_location: NonEmptyText,
142 ) -> PatientResult<DemographicsService<Initialised>> {
143 author.validate_commit_author()?;
144
145 let commit_message = VprCommitMessage::new(
146 VprCommitDomain::Demographics(Record),
147 VprCommitAction::Create,
148 "Demographics record created",
149 care_location,
150 )?;
151
152 let data_dir = self.cfg.patient_data_dir();
153 let demographics_dir = data_dir.join(DEMOGRAPHICS_DIR_NAME);
154
155 let demographics_uuid = ShardableUuid::new();
156 let patient_dir = demographics_uuid.sharded_dir(&demographics_dir);
157 let created_at = Utc::now();
158
159 let patient_data = PatientData {
160 id: demographics_uuid.clone(),
161 use_type: None,
162 family: None,
163 given: vec![],
164 birth_date: None,
165 last_updated: Some(created_at),
166 };
167
168 let patient_data_raw = Patient::render(&patient_data)?;
169
170 let files = [
171 FileToWrite {
172 relative_path: Path::new(GitIgnoreFile::NAME),
173 content: DEFAULT_GITIGNORE,
174 old_content: None,
175 },
176 FileToWrite {
177 relative_path: Path::new(PatientFile::NAME),
178 content: &patient_data_raw,
179 old_content: None,
180 },
181 ];
182
183 VersionedFileService::init_and_commit(&patient_dir, &author, &commit_message, &files)?;
184
185 Ok(DemographicsService {
186 cfg: self.cfg,
187 state: Initialised {
188 demographics_id: demographics_uuid,
189 },
190 })
191 }
192}
193
194impl DemographicsService<Initialised> {
195 pub fn with_id(cfg: Arc<CoreConfig>, demographics_id: &str) -> PatientResult<Self> {
205 let demographics_uuid = ShardableUuid::parse(demographics_id)?;
206 Ok(Self {
207 cfg,
208 state: Initialised {
209 demographics_id: demographics_uuid,
210 },
211 })
212 }
213
214 pub fn demographics_id(&self) -> &ShardableUuid {
216 &self.state.demographics_id
217 }
218}
219
220impl DemographicsService<Initialised> {
221 pub fn update(
238 &self,
239 given_names: Vec<NonEmptyText>,
240 last_name: &str,
241 birth_date: &str,
242 ) -> PatientResult<()> {
243 let data_dir = self.cfg.patient_data_dir();
244 let demographics_dir = data_dir.join(DEMOGRAPHICS_DIR_NAME);
245
246 let patient_dir = self.demographics_id().sharded_dir(&demographics_dir);
247 let filename = patient_dir.join(PatientFile::NAME);
248
249 let existing_yaml = fs::read_to_string(&filename).map_err(PatientError::FileRead)?;
251 let mut patient_data = Patient::parse(&existing_yaml)?;
252
253 patient_data.use_type = Some(NameUse::Official);
255 patient_data.family = Some(
256 NonEmptyText::new(last_name).map_err(|e| PatientError::InvalidInput(e.to_string()))?,
257 );
258 patient_data.given = given_names;
259 patient_data.birth_date = Some(
260 birth_date
261 .parse()
262 .map_err(|e| PatientError::InvalidInput(format!("Invalid birth date: {}", e)))?,
263 );
264
265 let yaml = Patient::render(&patient_data)?;
267 fs::write(&filename, yaml).map_err(PatientError::FileWrite)?;
268
269 Ok(())
270 }
271}
272
273impl<S> DemographicsService<S> {
278 pub fn list_patients(&self) -> Vec<pb::Patient> {
296 let data_dir = self.cfg.patient_data_dir();
297
298 let mut patients = Vec::new();
299
300 let demographics_dir = data_dir.join(DEMOGRAPHICS_DIR_NAME);
301 let s1_iter = match fs::read_dir(&demographics_dir) {
302 Ok(it) => it,
303 Err(_) => return patients,
304 };
305 for s1 in s1_iter.flatten() {
306 let s1_path = s1.path();
307 if !s1_path.is_dir() {
308 continue;
309 }
310
311 let s2_iter = match fs::read_dir(&s1_path) {
312 Ok(it) => it,
313 Err(_) => continue,
314 };
315
316 for s2 in s2_iter.flatten() {
317 let s2_path = s2.path();
318 if !s2_path.is_dir() {
319 continue;
320 }
321
322 let id_iter = match fs::read_dir(&s2_path) {
323 Ok(it) => it,
324 Err(_) => continue,
325 };
326
327 for id_ent in id_iter.flatten() {
328 let id_path = id_ent.path();
329 if !id_path.is_dir() {
330 continue;
331 }
332
333 let patient_path = id_path.join(PatientFile::NAME);
334 if !patient_path.is_file() {
335 continue;
336 }
337
338 if let Ok(contents) = fs::read_to_string(&patient_path) {
339 match Patient::parse(&contents) {
340 Ok(patient_data) => {
341 let id = patient_data.id.to_string();
342
343 let first_name = patient_data
345 .given
346 .first()
347 .map(|n| n.to_string())
348 .unwrap_or_else(|| String::from(""));
349 let last_name = patient_data
350 .family
351 .as_ref()
352 .map(|n| n.to_string())
353 .unwrap_or_else(|| String::from(""));
354 let created_at = patient_data
355 .last_updated
356 .map(|dt| dt.to_rfc3339())
357 .unwrap_or_default();
358
359 patients.push(pb::Patient {
360 id,
361 first_name,
362 last_name,
363 created_at,
364 national_id: String::new(), });
366 }
367 Err(e) => {
368 tracing::warn!(
369 "failed to parse patient.yaml: {} - {}",
370 patient_path.display(),
371 e
372 );
373 }
374 }
375 }
376 }
377 }
378 }
379
380 patients
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::constants::DEMOGRAPHICS_DIR_NAME;
388 use crate::{EmailAddress, NonEmptyText};
389 use chrono::NaiveDate;
390 use std::fs;
391 use tempfile::TempDir;
392
393 fn test_author() -> Author {
394 Author {
395 name: NonEmptyText::new("Test Author").unwrap(),
396 role: NonEmptyText::new("Clinician").unwrap(),
397 email: EmailAddress::parse("test@example.com").unwrap(),
398 registrations: vec![],
399 signature: None,
400 certificate: None,
401 }
402 }
403
404 fn test_cfg(patient_data_dir: &Path) -> Arc<CoreConfig> {
405 use crate::config::rm_system_version_from_env_value;
406
407 let rm_system_version = rm_system_version_from_env_value(None)
408 .expect("rm_system_version_from_env_value should succeed");
409
410 Arc::new(
411 CoreConfig::new(
412 patient_data_dir.to_path_buf(),
413 rm_system_version,
414 crate::NonEmptyText::new("vpr.dev.1").unwrap(),
415 )
416 .expect("CoreConfig::new should succeed"),
417 )
418 }
419
420 fn count_allocated_patient_dirs(demographics_dir: &Path) -> usize {
421 let mut count = 0;
422 if let Ok(s1_iter) = fs::read_dir(demographics_dir) {
423 for s1 in s1_iter.flatten() {
424 if let Ok(s2_iter) = fs::read_dir(s1.path()) {
425 for s2 in s2_iter.flatten() {
426 if let Ok(id_iter) = fs::read_dir(s2.path()) {
427 for _id in id_iter.flatten() {
428 count += 1;
429 }
430 }
431 }
432 }
433 }
434 }
435 count
436 }
437
438 #[test]
439 fn test_initialise_creates_demographics_record() {
440 let temp_dir = TempDir::new().expect("Failed to create temp dir");
441 let cfg = test_cfg(temp_dir.path());
442 let service = DemographicsService::new(cfg.clone());
443
444 let author = test_author();
445 let demographics_service = service
446 .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
447 .expect("initialise should succeed");
448
449 let demographics_id = demographics_service.demographics_id();
450 let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
451 let patient_dir = demographics_id.sharded_dir(&demographics_dir);
452
453 assert!(patient_dir.exists(), "patient directory should exist");
454 assert!(
455 patient_dir.join(".git").is_dir(),
456 "git repository should be initialised"
457 );
458 assert!(
459 patient_dir.join(".gitignore").is_file(),
460 ".gitignore file should exist"
461 );
462 assert!(
463 patient_dir.join(PatientFile::NAME).is_file(),
464 "patient.yaml file should exist"
465 );
466
467 let yaml_content = fs::read_to_string(patient_dir.join(PatientFile::NAME))
469 .expect("should read patient.yaml");
470 let patient_data = Patient::parse(&yaml_content).expect("should parse patient.yaml");
471
472 assert_eq!(patient_data.id.uuid(), demographics_id.uuid());
473 assert_eq!(patient_data.use_type, None);
474 assert_eq!(patient_data.family, None);
475 assert_eq!(patient_data.given.len(), 0);
476 assert_eq!(patient_data.birth_date, None);
477 assert!(patient_data.last_updated.is_some());
478 }
479
480 #[test]
481 fn test_initialise_fails_fast_on_invalid_author_and_creates_no_files() {
482 let temp_dir = TempDir::new().expect("Failed to create temp dir");
483 let cfg = test_cfg(temp_dir.path());
484 let _service = DemographicsService::new(cfg);
485
486 let err =
488 NonEmptyText::new("").expect_err("creating NonEmptyText from empty string should fail");
489
490 assert!(
491 matches!(err, crate::TextError::Empty),
492 "should return TextError::Empty"
493 );
494
495 let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
496 assert_eq!(
497 count_allocated_patient_dirs(&demographics_dir),
498 0,
499 "no patient directories should be created"
500 );
501 }
502
503 #[test]
504 fn test_initialise_rejects_missing_care_location_and_creates_no_files() {
505 let temp_dir = TempDir::new().expect("Failed to create temp dir");
506 let cfg = test_cfg(temp_dir.path());
507 let _service = DemographicsService::new(cfg);
508
509 let _author = test_author();
510 let err =
512 NonEmptyText::new("").expect_err("creating NonEmptyText from empty string should fail");
513
514 assert!(
515 matches!(err, crate::TextError::Empty),
516 "should return TextError::Empty"
517 );
518
519 let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
520 assert_eq!(
521 count_allocated_patient_dirs(&demographics_dir),
522 0,
523 "no patient directories should be created"
524 );
525 }
526
527 #[test]
528 fn test_initialise_cleans_up_on_failure() {
529 let temp_dir = TempDir::new().expect("Failed to create temp dir");
530 let cfg = test_cfg(temp_dir.path());
531 let _service = DemographicsService::new(cfg);
532
533 let _author = test_author();
534
535 let _err =
537 NonEmptyText::new("").expect_err("creating NonEmptyText from empty string should fail");
538
539 let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
540 assert_eq!(
541 count_allocated_patient_dirs(&demographics_dir),
542 0,
543 "no patient directories should exist after failure"
544 );
545 }
546
547 #[test]
548 fn test_with_id_creates_initialised_service() {
549 let temp_dir = TempDir::new().expect("Failed to create temp dir");
550 let cfg = test_cfg(temp_dir.path());
551
552 let demographics_uuid = ShardableUuid::new();
553 let demographics_id_str = demographics_uuid.to_string();
554
555 let service = DemographicsService::with_id(cfg, &demographics_id_str)
556 .expect("with_id should succeed");
557
558 assert_eq!(
559 service.demographics_id().to_string(),
560 demographics_id_str,
561 "demographics_id should match"
562 );
563 }
564
565 #[test]
566 fn test_with_id_rejects_invalid_uuid() {
567 let temp_dir = TempDir::new().expect("Failed to create temp dir");
568 let cfg = test_cfg(temp_dir.path());
569
570 let err = DemographicsService::with_id(cfg, "not-a-valid-uuid")
571 .expect_err("with_id should fail with invalid UUID");
572
573 assert!(
574 matches!(err, PatientError::Uuid(_)),
575 "should return Uuid error"
576 );
577 }
578
579 #[test]
580 fn test_update_modifies_patient_data() {
581 let temp_dir = TempDir::new().expect("Failed to create temp dir");
582 let cfg = test_cfg(temp_dir.path());
583 let service = DemographicsService::new(cfg);
584
585 let author = test_author();
586 let demographics_service = service
587 .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
588 .expect("initialise should succeed");
589
590 demographics_service
592 .update(
593 vec![
594 NonEmptyText::new("John").unwrap(),
595 NonEmptyText::new("Paul").unwrap(),
596 ],
597 "Smith",
598 "1990-01-15",
599 )
600 .expect("update should succeed");
601
602 let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
604 let patient_dir = demographics_service
605 .demographics_id()
606 .sharded_dir(&demographics_dir);
607 let yaml_content = fs::read_to_string(patient_dir.join(PatientFile::NAME))
608 .expect("should read patient.yaml");
609 let patient_data = Patient::parse(&yaml_content).expect("should parse patient.yaml");
610
611 assert_eq!(patient_data.use_type, Some(NameUse::Official));
612 assert_eq!(
613 patient_data.family,
614 Some(NonEmptyText::new("Smith").unwrap())
615 );
616 assert_eq!(
617 patient_data.given,
618 vec![
619 NonEmptyText::new("John").unwrap(),
620 NonEmptyText::new("Paul").unwrap()
621 ]
622 );
623 assert_eq!(
624 patient_data.birth_date,
625 Some(NaiveDate::from_ymd_opt(1990, 1, 15).unwrap())
626 );
627 }
628
629 #[test]
630 fn test_list_patients_returns_empty_for_nonexistent_directory() {
631 let temp_dir = TempDir::new().expect("Failed to create temp dir");
632 let cfg = test_cfg(temp_dir.path());
633 let service = DemographicsService::new(cfg);
634
635 let patients = service.list_patients();
636 assert_eq!(patients.len(), 0, "should return empty list");
637 }
638
639 #[test]
640 fn test_list_patients_returns_created_patients() {
641 let temp_dir = TempDir::new().expect("Failed to create temp dir");
642 let cfg = test_cfg(temp_dir.path());
643
644 let service1 = DemographicsService::new(cfg.clone());
646 let demographics_service1 = service1
647 .initialise(test_author(), NonEmptyText::new("Test Hospital").unwrap())
648 .expect("initialise should succeed");
649 demographics_service1
650 .update(
651 vec![NonEmptyText::new("Alice").unwrap()],
652 "Smith",
653 "1990-01-15",
654 )
655 .expect("update should succeed");
656
657 let service2 = DemographicsService::new(cfg.clone());
659 let demographics_service2 = service2
660 .initialise(test_author(), NonEmptyText::new("Test Hospital").unwrap())
661 .expect("initialise should succeed");
662 demographics_service2
663 .update(
664 vec![NonEmptyText::new("Bob").unwrap()],
665 "Jones",
666 "1985-06-20",
667 )
668 .expect("update should succeed");
669
670 let list_service = DemographicsService::new(cfg);
672 let patients = list_service.list_patients();
673
674 assert_eq!(patients.len(), 2, "should return 2 patients");
675
676 let alice = patients.iter().find(|p| p.first_name == "Alice");
678 let bob = patients.iter().find(|p| p.first_name == "Bob");
679
680 assert!(alice.is_some(), "should find Alice");
681 assert!(bob.is_some(), "should find Bob");
682
683 let alice = alice.unwrap();
684 assert_eq!(alice.last_name, "Smith");
685 assert!(!alice.created_at.is_empty());
686
687 let bob = bob.unwrap();
688 assert_eq!(bob.last_name, "Jones");
689 assert!(!bob.created_at.is_empty());
690 }
691
692 #[test]
693 fn test_list_patients_skips_invalid_yaml() {
694 let temp_dir = TempDir::new().expect("Failed to create temp dir");
695 let cfg = test_cfg(temp_dir.path());
696
697 let service1 = DemographicsService::new(cfg.clone());
699 let demographics_service1 = service1
700 .initialise(test_author(), NonEmptyText::new("Test Hospital").unwrap())
701 .expect("initialise should succeed");
702 demographics_service1
703 .update(
704 vec![NonEmptyText::new("Valid").unwrap()],
705 "Patient",
706 "1990-01-15",
707 )
708 .expect("update should succeed");
709
710 let demographics_uuid = ShardableUuid::new();
712 let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
713 let invalid_patient_dir = demographics_uuid.sharded_dir(&demographics_dir);
714 fs::create_dir_all(&invalid_patient_dir).expect("should create directory");
715 fs::write(
716 invalid_patient_dir.join(PatientFile::NAME),
717 "invalid: yaml: content: [[[",
718 )
719 .expect("should write invalid yaml");
720
721 let list_service = DemographicsService::new(cfg);
723 let patients = list_service.list_patients();
724
725 assert_eq!(
726 patients.len(),
727 1,
728 "should return only 1 valid patient, skipping invalid"
729 );
730 assert_eq!(patients[0].first_name, "Valid");
731 }
732}