//! FAPP — Free Appointment Propagation Protocol. //! //! Decentralized psychotherapy appointment discovery. //! //! ## Flow //! //! 1. Therapist announces available slots (specialism, location, modality). //! 2. Announcement floods through mesh (TTL-limited, signature-verified). //! 3. Patient queries for matching slots (specialism, distance). //! 4. Relays respond with cached matches. //! 5. Patient reserves slot (E2E encrypted to therapist). //! 6. Therapist confirms/rejects. use serde::{Deserialize, Serialize}; use crate::error::ServiceError; use crate::message::{MessageType, ServiceMessage}; use crate::router::{HandlerContext, ServiceAction, ServiceHandler}; use crate::service_ids::FAPP; use crate::store::StoredMessage; use crate::wire::{decode_payload, encode_payload}; /// Therapy specialisms. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[repr(u8)] pub enum Specialism { GeneralPsychotherapy = 0x01, CognitiveBehavioral = 0x02, Psychoanalysis = 0x03, SystemicTherapy = 0x04, TraumaFocused = 0x05, ChildAndAdolescent = 0x06, CoupleAndFamily = 0x07, Addiction = 0x08, Neuropsychology = 0x09, } impl TryFrom for Specialism { type Error = (); fn try_from(value: u8) -> Result { match value { 0x01 => Ok(Self::GeneralPsychotherapy), 0x02 => Ok(Self::CognitiveBehavioral), 0x03 => Ok(Self::Psychoanalysis), 0x04 => Ok(Self::SystemicTherapy), 0x05 => Ok(Self::TraumaFocused), 0x06 => Ok(Self::ChildAndAdolescent), 0x07 => Ok(Self::CoupleAndFamily), 0x08 => Ok(Self::Addiction), 0x09 => Ok(Self::Neuropsychology), _ => Err(()), } } } /// Therapy modality. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[repr(u8)] pub enum Modality { InPerson = 0x01, VideoCall = 0x02, PhoneCall = 0x03, TextBased = 0x04, } /// Slot announcement payload. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlotAnnounce { /// Therapist's specialisms (bitfield). pub specialisms: u16, /// Modality (bitfield). pub modality: u8, /// Postal code (first 3 digits for privacy). pub postal_prefix: String, /// Geohash (6 chars, ~1.2km precision). #[serde(default, skip_serializing_if = "Option::is_none")] pub geohash: Option, /// Available slots count. pub available_slots: u8, /// Earliest available date (days from epoch). pub earliest_days: u16, /// Insurance types accepted (bitfield). pub insurance: u8, /// Optional profile URL for verification. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_url: Option, /// Optional display name. #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, } impl SlotAnnounce { /// Create a new announcement. pub fn new(specialisms: &[Specialism], modality: Modality, postal_prefix: &str) -> Self { let spec_bits = specialisms.iter().fold(0u16, |acc, s| acc | (1 << (*s as u8))); Self { specialisms: spec_bits, modality: modality as u8, postal_prefix: postal_prefix.into(), geohash: None, available_slots: 1, earliest_days: 0, insurance: 0xFF, // All accepted by default profile_url: None, display_name: None, } } /// Set geohash location. pub fn with_geohash(mut self, geohash: &str) -> Self { self.geohash = Some(geohash[..6.min(geohash.len())].into()); self } /// Set available slots count. pub fn with_slots(mut self, count: u8) -> Self { self.available_slots = count; self } /// Set earliest availability. pub fn with_earliest(mut self, days_from_now: u16) -> Self { self.earliest_days = days_from_now; self } /// Set profile URL. pub fn with_profile(mut self, url: &str) -> Self { self.profile_url = Some(url.into()); self } /// Set display name. pub fn with_name(mut self, name: &str) -> Self { self.display_name = Some(name.into()); self } /// Check if a specialism is offered. pub fn has_specialism(&self, spec: Specialism) -> bool { self.specialisms & (1 << (spec as u8)) != 0 } /// Encode to CBOR bytes. pub fn to_bytes(&self) -> Result, ServiceError> { encode_payload(self) } /// Decode from CBOR bytes. pub fn from_bytes(data: &[u8]) -> Result { decode_payload(data) } } /// Insurance types. pub mod insurance { pub const PRIVATE: u8 = 0x01; pub const PUBLIC: u8 = 0x02; pub const SELF_PAY: u8 = 0x04; } /// Slot query payload. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlotQuery { /// Desired specialisms (bitfield, any match). pub specialisms: u16, /// Postal prefix to search. pub postal_prefix: String, /// Max distance in km (optional). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_distance_km: Option, /// Required modality (0 = any). pub modality: u8, /// Max wait in days. pub max_wait_days: u16, /// Insurance type required. pub insurance: u8, } impl SlotQuery { /// Create a query for a specialism in a postal area. pub fn new(specialism: Specialism, postal_prefix: &str) -> Self { Self { specialisms: 1 << (specialism as u8), postal_prefix: postal_prefix.into(), max_distance_km: None, modality: 0, max_wait_days: 365, insurance: 0xFF, } } /// Require specific modality. pub fn with_modality(mut self, modality: Modality) -> Self { self.modality = modality as u8; self } /// Set max wait time. pub fn with_max_wait(mut self, days: u16) -> Self { self.max_wait_days = days; self } /// Check if an announce matches this query. pub fn matches(&self, announce: &SlotAnnounce) -> bool { // Specialism overlap if announce.specialisms & self.specialisms == 0 { return false; } // Postal prefix if !announce.postal_prefix.starts_with(&self.postal_prefix) && !self.postal_prefix.starts_with(&announce.postal_prefix) { return false; } // Modality if self.modality != 0 && announce.modality & self.modality == 0 { return false; } // Wait time if announce.earliest_days > self.max_wait_days { return false; } // Insurance if announce.insurance & self.insurance == 0 { return false; } // Available slots announce.available_slots > 0 } /// Encode to CBOR bytes. pub fn to_bytes(&self) -> Result, ServiceError> { encode_payload(self) } /// Decode from CBOR bytes. pub fn from_bytes(data: &[u8]) -> Result { decode_payload(data) } } /// FAPP service handler. pub struct FappService { /// Whether this node is a therapist (can announce). pub is_provider: bool, /// Whether this node relays FAPP messages. pub is_relay: bool, } impl FappService { /// Create a new FAPP handler. pub fn new(is_provider: bool, is_relay: bool) -> Self { Self { is_provider, is_relay, } } /// Create a relay-only handler. pub fn relay() -> Self { Self::new(false, true) } /// Create a provider handler. pub fn provider() -> Self { Self::new(true, true) } } impl ServiceHandler for FappService { fn service_id(&self) -> u32 { FAPP } fn name(&self) -> &str { "FAPP" } fn handle( &self, message: &ServiceMessage, context: &HandlerContext, ) -> Result { match message.message_type { MessageType::Announce => { // Validate payload let _announce = SlotAnnounce::from_bytes(&message.payload)?; // Store and forward if we're a relay if self.is_relay { Ok(ServiceAction::StoreAndForward) } else { Ok(ServiceAction::Store) } } MessageType::Query => { // Parse query let query = SlotQuery::from_bytes(&message.payload)?; // Find matches in store let matches: Vec<_> = context .store .by_service(FAPP) .into_iter() .filter(|stored| { if stored.message.message_type != MessageType::Announce { return false; } if let Ok(announce) = SlotAnnounce::from_bytes(&stored.message.payload) { query.matches(&announce) } else { false } }) .collect(); // If we have matches, we could respond (simplified for now) if !matches.is_empty() { // In a real impl, we'd aggregate and send response Ok(ServiceAction::Handled) } else if self.is_relay { Ok(ServiceAction::ForwardOnly) } else { Ok(ServiceAction::Handled) } } MessageType::Reserve | MessageType::Confirm | MessageType::Cancel => { // E2E encrypted, just forward if self.is_relay { Ok(ServiceAction::ForwardOnly) } else { Ok(ServiceAction::Handled) } } MessageType::Revoke => { // Remove from store Ok(ServiceAction::Handled) } _ => Ok(ServiceAction::Drop), } } fn validate(&self, message: &ServiceMessage) -> Result<(), ServiceError> { match message.message_type { MessageType::Announce => { SlotAnnounce::from_bytes(&message.payload)?; } MessageType::Query => { SlotQuery::from_bytes(&message.payload)?; } _ => {} } Ok(()) } fn matches_query(&self, announce: &StoredMessage, query_msg: &ServiceMessage) -> bool { let Ok(announce_data) = SlotAnnounce::from_bytes(&announce.message.payload) else { return false; }; let Ok(query) = SlotQuery::from_bytes(&query_msg.payload) else { return false; }; query.matches(&announce_data) } } /// Helper to create a FAPP announce message. pub fn create_announce( identity: &crate::ServiceIdentity, announce: &SlotAnnounce, sequence: u64, ) -> Result { let payload = announce.to_bytes()?; Ok(ServiceMessage::announce(identity, FAPP, payload, sequence)) } /// Helper to create a FAPP query message. pub fn create_query( identity: &crate::ServiceIdentity, query: &SlotQuery, ) -> Result { let payload = query.to_bytes()?; Ok(ServiceMessage::query(identity, FAPP, payload)) } #[cfg(test)] mod tests { use super::*; use crate::identity::ServiceIdentity; #[test] fn slot_announce_roundtrip() { let announce = SlotAnnounce::new( &[Specialism::CognitiveBehavioral, Specialism::TraumaFocused], Modality::VideoCall, "104", ) .with_slots(3) .with_profile("https://therapists.de/dr-mueller"); let bytes = announce.to_bytes().unwrap(); let decoded = SlotAnnounce::from_bytes(&bytes).unwrap(); assert!(decoded.has_specialism(Specialism::CognitiveBehavioral)); assert!(decoded.has_specialism(Specialism::TraumaFocused)); assert!(!decoded.has_specialism(Specialism::Addiction)); assert_eq!(decoded.available_slots, 3); assert_eq!( decoded.profile_url, Some("https://therapists.de/dr-mueller".into()) ); } #[test] fn query_matches_announce() { let announce = SlotAnnounce::new( &[Specialism::CognitiveBehavioral], Modality::InPerson, "104", ) .with_slots(2); let matching_query = SlotQuery::new(Specialism::CognitiveBehavioral, "104"); assert!(matching_query.matches(&announce)); let wrong_spec = SlotQuery::new(Specialism::Addiction, "104"); assert!(!wrong_spec.matches(&announce)); let wrong_location = SlotQuery::new(Specialism::CognitiveBehavioral, "200"); assert!(!wrong_location.matches(&announce)); } #[test] fn create_message_helpers() { let id = ServiceIdentity::generate(); let announce = SlotAnnounce::new(&[Specialism::GeneralPsychotherapy], Modality::VideoCall, "10"); let msg = create_announce(&id, &announce, 1).unwrap(); assert_eq!(msg.service_id, FAPP); assert_eq!(msg.message_type, MessageType::Announce); let query = SlotQuery::new(Specialism::GeneralPsychotherapy, "10"); let msg = create_query(&id, &query).unwrap(); assert_eq!(msg.service_id, FAPP); assert_eq!(msg.message_type, MessageType::Query); } #[test] fn fapp_handler_processes_announce() { use crate::router::ServiceRouter; use crate::capabilities; let mut router = ServiceRouter::new(capabilities::RELAY); router.register(Box::new(FappService::relay())); let id = ServiceIdentity::generate(); let announce = SlotAnnounce::new(&[Specialism::TraumaFocused], Modality::InPerson, "100"); let msg = create_announce(&id, &announce, 1).unwrap(); let action = router.handle(msg.clone(), Some(id.public_key())).unwrap(); assert!(matches!(action, ServiceAction::StoreAndForward)); // Should be stored assert_eq!(router.store().service_count(FAPP), 1); } }