forge/lib/services/src/phone.rs
Jacob Schmidt a8415eb1fd Add phone addon and wire UI events
- Introduce client phone addon, UI, and XEH handlers
- Route actor phone interaction to the new phone UI
- Add initial phone state, event handling, and persistence
2026-04-06 19:07:18 -05:00

225 lines
6.9 KiB
Rust

use forge_models::{PhoneEmail, PhoneMessage, PhonePayload};
use forge_repositories::PhoneRepository;
pub struct PhoneStateService<R: PhoneRepository> {
repository: R,
}
impl<R: PhoneRepository> PhoneStateService<R> {
pub fn new(repository: R) -> Self {
Self { repository }
}
pub fn init(&self, uid: String) -> Result<PhonePayload, String> {
let uid = Self::validate_uid(uid)?;
self.repository.init(&uid)?;
self.payload_for(&uid)
}
pub fn add_contact(&self, uid: String, contact_uid: String) -> Result<bool, String> {
let uid = Self::validate_uid(uid)?;
let contact_uid = Self::validate_uid(contact_uid)?;
if uid == contact_uid {
return Err("Cannot add self as a phone contact.".to_string());
}
self.repository.add_contact(&uid, &contact_uid)
}
pub fn remove_contact(&self, uid: String, contact_uid: String) -> Result<bool, String> {
let uid = Self::validate_uid(uid)?;
let contact_uid = Self::validate_uid(contact_uid)?;
self.repository.remove_contact(&uid, &contact_uid)
}
pub fn list_contacts(&self, uid: String) -> Result<Vec<String>, String> {
let uid = Self::validate_uid(uid)?;
self.repository.list_contacts(&uid)
}
pub fn send_message(
&self,
from_uid: String,
to_uid: String,
message: String,
timestamp: String,
) -> Result<PhoneMessage, String> {
let from_uid = Self::validate_uid(from_uid)?;
let to_uid = Self::validate_uid(to_uid)?;
let message = Self::validate_non_empty(message, "Message body is required.")?;
let timestamp = Self::parse_timestamp(timestamp);
let id = format!(
"phone-message:{}:{}:{}",
from_uid,
to_uid,
self.repository.next_sequence()?
);
let record = PhoneMessage {
id,
from: from_uid.clone(),
to: to_uid.clone(),
message,
timestamp,
read: false,
};
self.repository.append_message(&from_uid, record.clone())?;
self.repository.append_message(&to_uid, record.clone())?;
Ok(record)
}
pub fn list_messages(&self, uid: String) -> Result<Vec<PhoneMessage>, String> {
let uid = Self::validate_uid(uid)?;
self.repository.list_messages(&uid)
}
pub fn message_thread(
&self,
uid: String,
other_uid: String,
) -> Result<Vec<PhoneMessage>, String> {
let uid = Self::validate_uid(uid)?;
let other_uid = Self::validate_uid(other_uid)?;
Ok(self
.repository
.list_messages(&uid)?
.into_iter()
.filter(|message| {
(message.from == uid && message.to == other_uid)
|| (message.from == other_uid && message.to == uid)
})
.collect())
}
pub fn mark_message_read(&self, uid: String, message_id: String) -> Result<bool, String> {
let uid = Self::validate_uid(uid)?;
let message_id = Self::validate_non_empty(message_id, "Message ID is required.")?;
self.repository.mark_message_read(&uid, &message_id)
}
pub fn send_email(
&self,
from_uid: String,
to_uid: String,
subject: String,
body: String,
timestamp: String,
) -> Result<PhoneEmail, String> {
let from_uid = Self::validate_uid(from_uid)?;
let to_uid = Self::validate_uid(to_uid)?;
let subject = Self::validate_non_empty(subject, "Email subject is required.")?;
let body = Self::validate_non_empty(body, "Email body is required.")?;
let timestamp = Self::parse_timestamp(timestamp);
let id = format!(
"phone-email:{}:{}:{}",
from_uid,
to_uid,
self.repository.next_sequence()?
);
let record = PhoneEmail {
id,
from: from_uid,
to: to_uid.clone(),
subject,
body,
timestamp,
read: false,
};
self.repository.append_email(&to_uid, record.clone())?;
Ok(record)
}
pub fn list_emails(&self, uid: String) -> Result<Vec<PhoneEmail>, String> {
let uid = Self::validate_uid(uid)?;
self.repository.list_emails(&uid)
}
pub fn mark_email_read(&self, uid: String, email_id: String) -> Result<bool, String> {
let uid = Self::validate_uid(uid)?;
let email_id = Self::validate_non_empty(email_id, "Email ID is required.")?;
self.repository.mark_email_read(&uid, &email_id)
}
pub fn remove(&self, uid: String) -> Result<(), String> {
let uid = Self::validate_uid(uid)?;
self.repository.remove_phone(&uid)
}
fn payload_for(&self, uid: &str) -> Result<PhonePayload, String> {
Ok(PhonePayload {
contacts: self.repository.list_contacts(uid)?,
messages: self.repository.list_messages(uid)?,
emails: self.repository.list_emails(uid)?,
})
}
fn validate_uid(uid: String) -> Result<String, String> {
let uid = uid.trim().to_string();
if uid.is_empty() {
Err("UID is required.".to_string())
} else {
Ok(uid)
}
}
fn validate_non_empty(value: String, message: &str) -> Result<String, String> {
let value = value.trim().to_string();
if value.is_empty() {
Err(message.to_string())
} else {
Ok(value)
}
}
fn parse_timestamp(timestamp: String) -> f64 {
timestamp.trim().parse::<f64>().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::PhoneStateService;
use forge_repositories::InMemoryPhoneRepository;
#[test]
fn send_message_indexes_sender_and_receiver_threads() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
let message = service
.send_message(
"sender".to_string(),
"receiver".to_string(),
"Test".to_string(),
"123".to_string(),
)
.expect("message should send");
assert_eq!(
service
.list_messages("sender".to_string())
.expect("sender messages should load")
.len(),
1
);
assert_eq!(
service
.message_thread("receiver".to_string(), "sender".to_string())
.expect("thread should load")
.first()
.map(|entry| entry.id.clone()),
Some(message.id)
);
}
#[test]
fn contact_cannot_reference_self() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
assert!(
service
.add_contact("same".to_string(), "same".to_string())
.is_err()
);
}
}