vpr_core/repositories/
coordination.rs

1//! Care Coordination Repository (CCR) Management.
2//!
3//! This module manages care coordination records, focusing on asynchronous
4//! clinical communication and task management between clinicians, patients, and
5//! other authorised participants.
6//!
7//! ## Architecture
8//!
9//! Like the clinical repository, the coordination repository uses:
10//! - **Type-state pattern** for compile-time safety (Uninitialised/Initialised)
11//! - **UUID-based sharded storage** for scalability
12//! - **Git-based versioning** for all operations
13//! - **Immutable append-only records** for audit and legal compliance
14//!
15//! ## Storage Layout
16//!
17//! Coordination records are stored in a sharded structure:
18//!
19//! ```text
20//! coordination/
21//!   <s1>/
22//!     <s2>/
23//!       <id>/
24//!         COORDINATION_STATUS.yaml    # Links to clinical record
25//!         communications/             # Messaging threads
26//!           <communication_id>/
27//!             ledger.yaml            # Thread metadata and participants
28//!             thread.md              # Thread messages in markdown
29//!         .git/                      # Git repository for versioning
30//! ```
31//!
32//! where `s1` and `s2` are the first four hex characters of the coordination UUID.
33//!
34//! **Conceptually:**
35//!
36//! - a communication is a thread and a ledger file
37//! - a thread is a list of messages stored in `thread.md`
38//! - the ledger contains metadata such as participants, status, policies, and visibility settings.
39//!
40//! ## Pure Data Operations
41//!
42//! This module contains **only** data operations—no API concerns such as
43//! authentication, HTTP/gRPC servers, or service interfaces. API-level logic
44//! belongs in `api-grpc`, `api-rest`, or `api-shared`.
45
46use 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// ============================================================================
74// TYPE-STATE MARKERS
75// ============================================================================
76
77/// Marker type: coordination record does not yet exist.
78///
79/// Used in type-state pattern to prevent operations on non-existent records.
80/// Only `initialise()` can be called in this state.
81#[derive(Clone, Copy, Debug)]
82pub struct Uninitialised;
83
84/// Marker type: coordination record exists.
85///
86/// Indicates a valid coordination repository with a known UUID.
87/// Enables operations like creating threads and adding messages.
88#[derive(Clone, Debug)]
89pub struct Initialised {
90    coordination_id: ShardableUuid,
91}
92
93// ============================================================================
94// COORDINATION SERVICE
95// ============================================================================
96
97/// Service for managing coordination repository operations.
98///
99/// Uses type-state pattern to enforce correct usage at compile time.
100/// Generic parameter `S` is either `Uninitialised` or `Initialised`.
101#[derive(Clone, Debug)]
102pub struct CoordinationService<S> {
103    cfg: Arc<CoreConfig>,
104    state: S,
105}
106
107impl CoordinationService<Uninitialised> {
108    /// Creates a new coordination service in the uninitialised state.
109    ///
110    /// # Arguments
111    ///
112    /// * `cfg` - Core configuration containing patient data directory paths
113    pub fn new(cfg: Arc<CoreConfig>) -> Self {
114        Self {
115            cfg,
116            state: Uninitialised,
117        }
118    }
119
120    /// Initialises a new coordination repository for a patient.
121    ///
122    /// Creates a new coordination record with UUID-based sharded directory,
123    /// COORDINATION_STATUS.yaml linking to the clinical record, and a Git
124    /// repository for version control.
125    ///
126    /// Consumes `self` and returns `CoordinationService<Initialised>`.
127    ///
128    /// # Arguments
129    ///
130    /// * `author` - Author information for the initial Git commit
131    /// * `care_location` - High-level organisational location for the commit
132    /// * `clinical_id` - UUID of the linked clinical record
133    ///
134    /// # Returns
135    ///
136    /// Coordination service in initialised state with the new coordination UUID.
137    ///
138    /// # Errors
139    ///
140    /// Returns `PatientError` if:
141    /// - Author validation fails
142    /// - Directory creation fails
143    /// - YAML serialisation fails
144    /// - Git repository initialisation or commit fails
145    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    /// Creates a coordination service for an existing record.
205    ///
206    /// Use this when you already have a coordination record and want to perform
207    /// operations on it, such as creating threads or adding messages.
208    ///
209    /// # Arguments
210    ///
211    /// * `cfg` - Core configuration containing patient data directory paths
212    /// * `coordination_id` - UUID of the existing coordination record
213    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    /// Returns the coordination UUID.
223    pub fn coordination_id(&self) -> &ShardableUuid {
224        &self.state.coordination_id
225    }
226}
227
228// ============================================================================
229// MESSAGING OPERATIONS
230// ============================================================================
231
232impl CoordinationService<Initialised> {
233    /// Creates a new messaging thread in the coordination repository.
234    ///
235    /// Creates a new communication thread for asynchronous messaging between care team
236    /// participants. The thread is initialised with metadata (participants, policies,
237    /// visibility) and optionally an initial message.
238    ///
239    /// Creates:
240    /// - `communications/{communication_id}/` directory
241    /// - `ledger.yaml` with participants, status, policies, and visibility settings
242    /// - `thread.md` with message collection
243    /// - Git commit with communication creation
244    ///
245    /// Communication ID format: `YYYYMMDDTHHMMSS.sssZ-UUID` (timestamp-based for ordering).
246    ///
247    /// # Arguments
248    ///
249    /// * `author` - The author creating the thread (validated for commit permissions)
250    /// * `care_location` - The care location context for the Git commit message
251    /// * `authors` - Initial list of thread participants with roles
252    /// * `initial_message` - Initial message to include when creating the thread (required)
253    ///
254    /// # Returns
255    ///
256    /// The generated thread ID on success.
257    ///
258    /// # Errors
259    ///
260    /// Returns `PatientError` if:
261    /// - Author validation fails - [`PatientError::InvalidInput`], [`PatientError::MissingCommitAuthor`]
262    /// - Thread ID generation fails - [`PatientError::TimestampIdError`]
263    /// - Directory creation fails - [`PatientError::PatientDirCreation`]
264    /// - Ledger serialization fails - [`PatientError::InvalidInput`]
265    /// - File write or Git commit fails - [`PatientError::FileWrite`], various Git errors
266    /// - Initial message body is empty - [`PatientError::InvalidInput`]
267    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    /// Adds a message to an existing thread.
357    ///
358    /// Appends a new message to the thread's thread.md file and updates the ledger's
359    /// last_updated_at timestamp. Both files are committed atomically to Git.
360    ///
361    /// # Arguments
362    ///
363    /// * `author` - Author creating the message (validated for commit permissions)
364    /// * `care_location` - Care location context for the Git commit message
365    /// * `thread_id` - ID of the thread to add the message to
366    /// * `new_message` - Message content with author, body, and optional correction reference
367    ///
368    /// # Returns
369    ///
370    /// The generated message UUID.
371    ///
372    /// # Errors
373    ///
374    /// Returns `PatientError` if:
375    /// - Author validation fails
376    /// - Thread does not exist (thread.md not found)
377    /// - File read, write, or Git commit operations fail
378    /// - YAML serialisation or parsing fails
379    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        // Read and parse existing messages
412        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        // Create new thread with appended message
417        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        // Render all messages back to markdown
428        let new_thread_raw = markdown_service.thread_render(&new_thread)?;
429
430        // Update ledger last_updated_at
431        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        // Write and commit
439        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    /// Reads an entire thread including messages and metadata.
472    ///
473    /// Reads both thread.md and ledger.yaml files, parses their contents, and
474    /// returns structured data with full thread information.
475    ///
476    /// # Arguments
477    ///
478    /// * `thread_id` - ID of the thread to read
479    ///
480    /// # Returns
481    ///
482    /// Complete thread data containing the thread ID, ledger metadata (participants,
483    /// status, policies, visibility), and all messages with correction relationships.
484    ///
485    /// # Errors
486    ///
487    /// Returns `PatientError` if:
488    /// - Thread does not exist (thread.md or ledger.yaml not found)
489    /// - File read operations fail
490    /// - YAML or markdown parsing fails
491    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    /// Updates thread ledger metadata.
507    ///
508    /// Modifies ledger.yaml with updated participants, status, policies, or visibility
509    /// settings. The thread.md file is not modified. Changes are committed atomically
510    /// to Git.
511    ///
512    /// # Arguments
513    ///
514    /// * `author` - Author making the update (validated for commit permissions)
515    /// * `care_location` - Care location context for the Git commit message
516    /// * `thread_id` - ID of the thread to update
517    /// * `ledger_update` - Update specification (add/remove participants, change status, etc.)
518    ///
519    /// # Errors
520    ///
521    /// Returns `PatientError` if:
522    /// - Author validation fails
523    /// - Thread does not exist (ledger.yaml not found)
524    /// - File read, write, or Git commit operations fail
525    /// - YAML serialisation or parsing fails
526    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        // Read existing ledger
549        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        // Apply updates
553        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    /// Updates coordination record status.
601    ///
602    /// Modifies COORDINATION_STATUS.yaml with updated lifecycle state, open/queryable/modifiable
603    /// flags. Changes are committed atomically to Git.
604    ///
605    /// # Arguments
606    ///
607    /// * `commit_author` - Author making the update (validated for commit permissions)
608    /// * `care_location` - Care location context for the Git commit message
609    /// * `status_update` - Update specification (lifecycle state, flags)
610    ///
611    /// # Errors
612    ///
613    /// Returns `PatientError` if:
614    /// - Author validation fails
615    /// - COORDINATION_STATUS.yaml does not exist or cannot be read
616    /// - File read, write, or Git commit operations fail
617    /// - YAML serialisation or parsing fails
618    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        // Read existing status
636        let old_status_raw = self.coordination_status_file_read()?;
637        let mut status_data = CoordinationStatus::parse(old_status_raw.as_str())?;
638
639        // Apply updates
640        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// ============================================================================
674// DATA STRUCTURES
675// ============================================================================
676
677/// Content of a message to be added to a thread.
678#[derive(Clone, Debug)]
679pub struct MessageContent {
680    author: MessageAuthor,
681    body: NonEmptyText,
682    corrects: Option<Uuid>, // For correction messages
683}
684
685impl MessageContent {
686    /// Creates a new message with validated content.
687    ///
688    /// # Arguments
689    ///
690    /// * `author` - The author of the message
691    /// * `body` - The message body (must not be empty after trimming)
692    /// * `corrects` - Optional UUID of a message this corrects
693    ///
694    /// # Errors
695    ///
696    /// Returns `PatientError::InvalidInput` if the body is empty or only whitespace.
697    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    /// Returns a reference to the message author.
715    pub fn author(&self) -> &MessageAuthor {
716        &self.author
717    }
718
719    /// Returns the message body as a string slice.
720    pub fn body(&self) -> &NonEmptyText {
721        &self.body
722    }
723
724    /// Returns the UUID of the message this corrects, if any.
725    pub fn corrects(&self) -> Option<Uuid> {
726        self.corrects
727    }
728}
729
730/// Complete thread data (messages + ledger).
731#[derive(Clone, Debug)]
732pub struct Communication {
733    pub communication_id: TimestampId,
734    pub ledger: LedgerData,
735    pub messages: Vec<Message>,
736}
737
738/// Update to apply to a thread ledger.
739#[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/// Update to apply to coordination status.
749#[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    /// Returns the path to the coordination records directory.
759    ///
760    /// # Returns
761    ///
762    /// Absolute path to the coordination root directory.
763    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    /// Returns the path to a specific patient's coordination record directory.
769    ///
770    /// # Arguments
771    ///
772    /// * `coordination_id` - UUID of the coordination record
773    ///
774    /// # Returns
775    ///
776    /// Absolute path to the sharded coordination directory.
777    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    /// Returns the path to a specific thread directory.
785    ///
786    /// Constructs the absolute path by combining the coordination directory with
787    /// the communications subdirectory and thread identifier.
788    ///
789    /// # Arguments
790    ///
791    /// * `thread_id` - Timestamp-based thread identifier
792    ///
793    /// # Returns
794    ///
795    /// Absolute path to the thread directory containing thread.md and ledger.yaml.
796    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    /// Reads a file from a thread directory.
804    ///
805    /// Constructs the absolute path from the thread ID and relative filename,
806    /// then reads the file contents.
807    ///
808    /// # Arguments
809    ///
810    /// * `thread_id` - Timestamp-based thread identifier
811    /// * `filename` - Relative filename within the thread directory
812    ///
813    /// # Returns
814    ///
815    /// File contents as a string.
816    ///
817    /// # Errors
818    ///
819    /// Returns `PatientError::FileRead` if the file cannot be read.
820    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    /// Checks if a file exists within the coordination directory.
832    ///
833    /// Constructs a path from the coordination directory by joining all provided
834    /// path components (folders and file names) and validates the file exists.
835    ///
836    /// # Arguments
837    ///
838    /// * `path_components` - Variable path components relative to the coordination directory
839    ///
840    /// # Returns
841    ///
842    /// Ok(()) if the file exists.
843    ///
844    /// # Errors
845    ///
846    /// Returns `PatientError::InvalidInput` if the file does not exist.
847    ///
848    /// # Examples
849    ///
850    /// ```ignore
851    /// // Check coordination status file
852    /// self.file_exists(&[CoordinationStatusFile::NAME])?;
853    ///
854    /// // Check thread file
855    /// self.file_exists(&["communications", &thread_id.to_string(), "thread.md"])?;
856    /// ```
857    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    /// Returns the path to the coordination status file.
876    ///
877    /// # Returns
878    ///
879    /// Absolute path to COORDINATION_STATUS.yaml.
880    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    /// Reads the coordination status file.
886    ///
887    /// # Returns
888    ///
889    /// File contents as a string.
890    ///
891    /// # Errors
892    ///
893    /// Returns `PatientError::FileRead` if the file cannot be read.
894    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
901// ============================================================================
902// HELPER FUNCTIONS
903// ============================================================================
904
905/// Generates a new message ID (UUID v4).
906fn generate_message_id() -> Uuid {
907    Uuid::new_v4()
908}
909
910/// Constructs a relative file path from variable path components.
911///
912/// Joins all provided path components to create a relative path within the
913/// coordination directory. Useful for constructing paths to files and directories.
914///
915/// # Arguments
916///
917/// * `path_components` - Variable path components to join
918///
919/// # Returns
920///
921/// Relative path constructed from all components.
922///
923/// # Examples
924///
925/// ```ignore
926/// // Communication file path
927/// relative_path(&["communications", &thread_id.to_string(), "thread.md"])
928/// // Returns: communications/{thread_id}/thread.md
929///
930/// // Status file path  
931/// relative_path(&["COORDINATION_STATUS.yaml"])
932/// // Returns: COORDINATION_STATUS.yaml
933/// ```
934fn 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
942/// Validates that authors list is not empty and all author names contain content.
943///
944/// # Arguments
945///
946/// * `authors` - List of thread participants to validate
947///
948/// # Errors
949///
950/// Returns `PatientError::InvalidInput` if:
951/// - Authors list is empty
952/// - Any author name is empty or whitespace-only
953fn 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// ============================================================================
972// TESTS
973// ============================================================================
974
975#[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        // NonEmptyText validation prevents empty strings at the type level
1045        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        // Verify thread directory exists
1082        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        // Verify thread.md contains initial message
1089        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        // NonEmptyText::new should fail for whitespace-only string
1108        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        // NonEmptyText::new should fail for whitespace-only string
1121        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        // Add second message
1163        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        // Read thread and verify both messages
1180        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        // Add correction message
1223        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        // Verify correction is recorded
1239        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        // Add new participant
1362        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        // Verify participant was added
1382        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        // Remove participant
1423        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        // Verify participant was removed
1437        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        // Close the thread
1473        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        // Verify status changed
1487        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        // Change visibility
1522        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        // Verify visibility changed
1536        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        // Change policies
1572        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        // Verify policies changed
1586        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}