forge/lib/services/src/phone.rs
Jacob Schmidt 4532e7b73d Add SurrealDB-backed phone storage and message deletion
- Wire phone, garage, and locker stores to the new storage layer
- Add delete flows for messages and emails in the phone UI
- Update contact, mail, and message views for the new data model
2026-04-11 22:36:11 -05:00

438 lines
13 KiB
Rust

use forge_models::{PhoneEmail, PhoneMessage, PhonePayload};
use forge_repositories::PhoneRepository;
const FIELD_COMMANDER_UID: &str = "field_commander";
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.repository.add_contact(&uid, &uid)?;
self.repository.add_contact(&uid, FIELD_COMMANDER_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)?;
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.")?;
Self::validate_send_target(
&from_uid,
&to_uid,
"Field Commander cannot receive player messages.",
)?;
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())?;
if to_uid != from_uid {
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 delete_message(&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.delete_message(&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::default_subject(subject);
let body = Self::validate_non_empty(body, "Email body is required.")?;
Self::validate_send_target(
&from_uid,
&to_uid,
"Field Commander cannot receive player emails.",
)?;
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.clone(),
to: to_uid.clone(),
subject,
body,
timestamp,
read: false,
};
self.repository.append_email(&to_uid, record.clone())?;
if from_uid != to_uid {
self.repository.append_email(&from_uid, record.clone())?;
self.repository.mark_email_read(&from_uid, &record.id)?;
}
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 delete_email(&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.delete_email(&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 default_subject(value: String) -> String {
let value = value.trim().to_string();
if value.is_empty() {
"No subject".to_string()
} else {
value
}
}
fn validate_send_target(from_uid: &str, to_uid: &str, message: &str) -> Result<(), String> {
if to_uid == FIELD_COMMANDER_UID && from_uid != FIELD_COMMANDER_UID {
Err(message.to_string())
} else {
Ok(())
}
}
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_can_reference_self_for_owner_card() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
assert!(
service
.add_contact("same".to_string(), "same".to_string())
.expect("self contact should be allowed")
);
}
#[test]
fn init_seeds_owner_and_field_commander_contacts() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
let payload = service
.init("player".to_string())
.expect("phone should initialize");
assert!(payload.contacts.iter().any(|uid| uid == "player"));
assert!(payload.contacts.iter().any(|uid| uid == "field_commander"));
}
#[test]
fn player_cannot_message_field_commander() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
assert!(
service
.send_message(
"player".to_string(),
"field_commander".to_string(),
"Test".to_string(),
"123".to_string(),
)
.is_err()
);
}
#[test]
fn field_commander_can_message_player() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
assert!(
service
.send_message(
"field_commander".to_string(),
"player".to_string(),
"Orders".to_string(),
"123".to_string(),
)
.is_ok()
);
}
#[test]
fn player_cannot_email_field_commander() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
assert!(
service
.send_email(
"player".to_string(),
"field_commander".to_string(),
"Subject".to_string(),
"Body".to_string(),
"123".to_string(),
)
.is_err()
);
}
#[test]
fn email_allows_empty_subject() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
let email = service
.send_email(
"player".to_string(),
"player".to_string(),
"".to_string(),
"Body".to_string(),
"123".to_string(),
)
.expect("email should allow empty subject");
assert_eq!(email.subject, "No subject");
}
#[test]
fn self_message_is_indexed_once() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
service
.send_message(
"same".to_string(),
"same".to_string(),
"Test".to_string(),
"123".to_string(),
)
.expect("self message should send");
assert_eq!(
service
.list_messages("same".to_string())
.expect("self messages should load")
.len(),
1
);
}
#[test]
fn delete_message_removes_only_requesting_users_index() {
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!(
service
.delete_message("sender".to_string(), message.id.clone())
.expect("message should delete")
);
assert!(
service
.list_messages("sender".to_string())
.expect("sender messages should load")
.is_empty()
);
assert_eq!(
service
.list_messages("receiver".to_string())
.expect("receiver messages should load")
.len(),
1
);
}
#[test]
fn delete_email_removes_requesting_users_index() {
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
let email = service
.send_email(
"sender".to_string(),
"receiver".to_string(),
"Subject".to_string(),
"Body".to_string(),
"123".to_string(),
)
.expect("email should send");
assert!(
service
.delete_email("receiver".to_string(), email.id.clone())
.expect("email should delete")
);
assert!(
service
.list_emails("receiver".to_string())
.expect("receiver emails should load")
.is_empty()
);
assert_eq!(
service
.list_emails("sender".to_string())
.expect("sender emails should remain")
.len(),
1
);
}
}