1use crate::author::Author;
47use crate::config::CoreConfig;
48use crate::constants::{
49 COORDINATION_DIR_NAME, DEFAULT_GITIGNORE, THREAD_FILENAME, THREAD_LEDGER_FILENAME,
50};
51use crate::error::{PatientError, PatientResult};
52use crate::markdown::{MarkdownService, Message, MessageMetadata};
53use crate::paths::common::GitIgnoreFile;
54use crate::paths::coordination::coordination_status::CoordinationStatusFile;
55use crate::repositories::shared::create_uuid_and_shard_dir;
56use crate::versioned_files::{
57 CoordinationDomain::{Messaging, Record},
58 FileToWrite, VersionedFileService, VprCommitAction, VprCommitDomain, VprCommitMessage,
59};
60use crate::NonEmptyText;
61use crate::ShardableUuid;
62use chrono::Utc;
63use fhir::{
64 CoordinationStatus, CoordinationStatusData, LedgerData, LifecycleState, MessageAuthor,
65 Messaging as FhirMessaging, SensitivityLevel, ThreadStatus as FhirThreadStatus,
66};
67use std::fs;
68use std::path::{Path, PathBuf};
69use std::sync::Arc;
70use uuid::Uuid;
71use vpr_uuid::{TimestampId, TimestampIdGenerator};
72
73#[derive(Clone, Copy, Debug)]
82pub struct Uninitialised;
83
84#[derive(Clone, Debug)]
89pub struct Initialised {
90 coordination_id: ShardableUuid,
91}
92
93#[derive(Clone, Debug)]
102pub struct CoordinationService<S> {
103 cfg: Arc<CoreConfig>,
104 state: S,
105}
106
107impl CoordinationService<Uninitialised> {
108 pub fn new(cfg: Arc<CoreConfig>) -> Self {
114 Self {
115 cfg,
116 state: Uninitialised,
117 }
118 }
119
120 pub fn initialise(
146 self,
147 commit_author: Author,
148 care_location: NonEmptyText,
149 clinical_id: Uuid,
150 ) -> PatientResult<CoordinationService<Initialised>> {
151 commit_author.validate_commit_author()?;
152
153 let commit_message = VprCommitMessage::new(
154 VprCommitDomain::Coordination(Record),
155 VprCommitAction::Create,
156 "Created coordination record",
157 care_location,
158 )?;
159
160 let coordination_root_dir = self.coordination_root_dir();
161 let (coordination_uuid, patient_dir) = create_uuid_and_shard_dir(&coordination_root_dir)?;
162
163 let coordination_status = CoordinationStatusData {
164 coordination_id: coordination_uuid.clone(),
165 clinical_id: ShardableUuid::from_uuid(clinical_id),
166 lifecycle_state: LifecycleState::Active,
167 record_open: true,
168 record_queryable: true,
169 record_modifiable: true,
170 };
171
172 let coordination_status_raw = CoordinationStatus::render(&coordination_status)?;
173
174 let files = [
175 FileToWrite {
176 relative_path: Path::new(GitIgnoreFile::NAME),
177 content: DEFAULT_GITIGNORE,
178 old_content: None,
179 },
180 FileToWrite {
181 relative_path: Path::new(CoordinationStatusFile::NAME),
182 content: &coordination_status_raw,
183 old_content: None,
184 },
185 ];
186
187 VersionedFileService::init_and_commit(
188 &patient_dir,
189 &commit_author,
190 &commit_message,
191 &files,
192 )?;
193
194 Ok(CoordinationService {
195 cfg: self.cfg,
196 state: Initialised {
197 coordination_id: coordination_uuid,
198 },
199 })
200 }
201}
202
203impl CoordinationService<Initialised> {
204 pub fn with_id(cfg: Arc<CoreConfig>, coordination_id: Uuid) -> Self {
214 Self {
215 cfg,
216 state: Initialised {
217 coordination_id: ShardableUuid::from_uuid(coordination_id),
218 },
219 }
220 }
221
222 pub fn coordination_id(&self) -> &ShardableUuid {
224 &self.state.coordination_id
225 }
226}
227
228impl CoordinationService<Initialised> {
233 pub fn communication_create(
268 &self,
269 commit_author: &Author,
270 care_location: NonEmptyText,
271 communication_authors: Vec<MessageAuthor>,
272 initial_message: MessageContent,
273 ) -> PatientResult<TimestampId> {
274 commit_author.validate_commit_author()?;
275
276 let commit_message = VprCommitMessage::new(
277 VprCommitDomain::Coordination(Messaging),
278 VprCommitAction::Create,
279 "Created messaging thread",
280 care_location,
281 )?;
282
283 validate_communication_authors(&communication_authors)?;
284
285 let communication_id = TimestampIdGenerator::generate(None)?;
286 let coordination_dir = self.coordination_dir(self.coordination_id());
287
288 let now = Utc::now();
289 let message_id = generate_message_id();
290
291 let metadata = MessageMetadata {
292 message_id,
293 timestamp: now,
294 author: initial_message.author().clone(),
295 };
296
297 let initial_message = Message {
298 metadata,
299 body: NonEmptyText::new(initial_message.body()).map_err(|_| {
300 PatientError::InvalidInput("Message body cannot be empty".to_string())
301 })?,
302 corrects: None,
303 };
304
305 let markdown_service = MarkdownService::new();
306 let messages_content_raw = markdown_service.thread_render(&[initial_message])?;
307
308 let ledger = LedgerData {
309 communication_id: communication_id.clone(),
310 status: FhirThreadStatus::Open,
311 created_at: now,
312 last_updated_at: now,
313 participants: communication_authors,
314 sensitivity: SensitivityLevel::Standard,
315 restricted: false,
316 allow_patient_participation: true,
317 allow_external_organisations: true,
318 };
319
320 let ledger_content_raw = FhirMessaging::ledger_render(&ledger)?;
321
322 let messages_relative = relative_path(&[
323 "communications",
324 &communication_id.to_string(),
325 THREAD_FILENAME,
326 ]);
327 let ledger_relative = relative_path(&[
328 "communications",
329 &communication_id.to_string(),
330 THREAD_LEDGER_FILENAME,
331 ]);
332
333 let files_to_write = [
334 FileToWrite {
335 relative_path: &messages_relative,
336 content: messages_content_raw.as_str(),
337 old_content: None,
338 },
339 FileToWrite {
340 relative_path: &ledger_relative,
341 content: &ledger_content_raw,
342 old_content: None,
343 },
344 ];
345
346 VersionedFileService::write_and_commit_files(
347 &coordination_dir,
348 commit_author,
349 &commit_message,
350 &files_to_write,
351 )?;
352
353 Ok(ledger.communication_id)
354 }
355
356 pub fn message_add(
380 &self,
381 commit_author: &Author,
382 care_location: NonEmptyText,
383 thread_id: &TimestampId,
384 new_message: MessageContent,
385 ) -> PatientResult<Uuid> {
386 self.file_exists(&["communications", &thread_id.to_string(), THREAD_FILENAME])?;
387 self.file_exists(&[
388 "communications",
389 &thread_id.to_string(),
390 THREAD_LEDGER_FILENAME,
391 ])?;
392
393 commit_author.validate_commit_author()?;
394
395 let commit_message = VprCommitMessage::new(
396 VprCommitDomain::Coordination(Messaging),
397 VprCommitAction::Update,
398 "Added message to thread",
399 care_location,
400 )?;
401
402 let message_id = generate_message_id();
403 let now = Utc::now();
404
405 let metadata = MessageMetadata {
406 message_id,
407 timestamp: now,
408 author: new_message.author().clone(),
409 };
410
411 let old_thread_raw = self.thread_file_read(thread_id, THREAD_FILENAME)?;
413 let markdown_service = MarkdownService::new();
414 let old_thread = markdown_service.thread_parse(old_thread_raw.as_str())?;
415
416 let new_message = Message {
418 metadata,
419 body: NonEmptyText::new(new_message.body()).map_err(|_| {
420 PatientError::InvalidInput("Message body cannot be empty".to_string())
421 })?,
422 corrects: new_message.corrects(),
423 };
424 let mut new_thread = old_thread;
425 new_thread.push(new_message);
426
427 let new_thread_raw = markdown_service.thread_render(&new_thread)?;
429
430 let old_ledger_raw = self.thread_file_read(thread_id, THREAD_LEDGER_FILENAME)?;
432 let old_ledger = FhirMessaging::ledger_parse(old_ledger_raw.as_str())?;
433 let mut new_ledger = old_ledger;
434 new_ledger.last_updated_at = now;
435
436 let new_ledger_raw = FhirMessaging::ledger_render(&new_ledger)?;
437
438 let coordination_dir = self.coordination_dir(self.coordination_id());
440 let messages_relative =
441 relative_path(&["communications", &thread_id.to_string(), THREAD_FILENAME]);
442 let ledger_relative = relative_path(&[
443 "communications",
444 &thread_id.to_string(),
445 THREAD_LEDGER_FILENAME,
446 ]);
447
448 let files_to_write = [
449 FileToWrite {
450 relative_path: &messages_relative,
451 content: new_thread_raw.as_str(),
452 old_content: Some(old_thread_raw.as_str()),
453 },
454 FileToWrite {
455 relative_path: &ledger_relative,
456 content: &new_ledger_raw,
457 old_content: Some(old_ledger_raw.as_str()),
458 },
459 ];
460
461 VersionedFileService::write_and_commit_files(
462 &coordination_dir,
463 commit_author,
464 &commit_message,
465 &files_to_write,
466 )?;
467
468 Ok(message_id)
469 }
470
471 pub fn read_communication(&self, thread_id: &TimestampId) -> PatientResult<Communication> {
492 let messages_raw = self.thread_file_read(thread_id, THREAD_FILENAME)?;
493 let ledger_raw = self.thread_file_read(thread_id, THREAD_LEDGER_FILENAME)?;
494
495 let markdown_service = MarkdownService::new();
496 let messages = markdown_service.thread_parse(messages_raw.as_str())?;
497 let ledger = FhirMessaging::ledger_parse(ledger_raw.as_str())?;
498
499 Ok(Communication {
500 communication_id: thread_id.clone(),
501 ledger,
502 messages,
503 })
504 }
505
506 pub fn update_communication_ledger(
527 &self,
528 commit_author: &Author,
529 care_location: NonEmptyText,
530 thread_id: &TimestampId,
531 ledger_update: LedgerUpdate,
532 ) -> PatientResult<()> {
533 commit_author.validate_commit_author()?;
534
535 let msg = VprCommitMessage::new(
536 VprCommitDomain::Coordination(Messaging),
537 VprCommitAction::Update,
538 "Updated thread ledger",
539 care_location,
540 )?;
541
542 self.file_exists(&[
543 "communications",
544 &thread_id.to_string(),
545 THREAD_LEDGER_FILENAME,
546 ])?;
547
548 let old_ledger_raw = self.thread_file_read(thread_id, THREAD_LEDGER_FILENAME)?;
550 let mut ledger_data = FhirMessaging::ledger_parse(old_ledger_raw.as_str())?;
551
552 if let Some(add_participants) = ledger_update.add_participants {
554 ledger_data.participants.extend(add_participants);
555 }
556 if let Some(remove_ids) = ledger_update.remove_participants {
557 ledger_data
558 .participants
559 .retain(|p| !remove_ids.contains(&p.id));
560 }
561 if let Some(status) = ledger_update.set_status {
562 ledger_data.status = status;
563 }
564 if let Some((sensitivity, restricted)) = ledger_update.set_visibility {
565 ledger_data.sensitivity = sensitivity;
566 ledger_data.restricted = restricted;
567 }
568 if let Some((allow_patient, allow_external)) = ledger_update.set_policies {
569 ledger_data.allow_patient_participation = allow_patient;
570 ledger_data.allow_external_organisations = allow_external;
571 }
572
573 ledger_data.last_updated_at = Utc::now();
574
575 let new_ledger_raw = FhirMessaging::ledger_render(&ledger_data)?;
576
577 let coordination_dir = self.coordination_dir(self.coordination_id());
578 let ledger_relative = relative_path(&[
579 "communications",
580 &thread_id.to_string(),
581 THREAD_LEDGER_FILENAME,
582 ]);
583
584 let files_to_write = [FileToWrite {
585 relative_path: &ledger_relative,
586 content: &new_ledger_raw,
587 old_content: Some(old_ledger_raw.as_str()),
588 }];
589
590 VersionedFileService::write_and_commit_files(
591 &coordination_dir,
592 commit_author,
593 &msg,
594 &files_to_write,
595 )?;
596
597 Ok(())
598 }
599
600 pub fn update_coordination_status(
619 &self,
620 commit_author: &Author,
621 care_location: NonEmptyText,
622 status_update: CoordinationStatusUpdate,
623 ) -> PatientResult<()> {
624 commit_author.validate_commit_author()?;
625
626 let commit_message = VprCommitMessage::new(
627 VprCommitDomain::Coordination(Record),
628 VprCommitAction::Update,
629 "Updated coordination status",
630 care_location,
631 )?;
632
633 self.file_exists(&[CoordinationStatusFile::NAME])?;
634
635 let old_status_raw = self.coordination_status_file_read()?;
637 let mut status_data = CoordinationStatus::parse(old_status_raw.as_str())?;
638
639 if let Some(lifecycle_state) = status_update.set_lifecycle_state {
641 status_data.lifecycle_state = lifecycle_state;
642 }
643 if let Some(record_open) = status_update.set_record_open {
644 status_data.record_open = record_open;
645 }
646 if let Some(record_queryable) = status_update.set_record_queryable {
647 status_data.record_queryable = record_queryable;
648 }
649 if let Some(record_modifiable) = status_update.set_record_modifiable {
650 status_data.record_modifiable = record_modifiable;
651 }
652
653 let new_status_raw = CoordinationStatus::render(&status_data)?;
654
655 let coordination_dir = self.coordination_dir(self.coordination_id());
656 let files_to_write = [FileToWrite {
657 relative_path: Path::new(CoordinationStatusFile::NAME),
658 content: &new_status_raw,
659 old_content: Some(old_status_raw.as_str()),
660 }];
661
662 VersionedFileService::write_and_commit_files(
663 &coordination_dir,
664 commit_author,
665 &commit_message,
666 &files_to_write,
667 )?;
668
669 Ok(())
670 }
671}
672
673#[derive(Clone, Debug)]
679pub struct MessageContent {
680 author: MessageAuthor,
681 body: NonEmptyText,
682 corrects: Option<Uuid>, }
684
685impl MessageContent {
686 pub fn new(
698 author: MessageAuthor,
699 body: NonEmptyText,
700 corrects: Option<Uuid>,
701 ) -> PatientResult<Self> {
702 if body.as_str().trim().is_empty() {
703 return Err(PatientError::InvalidInput(
704 "Message body must not be empty".to_string(),
705 ));
706 }
707 Ok(Self {
708 author,
709 body,
710 corrects,
711 })
712 }
713
714 pub fn author(&self) -> &MessageAuthor {
716 &self.author
717 }
718
719 pub fn body(&self) -> &NonEmptyText {
721 &self.body
722 }
723
724 pub fn corrects(&self) -> Option<Uuid> {
726 self.corrects
727 }
728}
729
730#[derive(Clone, Debug)]
732pub struct Communication {
733 pub communication_id: TimestampId,
734 pub ledger: LedgerData,
735 pub messages: Vec<Message>,
736}
737
738#[derive(Clone, Debug, Default)]
740pub struct LedgerUpdate {
741 pub add_participants: Option<Vec<MessageAuthor>>,
742 pub remove_participants: Option<Vec<Uuid>>,
743 pub set_status: Option<FhirThreadStatus>,
744 pub set_visibility: Option<(SensitivityLevel, bool)>,
745 pub set_policies: Option<(bool, bool)>,
746}
747
748#[derive(Clone, Debug, Default)]
750pub struct CoordinationStatusUpdate {
751 pub set_lifecycle_state: Option<LifecycleState>,
752 pub set_record_open: Option<bool>,
753 pub set_record_queryable: Option<bool>,
754 pub set_record_modifiable: Option<bool>,
755}
756
757impl<S> CoordinationService<S> {
758 fn coordination_root_dir(&self) -> PathBuf {
764 let data_dir = self.cfg.patient_data_dir().to_path_buf();
765 data_dir.join(COORDINATION_DIR_NAME)
766 }
767
768 fn coordination_dir(&self, coordination_id: &ShardableUuid) -> PathBuf {
778 let coordination_root_dir = self.coordination_root_dir();
779 coordination_id.sharded_dir(&coordination_root_dir)
780 }
781}
782
783impl CoordinationService<Initialised> {
784 fn thread_dir(&self, thread_id: &TimestampId) -> PathBuf {
797 let coordination_dir = self.coordination_dir(self.coordination_id());
798 coordination_dir
799 .join("communications")
800 .join(thread_id.to_string())
801 }
802
803 fn thread_file_read(
821 &self,
822 thread_id: &TimestampId,
823 filename: &str,
824 ) -> PatientResult<NonEmptyText> {
825 let thread_dir = self.thread_dir(thread_id);
826 let file_path = thread_dir.join(filename);
827 let content = fs::read_to_string(&file_path).map_err(PatientError::FileRead)?;
828 NonEmptyText::new(content).map_err(|e| PatientError::InvalidInput(e.to_string()))
829 }
830
831 fn file_exists(&self, path_components: &[&str]) -> PatientResult<()> {
858 let coordination_dir = self.coordination_dir(self.coordination_id());
859 let mut file_path = coordination_dir;
860
861 for component in path_components {
862 file_path = file_path.join(component);
863 }
864
865 if !file_path.exists() {
866 return Err(PatientError::InvalidInput(format!(
867 "File does not exist: {}",
868 path_components.join("/")
869 )));
870 }
871
872 Ok(())
873 }
874
875 fn coordination_status_file_path(&self) -> PathBuf {
881 let coordination_dir = self.coordination_dir(self.coordination_id());
882 coordination_dir.join(CoordinationStatusFile::NAME)
883 }
884
885 fn coordination_status_file_read(&self) -> PatientResult<NonEmptyText> {
895 let status_path = self.coordination_status_file_path();
896 let content = fs::read_to_string(&status_path).map_err(PatientError::FileRead)?;
897 NonEmptyText::new(content).map_err(|e| PatientError::InvalidInput(e.to_string()))
898 }
899}
900
901fn generate_message_id() -> Uuid {
907 Uuid::new_v4()
908}
909
910fn relative_path(path_components: &[&str]) -> PathBuf {
935 let mut path = PathBuf::new();
936 for component in path_components {
937 path = path.join(component);
938 }
939 path
940}
941
942fn validate_communication_authors(authors: &[MessageAuthor]) -> PatientResult<()> {
954 if authors.is_empty() {
955 return Err(PatientError::InvalidInput(
956 "Authors list must not be empty".to_string(),
957 ));
958 }
959
960 for author in authors {
961 if author.name.as_str().trim().is_empty() {
962 return Err(PatientError::InvalidInput(
963 "Author name must not be empty".to_string(),
964 ));
965 }
966 }
967
968 Ok(())
969}
970
971#[cfg(test)]
976mod tests {
977 use super::*;
978 use crate::{EmailAddress, NonEmptyText};
979 use std::fs;
980 use tempfile::TempDir;
981
982 fn setup_test_env() -> (TempDir, Arc<CoreConfig>, Author) {
983 let temp_dir = TempDir::new().unwrap();
984
985 let cfg = Arc::new(
986 CoreConfig::new(
987 temp_dir.path().to_path_buf(),
988 openehr::RmVersion::rm_1_1_0,
989 NonEmptyText::new("test-namespace").unwrap(),
990 )
991 .unwrap(),
992 );
993
994 let author = Author {
995 name: NonEmptyText::new("Dr. Test").unwrap(),
996 role: NonEmptyText::new("Clinician").unwrap(),
997 email: EmailAddress::parse("test@example.com").unwrap(),
998 registrations: vec![],
999 signature: None,
1000 certificate: None,
1001 };
1002
1003 (temp_dir, cfg, author)
1004 }
1005
1006 fn create_test_participants() -> Vec<MessageAuthor> {
1007 vec![
1008 MessageAuthor {
1009 id: Uuid::new_v4(),
1010 name: NonEmptyText::new("Dr. Smith").unwrap(),
1011 role: fhir::AuthorRole::Clinician,
1012 },
1013 MessageAuthor {
1014 id: Uuid::new_v4(),
1015 name: NonEmptyText::new("Patient John").unwrap(),
1016 role: fhir::AuthorRole::Patient,
1017 },
1018 ]
1019 }
1020
1021 #[test]
1022 fn test_initialise_creates_coordination_repo() {
1023 let (_temp, cfg, author) = setup_test_env();
1024 let clinical_id = Uuid::new_v4();
1025
1026 let result = CoordinationService::new(cfg.clone()).initialise(
1027 author,
1028 NonEmptyText::new("Test Location").unwrap(),
1029 clinical_id,
1030 );
1031
1032 assert!(result.is_ok());
1033 let service = result.unwrap();
1034 let coord_dir = service.coordination_dir(service.coordination_id());
1035 assert!(coord_dir.exists());
1036 assert!(coord_dir.join(".git").exists());
1037 }
1038
1039 #[test]
1040 fn test_initialise_validates_author() {
1041 let (_temp, _cfg, _author) = setup_test_env();
1042 let _clinical_id = Uuid::new_v4();
1043
1044 let err =
1046 NonEmptyText::new("").expect_err("creating NonEmptyText from empty string should fail");
1047 assert!(matches!(err, crate::TextError::Empty));
1048 }
1049
1050 #[test]
1051 fn test_communication_create_with_initial_message() {
1052 let (_temp, cfg, author) = setup_test_env();
1053 let clinical_id = Uuid::new_v4();
1054
1055 let service = CoordinationService::new(cfg.clone())
1056 .initialise(
1057 author.clone(),
1058 NonEmptyText::new("Test Location").unwrap(),
1059 clinical_id,
1060 )
1061 .unwrap();
1062
1063 let participants = create_test_participants();
1064 let initial_message = MessageContent::new(
1065 participants[0].clone(),
1066 NonEmptyText::new("Initial thread message").unwrap(),
1067 None,
1068 )
1069 .unwrap();
1070
1071 let result = service.communication_create(
1072 &author,
1073 NonEmptyText::new("Test Location").unwrap(),
1074 participants.clone(),
1075 initial_message,
1076 );
1077
1078 assert!(result.is_ok());
1079 let thread_id = result.unwrap();
1080
1081 let coord_dir = service.coordination_dir(service.coordination_id());
1083 let thread_dir = coord_dir.join("communications").join(thread_id.to_string());
1084 assert!(thread_dir.exists());
1085 assert!(thread_dir.join("thread.md").exists());
1086 assert!(thread_dir.join("ledger.yaml").exists());
1087
1088 let messages_content = fs::read_to_string(thread_dir.join("thread.md")).unwrap();
1090 assert!(messages_content.contains("Initial thread message"));
1091 }
1092
1093 #[test]
1094 fn test_communication_create_validates_empty_body() {
1095 let (_temp, cfg, author) = setup_test_env();
1096 let clinical_id = Uuid::new_v4();
1097
1098 let _service = CoordinationService::new(cfg.clone())
1099 .initialise(
1100 author.clone(),
1101 NonEmptyText::new("Test Location").unwrap(),
1102 clinical_id,
1103 )
1104 .unwrap();
1105
1106 let _participants = create_test_participants();
1107 let empty_body_result = NonEmptyText::new(" ");
1109 assert!(empty_body_result.is_err());
1110 }
1111
1112 #[test]
1113 fn test_validate_communication_authors_empty_list() {
1114 let result = validate_communication_authors(&[]);
1115 assert!(result.is_err());
1116 }
1117
1118 #[test]
1119 fn test_validate_communication_authors_empty_name() {
1120 let empty_name_result = NonEmptyText::new(" ");
1122 assert!(empty_name_result.is_err());
1123 }
1124
1125 #[test]
1126 fn test_validate_communication_authors_valid() {
1127 let authors = create_test_participants();
1128 let result = validate_communication_authors(&authors);
1129 assert!(result.is_ok());
1130 }
1131
1132 #[test]
1133 fn test_message_add_appends_to_thread() {
1134 let (_temp, cfg, author) = setup_test_env();
1135 let clinical_id = Uuid::new_v4();
1136
1137 let service = CoordinationService::new(cfg.clone())
1138 .initialise(
1139 author.clone(),
1140 NonEmptyText::new("Test Location").unwrap(),
1141 clinical_id,
1142 )
1143 .unwrap();
1144
1145 let participants = create_test_participants();
1146 let initial_message = MessageContent::new(
1147 participants[0].clone(),
1148 NonEmptyText::new("First message").unwrap(),
1149 None,
1150 )
1151 .unwrap();
1152
1153 let thread_id = service
1154 .communication_create(
1155 &author,
1156 NonEmptyText::new("Test Location").unwrap(),
1157 participants.clone(),
1158 initial_message,
1159 )
1160 .unwrap();
1161
1162 let second_message = MessageContent::new(
1164 participants[1].clone(),
1165 NonEmptyText::new("Second message from patient").unwrap(),
1166 None,
1167 )
1168 .unwrap();
1169
1170 let result = service.message_add(
1171 &author,
1172 NonEmptyText::new("Test Location").unwrap(),
1173 &thread_id,
1174 second_message,
1175 );
1176
1177 assert!(result.is_ok());
1178
1179 let thread = service.read_communication(&thread_id).unwrap();
1181 assert_eq!(thread.messages.len(), 2);
1182 assert_eq!(thread.messages[0].body.as_str(), "First message");
1183 assert_eq!(
1184 thread.messages[1].body.as_str(),
1185 "Second message from patient"
1186 );
1187 }
1188
1189 #[test]
1190 fn test_message_add_with_correction() {
1191 let (_temp, cfg, author) = setup_test_env();
1192 let clinical_id = Uuid::new_v4();
1193
1194 let service = CoordinationService::new(cfg.clone())
1195 .initialise(
1196 author.clone(),
1197 NonEmptyText::new("Test Location").unwrap(),
1198 clinical_id,
1199 )
1200 .unwrap();
1201
1202 let participants = create_test_participants();
1203 let initial_message = MessageContent::new(
1204 participants[0].clone(),
1205 NonEmptyText::new("Original message with typo").unwrap(),
1206 None,
1207 )
1208 .unwrap();
1209
1210 let thread_id = service
1211 .communication_create(
1212 &author,
1213 NonEmptyText::new("Test Location").unwrap(),
1214 participants.clone(),
1215 initial_message,
1216 )
1217 .unwrap();
1218
1219 let thread = service.read_communication(&thread_id).unwrap();
1220 let original_msg_id = thread.messages[0].metadata.message_id;
1221
1222 let correction = MessageContent::new(
1224 participants[0].clone(),
1225 NonEmptyText::new("Corrected message without typo").unwrap(),
1226 Some(original_msg_id),
1227 )
1228 .unwrap();
1229
1230 let result = service.message_add(
1231 &author,
1232 NonEmptyText::new("Test Location").unwrap(),
1233 &thread_id,
1234 correction,
1235 );
1236 assert!(result.is_ok());
1237
1238 let thread = service.read_communication(&thread_id).unwrap();
1240 assert_eq!(thread.messages.len(), 2);
1241 assert_eq!(thread.messages[1].corrects, Some(original_msg_id));
1242 }
1243
1244 #[test]
1245 fn test_message_add_to_nonexistent_thread() {
1246 let (_temp, cfg, author) = setup_test_env();
1247 let clinical_id = Uuid::new_v4();
1248
1249 let service = CoordinationService::new(cfg.clone())
1250 .initialise(
1251 author.clone(),
1252 NonEmptyText::new("Test Location").unwrap(),
1253 clinical_id,
1254 )
1255 .unwrap();
1256
1257 let fake_thread_id = TimestampIdGenerator::generate(None).unwrap();
1258 let participants = create_test_participants();
1259 let message = MessageContent::new(
1260 participants[0].clone(),
1261 NonEmptyText::new("Message to nowhere").unwrap(),
1262 None,
1263 )
1264 .unwrap();
1265
1266 let result = service.message_add(
1267 &author,
1268 NonEmptyText::new("Test Location").unwrap(),
1269 &fake_thread_id,
1270 message,
1271 );
1272 assert!(result.is_err());
1273 }
1274
1275 #[test]
1276 fn test_read_communication_returns_complete_data() {
1277 let (_temp, cfg, author) = setup_test_env();
1278 let clinical_id = Uuid::new_v4();
1279
1280 let service = CoordinationService::new(cfg.clone())
1281 .initialise(
1282 author.clone(),
1283 NonEmptyText::new("Test Location").unwrap(),
1284 clinical_id,
1285 )
1286 .unwrap();
1287
1288 let participants = create_test_participants();
1289 let initial_message = MessageContent::new(
1290 participants[0].clone(),
1291 NonEmptyText::new("Test message").unwrap(),
1292 None,
1293 )
1294 .unwrap();
1295
1296 let thread_id = service
1297 .communication_create(
1298 &author,
1299 NonEmptyText::new("Test Location").unwrap(),
1300 participants.clone(),
1301 initial_message,
1302 )
1303 .unwrap();
1304
1305 let thread = service.read_communication(&thread_id).unwrap();
1306
1307 assert_eq!(thread.communication_id, thread_id);
1308 assert_eq!(thread.ledger.participants.len(), 2);
1309 assert_eq!(thread.messages.len(), 1);
1310 assert_eq!(thread.ledger.status, FhirThreadStatus::Open);
1311 }
1312
1313 #[test]
1314 fn test_read_communication_nonexistent() {
1315 let (_temp, cfg, author) = setup_test_env();
1316 let clinical_id = Uuid::new_v4();
1317
1318 let service = CoordinationService::new(cfg.clone())
1319 .initialise(
1320 author.clone(),
1321 NonEmptyText::new("Test Location").unwrap(),
1322 clinical_id,
1323 )
1324 .unwrap();
1325
1326 let fake_thread_id = TimestampIdGenerator::generate(None).unwrap();
1327 let result = service.read_communication(&fake_thread_id);
1328 assert!(result.is_err());
1329 }
1330
1331 #[test]
1332 fn test_update_communication_ledger_add_participants() {
1333 let (_temp, cfg, author) = setup_test_env();
1334 let clinical_id = Uuid::new_v4();
1335
1336 let service = CoordinationService::new(cfg.clone())
1337 .initialise(
1338 author.clone(),
1339 NonEmptyText::new("Test Location").unwrap(),
1340 clinical_id,
1341 )
1342 .unwrap();
1343
1344 let participants = create_test_participants();
1345 let initial_message = MessageContent::new(
1346 participants[0].clone(),
1347 NonEmptyText::new("Test").unwrap(),
1348 None,
1349 )
1350 .unwrap();
1351
1352 let thread_id = service
1353 .communication_create(
1354 &author,
1355 NonEmptyText::new("Test Location").unwrap(),
1356 participants,
1357 initial_message,
1358 )
1359 .unwrap();
1360
1361 let new_participant = MessageAuthor {
1363 id: Uuid::new_v4(),
1364 name: NonEmptyText::new("Nurse Jane").unwrap(),
1365 role: fhir::AuthorRole::Clinician,
1366 };
1367
1368 let update = LedgerUpdate {
1369 add_participants: Some(vec![new_participant.clone()]),
1370 ..Default::default()
1371 };
1372
1373 let result = service.update_communication_ledger(
1374 &author,
1375 NonEmptyText::new("Test Location").unwrap(),
1376 &thread_id,
1377 update,
1378 );
1379 assert!(result.is_ok());
1380
1381 let thread = service.read_communication(&thread_id).unwrap();
1383 assert_eq!(thread.ledger.participants.len(), 3);
1384 assert!(thread
1385 .ledger
1386 .participants
1387 .iter()
1388 .any(|p| p.name.as_str() == "Nurse Jane"));
1389 }
1390
1391 #[test]
1392 fn test_update_communication_ledger_remove_participants() {
1393 let (_temp, cfg, author) = setup_test_env();
1394 let clinical_id = Uuid::new_v4();
1395
1396 let service = CoordinationService::new(cfg.clone())
1397 .initialise(
1398 author.clone(),
1399 NonEmptyText::new("Test Location").unwrap(),
1400 clinical_id,
1401 )
1402 .unwrap();
1403
1404 let participants = create_test_participants();
1405 let remove_id = participants[1].id;
1406 let initial_message = MessageContent::new(
1407 participants[0].clone(),
1408 NonEmptyText::new("Test").unwrap(),
1409 None,
1410 )
1411 .unwrap();
1412
1413 let thread_id = service
1414 .communication_create(
1415 &author,
1416 NonEmptyText::new("Test Location").unwrap(),
1417 participants,
1418 initial_message,
1419 )
1420 .unwrap();
1421
1422 let update = LedgerUpdate {
1424 remove_participants: Some(vec![remove_id]),
1425 ..Default::default()
1426 };
1427
1428 let result = service.update_communication_ledger(
1429 &author,
1430 NonEmptyText::new("Test Location").unwrap(),
1431 &thread_id,
1432 update,
1433 );
1434 assert!(result.is_ok());
1435
1436 let thread = service.read_communication(&thread_id).unwrap();
1438 assert_eq!(thread.ledger.participants.len(), 1);
1439 assert!(!thread.ledger.participants.iter().any(|p| p.id == remove_id));
1440 }
1441
1442 #[test]
1443 fn test_update_communication_ledger_change_status() {
1444 let (_temp, cfg, author) = setup_test_env();
1445 let clinical_id = Uuid::new_v4();
1446
1447 let service = CoordinationService::new(cfg.clone())
1448 .initialise(
1449 author.clone(),
1450 NonEmptyText::new("Test Location").unwrap(),
1451 clinical_id,
1452 )
1453 .unwrap();
1454
1455 let participants = create_test_participants();
1456 let initial_message = MessageContent::new(
1457 participants[0].clone(),
1458 NonEmptyText::new("Test").unwrap(),
1459 None,
1460 )
1461 .unwrap();
1462
1463 let thread_id = service
1464 .communication_create(
1465 &author,
1466 NonEmptyText::new("Test Location").unwrap(),
1467 participants,
1468 initial_message,
1469 )
1470 .unwrap();
1471
1472 let update = LedgerUpdate {
1474 set_status: Some(FhirThreadStatus::Closed),
1475 ..Default::default()
1476 };
1477
1478 let result = service.update_communication_ledger(
1479 &author,
1480 NonEmptyText::new("Test Location").unwrap(),
1481 &thread_id,
1482 update,
1483 );
1484 assert!(result.is_ok());
1485
1486 let thread = service.read_communication(&thread_id).unwrap();
1488 assert_eq!(thread.ledger.status, FhirThreadStatus::Closed);
1489 }
1490
1491 #[test]
1492 fn test_update_communication_ledger_change_visibility() {
1493 let (_temp, cfg, author) = setup_test_env();
1494 let clinical_id = Uuid::new_v4();
1495
1496 let service = CoordinationService::new(cfg.clone())
1497 .initialise(
1498 author.clone(),
1499 NonEmptyText::new("Test Location").unwrap(),
1500 clinical_id,
1501 )
1502 .unwrap();
1503
1504 let participants = create_test_participants();
1505 let initial_message = MessageContent::new(
1506 participants[0].clone(),
1507 NonEmptyText::new("Test").unwrap(),
1508 None,
1509 )
1510 .unwrap();
1511
1512 let thread_id = service
1513 .communication_create(
1514 &author,
1515 NonEmptyText::new("Test Location").unwrap(),
1516 participants,
1517 initial_message,
1518 )
1519 .unwrap();
1520
1521 let update = LedgerUpdate {
1523 set_visibility: Some((SensitivityLevel::Confidential, true)),
1524 ..Default::default()
1525 };
1526
1527 let result = service.update_communication_ledger(
1528 &author,
1529 NonEmptyText::new("Test Location").unwrap(),
1530 &thread_id,
1531 update,
1532 );
1533 assert!(result.is_ok());
1534
1535 let thread = service.read_communication(&thread_id).unwrap();
1537 assert_eq!(thread.ledger.sensitivity, SensitivityLevel::Confidential);
1538 assert!(thread.ledger.restricted);
1539 }
1540
1541 #[test]
1542 fn test_update_communication_ledger_change_policies() {
1543 let (_temp, cfg, author) = setup_test_env();
1544 let clinical_id = Uuid::new_v4();
1545
1546 let service = CoordinationService::new(cfg.clone())
1547 .initialise(
1548 author.clone(),
1549 NonEmptyText::new("Test Location").unwrap(),
1550 clinical_id,
1551 )
1552 .unwrap();
1553
1554 let participants = create_test_participants();
1555 let initial_message = MessageContent::new(
1556 participants[0].clone(),
1557 NonEmptyText::new("Test").unwrap(),
1558 None,
1559 )
1560 .unwrap();
1561
1562 let thread_id = service
1563 .communication_create(
1564 &author,
1565 NonEmptyText::new("Test Location").unwrap(),
1566 participants,
1567 initial_message,
1568 )
1569 .unwrap();
1570
1571 let update = LedgerUpdate {
1573 set_policies: Some((false, false)),
1574 ..Default::default()
1575 };
1576
1577 let result = service.update_communication_ledger(
1578 &author,
1579 NonEmptyText::new("Test Location").unwrap(),
1580 &thread_id,
1581 update,
1582 );
1583 assert!(result.is_ok());
1584
1585 let thread = service.read_communication(&thread_id).unwrap();
1587 assert!(!thread.ledger.allow_patient_participation);
1588 assert!(!thread.ledger.allow_external_organisations);
1589 }
1590
1591 #[test]
1592 fn test_parse_messages_md_multiple_messages() {
1593 let content = r#"**Message ID:** 550e8400-e29b-41d4-a716-446655440000
1594**Author role:** clinician
1595**Timestamp:** 2026-01-22T10:30:00Z
1596**Author ID:** 550e8400-e29b-41d4-a716-446655440001
1597**Author name:** Dr. Smith
1598
1599First message body
1600
1601---
1602
1603**Message ID:** 550e8400-e29b-41d4-a716-446655440002
1604**Author role:** patient
1605**Timestamp:** 2026-01-22T11:30:00Z
1606**Author ID:** 550e8400-e29b-41d4-a716-446655440003
1607**Author name:** Patient John
1608
1609Second message body
1610
1611---
1612"#;
1613
1614 let markdown_service = MarkdownService::new();
1615 let parsed_messages = markdown_service.thread_parse(content).unwrap();
1616 assert_eq!(parsed_messages.len(), 2);
1617 assert_eq!(parsed_messages[0].body.as_str(), "First message body");
1618 assert_eq!(parsed_messages[1].body.as_str(), "Second message body");
1619 assert_eq!(
1620 parsed_messages[0].metadata.author.name.as_str(),
1621 "Dr. Smith"
1622 );
1623 assert_eq!(
1624 parsed_messages[1].metadata.author.name.as_str(),
1625 "Patient John"
1626 );
1627 }
1628
1629 #[test]
1630 fn test_parse_messages_md_with_correction() {
1631 let content = r#"**Message ID:** 550e8400-e29b-41d4-a716-446655440000
1632**Author role:** clinician
1633**Timestamp:** 2026-01-22T10:30:00Z
1634**Author ID:** 550e8400-e29b-41d4-a716-446655440001
1635**Author name:** Dr. Smith
1636**Corrects:** 550e8400-e29b-41d4-a716-446655440099
1637
1638Corrected message body
1639
1640---
1641"#;
1642
1643 let markdown_service = MarkdownService::new();
1644 let parsed_messages = markdown_service.thread_parse(content).unwrap();
1645 assert_eq!(parsed_messages.len(), 1);
1646 assert!(parsed_messages[0].corrects.is_some());
1647 assert_eq!(
1648 parsed_messages[0].corrects.unwrap().to_string(),
1649 "550e8400-e29b-41d4-a716-446655440099"
1650 );
1651 }
1652
1653 #[test]
1654 fn test_message_id_generation_is_unique() {
1655 let id1 = generate_message_id();
1656 let id2 = generate_message_id();
1657 assert_ne!(id1, id2);
1658 }
1659}