diff --git a/bindings_ffi/src/mls.rs b/bindings_ffi/src/mls.rs index 0543e5fa69..a676e73f7a 100644 --- a/bindings_ffi/src/mls.rs +++ b/bindings_ffi/src/mls.rs @@ -1091,12 +1091,14 @@ impl From for BackupOptions { pub enum FfiBackupElementSelection { Messages, Consent, + Contacts, } impl From for BackupElementSelection { fn from(value: FfiBackupElementSelection) -> Self { match value { FfiBackupElementSelection::Consent => Self::Consent, FfiBackupElementSelection::Messages => Self::Messages, + FfiBackupElementSelection::Contacts => Self::Contacts, } } } @@ -1107,6 +1109,7 @@ impl TryFrom for FfiBackupElementSelection { let v = match value { BackupElementSelection::Consent => Self::Consent, BackupElementSelection::Messages => Self::Messages, + BackupElementSelection::Contacts => Self::Contacts, _ => { return Err(DeserializationError::Unspecified( "Backup Element Selection", diff --git a/xmtp_archive/src/export_stream/contact_save.rs b/xmtp_archive/src/export_stream/contact_save.rs new file mode 100644 index 0000000000..ab2ec95d8e --- /dev/null +++ b/xmtp_archive/src/export_stream/contact_save.rs @@ -0,0 +1,26 @@ +use super::*; +use xmtp_proto::xmtp::device_sync::{backup_element::Element, contact_backup::ContactSave}; + +#[xmtp_common::async_trait] +impl BackupRecordProvider for ContactSave { + const BATCH_SIZE: i64 = 100; + async fn backup_records( + state: Arc>, + ) -> Result, StorageError> + where + Self: Sized, + D: DbQuery, + { + let cursor = state.cursor.load(Ordering::SeqCst); + let batch = state.db.contacts_paged(Self::BATCH_SIZE, cursor)?; + + let records = batch + .into_iter() + .map(|record| BackupElement { + element: Some(Element::Contact(record.into())), + }) + .collect(); + + Ok(records) + } +} diff --git a/xmtp_archive/src/export_stream/mod.rs b/xmtp_archive/src/export_stream/mod.rs index 416f2937be..6872f5a980 100644 --- a/xmtp_archive/src/export_stream/mod.rs +++ b/xmtp_archive/src/export_stream/mod.rs @@ -13,10 +13,11 @@ use xmtp_common::{MaybeSend, MaybeSendFuture, if_native, if_wasm}; use xmtp_db::{StorageError, prelude::*}; use xmtp_proto::xmtp::device_sync::{ BackupElement, BackupElementSelection, BackupOptions, consent_backup::ConsentSave, - group_backup::GroupSave, message_backup::GroupMessageSave, + contact_backup::ContactSave, group_backup::GroupSave, message_backup::GroupMessageSave, }; pub(crate) mod consent_save; +pub(crate) mod contact_save; pub(crate) mod group_save; pub(crate) mod message_save; @@ -58,6 +59,12 @@ impl BatchExportStream { ), ], BackupElementSelection::Event => vec![], + BackupElementSelection::Contacts => { + vec![BackupRecordStreamer::::new_stream( + db.clone(), + opts.clone(), + )] + } BackupElementSelection::Unspecified => vec![], }) .rev() diff --git a/xmtp_db/migrations/2025-12-28-000000_create_contacts/down.sql b/xmtp_db/migrations/2025-12-28-000000_create_contacts/down.sql new file mode 100644 index 0000000000..025b1dd257 --- /dev/null +++ b/xmtp_db/migrations/2025-12-28-000000_create_contacts/down.sql @@ -0,0 +1,39 @@ +-- Drop FTS triggers first +DROP TRIGGER IF EXISTS contact_addresses_fts_delete; +DROP TRIGGER IF EXISTS contact_addresses_fts_update; +DROP TRIGGER IF EXISTS contact_addresses_fts_insert; +DROP TRIGGER IF EXISTS contact_aliases_fts_delete; +DROP TRIGGER IF EXISTS contact_aliases_fts_update; +DROP TRIGGER IF EXISTS contact_aliases_fts_insert; +DROP TRIGGER IF EXISTS contact_wallet_addresses_fts_delete; +DROP TRIGGER IF EXISTS contact_wallet_addresses_fts_update; +DROP TRIGGER IF EXISTS contact_wallet_addresses_fts_insert; +DROP TRIGGER IF EXISTS contact_urls_fts_delete; +DROP TRIGGER IF EXISTS contact_urls_fts_update; +DROP TRIGGER IF EXISTS contact_urls_fts_insert; +DROP TRIGGER IF EXISTS contact_emails_fts_delete; +DROP TRIGGER IF EXISTS contact_emails_fts_update; +DROP TRIGGER IF EXISTS contact_emails_fts_insert; +DROP TRIGGER IF EXISTS contact_phone_numbers_fts_delete; +DROP TRIGGER IF EXISTS contact_phone_numbers_fts_update; +DROP TRIGGER IF EXISTS contact_phone_numbers_fts_insert; +DROP TRIGGER IF EXISTS contacts_fts_delete; +DROP TRIGGER IF EXISTS contacts_fts_update; +DROP TRIGGER IF EXISTS contacts_fts_insert; + +-- Drop FTS table +DROP TABLE IF EXISTS contacts_fts; + +-- Drop view +DROP VIEW IF EXISTS contact_list; + +-- Drop companion tables (due to foreign key constraints) +DROP TABLE IF EXISTS contact_addresses; +DROP TABLE IF EXISTS contact_aliases; +DROP TABLE IF EXISTS contact_wallet_addresses; +DROP TABLE IF EXISTS contact_urls; +DROP TABLE IF EXISTS contact_emails; +DROP TABLE IF EXISTS contact_phone_numbers; + +-- Drop main contacts table +DROP TABLE IF EXISTS contacts; diff --git a/xmtp_db/migrations/2025-12-28-000000_create_contacts/up.sql b/xmtp_db/migrations/2025-12-28-000000_create_contacts/up.sql new file mode 100644 index 0000000000..bfe61f33f3 --- /dev/null +++ b/xmtp_db/migrations/2025-12-28-000000_create_contacts/up.sql @@ -0,0 +1,541 @@ +-- Contacts table +CREATE TABLE contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + -- Link to XMTP identity + inbox_id TEXT NOT NULL, + -- Name fields + display_name TEXT, + first_name TEXT, + last_name TEXT, + prefix TEXT, + suffix TEXT, + -- Professional info + company TEXT, + job_title TEXT, + -- Other info + birthday TEXT, + note TEXT, + image_url TEXT, + -- Status + is_favorite INTEGER NOT NULL DEFAULT 0, + -- Timestamps (managed by application layer with nanosecond precision) + created_at_ns BIGINT NOT NULL, + updated_at_ns BIGINT NOT NULL +); + +-- Index for inbox_id lookups +CREATE INDEX idx_contacts_inbox_id ON contacts(inbox_id); + +-- Phone numbers companion table +CREATE TABLE contact_phone_numbers ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + contact_id INTEGER NOT NULL, + phone_number TEXT NOT NULL, + label TEXT, + FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE +); + +CREATE INDEX idx_contact_phone_numbers_contact_id ON contact_phone_numbers(contact_id); + +-- Emails companion table +CREATE TABLE contact_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + contact_id INTEGER NOT NULL, + email TEXT NOT NULL, + label TEXT, + FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE +); + +CREATE INDEX idx_contact_emails_contact_id ON contact_emails(contact_id); + +-- URLs companion table +CREATE TABLE contact_urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + contact_id INTEGER NOT NULL, + url TEXT NOT NULL, + label TEXT, + FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE +); + +CREATE INDEX idx_contact_urls_contact_id ON contact_urls(contact_id); + +-- Wallet addresses companion table +CREATE TABLE contact_wallet_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + contact_id INTEGER NOT NULL, + wallet_address TEXT NOT NULL, + label TEXT, + FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE +); + +CREATE INDEX idx_contact_wallet_addresses_contact_id ON contact_wallet_addresses(contact_id); + +-- Addresses companion table +CREATE TABLE contact_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + contact_id INTEGER NOT NULL, + address1 TEXT, + address2 TEXT, + address3 TEXT, + city TEXT, + region TEXT, + postal_code TEXT, + country TEXT, + label TEXT, + FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE +); + +CREATE INDEX idx_contact_addresses_contact_id ON contact_addresses(contact_id); + +-- View for full contact with all related data +CREATE VIEW contact_list AS +SELECT + c.inbox_id, + c.display_name, + c.first_name, + c.last_name, + c.prefix, + c.suffix, + c.company, + c.job_title, + c.birthday, + c.note, + c.image_url, + c.is_favorite, + c.created_at_ns, + c.updated_at_ns, + (SELECT COALESCE(json_group_array(json_object('id', id, 'phone_number', phone_number, 'label', label)), '[]') + FROM contact_phone_numbers WHERE contact_id = c.id) as phone_numbers, + (SELECT COALESCE(json_group_array(json_object('id', id, 'email', email, 'label', label)), '[]') + FROM contact_emails WHERE contact_id = c.id) as emails, + (SELECT COALESCE(json_group_array(json_object('id', id, 'url', url, 'label', label)), '[]') + FROM contact_urls WHERE contact_id = c.id) as urls, + (SELECT COALESCE(json_group_array(json_object('id', id, 'wallet_address', wallet_address, 'label', label)), '[]') + FROM contact_wallet_addresses WHERE contact_id = c.id) as wallet_addresses, + (SELECT COALESCE(json_group_array(json_object('id', id, 'address1', address1, 'address2', address2, 'address3', address3, 'city', city, 'region', region, 'postal_code', postal_code, 'country', country, 'label', label)), '[]') + FROM contact_addresses WHERE contact_id = c.id) as addresses +FROM contacts c; + +-- FTS5 full-text search index for contacts +-- Using trigram tokenizer for substring matching anywhere in text +-- detail='full' required for trigram phrase queries +CREATE VIRTUAL TABLE contacts_fts USING fts5( + inbox_id UNINDEXED, + searchable_text, + tokenize='trigram' +); + +-- Helper function to build searchable text for a contact +-- We use a trigger-based approach to keep FTS in sync + +-- Trigger: After INSERT on contacts +CREATE TRIGGER contacts_fts_insert AFTER INSERT ON contacts BEGIN + INSERT INTO contacts_fts(inbox_id, searchable_text) + VALUES ( + NEW.inbox_id, + COALESCE(NEW.display_name, '') || ' ' || + COALESCE(NEW.first_name, '') || ' ' || + COALESCE(NEW.last_name, '') || ' ' || + COALESCE(NEW.prefix, '') || ' ' || + COALESCE(NEW.suffix, '') || ' ' || + COALESCE(NEW.company, '') || ' ' || + COALESCE(NEW.job_title, '') || ' ' || + COALESCE(NEW.note, '') + ); +END; + +-- Trigger: After UPDATE on contacts +CREATE TRIGGER contacts_fts_update AFTER UPDATE ON contacts BEGIN + DELETE FROM contacts_fts WHERE inbox_id = OLD.inbox_id; + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + NEW.inbox_id, + COALESCE(NEW.display_name, '') || ' ' || + COALESCE(NEW.first_name, '') || ' ' || + COALESCE(NEW.last_name, '') || ' ' || + COALESCE(NEW.prefix, '') || ' ' || + COALESCE(NEW.suffix, '') || ' ' || + COALESCE(NEW.company, '') || ' ' || + COALESCE(NEW.job_title, '') || ' ' || + COALESCE(NEW.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = NEW.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = NEW.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = NEW.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = NEW.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = NEW.id), ''); +END; + +-- Trigger: After DELETE on contacts +CREATE TRIGGER contacts_fts_delete AFTER DELETE ON contacts BEGIN + DELETE FROM contacts_fts WHERE inbox_id = OLD.inbox_id; +END; + +-- Triggers for companion tables: rebuild FTS entry when companion data changes + +-- Phone numbers +CREATE TRIGGER contact_phone_numbers_fts_insert AFTER INSERT ON contact_phone_numbers BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_phone_numbers_fts_update AFTER UPDATE ON contact_phone_numbers BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_phone_numbers_fts_delete AFTER DELETE ON contact_phone_numbers BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = OLD.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = OLD.contact_id; +END; + +-- Emails +CREATE TRIGGER contact_emails_fts_insert AFTER INSERT ON contact_emails BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_emails_fts_update AFTER UPDATE ON contact_emails BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_emails_fts_delete AFTER DELETE ON contact_emails BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = OLD.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = OLD.contact_id; +END; + +-- URLs +CREATE TRIGGER contact_urls_fts_insert AFTER INSERT ON contact_urls BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_urls_fts_update AFTER UPDATE ON contact_urls BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_urls_fts_delete AFTER DELETE ON contact_urls BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = OLD.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = OLD.contact_id; +END; + +-- Wallet addresses +CREATE TRIGGER contact_wallet_addresses_fts_insert AFTER INSERT ON contact_wallet_addresses BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_wallet_addresses_fts_update AFTER UPDATE ON contact_wallet_addresses BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_wallet_addresses_fts_delete AFTER DELETE ON contact_wallet_addresses BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = OLD.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = OLD.contact_id; +END; + +-- Addresses +CREATE TRIGGER contact_addresses_fts_insert AFTER INSERT ON contact_addresses BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_addresses_fts_update AFTER UPDATE ON contact_addresses BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = NEW.contact_id; +END; + +CREATE TRIGGER contact_addresses_fts_delete AFTER DELETE ON contact_addresses BEGIN + DELETE FROM contacts_fts WHERE inbox_id = (SELECT inbox_id FROM contacts WHERE id = OLD.contact_id); + INSERT INTO contacts_fts(inbox_id, searchable_text) + SELECT + c.inbox_id, + COALESCE(c.display_name, '') || ' ' || + COALESCE(c.first_name, '') || ' ' || + COALESCE(c.last_name, '') || ' ' || + COALESCE(c.prefix, '') || ' ' || + COALESCE(c.suffix, '') || ' ' || + COALESCE(c.company, '') || ' ' || + COALESCE(c.job_title, '') || ' ' || + COALESCE(c.note, '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(phone_number, ' ') FROM contact_phone_numbers WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(url, ' ') FROM contact_urls WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT(wallet_address, ' ') FROM contact_wallet_addresses WHERE contact_id = c.id), '') || ' ' || + COALESCE((SELECT GROUP_CONCAT( + COALESCE(address1, '') || ' ' || COALESCE(address2, '') || ' ' || COALESCE(address3, '') || ' ' || + COALESCE(city, '') || ' ' || COALESCE(region, '') || ' ' || COALESCE(postal_code, '') || ' ' || COALESCE(country, ''), ' ') + FROM contact_addresses WHERE contact_id = c.id), '') + FROM contacts c WHERE c.id = OLD.contact_id; +END; diff --git a/xmtp_db/src/encrypted_store/consent_record.rs b/xmtp_db/src/encrypted_store/consent_record.rs index a0fbdcb896..9c0e2c22ed 100644 --- a/xmtp_db/src/encrypted_store/consent_record.rs +++ b/xmtp_db/src/encrypted_store/consent_record.rs @@ -117,6 +117,14 @@ pub trait QueryConsentRecord { &self, dm_id: &str, ) -> Result, crate::ConnectionError>; + + /// Batch lookup of consent records by entity and type. + /// Entities not present in the database are ignored. + fn get_consent_records_batch( + &self, + entities: &[String], + entity_type: ConsentType, + ) -> Result, crate::ConnectionError>; } impl QueryConsentRecord for DbConnection { @@ -283,6 +291,31 @@ impl QueryConsentRecord for DbConnection { .load::(conn) }) } + + fn get_consent_records_batch( + &self, + entities: &[String], + entity_type: ConsentType, + ) -> Result, crate::ConnectionError> { + if entities.is_empty() { + return Ok(vec![]); + } + + // SQLite has a limit of 999 bind variables. Chunk to stay well under. + const CHUNK_SIZE: usize = 900; + + let mut results = Vec::with_capacity(entities.len()); + for chunk in entities.chunks(CHUNK_SIZE) { + let mut chunk_results = self.raw_query_read(|conn| { + dsl::consent_records + .filter(dsl::entity.eq_any(chunk)) + .filter(dsl::entity_type.eq(entity_type)) + .load::(conn) + })?; + results.append(&mut chunk_results); + } + Ok(results) + } } impl QueryConsentRecord for &T { @@ -333,6 +366,14 @@ impl QueryConsentRecord for &T { ) -> Result, crate::ConnectionError> { (**self).find_consent_by_dm_id(dm_id) } + + fn get_consent_records_batch( + &self, + entities: &[String], + entity_type: ConsentType, + ) -> Result, crate::ConnectionError> { + (**self).get_consent_records_batch(entities, entity_type) + } } #[repr(i32)] @@ -511,4 +552,80 @@ mod tests { assert_eq!(db_cr.state, existing.state); }) } + + #[xmtp_common::test] + fn batch_read_consent_records() { + with_connection(|conn| { + // Insert multiple consent records + let record1 = generate_consent_record( + ConsentType::InboxId, + ConsentState::Allowed, + "inbox_1".to_string(), + ); + let record2 = generate_consent_record( + ConsentType::InboxId, + ConsentState::Denied, + "inbox_2".to_string(), + ); + let record3 = generate_consent_record( + ConsentType::InboxId, + ConsentState::Unknown, + "inbox_3".to_string(), + ); + // Different type - should not be returned + let record4 = generate_consent_record( + ConsentType::ConversationId, + ConsentState::Allowed, + "inbox_1".to_string(), // Same entity, different type + ); + + conn.insert_or_replace_consent_records(&[ + record1.clone(), + record2.clone(), + record3.clone(), + record4, + ]) + .expect("should store without error"); + + // Batch read all three InboxId records + let entities = vec![ + "inbox_1".to_string(), + "inbox_2".to_string(), + "inbox_3".to_string(), + ]; + let results = conn + .get_consent_records_batch(&entities, ConsentType::InboxId) + .expect("batch read should work"); + + assert_eq!(results.len(), 3); + + // Verify each record is present with correct state + let result_map: std::collections::HashMap<_, _> = results + .iter() + .map(|r| (r.entity.clone(), r.state)) + .collect(); + assert_eq!(result_map.get("inbox_1"), Some(&ConsentState::Allowed)); + assert_eq!(result_map.get("inbox_2"), Some(&ConsentState::Denied)); + assert_eq!(result_map.get("inbox_3"), Some(&ConsentState::Unknown)); + + // Batch read with some missing entities + let partial_entities = vec![ + "inbox_1".to_string(), + "inbox_nonexistent".to_string(), + "inbox_2".to_string(), + ]; + let partial_results = conn + .get_consent_records_batch(&partial_entities, ConsentType::InboxId) + .expect("batch read should work"); + + // Should only return the 2 that exist + assert_eq!(partial_results.len(), 2); + + // Batch read with empty list + let empty_results = conn + .get_consent_records_batch(&[], ConsentType::InboxId) + .expect("empty batch read should work"); + assert!(empty_results.is_empty()); + }) + } } diff --git a/xmtp_db/src/encrypted_store/contacts/addresses.rs b/xmtp_db/src/encrypted_store/contacts/addresses.rs new file mode 100644 index 0000000000..ea611afc18 --- /dev/null +++ b/xmtp_db/src/encrypted_store/contacts/addresses.rs @@ -0,0 +1,82 @@ +use super::StoredContact; +use crate::encrypted_store::schema::contact_addresses; +use crate::impl_store; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Insertable, + Identifiable, + Queryable, + Selectable, + Associations, + PartialEq, + Eq, +)] +#[diesel(table_name = contact_addresses)] +#[diesel(primary_key(id))] +#[diesel(belongs_to(StoredContact, foreign_key = contact_id))] +pub struct StoredContactAddress { + pub id: i32, + pub contact_id: i32, + pub address1: Option, + pub address2: Option, + pub address3: Option, + pub city: Option, + pub region: Option, + pub postal_code: Option, + pub country: Option, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Insertable)] +#[diesel(table_name = contact_addresses)] +pub struct NewContactAddress { + pub contact_id: i32, + pub address1: Option, + pub address2: Option, + pub address3: Option, + pub city: Option, + pub region: Option, + pub postal_code: Option, + pub country: Option, + pub label: Option, +} + +impl_store!(NewContactAddress, contact_addresses); + +/// Address data used for add/update operations and in FullContact responses +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AddressData { + /// The id of the address (None when creating, Some when retrieved from DB) + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub address1: Option, + pub address2: Option, + pub address3: Option, + pub city: Option, + pub region: Option, + pub postal_code: Option, + pub country: Option, + pub label: Option, +} + +impl From for NewContactAddress { + fn from(data: AddressData) -> Self { + Self { + contact_id: 0, // Will be set by the add function + address1: data.address1, + address2: data.address2, + address3: data.address3, + city: data.city, + region: data.region, + postal_code: data.postal_code, + country: data.country, + label: data.label, + } + } +} diff --git a/xmtp_db/src/encrypted_store/contacts/convert.rs b/xmtp_db/src/encrypted_store/contacts/convert.rs new file mode 100644 index 0000000000..0de3a7fb5f --- /dev/null +++ b/xmtp_db/src/encrypted_store/contacts/convert.rs @@ -0,0 +1,103 @@ +//! Conversion implementations between database types and proto types for contacts backup. + +use super::{AddressData, Email, FullContact, PhoneNumber, Url, WalletAddress}; +use xmtp_proto::xmtp::device_sync::contact_backup::{ + AddressSave, ContactSave, EmailSave, PhoneNumberSave, UrlSave, WalletAddressSave, +}; + +impl From for ContactSave { + fn from(contact: FullContact) -> Self { + Self { + inbox_id: contact.inbox_id, + display_name: contact.display_name, + first_name: contact.first_name, + last_name: contact.last_name, + prefix: contact.prefix, + suffix: contact.suffix, + company: contact.company, + job_title: contact.job_title, + birthday: contact.birthday, + note: contact.note, + image_url: contact.image_url, + is_favorite: contact.is_favorite, + created_at_ns: contact.created_at_ns, + updated_at_ns: contact.updated_at_ns, + phone_numbers: contact.phone_numbers.into_iter().map(Into::into).collect(), + emails: contact.emails.into_iter().map(Into::into).collect(), + urls: contact.urls.into_iter().map(Into::into).collect(), + wallet_addresses: contact + .wallet_addresses + .into_iter() + .map(Into::into) + .collect(), + addresses: contact.addresses.into_iter().map(Into::into).collect(), + } + } +} + +impl From for PhoneNumberSave { + fn from(p: PhoneNumber) -> Self { + Self { + phone_number: p.phone_number, + label: p.label, + } + } +} + +impl From for EmailSave { + fn from(e: Email) -> Self { + Self { + email: e.email, + label: e.label, + } + } +} + +impl From for UrlSave { + fn from(u: Url) -> Self { + Self { + url: u.url, + label: u.label, + } + } +} + +impl From for WalletAddressSave { + fn from(w: WalletAddress) -> Self { + Self { + wallet_address: w.wallet_address, + label: w.label, + } + } +} + +impl From for AddressSave { + fn from(a: AddressData) -> Self { + Self { + address1: a.address1, + address2: a.address2, + address3: a.address3, + city: a.city, + region: a.region, + postal_code: a.postal_code, + country: a.country, + label: a.label, + } + } +} + +impl From for AddressData { + fn from(a: AddressSave) -> Self { + Self { + id: None, + address1: a.address1, + address2: a.address2, + address3: a.address3, + city: a.city, + region: a.region, + postal_code: a.postal_code, + country: a.country, + label: a.label, + } + } +} diff --git a/xmtp_db/src/encrypted_store/contacts/emails.rs b/xmtp_db/src/encrypted_store/contacts/emails.rs new file mode 100644 index 0000000000..617c2fe83e --- /dev/null +++ b/xmtp_db/src/encrypted_store/contacts/emails.rs @@ -0,0 +1,38 @@ +use super::StoredContact; +use crate::encrypted_store::schema::contact_emails; +use crate::impl_store; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Insertable, + Identifiable, + Queryable, + Selectable, + Associations, + PartialEq, + Eq, +)] +#[diesel(table_name = contact_emails)] +#[diesel(primary_key(id))] +#[diesel(belongs_to(StoredContact, foreign_key = contact_id))] +pub struct StoredContactEmail { + pub id: i32, + pub contact_id: i32, + pub email: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Insertable)] +#[diesel(table_name = contact_emails)] +pub struct NewContactEmail { + pub contact_id: i32, + pub email: String, + pub label: Option, +} + +impl_store!(NewContactEmail, contact_emails); diff --git a/xmtp_db/src/encrypted_store/contacts/mod.rs b/xmtp_db/src/encrypted_store/contacts/mod.rs new file mode 100644 index 0000000000..66f873b366 --- /dev/null +++ b/xmtp_db/src/encrypted_store/contacts/mod.rs @@ -0,0 +1,1794 @@ +//! Contacts database operations for storing and querying contacts. +//! +//! # Architecture +//! +//! Contacts are stored with a main `contacts` table and five companion tables for +//! multi-valued fields (phone numbers, emails, URLs, wallet addresses, and addresses). +//! A SQL view (`contact_list`) aggregates all companion data as JSON for efficient retrieval. +//! +//! # Full-Text Search +//! +//! The `contacts_fts` FTS5 table with trigram tokenizer enables substring matching across +//! all contact fields. Database triggers automatically keep the FTS index synchronized. +//! +//! **Performance Note:** FTS triggers execute on every insert/update/delete of companion +//! table rows. For bulk imports (e.g., device sync), this means each companion record +//! triggers a full FTS rebuild for that contact. This is acceptable for typical contact +//! counts but could be optimized for very large imports by disabling triggers during +//! bulk operations. + +mod addresses; +mod convert; +mod emails; +mod phone_numbers; +mod urls; +mod wallet_addresses; + +pub use addresses::*; +pub use emails::*; +pub use phone_numbers::*; +pub use urls::*; +pub use wallet_addresses::*; +use xmtp_proto::mls_v1::SortDirection; + +use super::ConnectionExt; +use super::db_connection::DbConnection; +use super::schema::contacts::{self, dsl}; +use super::schema::{ + contact_addresses, contact_emails, contact_phone_numbers, contact_urls, + contact_wallet_addresses, +}; +use crate::StorageError; +use diesel::prelude::*; +use diesel::sql_types::{BigInt, Integer, Nullable, Text}; +use serde::{Deserialize, Serialize}; + +/// StoredContact represents a contact in the database +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Insertable, + Identifiable, + Queryable, + Selectable, + PartialEq, + Eq, +)] +#[diesel(table_name = contacts)] +#[diesel(primary_key(id))] +pub struct StoredContact { + pub id: i32, + pub inbox_id: String, + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, + pub prefix: Option, + pub suffix: Option, + pub company: Option, + pub job_title: Option, + pub birthday: Option, + pub note: Option, + pub image_url: Option, + pub is_favorite: i32, + pub created_at_ns: i64, + pub updated_at_ns: i64, +} + +/// Contact data for creating or updating contacts (without id, inbox_id, or timestamps) +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContactData { + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, + pub prefix: Option, + pub suffix: Option, + pub company: Option, + pub job_title: Option, + pub birthday: Option, + pub note: Option, + pub image_url: Option, + pub is_favorite: Option, +} + +/// Internal struct for inserting contacts with timestamps +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = contacts)] +struct InsertableContact { + inbox_id: String, + display_name: Option, + first_name: Option, + last_name: Option, + prefix: Option, + suffix: Option, + company: Option, + job_title: Option, + birthday: Option, + note: Option, + image_url: Option, + is_favorite: i32, + created_at_ns: i64, + updated_at_ns: i64, +} + +impl InsertableContact { + fn new(inbox_id: String, data: ContactData) -> Self { + let now = xmtp_common::time::now_ns(); + Self { + inbox_id, + display_name: data.display_name, + first_name: data.first_name, + last_name: data.last_name, + prefix: data.prefix, + suffix: data.suffix, + company: data.company, + job_title: data.job_title, + birthday: data.birthday, + note: data.note, + image_url: data.image_url, + is_favorite: data.is_favorite.map(|b| b as i32).unwrap_or(0), + created_at_ns: now, + updated_at_ns: now, + } + } +} + +/// Internal struct for updating contacts (None fields are not updated) +#[derive(Debug, Clone, AsChangeset)] +#[diesel(table_name = contacts)] +struct UpdatableContact { + display_name: Option, + first_name: Option, + last_name: Option, + prefix: Option, + suffix: Option, + company: Option, + job_title: Option, + birthday: Option, + note: Option, + image_url: Option, + is_favorite: Option, + updated_at_ns: i64, +} + +impl UpdatableContact { + fn from_data(data: ContactData) -> Self { + Self { + display_name: data.display_name, + first_name: data.first_name, + last_name: data.last_name, + prefix: data.prefix, + suffix: data.suffix, + company: data.company, + job_title: data.job_title, + birthday: data.birthday, + note: data.note, + image_url: data.image_url, + is_favorite: data.is_favorite.map(|b| b as i32), + updated_at_ns: xmtp_common::time::now_ns(), + } + } +} + +/// Phone number data for FullContact +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PhoneNumber { + pub id: i32, + pub phone_number: String, + pub label: Option, +} + +/// Email data for FullContact +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Email { + pub id: i32, + pub email: String, + pub label: Option, +} + +/// URL data for FullContact +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Url { + pub id: i32, + pub url: String, + pub label: Option, +} + +/// Wallet address data for FullContact +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WalletAddress { + pub id: i32, + pub wallet_address: String, + pub label: Option, +} + +/// Raw struct for querying the contact_list view +#[derive(Debug, Clone, QueryableByName)] +struct RawFullContact { + #[diesel(sql_type = Text)] + inbox_id: String, + #[diesel(sql_type = Nullable)] + display_name: Option, + #[diesel(sql_type = Nullable)] + first_name: Option, + #[diesel(sql_type = Nullable)] + last_name: Option, + #[diesel(sql_type = Nullable)] + prefix: Option, + #[diesel(sql_type = Nullable)] + suffix: Option, + #[diesel(sql_type = Nullable)] + company: Option, + #[diesel(sql_type = Nullable)] + job_title: Option, + #[diesel(sql_type = Nullable)] + birthday: Option, + #[diesel(sql_type = Nullable)] + note: Option, + #[diesel(sql_type = Nullable)] + image_url: Option, + #[diesel(sql_type = Integer)] + is_favorite: i32, + #[diesel(sql_type = BigInt)] + created_at_ns: i64, + #[diesel(sql_type = BigInt)] + updated_at_ns: i64, + #[diesel(sql_type = Text)] + phone_numbers: String, + #[diesel(sql_type = Text)] + emails: String, + #[diesel(sql_type = Text)] + urls: String, + #[diesel(sql_type = Text)] + wallet_addresses: String, + #[diesel(sql_type = Text)] + addresses: String, +} + +/// A complete contact with all related data +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FullContact { + pub inbox_id: String, + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, + pub prefix: Option, + pub suffix: Option, + pub company: Option, + pub job_title: Option, + pub birthday: Option, + pub note: Option, + pub image_url: Option, + pub is_favorite: bool, + pub created_at_ns: i64, + pub updated_at_ns: i64, + pub phone_numbers: Vec, + pub emails: Vec, + pub urls: Vec, + pub wallet_addresses: Vec, + pub addresses: Vec, +} + +impl From for FullContact { + fn from(raw: RawFullContact) -> Self { + // Clone inbox_id for use in error logging closures + let inbox_id = raw.inbox_id; + Self { + phone_numbers: serde_json::from_str(&raw.phone_numbers).unwrap_or_else(|e| { + tracing::warn!( + inbox_id = %inbox_id, + field = "phone_numbers", + error = %e, + "Failed to parse contact companion data, using empty default" + ); + Vec::new() + }), + emails: serde_json::from_str(&raw.emails).unwrap_or_else(|e| { + tracing::warn!( + inbox_id = %inbox_id, + field = "emails", + error = %e, + "Failed to parse contact companion data, using empty default" + ); + Vec::new() + }), + urls: serde_json::from_str(&raw.urls).unwrap_or_else(|e| { + tracing::warn!( + inbox_id = %inbox_id, + field = "urls", + error = %e, + "Failed to parse contact companion data, using empty default" + ); + Vec::new() + }), + wallet_addresses: serde_json::from_str(&raw.wallet_addresses).unwrap_or_else(|e| { + tracing::warn!( + inbox_id = %inbox_id, + field = "wallet_addresses", + error = %e, + "Failed to parse contact companion data, using empty default" + ); + Vec::new() + }), + addresses: serde_json::from_str(&raw.addresses).unwrap_or_else(|e| { + tracing::warn!( + inbox_id = %inbox_id, + field = "addresses", + error = %e, + "Failed to parse contact companion data, using empty default" + ); + Vec::new() + }), + inbox_id, + display_name: raw.display_name, + first_name: raw.first_name, + last_name: raw.last_name, + prefix: raw.prefix, + suffix: raw.suffix, + company: raw.company, + job_title: raw.job_title, + birthday: raw.birthday, + note: raw.note, + image_url: raw.image_url, + is_favorite: raw.is_favorite != 0, + created_at_ns: raw.created_at_ns, + updated_at_ns: raw.updated_at_ns, + } + } +} + +/// Sort field for contacts query +#[derive(Debug, Clone, Copy, Default)] +pub enum ContactSortField { + #[default] + DisplayName, + FirstName, + LastName, + InboxId, + CreatedAt, + UpdatedAt, +} + +/// Query parameters for filtering, searching, and paginating contacts +#[derive(Debug, Clone, Default)] +pub struct ContactsQuery { + /// Text search across name fields (display_name, first_name, last_name) + pub search: Option, + /// Filter by favorite status + pub is_favorite: Option, + /// Field to sort by + pub sort_by: Option, + /// Sort direction + pub sort_direction: Option, + /// Maximum number of results to return + pub limit: Option, + /// Number of results to skip + pub offset: Option, +} + +pub trait QueryContacts { + /// Add a new contact (timestamps set automatically) + fn add_contact(&self, inbox_id: &str, data: ContactData) + -> Result; + + /// Update an existing contact (updated_at_ns set automatically) + fn update_contact(&self, inbox_id: &str, data: ContactData) -> Result<(), StorageError>; + + /// Get a contact with all related data by inbox_id + fn get_contact(&self, inbox_id: &str) -> Result, StorageError>; + + /// Get contacts with optional filtering, search, and pagination + fn get_contacts(&self, query: Option) -> Result, StorageError>; + + /// Delete a contact and all related data by inbox_id + fn delete_contact(&self, inbox_id: &str) -> Result<(), StorageError>; + + /// Get contacts with pagination for device sync + fn contacts_paged(&self, limit: i64, offset: i64) -> Result, StorageError>; + + // Phone number operations + fn get_phone_numbers(&self, inbox_id: &str) -> Result, StorageError>; + fn add_phone_number( + &self, + inbox_id: &str, + phone_number: String, + label: Option, + ) -> Result; + fn update_phone_number( + &self, + id: i32, + phone_number: String, + label: Option, + ) -> Result<(), StorageError>; + fn delete_phone_number(&self, id: i32) -> Result<(), StorageError>; + + // Email operations + fn get_emails(&self, inbox_id: &str) -> Result, StorageError>; + fn add_email( + &self, + inbox_id: &str, + email: String, + label: Option, + ) -> Result; + fn update_email( + &self, + id: i32, + email: String, + label: Option, + ) -> Result<(), StorageError>; + fn delete_email(&self, id: i32) -> Result<(), StorageError>; + + // URL operations + fn get_urls(&self, inbox_id: &str) -> Result, StorageError>; + fn add_url( + &self, + inbox_id: &str, + url: String, + label: Option, + ) -> Result; + fn update_url(&self, id: i32, url: String, label: Option) -> Result<(), StorageError>; + fn delete_url(&self, id: i32) -> Result<(), StorageError>; + + // Wallet address operations + fn get_wallet_addresses(&self, inbox_id: &str) -> Result, StorageError>; + fn add_wallet_address( + &self, + inbox_id: &str, + wallet_address: String, + label: Option, + ) -> Result; + fn update_wallet_address( + &self, + id: i32, + wallet_address: String, + label: Option, + ) -> Result<(), StorageError>; + fn delete_wallet_address(&self, id: i32) -> Result<(), StorageError>; + + // Street address operations + fn get_addresses(&self, inbox_id: &str) -> Result, StorageError>; + fn add_address( + &self, + inbox_id: &str, + data: AddressData, + ) -> Result; + fn update_address(&self, id: i32, data: AddressData) -> Result<(), StorageError>; + fn delete_address(&self, id: i32) -> Result<(), StorageError>; +} + +impl QueryContacts for DbConnection { + fn add_contact( + &self, + inbox_id: &str, + data: ContactData, + ) -> Result { + let insertable = InsertableContact::new(inbox_id.to_string(), data); + Ok(self.raw_query_write(|conn| { + diesel::insert_into(contacts::table) + .values(&insertable) + .get_result(conn) + })?) + } + + fn update_contact(&self, inbox_id: &str, data: ContactData) -> Result<(), StorageError> { + let updatable = UpdatableContact::from_data(data); + self.raw_query_write(|conn| { + let rows_affected = diesel::update(dsl::contacts.filter(dsl::inbox_id.eq(inbox_id))) + .set(&updatable) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn get_contact(&self, inbox_id: &str) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let result: Option = + diesel::sql_query("SELECT * FROM contact_list WHERE inbox_id = ?") + .bind::(inbox_id) + .get_result(conn) + .optional()?; + Ok(result.map(FullContact::from)) + })?) + } + + fn get_contacts(&self, query: Option) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let query = query.unwrap_or_default(); + + // Check if we have a non-empty search term + let has_search = query.search.as_ref().is_some_and(|s| !s.is_empty()); + + // Build the query based on whether we have a search term + // With search: JOIN with FTS5 table for fast indexed search + // Without search: query contact_list directly + let mut sql = if has_search { + String::from( + "SELECT cl.* FROM contact_list cl \ + JOIN contacts_fts fts ON fts.inbox_id = cl.inbox_id \ + WHERE contacts_fts MATCH ?", + ) + } else { + String::from("SELECT * FROM contact_list WHERE 1=1") + }; + + // Build FTS search pattern if searching + let search_pattern = if has_search { + query.search.as_ref().map(|search| { + // FTS5 trigram tokenizer uses quoted string for substring match + // Escape any quotes in the search term + let escaped = search.replace('"', "\"\""); + format!("\"{}\"", escaped) + }) + } else { + None + }; + + // is_favorite filter + // SAFETY: is_fav is a Rust bool, so the interpolation is always "0" or "1" + if let Some(is_fav) = query.is_favorite { + sql.push_str(&format!( + " AND is_favorite = {}", + if is_fav { 1 } else { 0 } + )); + } + + // Sorting + // SAFETY: sort_field and sort_dir are string literals from enum matches, + // not user-controlled strings, so they cannot cause SQL injection. + let sort_field = match query.sort_by.unwrap_or_default() { + ContactSortField::DisplayName => "display_name", + ContactSortField::FirstName => "first_name", + ContactSortField::LastName => "last_name", + ContactSortField::InboxId => "inbox_id", + ContactSortField::CreatedAt => "created_at_ns", + ContactSortField::UpdatedAt => "updated_at_ns", + }; + let sort_dir = match query.sort_direction.unwrap_or_default() { + SortDirection::Ascending => "ASC", + SortDirection::Descending => "DESC", + SortDirection::Unspecified => "ASC", + }; + sql.push_str(&format!(" ORDER BY {} {}", sort_field, sort_dir)); + + // Pagination + // SAFETY: limit and offset are i64 values. We clamp them to reasonable bounds + // to prevent potential integer overflow issues, and the formatted value is + // always a valid decimal integer literal. + if let Some(limit) = query.limit { + let safe_limit = limit.clamp(0, i64::MAX); + sql.push_str(&format!(" LIMIT {}", safe_limit)); + } + if let Some(offset) = query.offset { + let safe_offset = offset.clamp(0, i64::MAX); + sql.push_str(&format!(" OFFSET {}", safe_offset)); + } + + // Execute query - only search requires binding (user-provided string) + let results: Vec = match &search_pattern { + None => diesel::sql_query(&sql).load(conn)?, + Some(s) => diesel::sql_query(&sql).bind::(s).load(conn)?, + }; + + Ok(results.into_iter().map(FullContact::from).collect()) + })?) + } + + fn delete_contact(&self, inbox_id: &str) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = + diesel::delete(dsl::contacts.filter(dsl::inbox_id.eq(inbox_id))).execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn contacts_paged(&self, limit: i64, offset: i64) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let sql = format!( + "SELECT * FROM contact_list ORDER BY inbox_id LIMIT {} OFFSET {}", + limit, offset + ); + let results: Vec = diesel::sql_query(&sql).load(conn)?; + Ok(results.into_iter().map(FullContact::from).collect()) + })?) + } + + fn get_phone_numbers(&self, inbox_id: &str) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let contact: Option = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn) + .optional()?; + let Some(contact) = contact else { + return Ok(Vec::new()); + }; + let phone_numbers: Vec = + contact_phone_numbers::dsl::contact_phone_numbers + .filter(contact_phone_numbers::dsl::contact_id.eq(contact.id)) + .load(conn)?; + Ok(phone_numbers + .into_iter() + .map(|p| PhoneNumber { + id: p.id, + phone_number: p.phone_number, + label: p.label, + }) + .collect()) + })?) + } + + fn add_phone_number( + &self, + inbox_id: &str, + phone_number: String, + label: Option, + ) -> Result { + Ok(self.raw_query_write(|conn| { + let contact: StoredContact = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn)?; + let new = NewContactPhoneNumber { + contact_id: contact.id, + phone_number, + label, + }; + diesel::insert_into(contact_phone_numbers::table) + .values(&new) + .get_result(conn) + })?) + } + + fn update_phone_number( + &self, + id: i32, + phone_number: String, + label: Option, + ) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::update( + contact_phone_numbers::dsl::contact_phone_numbers + .filter(contact_phone_numbers::dsl::id.eq(id)), + ) + .set(( + contact_phone_numbers::dsl::phone_number.eq(phone_number), + contact_phone_numbers::dsl::label.eq(label), + )) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn delete_phone_number(&self, id: i32) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::delete( + contact_phone_numbers::dsl::contact_phone_numbers + .filter(contact_phone_numbers::dsl::id.eq(id)), + ) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn get_emails(&self, inbox_id: &str) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let contact: Option = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn) + .optional()?; + let Some(contact) = contact else { + return Ok(Vec::new()); + }; + let emails: Vec = contact_emails::dsl::contact_emails + .filter(contact_emails::dsl::contact_id.eq(contact.id)) + .load(conn)?; + Ok(emails + .into_iter() + .map(|e| Email { + id: e.id, + email: e.email, + label: e.label, + }) + .collect()) + })?) + } + + fn add_email( + &self, + inbox_id: &str, + email: String, + label: Option, + ) -> Result { + Ok(self.raw_query_write(|conn| { + let contact: StoredContact = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn)?; + let new = NewContactEmail { + contact_id: contact.id, + email, + label, + }; + diesel::insert_into(contact_emails::table) + .values(&new) + .get_result(conn) + })?) + } + + fn update_email( + &self, + id: i32, + email: String, + label: Option, + ) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::update( + contact_emails::dsl::contact_emails.filter(contact_emails::dsl::id.eq(id)), + ) + .set(( + contact_emails::dsl::email.eq(email), + contact_emails::dsl::label.eq(label), + )) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn delete_email(&self, id: i32) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::delete( + contact_emails::dsl::contact_emails.filter(contact_emails::dsl::id.eq(id)), + ) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn get_urls(&self, inbox_id: &str) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let contact: Option = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn) + .optional()?; + let Some(contact) = contact else { + return Ok(Vec::new()); + }; + let urls: Vec = contact_urls::dsl::contact_urls + .filter(contact_urls::dsl::contact_id.eq(contact.id)) + .load(conn)?; + Ok(urls + .into_iter() + .map(|u| Url { + id: u.id, + url: u.url, + label: u.label, + }) + .collect()) + })?) + } + + fn add_url( + &self, + inbox_id: &str, + url: String, + label: Option, + ) -> Result { + Ok(self.raw_query_write(|conn| { + let contact: StoredContact = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn)?; + let new = NewContactUrl { + contact_id: contact.id, + url, + label, + }; + diesel::insert_into(contact_urls::table) + .values(&new) + .get_result(conn) + })?) + } + + fn update_url(&self, id: i32, url: String, label: Option) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::update( + contact_urls::dsl::contact_urls.filter(contact_urls::dsl::id.eq(id)), + ) + .set(( + contact_urls::dsl::url.eq(url), + contact_urls::dsl::label.eq(label), + )) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn delete_url(&self, id: i32) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::delete( + contact_urls::dsl::contact_urls.filter(contact_urls::dsl::id.eq(id)), + ) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn get_wallet_addresses(&self, inbox_id: &str) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let contact: Option = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn) + .optional()?; + let Some(contact) = contact else { + return Ok(Vec::new()); + }; + let wallet_addresses: Vec = + contact_wallet_addresses::dsl::contact_wallet_addresses + .filter(contact_wallet_addresses::dsl::contact_id.eq(contact.id)) + .load(conn)?; + Ok(wallet_addresses + .into_iter() + .map(|w| WalletAddress { + id: w.id, + wallet_address: w.wallet_address, + label: w.label, + }) + .collect()) + })?) + } + + fn add_wallet_address( + &self, + inbox_id: &str, + wallet_address: String, + label: Option, + ) -> Result { + Ok(self.raw_query_write(|conn| { + let contact: StoredContact = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn)?; + let new = NewContactWalletAddress { + contact_id: contact.id, + wallet_address, + label, + }; + diesel::insert_into(contact_wallet_addresses::table) + .values(&new) + .get_result(conn) + })?) + } + + fn update_wallet_address( + &self, + id: i32, + wallet_address: String, + label: Option, + ) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::update( + contact_wallet_addresses::dsl::contact_wallet_addresses + .filter(contact_wallet_addresses::dsl::id.eq(id)), + ) + .set(( + contact_wallet_addresses::dsl::wallet_address.eq(wallet_address), + contact_wallet_addresses::dsl::label.eq(label), + )) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn delete_wallet_address(&self, id: i32) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::delete( + contact_wallet_addresses::dsl::contact_wallet_addresses + .filter(contact_wallet_addresses::dsl::id.eq(id)), + ) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn get_addresses(&self, inbox_id: &str) -> Result, StorageError> { + Ok(self.raw_query_read(|conn| { + let contact: Option = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn) + .optional()?; + let Some(contact) = contact else { + return Ok(Vec::new()); + }; + let addresses: Vec = contact_addresses::dsl::contact_addresses + .filter(contact_addresses::dsl::contact_id.eq(contact.id)) + .load(conn)?; + Ok(addresses + .into_iter() + .map(|s| AddressData { + id: Some(s.id), + address1: s.address1, + address2: s.address2, + address3: s.address3, + city: s.city, + region: s.region, + postal_code: s.postal_code, + country: s.country, + label: s.label, + }) + .collect()) + })?) + } + + fn add_address( + &self, + inbox_id: &str, + data: AddressData, + ) -> Result { + Ok(self.raw_query_write(|conn| { + let contact: StoredContact = dsl::contacts + .filter(dsl::inbox_id.eq(inbox_id)) + .first(conn)?; + let mut new: NewContactAddress = data.into(); + new.contact_id = contact.id; + diesel::insert_into(contact_addresses::table) + .values(&new) + .get_result(conn) + })?) + } + + fn update_address(&self, id: i32, data: AddressData) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::update( + contact_addresses::dsl::contact_addresses.filter(contact_addresses::dsl::id.eq(id)), + ) + .set(( + contact_addresses::dsl::address1.eq(data.address1), + contact_addresses::dsl::address2.eq(data.address2), + contact_addresses::dsl::address3.eq(data.address3), + contact_addresses::dsl::city.eq(data.city), + contact_addresses::dsl::region.eq(data.region), + contact_addresses::dsl::postal_code.eq(data.postal_code), + contact_addresses::dsl::country.eq(data.country), + contact_addresses::dsl::label.eq(data.label), + )) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } + + fn delete_address(&self, id: i32) -> Result<(), StorageError> { + self.raw_query_write(|conn| { + let rows_affected = diesel::delete( + contact_addresses::dsl::contact_addresses.filter(contact_addresses::dsl::id.eq(id)), + ) + .execute(conn)?; + if rows_affected == 0 { + return Err(diesel::result::Error::NotFound); + } + Ok(()) + })?; + Ok(()) + } +} + +impl QueryContacts for &T { + fn add_contact( + &self, + inbox_id: &str, + data: ContactData, + ) -> Result { + (**self).add_contact(inbox_id, data) + } + + fn update_contact(&self, inbox_id: &str, data: ContactData) -> Result<(), StorageError> { + (**self).update_contact(inbox_id, data) + } + + fn get_contact(&self, inbox_id: &str) -> Result, StorageError> { + (**self).get_contact(inbox_id) + } + + fn get_contacts(&self, query: Option) -> Result, StorageError> { + (**self).get_contacts(query) + } + + fn delete_contact(&self, inbox_id: &str) -> Result<(), StorageError> { + (**self).delete_contact(inbox_id) + } + + fn contacts_paged(&self, limit: i64, offset: i64) -> Result, StorageError> { + (**self).contacts_paged(limit, offset) + } + + fn get_phone_numbers(&self, inbox_id: &str) -> Result, StorageError> { + (**self).get_phone_numbers(inbox_id) + } + + fn add_phone_number( + &self, + inbox_id: &str, + phone_number: String, + label: Option, + ) -> Result { + (**self).add_phone_number(inbox_id, phone_number, label) + } + + fn update_phone_number( + &self, + id: i32, + phone_number: String, + label: Option, + ) -> Result<(), StorageError> { + (**self).update_phone_number(id, phone_number, label) + } + + fn delete_phone_number(&self, id: i32) -> Result<(), StorageError> { + (**self).delete_phone_number(id) + } + + fn get_emails(&self, inbox_id: &str) -> Result, StorageError> { + (**self).get_emails(inbox_id) + } + + fn add_email( + &self, + inbox_id: &str, + email: String, + label: Option, + ) -> Result { + (**self).add_email(inbox_id, email, label) + } + + fn update_email( + &self, + id: i32, + email: String, + label: Option, + ) -> Result<(), StorageError> { + (**self).update_email(id, email, label) + } + + fn delete_email(&self, id: i32) -> Result<(), StorageError> { + (**self).delete_email(id) + } + + fn get_urls(&self, inbox_id: &str) -> Result, StorageError> { + (**self).get_urls(inbox_id) + } + + fn add_url( + &self, + inbox_id: &str, + url: String, + label: Option, + ) -> Result { + (**self).add_url(inbox_id, url, label) + } + + fn update_url(&self, id: i32, url: String, label: Option) -> Result<(), StorageError> { + (**self).update_url(id, url, label) + } + + fn delete_url(&self, id: i32) -> Result<(), StorageError> { + (**self).delete_url(id) + } + + fn get_wallet_addresses(&self, inbox_id: &str) -> Result, StorageError> { + (**self).get_wallet_addresses(inbox_id) + } + + fn add_wallet_address( + &self, + inbox_id: &str, + wallet_address: String, + label: Option, + ) -> Result { + (**self).add_wallet_address(inbox_id, wallet_address, label) + } + + fn update_wallet_address( + &self, + id: i32, + wallet_address: String, + label: Option, + ) -> Result<(), StorageError> { + (**self).update_wallet_address(id, wallet_address, label) + } + + fn delete_wallet_address(&self, id: i32) -> Result<(), StorageError> { + (**self).delete_wallet_address(id) + } + + fn get_addresses(&self, inbox_id: &str) -> Result, StorageError> { + (**self).get_addresses(inbox_id) + } + + fn add_address( + &self, + inbox_id: &str, + data: AddressData, + ) -> Result { + (**self).add_address(inbox_id, data) + } + + fn update_address(&self, id: i32, data: AddressData) -> Result<(), StorageError> { + (**self).update_address(id, data) + } + + fn delete_address(&self, id: i32) -> Result<(), StorageError> { + (**self).delete_address(id) + } +} + +#[cfg(test)] +mod tests { + use crate::test_utils::with_connection; + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); + + use super::*; + + #[xmtp_common::test(unwrap_try = true)] + fn test_add_and_get_contact() { + with_connection(|conn| { + let data = ContactData { + display_name: Some("Alice".to_string()), + first_name: Some("Alice".to_string()), + last_name: Some("Smith".to_string()), + ..Default::default() + }; + + let contact = conn.add_contact("inbox_alice", data)?; + assert_eq!(contact.inbox_id, "inbox_alice"); + assert_eq!(contact.display_name, Some("Alice".to_string())); + assert_eq!(contact.first_name, Some("Alice".to_string())); + assert_eq!(contact.last_name, Some("Smith".to_string())); + assert_eq!(contact.is_favorite, 0); + assert!(contact.created_at_ns > 0); + assert_eq!(contact.created_at_ns, contact.updated_at_ns); + + let retrieved = conn.get_contact("inbox_alice")?; + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.inbox_id, "inbox_alice"); + assert_eq!(retrieved.display_name, Some("Alice".to_string())); + assert!(retrieved.phone_numbers.is_empty()); + assert!(retrieved.emails.is_empty()); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_update_contact() { + with_connection(|conn| { + let data = ContactData { + display_name: Some("Bob".to_string()), + ..Default::default() + }; + let contact = conn.add_contact("inbox_bob", data)?; + let original_created_at = contact.created_at_ns; + + // Small delay to ensure updated_at changes (20ms to avoid flakiness on slow CI) + std::thread::sleep(std::time::Duration::from_millis(20)); + + let update_data = ContactData { + display_name: Some("Robert".to_string()), + is_favorite: Some(true), + ..Default::default() + }; + conn.update_contact("inbox_bob", update_data)?; + + let updated = conn.get_contact("inbox_bob")?.unwrap(); + assert_eq!(updated.display_name, Some("Robert".to_string())); + assert!(updated.is_favorite); + assert_eq!(updated.created_at_ns, original_created_at); + assert!(updated.updated_at_ns > original_created_at); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_delete_contact() { + with_connection(|conn| { + let data = ContactData { + display_name: Some("Charlie".to_string()), + ..Default::default() + }; + conn.add_contact("inbox_charlie", data)?; + + assert!(conn.get_contact("inbox_charlie")?.is_some()); + + conn.delete_contact("inbox_charlie")?; + + assert!(conn.get_contact("inbox_charlie")?.is_none()); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_get_contacts() { + with_connection(|conn| { + conn.add_contact( + "inbox_1", + ContactData { + display_name: Some("User 1".to_string()), + ..Default::default() + }, + )?; + conn.add_contact( + "inbox_2", + ContactData { + display_name: Some("User 2".to_string()), + ..Default::default() + }, + )?; + + let all = conn.get_contacts(None)?; + assert_eq!(all.len(), 2); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_phone_numbers() { + with_connection(|conn| { + conn.add_contact( + "inbox_phone", + ContactData { + display_name: Some("Phone Test".to_string()), + ..Default::default() + }, + )?; + + let phone = conn.add_phone_number( + "inbox_phone", + "555-1234".to_string(), + Some("Mobile".to_string()), + )?; + assert_eq!(phone.phone_number, "555-1234"); + assert_eq!(phone.label, Some("Mobile".to_string())); + + conn.update_phone_number(phone.id, "555-5678".to_string(), Some("Work".to_string()))?; + + let contact = conn.get_contact("inbox_phone")?.unwrap(); + assert_eq!(contact.phone_numbers.len(), 1); + assert_eq!(contact.phone_numbers[0].phone_number, "555-5678"); + assert_eq!(contact.phone_numbers[0].label, Some("Work".to_string())); + + conn.delete_phone_number(phone.id)?; + let contact = conn.get_contact("inbox_phone")?.unwrap(); + assert!(contact.phone_numbers.is_empty()); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_emails() { + with_connection(|conn| { + conn.add_contact( + "inbox_email", + ContactData { + display_name: Some("Email Test".to_string()), + ..Default::default() + }, + )?; + + let email = conn.add_email( + "inbox_email", + "test@example.com".to_string(), + Some("Personal".to_string()), + )?; + assert_eq!(email.email, "test@example.com"); + + let contact = conn.get_contact("inbox_email")?.unwrap(); + assert_eq!(contact.emails.len(), 1); + + conn.delete_email(email.id)?; + let contact = conn.get_contact("inbox_email")?.unwrap(); + assert!(contact.emails.is_empty()); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_addresses() { + with_connection(|conn| { + conn.add_contact( + "inbox_addr", + ContactData { + display_name: Some("Address Test".to_string()), + ..Default::default() + }, + )?; + + let addr_data = AddressData { + address1: Some("123 Main St".to_string()), + city: Some("Springfield".to_string()), + region: Some("IL".to_string()), + postal_code: Some("62701".to_string()), + country: Some("USA".to_string()), + label: Some("Home".to_string()), + ..Default::default() + }; + + let addr = conn.add_address("inbox_addr", addr_data)?; + assert_eq!(addr.address1, Some("123 Main St".to_string())); + assert_eq!(addr.city, Some("Springfield".to_string())); + + let contact = conn.get_contact("inbox_addr")?.unwrap(); + assert_eq!(contact.addresses.len(), 1); + + conn.delete_address(addr.id)?; + let contact = conn.get_contact("inbox_addr")?.unwrap(); + assert!(contact.addresses.is_empty()); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_multiple_companion_entries() { + with_connection(|conn| { + conn.add_contact( + "inbox_multi", + ContactData { + display_name: Some("Multi Test".to_string()), + ..Default::default() + }, + )?; + + conn.add_phone_number( + "inbox_multi", + "555-1111".to_string(), + Some("Mobile".to_string()), + )?; + conn.add_phone_number( + "inbox_multi", + "555-2222".to_string(), + Some("Work".to_string()), + )?; + conn.add_phone_number( + "inbox_multi", + "555-3333".to_string(), + Some("Home".to_string()), + )?; + + conn.add_email("inbox_multi", "personal@example.com".to_string(), None)?; + conn.add_email("inbox_multi", "work@example.com".to_string(), None)?; + + let contact = conn.get_contact("inbox_multi")?.unwrap(); + assert_eq!(contact.phone_numbers.len(), 3); + assert_eq!(contact.emails.len(), 2); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_cascade_delete() { + with_connection(|conn| { + let stored = conn.add_contact( + "inbox_cascade", + ContactData { + display_name: Some("Cascade Test".to_string()), + ..Default::default() + }, + )?; + let contact_id = stored.id; + + // Add data to all companion tables + conn.add_phone_number("inbox_cascade", "555-0000".to_string(), None)?; + conn.add_email("inbox_cascade", "cascade@example.com".to_string(), None)?; + conn.add_url("inbox_cascade", "https://example.com".to_string(), None)?; + conn.add_wallet_address("inbox_cascade", "0x1234".to_string(), None)?; + conn.add_address( + "inbox_cascade", + AddressData { + city: Some("Test City".to_string()), + ..Default::default() + }, + )?; + + let contact = conn.get_contact("inbox_cascade")?.unwrap(); + assert_eq!(contact.phone_numbers.len(), 1); + assert_eq!(contact.emails.len(), 1); + assert_eq!(contact.urls.len(), 1); + assert_eq!(contact.wallet_addresses.len(), 1); + assert_eq!(contact.addresses.len(), 1); + + // Delete contact - should cascade to all companion tables + conn.delete_contact("inbox_cascade")?; + + assert!(conn.get_contact("inbox_cascade")?.is_none()); + + // Verify companion tables are actually empty (no orphaned records) + // Use raw queries to confirm cascade delete worked + conn.raw_query_read(|db_conn| { + use diesel::sql_types::Integer; + + let phone_count: i32 = diesel::sql_query( + "SELECT COUNT(*) as count FROM contact_phone_numbers WHERE contact_id = ?", + ) + .bind::(contact_id) + .get_result::(db_conn)? + .count; + assert_eq!(phone_count, 0, "Phone numbers should be deleted"); + + let email_count: i32 = diesel::sql_query( + "SELECT COUNT(*) as count FROM contact_emails WHERE contact_id = ?", + ) + .bind::(contact_id) + .get_result::(db_conn)? + .count; + assert_eq!(email_count, 0, "Emails should be deleted"); + + let url_count: i32 = diesel::sql_query( + "SELECT COUNT(*) as count FROM contact_urls WHERE contact_id = ?", + ) + .bind::(contact_id) + .get_result::(db_conn)? + .count; + assert_eq!(url_count, 0, "URLs should be deleted"); + + let wallet_count: i32 = diesel::sql_query( + "SELECT COUNT(*) as count FROM contact_wallet_addresses WHERE contact_id = ?", + ) + .bind::(contact_id) + .get_result::(db_conn)? + .count; + assert_eq!(wallet_count, 0, "Wallet addresses should be deleted"); + + let address_count: i32 = diesel::sql_query( + "SELECT COUNT(*) as count FROM contact_addresses WHERE contact_id = ?", + ) + .bind::(contact_id) + .get_result::(db_conn)? + .count; + assert_eq!(address_count, 0, "Addresses should be deleted"); + + Ok(()) + })?; + }) + } + + /// Helper struct for COUNT(*) queries + #[derive(diesel::QueryableByName)] + struct CountResult { + #[diesel(sql_type = diesel::sql_types::Integer)] + count: i32, + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_search_by_name() { + with_connection(|conn| { + conn.add_contact( + "inbox_alice", + ContactData { + display_name: Some("Alice Johnson".to_string()), + first_name: Some("Alice".to_string()), + last_name: Some("Johnson".to_string()), + ..Default::default() + }, + )?; + conn.add_contact( + "inbox_bob", + ContactData { + display_name: Some("Bob Smith".to_string()), + first_name: Some("Bob".to_string()), + last_name: Some("Smith".to_string()), + ..Default::default() + }, + )?; + + // Search by first name + let results = conn.get_contacts(Some(ContactsQuery { + search: Some("alice".to_string()), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].inbox_id, "inbox_alice"); + + // Search by last name (case insensitive) + let results = conn.get_contacts(Some(ContactsQuery { + search: Some("SMITH".to_string()), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].inbox_id, "inbox_bob"); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_search_companion_tables() { + with_connection(|conn| { + conn.add_contact( + "inbox_searchable", + ContactData { + display_name: Some("Test User".to_string()), + ..Default::default() + }, + )?; + + // Add companion data + conn.add_email( + "inbox_searchable", + "unique.email@example.org".to_string(), + None, + )?; + conn.add_phone_number("inbox_searchable", "555-UNIQUE".to_string(), None)?; + conn.add_wallet_address("inbox_searchable", "0xUNIQUEWALLET123".to_string(), None)?; + conn.add_address( + "inbox_searchable", + AddressData { + city: Some("UniqueCity".to_string()), + ..Default::default() + }, + )?; + + // Add another contact without these unique values + conn.add_contact( + "inbox_other", + ContactData { + display_name: Some("Other User".to_string()), + ..Default::default() + }, + )?; + + // Search by email + let results = conn.get_contacts(Some(ContactsQuery { + search: Some("unique.email".to_string()), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].inbox_id, "inbox_searchable"); + + // Search by phone number + let results = conn.get_contacts(Some(ContactsQuery { + search: Some("555-unique".to_string()), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].inbox_id, "inbox_searchable"); + + // Search by wallet address + let results = conn.get_contacts(Some(ContactsQuery { + search: Some("uniquewallet".to_string()), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].inbox_id, "inbox_searchable"); + + // Search by city in street address + let results = conn.get_contacts(Some(ContactsQuery { + search: Some("uniquecity".to_string()), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].inbox_id, "inbox_searchable"); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_filter_by_favorite() { + with_connection(|conn| { + conn.add_contact( + "inbox_fav1", + ContactData { + display_name: Some("Favorite 1".to_string()), + is_favorite: Some(true), + ..Default::default() + }, + )?; + conn.add_contact( + "inbox_fav2", + ContactData { + display_name: Some("Favorite 2".to_string()), + is_favorite: Some(true), + ..Default::default() + }, + )?; + conn.add_contact( + "inbox_notfav", + ContactData { + display_name: Some("Not Favorite".to_string()), + is_favorite: Some(false), + ..Default::default() + }, + )?; + + // Get only favorites + let results = conn.get_contacts(Some(ContactsQuery { + is_favorite: Some(true), + ..Default::default() + }))?; + assert_eq!(results.len(), 2); + assert!(results.iter().all(|c| c.is_favorite)); + + // Get only non-favorites + let results = conn.get_contacts(Some(ContactsQuery { + is_favorite: Some(false), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert!(!results[0].is_favorite); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_pagination() { + with_connection(|conn| { + // Add 5 contacts + for i in 1..=5 { + conn.add_contact( + &format!("inbox_{}", i), + ContactData { + display_name: Some(format!("User {}", i)), + ..Default::default() + }, + )?; + } + + // Get first 2 + let results = conn.get_contacts(Some(ContactsQuery { + limit: Some(2), + ..Default::default() + }))?; + assert_eq!(results.len(), 2); + + // Get next 2 (offset 2) + let results = conn.get_contacts(Some(ContactsQuery { + limit: Some(2), + offset: Some(2), + ..Default::default() + }))?; + assert_eq!(results.len(), 2); + + // Get last 1 (offset 4) + let results = conn.get_contacts(Some(ContactsQuery { + limit: Some(2), + offset: Some(4), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_sorting() { + with_connection(|conn| { + conn.add_contact( + "inbox_charlie", + ContactData { + display_name: Some("Charlie".to_string()), + first_name: Some("Charlie".to_string()), + ..Default::default() + }, + )?; + conn.add_contact( + "inbox_alice", + ContactData { + display_name: Some("Alice".to_string()), + first_name: Some("Alice".to_string()), + ..Default::default() + }, + )?; + conn.add_contact( + "inbox_bob", + ContactData { + display_name: Some("Bob".to_string()), + first_name: Some("Bob".to_string()), + ..Default::default() + }, + )?; + + // Sort by display name ascending + let results = conn.get_contacts(Some(ContactsQuery { + sort_by: Some(ContactSortField::DisplayName), + sort_direction: Some(SortDirection::Ascending), + ..Default::default() + }))?; + assert_eq!(results[0].display_name, Some("Alice".to_string())); + assert_eq!(results[1].display_name, Some("Bob".to_string())); + assert_eq!(results[2].display_name, Some("Charlie".to_string())); + + // Sort by display name descending + let results = conn.get_contacts(Some(ContactsQuery { + sort_by: Some(ContactSortField::DisplayName), + sort_direction: Some(SortDirection::Descending), + ..Default::default() + }))?; + assert_eq!(results[0].display_name, Some("Charlie".to_string())); + assert_eq!(results[1].display_name, Some("Bob".to_string())); + assert_eq!(results[2].display_name, Some("Alice".to_string())); + + // Sort by inbox_id ascending + let results = conn.get_contacts(Some(ContactsQuery { + sort_by: Some(ContactSortField::InboxId), + sort_direction: Some(SortDirection::Ascending), + ..Default::default() + }))?; + assert_eq!(results[0].inbox_id, "inbox_alice"); + assert_eq!(results[1].inbox_id, "inbox_bob"); + assert_eq!(results[2].inbox_id, "inbox_charlie"); + }) + } + + #[xmtp_common::test(unwrap_try = true)] + fn test_combined_query() { + with_connection(|conn| { + // Add contacts with emails + conn.add_contact( + "inbox_1", + ContactData { + display_name: Some("Alice Tester".to_string()), + is_favorite: Some(true), + ..Default::default() + }, + )?; + conn.add_email("inbox_1", "alice@test.com".to_string(), None)?; + + conn.add_contact( + "inbox_2", + ContactData { + display_name: Some("Bob Tester".to_string()), + is_favorite: Some(true), + ..Default::default() + }, + )?; + conn.add_email("inbox_2", "bob@test.com".to_string(), None)?; + + conn.add_contact( + "inbox_3", + ContactData { + display_name: Some("Charlie Tester".to_string()), + is_favorite: Some(false), + ..Default::default() + }, + )?; + conn.add_email("inbox_3", "charlie@test.com".to_string(), None)?; + + // Search for "tester", filter by favorite, sort by display name desc, limit 1 + let results = conn.get_contacts(Some(ContactsQuery { + search: Some("tester".to_string()), + is_favorite: Some(true), + sort_by: Some(ContactSortField::DisplayName), + sort_direction: Some(SortDirection::Descending), + limit: Some(1), + ..Default::default() + }))?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].display_name, Some("Bob Tester".to_string())); + assert!(results[0].is_favorite); + }) + } +} diff --git a/xmtp_db/src/encrypted_store/contacts/phone_numbers.rs b/xmtp_db/src/encrypted_store/contacts/phone_numbers.rs new file mode 100644 index 0000000000..f5cb229044 --- /dev/null +++ b/xmtp_db/src/encrypted_store/contacts/phone_numbers.rs @@ -0,0 +1,38 @@ +use super::StoredContact; +use crate::encrypted_store::schema::contact_phone_numbers; +use crate::impl_store; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Insertable, + Identifiable, + Queryable, + Selectable, + Associations, + PartialEq, + Eq, +)] +#[diesel(table_name = contact_phone_numbers)] +#[diesel(primary_key(id))] +#[diesel(belongs_to(StoredContact, foreign_key = contact_id))] +pub struct StoredContactPhoneNumber { + pub id: i32, + pub contact_id: i32, + pub phone_number: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Insertable)] +#[diesel(table_name = contact_phone_numbers)] +pub struct NewContactPhoneNumber { + pub contact_id: i32, + pub phone_number: String, + pub label: Option, +} + +impl_store!(NewContactPhoneNumber, contact_phone_numbers); diff --git a/xmtp_db/src/encrypted_store/contacts/urls.rs b/xmtp_db/src/encrypted_store/contacts/urls.rs new file mode 100644 index 0000000000..bec350a3b1 --- /dev/null +++ b/xmtp_db/src/encrypted_store/contacts/urls.rs @@ -0,0 +1,38 @@ +use super::StoredContact; +use crate::encrypted_store::schema::contact_urls; +use crate::impl_store; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Insertable, + Identifiable, + Queryable, + Selectable, + Associations, + PartialEq, + Eq, +)] +#[diesel(table_name = contact_urls)] +#[diesel(primary_key(id))] +#[diesel(belongs_to(StoredContact, foreign_key = contact_id))] +pub struct StoredContactUrl { + pub id: i32, + pub contact_id: i32, + pub url: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Insertable)] +#[diesel(table_name = contact_urls)] +pub struct NewContactUrl { + pub contact_id: i32, + pub url: String, + pub label: Option, +} + +impl_store!(NewContactUrl, contact_urls); diff --git a/xmtp_db/src/encrypted_store/contacts/wallet_addresses.rs b/xmtp_db/src/encrypted_store/contacts/wallet_addresses.rs new file mode 100644 index 0000000000..09531c40e2 --- /dev/null +++ b/xmtp_db/src/encrypted_store/contacts/wallet_addresses.rs @@ -0,0 +1,38 @@ +use super::StoredContact; +use crate::encrypted_store::schema::contact_wallet_addresses; +use crate::impl_store; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Insertable, + Identifiable, + Queryable, + Selectable, + Associations, + PartialEq, + Eq, +)] +#[diesel(table_name = contact_wallet_addresses)] +#[diesel(primary_key(id))] +#[diesel(belongs_to(StoredContact, foreign_key = contact_id))] +pub struct StoredContactWalletAddress { + pub id: i32, + pub contact_id: i32, + pub wallet_address: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Insertable)] +#[diesel(table_name = contact_wallet_addresses)] +pub struct NewContactWalletAddress { + pub contact_id: i32, + pub wallet_address: String, + pub label: Option, +} + +impl_store!(NewContactWalletAddress, contact_wallet_addresses); diff --git a/xmtp_db/src/encrypted_store/mod.rs b/xmtp_db/src/encrypted_store/mod.rs index 05af67d516..f56e43f550 100644 --- a/xmtp_db/src/encrypted_store/mod.rs +++ b/xmtp_db/src/encrypted_store/mod.rs @@ -12,6 +12,7 @@ pub mod association_state; pub mod consent_record; +pub mod contacts; pub mod conversation_list; pub mod database; pub mod db_connection; diff --git a/xmtp_db/src/encrypted_store/schema_gen.rs b/xmtp_db/src/encrypted_store/schema_gen.rs index 6d774d175f..929ece4007 100644 --- a/xmtp_db/src/encrypted_store/schema_gen.rs +++ b/xmtp_db/src/encrypted_store/schema_gen.rs @@ -17,6 +17,77 @@ diesel::table! { } } +diesel::table! { + contact_emails (id) { + id -> Integer, + contact_id -> Integer, + email -> Text, + label -> Nullable, + } +} + +diesel::table! { + contact_phone_numbers (id) { + id -> Integer, + contact_id -> Integer, + phone_number -> Text, + label -> Nullable, + } +} + +diesel::table! { + contact_addresses (id) { + id -> Integer, + contact_id -> Integer, + address1 -> Nullable, + address2 -> Nullable, + address3 -> Nullable, + city -> Nullable, + region -> Nullable, + postal_code -> Nullable, + country -> Nullable, + label -> Nullable, + } +} + +diesel::table! { + contact_urls (id) { + id -> Integer, + contact_id -> Integer, + url -> Text, + label -> Nullable, + } +} + +diesel::table! { + contact_wallet_addresses (id) { + id -> Integer, + contact_id -> Integer, + wallet_address -> Text, + label -> Nullable, + } +} + +diesel::table! { + contacts (id) { + id -> Integer, + inbox_id -> Text, + display_name -> Nullable, + first_name -> Nullable, + last_name -> Nullable, + prefix -> Nullable, + suffix -> Nullable, + company -> Nullable, + job_title -> Nullable, + birthday -> Nullable, + note -> Nullable, + image_url -> Nullable, + is_favorite -> Integer, + created_at_ns -> BigInt, + updated_at_ns -> BigInt, + } +} + diesel::table! { group_intents (id) { id -> Integer, @@ -241,6 +312,11 @@ diesel::table! { } } +diesel::joinable!(contact_emails -> contacts (contact_id)); +diesel::joinable!(contact_phone_numbers -> contacts (contact_id)); +diesel::joinable!(contact_addresses -> contacts (contact_id)); +diesel::joinable!(contact_urls -> contacts (contact_id)); +diesel::joinable!(contact_wallet_addresses -> contacts (contact_id)); diesel::joinable!(group_intents -> groups (group_id)); diesel::joinable!(group_messages -> groups (group_id)); diesel::joinable!(icebox -> groups (group_id)); @@ -248,6 +324,12 @@ diesel::joinable!(icebox -> groups (group_id)); diesel::allow_tables_to_appear_in_same_query!( association_state, consent_records, + contact_emails, + contact_phone_numbers, + contact_addresses, + contact_urls, + contact_wallet_addresses, + contacts, group_intents, group_messages, groups, diff --git a/xmtp_db/src/errors.rs b/xmtp_db/src/errors.rs index 72cbd5ac63..66ec4250d6 100644 --- a/xmtp_db/src/errors.rs +++ b/xmtp_db/src/errors.rs @@ -112,6 +112,8 @@ pub enum NotFound { PostQuantumPrivateKey, #[error("Key Package {kp} not found", kp = hex::encode(_0))] KeyPackage(Vec), + #[error("Contact not found")] + Contact, } #[derive(Error, Debug)] diff --git a/xmtp_db/src/mock.rs b/xmtp_db/src/mock.rs index e522fe922d..d92b17172f 100644 --- a/xmtp_db/src/mock.rs +++ b/xmtp_db/src/mock.rs @@ -111,6 +111,12 @@ mock! { &self, dm_id: &str, ) -> Result, crate::ConnectionError>; + + fn get_consent_records_batch( + &self, + entities: &[String], + entity_type: crate::consent_record::ConsentType, + ) -> Result, crate::ConnectionError>; } impl QueryConversationList for DbQuery { @@ -786,6 +792,98 @@ mock! { fn prune_icebox(&self) -> Result; } + impl crate::contacts::QueryContacts for DbQuery { + fn add_contact( + &self, + inbox_id: &str, + data: crate::contacts::ContactData, + ) -> Result; + + fn update_contact(&self, inbox_id: &str, data: crate::contacts::ContactData) -> Result<(), StorageError>; + + fn get_contact(&self, inbox_id: &str) -> Result, StorageError>; + + fn get_contacts(&self, query: Option) -> Result, StorageError>; + + fn delete_contact(&self, inbox_id: &str) -> Result<(), StorageError>; + + fn contacts_paged(&self, limit: i64, offset: i64) -> Result, StorageError>; + + fn get_phone_numbers(&self, inbox_id: &str) -> Result, StorageError>; + + fn add_phone_number( + &self, + inbox_id: &str, + phone_number: String, + label: Option, + ) -> Result; + + fn update_phone_number( + &self, + id: i32, + phone_number: String, + label: Option, + ) -> Result<(), StorageError>; + + fn delete_phone_number(&self, id: i32) -> Result<(), StorageError>; + + fn get_emails(&self, inbox_id: &str) -> Result, StorageError>; + + fn add_email( + &self, + inbox_id: &str, + email: String, + label: Option, + ) -> Result; + + fn update_email(&self, id: i32, email: String, label: Option) -> Result<(), StorageError>; + + fn delete_email(&self, id: i32) -> Result<(), StorageError>; + + fn get_urls(&self, inbox_id: &str) -> Result, StorageError>; + + fn add_url( + &self, + inbox_id: &str, + url: String, + label: Option, + ) -> Result; + + fn update_url(&self, id: i32, url: String, label: Option) -> Result<(), StorageError>; + + fn delete_url(&self, id: i32) -> Result<(), StorageError>; + + fn get_wallet_addresses(&self, inbox_id: &str) -> Result, StorageError>; + + fn add_wallet_address( + &self, + inbox_id: &str, + wallet_address: String, + label: Option, + ) -> Result; + + fn update_wallet_address( + &self, + id: i32, + wallet_address: String, + label: Option, + ) -> Result<(), StorageError>; + + fn delete_wallet_address(&self, id: i32) -> Result<(), StorageError>; + + fn get_addresses(&self, inbox_id: &str) -> Result, StorageError>; + + fn add_address( + &self, + inbox_id: &str, + data: crate::contacts::AddressData, + ) -> Result; + + fn update_address(&self, id: i32, data: crate::contacts::AddressData) -> Result<(), StorageError>; + + fn delete_address(&self, id: i32) -> Result<(), StorageError>; + } + impl crate::migrations::QueryMigrations for DbQuery { fn applied_migrations(&self) -> Result, crate::ConnectionError>; diff --git a/xmtp_db/src/traits.rs b/xmtp_db/src/traits.rs index a321931238..5596b4d02b 100644 --- a/xmtp_db/src/traits.rs +++ b/xmtp_db/src/traits.rs @@ -1,6 +1,7 @@ use crate::ConnectionExt; use crate::StorageError; use crate::association_state::QueryAssociationStateCache; +use crate::encrypted_store::contacts::QueryContacts; use crate::icebox::QueryIcebox; use crate::pending_remove::QueryPendingRemove; use crate::prelude::*; @@ -68,6 +69,7 @@ pub trait DbQuery: + MaybeSync + ReadOnly + QueryConsentRecord + + QueryContacts + QueryConversationList + QueryDms + QueryGroup @@ -98,6 +100,7 @@ impl DbQuery for T where + MaybeSync + ReadOnly + QueryConsentRecord + + QueryContacts + QueryConversationList + QueryDms + QueryGroup diff --git a/xmtp_mls/src/contacts/address.rs b/xmtp_mls/src/contacts/address.rs new file mode 100644 index 0000000000..608d04fb47 --- /dev/null +++ b/xmtp_mls/src/contacts/address.rs @@ -0,0 +1,44 @@ +use xmtp_db::{ + StorageError, + encrypted_store::contacts::{AddressData, QueryContacts}, +}; + +use crate::context::XmtpSharedContext; + +pub struct ContactAddress { + context: Context, + id: i32, + pub address1: Option, + pub address2: Option, + pub address3: Option, + pub city: Option, + pub region: Option, + pub postal_code: Option, + pub country: Option, + pub label: Option, +} + +impl ContactAddress { + pub(crate) fn new(context: Context, data: AddressData) -> Option { + Some(Self { + context, + id: data.id?, + address1: data.address1, + address2: data.address2, + address3: data.address3, + city: data.city, + region: data.region, + postal_code: data.postal_code, + country: data.country, + label: data.label, + }) + } + + pub fn update(&self, data: AddressData) -> Result<(), StorageError> { + self.context.db().update_address(self.id, data) + } + + pub fn delete(&self) -> Result<(), StorageError> { + self.context.db().delete_address(self.id) + } +} diff --git a/xmtp_mls/src/contacts/client.rs b/xmtp_mls/src/contacts/client.rs new file mode 100644 index 0000000000..d53a041a4e --- /dev/null +++ b/xmtp_mls/src/contacts/client.rs @@ -0,0 +1,36 @@ +use super::Contact; +use crate::{Client, client::ClientError, context::XmtpSharedContext}; +use xmtp_db::encrypted_store::contacts::{ContactData, ContactsQuery, QueryContacts}; + +impl Client +where + Context: XmtpSharedContext + Clone, +{ + /// Get a contact by inbox ID. + pub fn get_contact(&self, inbox_id: &str) -> Result>, ClientError> { + let contact = self.context.db().get_contact(inbox_id)?; + Ok(contact.map(|c| Contact::new(self.context.clone(), c))) + } + + /// List contacts with optional filtering, search, and pagination. + pub fn list_contacts( + &self, + query: Option, + ) -> Result>, ClientError> { + let contacts = self.context.db().get_contacts(query)?; + Ok(contacts + .into_iter() + .map(|c| Contact::new(self.context.clone(), c)) + .collect()) + } + + /// Create a new contact. + pub fn create_contact( + &self, + inbox_id: &str, + data: ContactData, + ) -> Result, ClientError> { + let stored = self.context.db().add_contact(inbox_id, data)?; + Ok(Contact::from_stored(self.context.clone(), stored)) + } +} diff --git a/xmtp_mls/src/contacts/email.rs b/xmtp_mls/src/contacts/email.rs new file mode 100644 index 0000000000..9b3ebcff9a --- /dev/null +++ b/xmtp_mls/src/contacts/email.rs @@ -0,0 +1,32 @@ +use xmtp_db::{ + StorageError, + encrypted_store::contacts::{Email, QueryContacts}, +}; + +use crate::context::XmtpSharedContext; + +pub struct ContactEmail { + context: Context, + id: i32, + pub email: String, + pub label: Option, +} + +impl ContactEmail { + pub(crate) fn new(context: Context, data: Email) -> Self { + Self { + context, + id: data.id, + email: data.email, + label: data.label, + } + } + + pub fn update(&self, email: String, label: Option) -> Result<(), StorageError> { + self.context.db().update_email(self.id, email, label) + } + + pub fn delete(&self) -> Result<(), StorageError> { + self.context.db().delete_email(self.id) + } +} diff --git a/xmtp_mls/src/contacts/mod.rs b/xmtp_mls/src/contacts/mod.rs new file mode 100644 index 0000000000..c971747069 --- /dev/null +++ b/xmtp_mls/src/contacts/mod.rs @@ -0,0 +1,598 @@ +use xmtp_db::{ + StorageError, + encrypted_store::contacts::{ + AddressData, ContactData, FullContact, QueryContacts, StoredContact, + }, +}; + +use crate::context::XmtpSharedContext; + +mod address; +mod client; +mod email; +mod phone_number; +mod url; +mod wallet_address; + +pub use address::ContactAddress; +pub use email::ContactEmail; +pub use phone_number::ContactPhoneNumber; +pub use url::ContactUrl; +pub use wallet_address::ContactWalletAddress; + +pub struct Contact { + context: Context, + pub inbox_id: String, + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, + pub prefix: Option, + pub suffix: Option, + pub company: Option, + pub job_title: Option, + pub birthday: Option, + pub note: Option, + pub image_url: Option, + pub is_favorite: bool, + pub created_at_ns: i64, + pub updated_at_ns: i64, +} + +impl Contact { + pub fn new(context: Context, c: FullContact) -> Self { + Self { + context, + inbox_id: c.inbox_id, + display_name: c.display_name, + first_name: c.first_name, + last_name: c.last_name, + prefix: c.prefix, + suffix: c.suffix, + company: c.company, + job_title: c.job_title, + birthday: c.birthday, + note: c.note, + image_url: c.image_url, + is_favorite: c.is_favorite, + created_at_ns: c.created_at_ns, + updated_at_ns: c.updated_at_ns, + } + } + + pub fn from_stored(context: Context, c: StoredContact) -> Self { + Self { + context, + inbox_id: c.inbox_id, + display_name: c.display_name, + first_name: c.first_name, + last_name: c.last_name, + prefix: c.prefix, + suffix: c.suffix, + company: c.company, + job_title: c.job_title, + birthday: c.birthday, + note: c.note, + image_url: c.image_url, + is_favorite: c.is_favorite != 0, + created_at_ns: c.created_at_ns, + updated_at_ns: c.updated_at_ns, + } + } + + pub fn update(&self, data: ContactData) -> Result<(), StorageError> { + self.context.db().update_contact(&self.inbox_id, data) + } + + pub fn delete(&self) -> Result<(), StorageError> { + self.context.db().delete_contact(&self.inbox_id) + } + + pub fn phone_numbers(&self) -> Result>, StorageError> { + let phone_numbers = self.context.db().get_phone_numbers(&self.inbox_id)?; + Ok(phone_numbers + .into_iter() + .map(|p| ContactPhoneNumber::new(self.context.clone(), p)) + .collect()) + } + + pub fn add_phone_number( + &self, + phone_number: String, + label: Option, + ) -> Result<(), StorageError> { + self.context + .db() + .add_phone_number(&self.inbox_id, phone_number, label)?; + Ok(()) + } + + pub fn emails(&self) -> Result>, StorageError> { + let emails = self.context.db().get_emails(&self.inbox_id)?; + Ok(emails + .into_iter() + .map(|e| ContactEmail::new(self.context.clone(), e)) + .collect()) + } + + pub fn add_email(&self, email: String, label: Option) -> Result<(), StorageError> { + self.context.db().add_email(&self.inbox_id, email, label)?; + Ok(()) + } + + pub fn urls(&self) -> Result>, StorageError> { + let urls = self.context.db().get_urls(&self.inbox_id)?; + Ok(urls + .into_iter() + .map(|u| ContactUrl::new(self.context.clone(), u)) + .collect()) + } + + pub fn add_url(&self, url: String, label: Option) -> Result<(), StorageError> { + self.context.db().add_url(&self.inbox_id, url, label)?; + Ok(()) + } + + pub fn wallet_addresses(&self) -> Result>, StorageError> { + let wallet_addresses = self.context.db().get_wallet_addresses(&self.inbox_id)?; + Ok(wallet_addresses + .into_iter() + .map(|w| ContactWalletAddress::new(self.context.clone(), w)) + .collect()) + } + + pub fn add_wallet_address( + &self, + wallet_address: String, + label: Option, + ) -> Result<(), StorageError> { + self.context + .db() + .add_wallet_address(&self.inbox_id, wallet_address, label)?; + Ok(()) + } + + pub fn addresses(&self) -> Result>, StorageError> { + let addresses = self.context.db().get_addresses(&self.inbox_id)?; + Ok(addresses + .into_iter() + .filter_map(|s| ContactAddress::new(self.context.clone(), s)) + .collect()) + } + + pub fn add_address(&self, data: AddressData) -> Result<(), StorageError> { + self.context.db().add_address(&self.inbox_id, data)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::builder::ClientBuilder; + use xmtp_cryptography::utils::generate_local_wallet; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); + + #[xmtp_common::test] + async fn test_client_create_and_get_contact() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let data = ContactData { + display_name: Some("Alice".to_string()), + first_name: Some("Alice".to_string()), + last_name: Some("Smith".to_string()), + ..Default::default() + }; + + let contact = client.create_contact("inbox_alice", data).unwrap(); + assert_eq!(contact.inbox_id, "inbox_alice"); + assert_eq!(contact.display_name, Some("Alice".to_string())); + assert_eq!(contact.first_name, Some("Alice".to_string())); + + let retrieved = client.get_contact("inbox_alice").unwrap(); + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.inbox_id, "inbox_alice"); + assert_eq!(retrieved.display_name, Some("Alice".to_string())); + } + + #[xmtp_common::test] + async fn test_client_list_contacts() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + client + .create_contact( + "inbox_1", + ContactData { + display_name: Some("User 1".to_string()), + ..Default::default() + }, + ) + .unwrap(); + client + .create_contact( + "inbox_2", + ContactData { + display_name: Some("User 2".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + let contacts = client.list_contacts(None).unwrap(); + assert_eq!(contacts.len(), 2); + } + + #[xmtp_common::test] + async fn test_contact_update() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_update", + ContactData { + display_name: Some("Original".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .update(ContactData { + display_name: Some("Updated".to_string()), + is_favorite: Some(true), + ..Default::default() + }) + .unwrap(); + + let updated = client.get_contact("inbox_update").unwrap().unwrap(); + assert_eq!(updated.display_name, Some("Updated".to_string())); + assert!(updated.is_favorite); + } + + #[xmtp_common::test] + async fn test_contact_delete() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_delete", + ContactData { + display_name: Some("To Delete".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + assert!(client.get_contact("inbox_delete").unwrap().is_some()); + + contact.delete().unwrap(); + + assert!(client.get_contact("inbox_delete").unwrap().is_none()); + } + + #[xmtp_common::test] + async fn test_contact_phone_numbers_lazy_loading() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_phones", + ContactData { + display_name: Some("Phone Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + // Initially empty + let phones = contact.phone_numbers().unwrap(); + assert!(phones.is_empty()); + + // Add phone numbers + contact + .add_phone_number("555-1234".to_string(), Some("Mobile".to_string())) + .unwrap(); + contact + .add_phone_number("555-5678".to_string(), Some("Work".to_string())) + .unwrap(); + + // Now should have 2 phone numbers + let phones = contact.phone_numbers().unwrap(); + assert_eq!(phones.len(), 2); + assert_eq!(phones[0].phone_number, "555-1234"); + assert_eq!(phones[0].label, Some("Mobile".to_string())); + } + + #[xmtp_common::test] + async fn test_contact_emails_lazy_loading() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_emails", + ContactData { + display_name: Some("Email Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_email("test@example.com".to_string(), Some("Personal".to_string())) + .unwrap(); + + let emails = contact.emails().unwrap(); + assert_eq!(emails.len(), 1); + assert_eq!(emails[0].email, "test@example.com"); + assert_eq!(emails[0].label, Some("Personal".to_string())); + } + + #[xmtp_common::test] + async fn test_contact_urls_lazy_loading() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_urls", + ContactData { + display_name: Some("URL Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_url( + "https://example.com".to_string(), + Some("Website".to_string()), + ) + .unwrap(); + + let urls = contact.urls().unwrap(); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].url, "https://example.com"); + } + + #[xmtp_common::test] + async fn test_contact_wallet_addresses_lazy_loading() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_wallets", + ContactData { + display_name: Some("Wallet Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_wallet_address("0x1234567890abcdef".to_string(), Some("Main".to_string())) + .unwrap(); + + let wallets = contact.wallet_addresses().unwrap(); + assert_eq!(wallets.len(), 1); + assert_eq!(wallets[0].wallet_address, "0x1234567890abcdef"); + } + + #[xmtp_common::test] + async fn test_contact_addresses_lazy_loading() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_addrs", + ContactData { + display_name: Some("Address Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_address(AddressData { + address1: Some("123 Main St".to_string()), + city: Some("Springfield".to_string()), + region: Some("IL".to_string()), + postal_code: Some("62701".to_string()), + country: Some("USA".to_string()), + label: Some("Home".to_string()), + ..Default::default() + }) + .unwrap(); + + let addrs = contact.addresses().unwrap(); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].address1, Some("123 Main St".to_string())); + assert_eq!(addrs[0].city, Some("Springfield".to_string())); + } + + #[xmtp_common::test] + async fn test_phone_number_update() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_phone_update", + ContactData { + display_name: Some("Phone Update Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_phone_number("555-0000".to_string(), Some("Old".to_string())) + .unwrap(); + + let phones = contact.phone_numbers().unwrap(); + assert_eq!(phones.len(), 1); + + phones[0] + .update("555-9999".to_string(), Some("New".to_string())) + .unwrap(); + + let updated_phones = contact.phone_numbers().unwrap(); + assert_eq!(updated_phones[0].phone_number, "555-9999"); + assert_eq!(updated_phones[0].label, Some("New".to_string())); + } + + #[xmtp_common::test] + async fn test_phone_number_delete() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_phone_delete", + ContactData { + display_name: Some("Phone Delete Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_phone_number("555-1111".to_string(), None) + .unwrap(); + + let phones = contact.phone_numbers().unwrap(); + assert_eq!(phones.len(), 1); + + phones[0].delete().unwrap(); + + let phones = contact.phone_numbers().unwrap(); + assert!(phones.is_empty()); + } + + #[xmtp_common::test] + async fn test_email_update_and_delete() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_email_ops", + ContactData { + display_name: Some("Email Ops Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_email("old@example.com".to_string(), Some("Old".to_string())) + .unwrap(); + + let emails = contact.emails().unwrap(); + emails[0] + .update("new@example.com".to_string(), Some("New".to_string())) + .unwrap(); + + let updated = contact.emails().unwrap(); + assert_eq!(updated[0].email, "new@example.com"); + + updated[0].delete().unwrap(); + assert!(contact.emails().unwrap().is_empty()); + } + + #[xmtp_common::test] + async fn test_url_update_and_delete() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_url_ops", + ContactData { + display_name: Some("URL Ops Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_url("https://old.com".to_string(), None) + .unwrap(); + + let urls = contact.urls().unwrap(); + urls[0] + .update("https://new.com".to_string(), Some("Updated".to_string())) + .unwrap(); + + let updated = contact.urls().unwrap(); + assert_eq!(updated[0].url, "https://new.com"); + + updated[0].delete().unwrap(); + assert!(contact.urls().unwrap().is_empty()); + } + + #[xmtp_common::test] + async fn test_wallet_address_update_and_delete() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_wallet_ops", + ContactData { + display_name: Some("Wallet Ops Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_wallet_address("0xold".to_string(), None) + .unwrap(); + + let wallets = contact.wallet_addresses().unwrap(); + wallets[0] + .update("0xnew".to_string(), Some("Updated".to_string())) + .unwrap(); + + let updated = contact.wallet_addresses().unwrap(); + assert_eq!(updated[0].wallet_address, "0xnew"); + + updated[0].delete().unwrap(); + assert!(contact.wallet_addresses().unwrap().is_empty()); + } + + #[xmtp_common::test] + async fn test_address_update_and_delete() { + let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; + + let contact = client + .create_contact( + "inbox_addr_ops", + ContactData { + display_name: Some("Address Ops Test".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + contact + .add_address(AddressData { + address1: Some("Old Street".to_string()), + city: Some("Old City".to_string()), + ..Default::default() + }) + .unwrap(); + + let addrs = contact.addresses().unwrap(); + addrs[0] + .update(AddressData { + address1: Some("New Street".to_string()), + city: Some("New City".to_string()), + ..Default::default() + }) + .unwrap(); + + let updated = contact.addresses().unwrap(); + assert_eq!(updated[0].address1, Some("New Street".to_string())); + assert_eq!(updated[0].city, Some("New City".to_string())); + + updated[0].delete().unwrap(); + assert!(contact.addresses().unwrap().is_empty()); + } +} diff --git a/xmtp_mls/src/contacts/phone_number.rs b/xmtp_mls/src/contacts/phone_number.rs new file mode 100644 index 0000000000..d1cc6ad683 --- /dev/null +++ b/xmtp_mls/src/contacts/phone_number.rs @@ -0,0 +1,34 @@ +use xmtp_db::{ + StorageError, + encrypted_store::contacts::{PhoneNumber, QueryContacts}, +}; + +use crate::context::XmtpSharedContext; + +pub struct ContactPhoneNumber { + context: Context, + id: i32, + pub phone_number: String, + pub label: Option, +} + +impl ContactPhoneNumber { + pub(crate) fn new(context: Context, data: PhoneNumber) -> Self { + Self { + context, + id: data.id, + phone_number: data.phone_number, + label: data.label, + } + } + + pub fn update(&self, phone_number: String, label: Option) -> Result<(), StorageError> { + self.context + .db() + .update_phone_number(self.id, phone_number, label) + } + + pub fn delete(&self) -> Result<(), StorageError> { + self.context.db().delete_phone_number(self.id) + } +} diff --git a/xmtp_mls/src/contacts/url.rs b/xmtp_mls/src/contacts/url.rs new file mode 100644 index 0000000000..3b65de7b86 --- /dev/null +++ b/xmtp_mls/src/contacts/url.rs @@ -0,0 +1,32 @@ +use xmtp_db::{ + StorageError, + encrypted_store::contacts::{QueryContacts, Url}, +}; + +use crate::context::XmtpSharedContext; + +pub struct ContactUrl { + context: Context, + id: i32, + pub url: String, + pub label: Option, +} + +impl ContactUrl { + pub(crate) fn new(context: Context, data: Url) -> Self { + Self { + context, + id: data.id, + url: data.url, + label: data.label, + } + } + + pub fn update(&self, url: String, label: Option) -> Result<(), StorageError> { + self.context.db().update_url(self.id, url, label) + } + + pub fn delete(&self) -> Result<(), StorageError> { + self.context.db().delete_url(self.id) + } +} diff --git a/xmtp_mls/src/contacts/wallet_address.rs b/xmtp_mls/src/contacts/wallet_address.rs new file mode 100644 index 0000000000..e0e13fdf92 --- /dev/null +++ b/xmtp_mls/src/contacts/wallet_address.rs @@ -0,0 +1,38 @@ +use xmtp_db::{ + StorageError, + encrypted_store::contacts::{QueryContacts, WalletAddress}, +}; + +use crate::context::XmtpSharedContext; + +pub struct ContactWalletAddress { + context: Context, + id: i32, + pub wallet_address: String, + pub label: Option, +} + +impl ContactWalletAddress { + pub(crate) fn new(context: Context, data: WalletAddress) -> Self { + Self { + context, + id: data.id, + wallet_address: data.wallet_address, + label: data.label, + } + } + + pub fn update( + &self, + wallet_address: String, + label: Option, + ) -> Result<(), StorageError> { + self.context + .db() + .update_wallet_address(self.id, wallet_address, label) + } + + pub fn delete(&self) -> Result<(), StorageError> { + self.context.db().delete_wallet_address(self.id) + } +} diff --git a/xmtp_mls/src/groups/device_sync.rs b/xmtp_mls/src/groups/device_sync.rs index 65acca7156..e69e2f5866 100644 --- a/xmtp_mls/src/groups/device_sync.rs +++ b/xmtp_mls/src/groups/device_sync.rs @@ -48,6 +48,8 @@ use xmtp_proto::xmtp::{ }; pub mod archive; +pub mod contact_backup; +pub mod contact_sync; pub mod preference_sync; pub mod worker; @@ -93,6 +95,8 @@ pub enum DeviceSyncError { #[error(transparent)] Decode(#[from] prost::DecodeError), #[error(transparent)] + Encode(#[from] prost::EncodeError), + #[error(transparent)] Deserialization(#[from] DeserializationError), #[error("Sync interaction is already acknowledged by another installation")] AlreadyAcknowledged, @@ -327,6 +331,7 @@ fn default_archive_options() -> BackupOptions { elements: vec![ BackupElementSelection::Messages as i32, BackupElementSelection::Consent as i32, + BackupElementSelection::Contacts as i32, ], ..Default::default() } diff --git a/xmtp_mls/src/groups/device_sync/archive.rs b/xmtp_mls/src/groups/device_sync/archive.rs index 8e4946de55..c31a2d19d2 100644 --- a/xmtp_mls/src/groups/device_sync/archive.rs +++ b/xmtp_mls/src/groups/device_sync/archive.rs @@ -18,6 +18,8 @@ use xmtp_mls_common::group::{DMMetadataOptions, GroupMetadataOptions}; use xmtp_mls_common::group_mutable_metadata::MessageDisappearingSettings; use xmtp_proto::xmtp::device_sync::{BackupElement, backup_element::Element}; +use super::contact_sync::import_single_contact; + #[derive(Default)] struct ImportContext { group_timestamps: HashMap, Option>, @@ -156,6 +158,9 @@ fn insert( let message: StoredGroupMessage = message.try_into()?; message.store_or_ignore(&context.db())?; } + Element::Contact(contact) => { + import_single_contact(&context.db(), contact)?; + } _ => {} } diff --git a/xmtp_mls/src/groups/device_sync/contact_backup.rs b/xmtp_mls/src/groups/device_sync/contact_backup.rs new file mode 100644 index 0000000000..2115281aa5 --- /dev/null +++ b/xmtp_mls/src/groups/device_sync/contact_backup.rs @@ -0,0 +1,29 @@ +//! Contact backup extension trait for device sync. +//! Provides additional methods for ContactSave proto type. + +use xmtp_db::encrypted_store::contacts::ContactData; +use xmtp_proto::xmtp::device_sync::contact_backup::ContactSave; + +/// Extension trait for ContactSave to add conversion methods +pub trait ContactSaveExt { + /// Convert to ContactData for creating/updating a contact + fn to_contact_data(&self) -> ContactData; +} + +impl ContactSaveExt for ContactSave { + fn to_contact_data(&self) -> ContactData { + ContactData { + display_name: self.display_name.clone(), + first_name: self.first_name.clone(), + last_name: self.last_name.clone(), + prefix: self.prefix.clone(), + suffix: self.suffix.clone(), + company: self.company.clone(), + job_title: self.job_title.clone(), + birthday: self.birthday.clone(), + note: self.note.clone(), + image_url: self.image_url.clone(), + is_favorite: Some(self.is_favorite), + } + } +} diff --git a/xmtp_mls/src/groups/device_sync/contact_sync.rs b/xmtp_mls/src/groups/device_sync/contact_sync.rs new file mode 100644 index 0000000000..a5d7d0335f --- /dev/null +++ b/xmtp_mls/src/groups/device_sync/contact_sync.rs @@ -0,0 +1,369 @@ +//! Contact sync module for device sync. +//! Handles exporting and importing contacts during device sync. + +use super::DeviceSyncError; +use prost::Message; +use serde::{Deserialize, Serialize}; +use xmtp_db::encrypted_store::contacts::{AddressData, QueryContacts}; +use xmtp_proto::xmtp::device_sync::contact_backup::ContactSave; + +const CONTACTS_BATCH_SIZE: i64 = 100; + +/// Wrapper for a list of contacts to be serialized +#[derive(Clone, PartialEq, Message, Serialize, Deserialize)] +pub struct ContactsSave { + #[prost(message, repeated, tag = "1")] + pub contacts: Vec, +} + +/// Export all contacts to bytes for device sync. +pub fn export_contacts(db: &D) -> Result, DeviceSyncError> { + let mut all_contacts = Vec::new(); + let mut offset = 0; + + loop { + let batch = db.contacts_paged(CONTACTS_BATCH_SIZE, offset)?; + if batch.is_empty() { + break; + } + all_contacts.extend(batch); + offset += CONTACTS_BATCH_SIZE; + } + + let saves: Vec = all_contacts.into_iter().map(Into::into).collect(); + let contacts_save = ContactsSave { contacts: saves }; + + let mut bytes = Vec::new(); + contacts_save.encode(&mut bytes)?; + Ok(bytes) +} + +/// Import contacts from bytes during device sync. +pub fn import_contacts(db: &D, data: &[u8]) -> Result { + if data.is_empty() { + return Ok(0); + } + + let contacts_save = ContactsSave::decode(data)?; + let mut imported_count = 0; + + for contact_save in contacts_save.contacts { + if let Err(err) = import_single_contact(db, contact_save) { + tracing::warn!("Failed to import contact: {err:?}"); + } else { + imported_count += 1; + } + } + + Ok(imported_count) +} + +/// Import a single contact from a ContactSave proto. +/// This is used by both the byte-based import and the archive import. +pub fn import_single_contact( + db: &D, + contact_save: ContactSave, +) -> Result<(), DeviceSyncError> { + use super::contact_backup::ContactSaveExt; + + let inbox_id = &contact_save.inbox_id; + let contact_data = contact_save.to_contact_data(); + + // Check if contact already exists + let existing = db.get_contact(inbox_id)?; + + if let Some(existing_contact) = existing { + // Update existing contact if the incoming one is newer + if contact_save.updated_at_ns > existing_contact.updated_at_ns { + db.update_contact(inbox_id, contact_data)?; + + // Delete existing child data and re-add from sync + // This ensures we get the exact same data as the source + delete_all_child_data(db, inbox_id)?; + add_child_data(db, inbox_id, &contact_save)?; + } + } else { + // Create new contact + db.add_contact(inbox_id, contact_data)?; + add_child_data(db, inbox_id, &contact_save)?; + } + + Ok(()) +} + +fn delete_all_child_data(db: &D, inbox_id: &str) -> Result<(), DeviceSyncError> { + // Delete phone numbers + for phone in db.get_phone_numbers(inbox_id)? { + db.delete_phone_number(phone.id)?; + } + + // Delete emails + for email in db.get_emails(inbox_id)? { + db.delete_email(email.id)?; + } + + // Delete URLs + for url in db.get_urls(inbox_id)? { + db.delete_url(url.id)?; + } + + // Delete wallet addresses + for wallet in db.get_wallet_addresses(inbox_id)? { + db.delete_wallet_address(wallet.id)?; + } + + // Delete addresses + for addr in db.get_addresses(inbox_id)? { + if let Some(id) = addr.id { + db.delete_address(id)?; + } + } + + Ok(()) +} + +fn add_child_data( + db: &D, + inbox_id: &str, + contact_save: &ContactSave, +) -> Result<(), DeviceSyncError> { + // Add phone numbers + for phone in &contact_save.phone_numbers { + db.add_phone_number(inbox_id, phone.phone_number.clone(), phone.label.clone())?; + } + + // Add emails + for email in &contact_save.emails { + db.add_email(inbox_id, email.email.clone(), email.label.clone())?; + } + + // Add URLs + for url in &contact_save.urls { + db.add_url(inbox_id, url.url.clone(), url.label.clone())?; + } + + // Add wallet addresses + for wallet in &contact_save.wallet_addresses { + db.add_wallet_address( + inbox_id, + wallet.wallet_address.clone(), + wallet.label.clone(), + )?; + } + + // Add addresses + for addr in &contact_save.addresses { + let addr_data: AddressData = addr.clone().into(); + db.add_address(inbox_id, addr_data)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tester; + use xmtp_db::encrypted_store::contacts::ContactData; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); + + #[xmtp_common::test(unwrap_try = true)] + async fn test_contact_export_import() { + tester!(alix); + + // Create a contact with child data + let contact_data = ContactData { + display_name: Some("Test User".to_string()), + first_name: Some("Test".to_string()), + last_name: Some("User".to_string()), + is_favorite: Some(true), + ..Default::default() + }; + + alix.db().add_contact("inbox_test_123", contact_data)?; + alix.db().add_phone_number( + "inbox_test_123", + "555-1234".to_string(), + Some("Mobile".to_string()), + )?; + alix.db().add_email( + "inbox_test_123", + "test@example.com".to_string(), + Some("Work".to_string()), + )?; + alix.db() + .add_url("inbox_test_123", "https://example.com".to_string(), None)?; + alix.db().add_wallet_address( + "inbox_test_123", + "0x1234567890abcdef".to_string(), + Some("Main".to_string()), + )?; + alix.db().add_address( + "inbox_test_123", + AddressData { + address1: Some("123 Main St".to_string()), + city: Some("Springfield".to_string()), + ..Default::default() + }, + )?; + + // Export contacts + let exported = export_contacts(&alix.db())?; + assert!(!exported.is_empty()); + + // Create a second installation + tester!(alix2, from: alix); + + // Verify no contacts exist on alix2 + let contacts_before = alix2.db().get_contacts(None)?; + assert!(contacts_before.is_empty()); + + // Import contacts + let imported_count = import_contacts(&alix2.db(), &exported)?; + assert_eq!(imported_count, 1); + + // Verify contact was imported + let contact = alix2.db().get_contact("inbox_test_123")?; + assert!(contact.is_some()); + let contact = contact.unwrap(); + assert_eq!(contact.display_name, Some("Test User".to_string())); + assert_eq!(contact.first_name, Some("Test".to_string())); + assert!(contact.is_favorite); + + // Verify child data + assert_eq!(contact.phone_numbers.len(), 1); + assert_eq!(contact.phone_numbers[0].phone_number, "555-1234"); + + assert_eq!(contact.emails.len(), 1); + assert_eq!(contact.emails[0].email, "test@example.com"); + + assert_eq!(contact.urls.len(), 1); + assert_eq!(contact.urls[0].url, "https://example.com"); + + assert_eq!(contact.wallet_addresses.len(), 1); + assert_eq!( + contact.wallet_addresses[0].wallet_address, + "0x1234567890abcdef" + ); + + assert_eq!(contact.addresses.len(), 1); + assert_eq!( + contact.addresses[0].address1, + Some("123 Main St".to_string()) + ); + } + + #[xmtp_common::test(unwrap_try = true)] + async fn test_contact_import_update() { + tester!(alix); + tester!(alix2, from: alix); + + // Create a contact on alix2 first (older) + let contact_data_old = ContactData { + display_name: Some("Old Name".to_string()), + ..Default::default() + }; + alix2 + .db() + .add_contact("inbox_update_test", contact_data_old)?; + + // Wait a bit to ensure timestamp difference + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Create a contact on alix (newer) + let contact_data = ContactData { + display_name: Some("Updated Name".to_string()), + ..Default::default() + }; + alix.db().add_contact("inbox_update_test", contact_data)?; + + // Export from alix + let exported = export_contacts(&alix.db())?; + + // Import - should update because alix's contact is newer + let imported_count = import_contacts(&alix2.db(), &exported)?; + assert_eq!(imported_count, 1); + + // Verify the contact was updated + let contact = alix2.db().get_contact("inbox_update_test")?.unwrap(); + assert_eq!(contact.display_name, Some("Updated Name".to_string())); + } + + #[xmtp_common::test(unwrap_try = true)] + async fn test_contact_import_no_update_if_older() { + tester!(alix); + tester!(alix2, from: alix); + + // Create a contact on alix first (older) + let contact_data_old = ContactData { + display_name: Some("Older Name".to_string()), + ..Default::default() + }; + alix.db() + .add_contact("inbox_no_update_test", contact_data_old)?; + + // Export from alix + let exported = export_contacts(&alix.db())?; + + // Wait a bit to ensure timestamp difference + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Create a contact on alix2 (newer) + let contact_data_new = ContactData { + display_name: Some("Newer Name".to_string()), + ..Default::default() + }; + alix2 + .db() + .add_contact("inbox_no_update_test", contact_data_new)?; + + // Import - should NOT update because alix2's contact is newer + let imported_count = import_contacts(&alix2.db(), &exported)?; + assert_eq!(imported_count, 1); + + // Verify the contact was NOT updated (still has newer name) + let contact = alix2.db().get_contact("inbox_no_update_test")?.unwrap(); + assert_eq!(contact.display_name, Some("Newer Name".to_string())); + } + + #[xmtp_common::test(unwrap_try = true)] + async fn test_empty_contacts_export() { + tester!(alix); + + // Export with no contacts + let exported = export_contacts(&alix.db())?; + + // Should still be valid (just empty) + let contacts_save = ContactsSave::decode(exported.as_slice())?; + assert!(contacts_save.contacts.is_empty()); + } + + #[xmtp_common::test(unwrap_try = true)] + async fn test_multiple_contacts_export_import() { + tester!(alix); + + // Create multiple contacts + for i in 0..5 { + let contact_data = ContactData { + display_name: Some(format!("User {}", i)), + ..Default::default() + }; + alix.db() + .add_contact(&format!("inbox_{}", i), contact_data)?; + } + + // Export + let exported = export_contacts(&alix.db())?; + + // Import to new installation + tester!(alix2, from: alix); + let imported_count = import_contacts(&alix2.db(), &exported)?; + assert_eq!(imported_count, 5); + + // Verify all contacts exist + let contacts = alix2.db().get_contacts(None)?; + assert_eq!(contacts.len(), 5); + } +} diff --git a/xmtp_mls/src/groups/members.rs b/xmtp_mls/src/groups/members.rs index 8e3f0baf7a..c5cde6c374 100644 --- a/xmtp_mls/src/groups/members.rs +++ b/xmtp_mls/src/groups/members.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::{context::XmtpSharedContext, identity_updates::IdentityUpdates}; use super::{GroupError, MlsGroup, validated_commit::extract_group_membership}; @@ -85,6 +87,18 @@ where } } let mutable_metadata = self.mutable_metadata()?; + + // Batch fetch all consent records + let inbox_ids: Vec = association_states + .iter() + .map(|s| s.inbox_id().to_string()) + .collect(); + let consent_records = db.get_consent_records_batch(&inbox_ids, ConsentType::InboxId)?; + let consent_map: HashMap = consent_records + .into_iter() + .map(|r| (r.entity, r.state)) + .collect(); + let members = association_states .into_iter() .map(|association_state| { @@ -99,17 +113,20 @@ where PermissionLevel::Member }; - let consent = db.get_consent_record(inbox_id_str.clone(), ConsentType::InboxId)?; + let consent_state = consent_map + .get(&inbox_id_str) + .copied() + .unwrap_or(ConsentState::Unknown); - Ok(GroupMember { - inbox_id: inbox_id_str.clone(), + GroupMember { + inbox_id: inbox_id_str, account_identifiers: association_state.identifiers(), installation_ids: association_state.installation_ids(), permission_level, - consent_state: consent.map_or(ConsentState::Unknown, |c| c.state), - }) + consent_state, + } }) - .collect::, GroupError>>()?; + .collect(); Ok(members) } diff --git a/xmtp_mls/src/lib.rs b/xmtp_mls/src/lib.rs index f4c9ffe5d1..11dfaf80a5 100644 --- a/xmtp_mls/src/lib.rs +++ b/xmtp_mls/src/lib.rs @@ -3,6 +3,7 @@ pub mod builder; pub mod client; +pub mod contacts; pub mod context; pub mod cursor_store; mod definitions; diff --git a/xmtp_mls/src/mls_store.rs b/xmtp_mls/src/mls_store.rs index e8827cf807..29f776e312 100644 --- a/xmtp_mls/src/mls_store.rs +++ b/xmtp_mls/src/mls_store.rs @@ -13,7 +13,7 @@ use xmtp_proto::types::{GroupMessage, WelcomeMessage}; use crate::{ context::XmtpSharedContext, - groups::MlsGroup, + groups::{MlsGroup, validated_commit::extract_group_membership}, verified_key_package_v2::{KeyPackageVerificationError, VerifiedKeyPackageV2}, }; use thiserror::Error; @@ -147,6 +147,40 @@ where .collect()) } + /// Find groups that contain all specified inbox IDs as members + pub fn find_groups_with_inbox_ids( + &self, + inbox_ids: Vec, + opts: Option, + ) -> Result>, MlsStoreError> { + if inbox_ids.is_empty() { + return self.find_groups(opts.unwrap_or_default()); + } + + let groups = self.find_groups(opts.unwrap_or_default())?; + let storage = self.context.mls_storage(); + let mut filtered_groups = Vec::new(); + + for group in groups { + let group_membership = group + .load_mls_group_with_lock(storage, |mls_group| { + Ok(extract_group_membership(mls_group.extensions())?) + }) + .ok(); + + if let Some(membership) = group_membership { + let all_present = inbox_ids + .iter() + .all(|inbox_id| membership.members.contains_key(inbox_id)); + if all_present { + filtered_groups.push(group); + } + } + } + + Ok(filtered_groups) + } + /// Look up a group by its ID /// /// Returns a [`MlsGroup`] if the group exists, or an error if it does not diff --git a/xmtp_proto/src/gen/mod.rs b/xmtp_proto/src/gen/mod.rs index 3a64deff4d..6a67151c1d 100644 --- a/xmtp_proto/src/gen/mod.rs +++ b/xmtp_proto/src/gen/mod.rs @@ -6,6 +6,10 @@ pub mod xmtp { include!("xmtp.device_sync.consent_backup.rs"); include!("xmtp.device_sync.consent_backup.serde.rs"); } + pub mod contact_backup { + include!("xmtp.device_sync.contact_backup.rs"); + include!("xmtp.device_sync.contact_backup.serde.rs"); + } pub mod content { include!("xmtp.device_sync.content.rs"); include!("xmtp.device_sync.content.serde.rs"); diff --git a/xmtp_proto/src/gen/xmtp.device_sync.contact_backup.rs b/xmtp_proto/src/gen/xmtp.device_sync.contact_backup.rs new file mode 100644 index 0000000000..3fd3fb40e4 --- /dev/null +++ b/xmtp_proto/src/gen/xmtp.device_sync.contact_backup.rs @@ -0,0 +1,155 @@ +// This file is @generated by prost-build. +/// Proto representation of a contact save +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ContactSave { + #[prost(string, tag = "1")] + pub inbox_id: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub display_name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "3")] + pub first_name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "4")] + pub last_name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "5")] + pub prefix: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "6")] + pub suffix: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "7")] + pub company: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "8")] + pub job_title: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "9")] + pub birthday: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "10")] + pub note: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "11")] + pub image_url: ::core::option::Option<::prost::alloc::string::String>, + #[prost(bool, tag = "12")] + pub is_favorite: bool, + #[prost(int64, tag = "13")] + pub created_at_ns: i64, + #[prost(int64, tag = "14")] + pub updated_at_ns: i64, + #[prost(message, repeated, tag = "15")] + pub phone_numbers: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "16")] + pub emails: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "17")] + pub urls: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "18")] + pub wallet_addresses: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "19")] + pub addresses: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for ContactSave { + const NAME: &'static str = "ContactSave"; + const PACKAGE: &'static str = "xmtp.device_sync.contact_backup"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.device_sync.contact_backup.ContactSave".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.device_sync.contact_backup.ContactSave".into() + } +} +/// Phone number entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PhoneNumberSave { + #[prost(string, tag = "1")] + pub phone_number: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub label: ::core::option::Option<::prost::alloc::string::String>, +} +impl ::prost::Name for PhoneNumberSave { + const NAME: &'static str = "PhoneNumberSave"; + const PACKAGE: &'static str = "xmtp.device_sync.contact_backup"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.device_sync.contact_backup.PhoneNumberSave".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.device_sync.contact_backup.PhoneNumberSave".into() + } +} +/// Email entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EmailSave { + #[prost(string, tag = "1")] + pub email: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub label: ::core::option::Option<::prost::alloc::string::String>, +} +impl ::prost::Name for EmailSave { + const NAME: &'static str = "EmailSave"; + const PACKAGE: &'static str = "xmtp.device_sync.contact_backup"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.device_sync.contact_backup.EmailSave".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.device_sync.contact_backup.EmailSave".into() + } +} +/// URL entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UrlSave { + #[prost(string, tag = "1")] + pub url: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub label: ::core::option::Option<::prost::alloc::string::String>, +} +impl ::prost::Name for UrlSave { + const NAME: &'static str = "UrlSave"; + const PACKAGE: &'static str = "xmtp.device_sync.contact_backup"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.device_sync.contact_backup.UrlSave".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.device_sync.contact_backup.UrlSave".into() + } +} +/// Wallet address entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct WalletAddressSave { + #[prost(string, tag = "1")] + pub wallet_address: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub label: ::core::option::Option<::prost::alloc::string::String>, +} +impl ::prost::Name for WalletAddressSave { + const NAME: &'static str = "WalletAddressSave"; + const PACKAGE: &'static str = "xmtp.device_sync.contact_backup"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.device_sync.contact_backup.WalletAddressSave".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.device_sync.contact_backup.WalletAddressSave".into() + } +} +/// Street address entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AddressSave { + #[prost(string, optional, tag = "1")] + pub address1: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "2")] + pub address2: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "3")] + pub address3: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "4")] + pub city: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "5")] + pub region: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "6")] + pub postal_code: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "7")] + pub country: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "8")] + pub label: ::core::option::Option<::prost::alloc::string::String>, +} +impl ::prost::Name for AddressSave { + const NAME: &'static str = "AddressSave"; + const PACKAGE: &'static str = "xmtp.device_sync.contact_backup"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.device_sync.contact_backup.AddressSave".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.device_sync.contact_backup.AddressSave".into() + } +} diff --git a/xmtp_proto/src/gen/xmtp.device_sync.contact_backup.serde.rs b/xmtp_proto/src/gen/xmtp.device_sync.contact_backup.serde.rs new file mode 100644 index 0000000000..3f448ac11d --- /dev/null +++ b/xmtp_proto/src/gen/xmtp.device_sync.contact_backup.serde.rs @@ -0,0 +1,1085 @@ +impl serde::Serialize for AddressSave { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.address1.is_some() { + len += 1; + } + if self.address2.is_some() { + len += 1; + } + if self.address3.is_some() { + len += 1; + } + if self.city.is_some() { + len += 1; + } + if self.region.is_some() { + len += 1; + } + if self.postal_code.is_some() { + len += 1; + } + if self.country.is_some() { + len += 1; + } + if self.label.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.device_sync.contact_backup.AddressSave", len)?; + if let Some(v) = self.address1.as_ref() { + struct_ser.serialize_field("address1", v)?; + } + if let Some(v) = self.address2.as_ref() { + struct_ser.serialize_field("address2", v)?; + } + if let Some(v) = self.address3.as_ref() { + struct_ser.serialize_field("address3", v)?; + } + if let Some(v) = self.city.as_ref() { + struct_ser.serialize_field("city", v)?; + } + if let Some(v) = self.region.as_ref() { + struct_ser.serialize_field("region", v)?; + } + if let Some(v) = self.postal_code.as_ref() { + struct_ser.serialize_field("postal_code", v)?; + } + if let Some(v) = self.country.as_ref() { + struct_ser.serialize_field("country", v)?; + } + if let Some(v) = self.label.as_ref() { + struct_ser.serialize_field("label", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for AddressSave { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "address1", + "address2", + "address3", + "city", + "region", + "postal_code", + "postalCode", + "country", + "label", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Address1, + Address2, + Address3, + City, + Region, + PostalCode, + Country, + Label, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "address1" => Ok(GeneratedField::Address1), + "address2" => Ok(GeneratedField::Address2), + "address3" => Ok(GeneratedField::Address3), + "city" => Ok(GeneratedField::City), + "region" => Ok(GeneratedField::Region), + "postalCode" | "postal_code" => Ok(GeneratedField::PostalCode), + "country" => Ok(GeneratedField::Country), + "label" => Ok(GeneratedField::Label), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = AddressSave; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.device_sync.contact_backup.AddressSave") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut address1__ = None; + let mut address2__ = None; + let mut address3__ = None; + let mut city__ = None; + let mut region__ = None; + let mut postal_code__ = None; + let mut country__ = None; + let mut label__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Address1 => { + if address1__.is_some() { + return Err(serde::de::Error::duplicate_field("address1")); + } + address1__ = map_.next_value()?; + } + GeneratedField::Address2 => { + if address2__.is_some() { + return Err(serde::de::Error::duplicate_field("address2")); + } + address2__ = map_.next_value()?; + } + GeneratedField::Address3 => { + if address3__.is_some() { + return Err(serde::de::Error::duplicate_field("address3")); + } + address3__ = map_.next_value()?; + } + GeneratedField::City => { + if city__.is_some() { + return Err(serde::de::Error::duplicate_field("city")); + } + city__ = map_.next_value()?; + } + GeneratedField::Region => { + if region__.is_some() { + return Err(serde::de::Error::duplicate_field("region")); + } + region__ = map_.next_value()?; + } + GeneratedField::PostalCode => { + if postal_code__.is_some() { + return Err(serde::de::Error::duplicate_field("postalCode")); + } + postal_code__ = map_.next_value()?; + } + GeneratedField::Country => { + if country__.is_some() { + return Err(serde::de::Error::duplicate_field("country")); + } + country__ = map_.next_value()?; + } + GeneratedField::Label => { + if label__.is_some() { + return Err(serde::de::Error::duplicate_field("label")); + } + label__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(AddressSave { + address1: address1__, + address2: address2__, + address3: address3__, + city: city__, + region: region__, + postal_code: postal_code__, + country: country__, + label: label__, + }) + } + } + deserializer.deserialize_struct("xmtp.device_sync.contact_backup.AddressSave", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for ContactSave { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.inbox_id.is_empty() { + len += 1; + } + if self.display_name.is_some() { + len += 1; + } + if self.first_name.is_some() { + len += 1; + } + if self.last_name.is_some() { + len += 1; + } + if self.prefix.is_some() { + len += 1; + } + if self.suffix.is_some() { + len += 1; + } + if self.company.is_some() { + len += 1; + } + if self.job_title.is_some() { + len += 1; + } + if self.birthday.is_some() { + len += 1; + } + if self.note.is_some() { + len += 1; + } + if self.image_url.is_some() { + len += 1; + } + if self.is_favorite { + len += 1; + } + if self.created_at_ns != 0 { + len += 1; + } + if self.updated_at_ns != 0 { + len += 1; + } + if !self.phone_numbers.is_empty() { + len += 1; + } + if !self.emails.is_empty() { + len += 1; + } + if !self.urls.is_empty() { + len += 1; + } + if !self.wallet_addresses.is_empty() { + len += 1; + } + if !self.addresses.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.device_sync.contact_backup.ContactSave", len)?; + if !self.inbox_id.is_empty() { + struct_ser.serialize_field("inbox_id", &self.inbox_id)?; + } + if let Some(v) = self.display_name.as_ref() { + struct_ser.serialize_field("display_name", v)?; + } + if let Some(v) = self.first_name.as_ref() { + struct_ser.serialize_field("first_name", v)?; + } + if let Some(v) = self.last_name.as_ref() { + struct_ser.serialize_field("last_name", v)?; + } + if let Some(v) = self.prefix.as_ref() { + struct_ser.serialize_field("prefix", v)?; + } + if let Some(v) = self.suffix.as_ref() { + struct_ser.serialize_field("suffix", v)?; + } + if let Some(v) = self.company.as_ref() { + struct_ser.serialize_field("company", v)?; + } + if let Some(v) = self.job_title.as_ref() { + struct_ser.serialize_field("job_title", v)?; + } + if let Some(v) = self.birthday.as_ref() { + struct_ser.serialize_field("birthday", v)?; + } + if let Some(v) = self.note.as_ref() { + struct_ser.serialize_field("note", v)?; + } + if let Some(v) = self.image_url.as_ref() { + struct_ser.serialize_field("image_url", v)?; + } + if self.is_favorite { + struct_ser.serialize_field("is_favorite", &self.is_favorite)?; + } + if self.created_at_ns != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("created_at_ns", ToString::to_string(&self.created_at_ns).as_str())?; + } + if self.updated_at_ns != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("updated_at_ns", ToString::to_string(&self.updated_at_ns).as_str())?; + } + if !self.phone_numbers.is_empty() { + struct_ser.serialize_field("phone_numbers", &self.phone_numbers)?; + } + if !self.emails.is_empty() { + struct_ser.serialize_field("emails", &self.emails)?; + } + if !self.urls.is_empty() { + struct_ser.serialize_field("urls", &self.urls)?; + } + if !self.wallet_addresses.is_empty() { + struct_ser.serialize_field("wallet_addresses", &self.wallet_addresses)?; + } + if !self.addresses.is_empty() { + struct_ser.serialize_field("addresses", &self.addresses)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for ContactSave { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "inbox_id", + "inboxId", + "display_name", + "displayName", + "first_name", + "firstName", + "last_name", + "lastName", + "prefix", + "suffix", + "company", + "job_title", + "jobTitle", + "birthday", + "note", + "image_url", + "imageUrl", + "is_favorite", + "isFavorite", + "created_at_ns", + "createdAtNs", + "updated_at_ns", + "updatedAtNs", + "phone_numbers", + "phoneNumbers", + "emails", + "urls", + "wallet_addresses", + "walletAddresses", + "addresses", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + InboxId, + DisplayName, + FirstName, + LastName, + Prefix, + Suffix, + Company, + JobTitle, + Birthday, + Note, + ImageUrl, + IsFavorite, + CreatedAtNs, + UpdatedAtNs, + PhoneNumbers, + Emails, + Urls, + WalletAddresses, + Addresses, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "inboxId" | "inbox_id" => Ok(GeneratedField::InboxId), + "displayName" | "display_name" => Ok(GeneratedField::DisplayName), + "firstName" | "first_name" => Ok(GeneratedField::FirstName), + "lastName" | "last_name" => Ok(GeneratedField::LastName), + "prefix" => Ok(GeneratedField::Prefix), + "suffix" => Ok(GeneratedField::Suffix), + "company" => Ok(GeneratedField::Company), + "jobTitle" | "job_title" => Ok(GeneratedField::JobTitle), + "birthday" => Ok(GeneratedField::Birthday), + "note" => Ok(GeneratedField::Note), + "imageUrl" | "image_url" => Ok(GeneratedField::ImageUrl), + "isFavorite" | "is_favorite" => Ok(GeneratedField::IsFavorite), + "createdAtNs" | "created_at_ns" => Ok(GeneratedField::CreatedAtNs), + "updatedAtNs" | "updated_at_ns" => Ok(GeneratedField::UpdatedAtNs), + "phoneNumbers" | "phone_numbers" => Ok(GeneratedField::PhoneNumbers), + "emails" => Ok(GeneratedField::Emails), + "urls" => Ok(GeneratedField::Urls), + "walletAddresses" | "wallet_addresses" => Ok(GeneratedField::WalletAddresses), + "addresses" => Ok(GeneratedField::Addresses), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = ContactSave; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.device_sync.contact_backup.ContactSave") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut inbox_id__ = None; + let mut display_name__ = None; + let mut first_name__ = None; + let mut last_name__ = None; + let mut prefix__ = None; + let mut suffix__ = None; + let mut company__ = None; + let mut job_title__ = None; + let mut birthday__ = None; + let mut note__ = None; + let mut image_url__ = None; + let mut is_favorite__ = None; + let mut created_at_ns__ = None; + let mut updated_at_ns__ = None; + let mut phone_numbers__ = None; + let mut emails__ = None; + let mut urls__ = None; + let mut wallet_addresses__ = None; + let mut addresses__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::InboxId => { + if inbox_id__.is_some() { + return Err(serde::de::Error::duplicate_field("inboxId")); + } + inbox_id__ = Some(map_.next_value()?); + } + GeneratedField::DisplayName => { + if display_name__.is_some() { + return Err(serde::de::Error::duplicate_field("displayName")); + } + display_name__ = map_.next_value()?; + } + GeneratedField::FirstName => { + if first_name__.is_some() { + return Err(serde::de::Error::duplicate_field("firstName")); + } + first_name__ = map_.next_value()?; + } + GeneratedField::LastName => { + if last_name__.is_some() { + return Err(serde::de::Error::duplicate_field("lastName")); + } + last_name__ = map_.next_value()?; + } + GeneratedField::Prefix => { + if prefix__.is_some() { + return Err(serde::de::Error::duplicate_field("prefix")); + } + prefix__ = map_.next_value()?; + } + GeneratedField::Suffix => { + if suffix__.is_some() { + return Err(serde::de::Error::duplicate_field("suffix")); + } + suffix__ = map_.next_value()?; + } + GeneratedField::Company => { + if company__.is_some() { + return Err(serde::de::Error::duplicate_field("company")); + } + company__ = map_.next_value()?; + } + GeneratedField::JobTitle => { + if job_title__.is_some() { + return Err(serde::de::Error::duplicate_field("jobTitle")); + } + job_title__ = map_.next_value()?; + } + GeneratedField::Birthday => { + if birthday__.is_some() { + return Err(serde::de::Error::duplicate_field("birthday")); + } + birthday__ = map_.next_value()?; + } + GeneratedField::Note => { + if note__.is_some() { + return Err(serde::de::Error::duplicate_field("note")); + } + note__ = map_.next_value()?; + } + GeneratedField::ImageUrl => { + if image_url__.is_some() { + return Err(serde::de::Error::duplicate_field("imageUrl")); + } + image_url__ = map_.next_value()?; + } + GeneratedField::IsFavorite => { + if is_favorite__.is_some() { + return Err(serde::de::Error::duplicate_field("isFavorite")); + } + is_favorite__ = Some(map_.next_value()?); + } + GeneratedField::CreatedAtNs => { + if created_at_ns__.is_some() { + return Err(serde::de::Error::duplicate_field("createdAtNs")); + } + created_at_ns__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::UpdatedAtNs => { + if updated_at_ns__.is_some() { + return Err(serde::de::Error::duplicate_field("updatedAtNs")); + } + updated_at_ns__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::PhoneNumbers => { + if phone_numbers__.is_some() { + return Err(serde::de::Error::duplicate_field("phoneNumbers")); + } + phone_numbers__ = Some(map_.next_value()?); + } + GeneratedField::Emails => { + if emails__.is_some() { + return Err(serde::de::Error::duplicate_field("emails")); + } + emails__ = Some(map_.next_value()?); + } + GeneratedField::Urls => { + if urls__.is_some() { + return Err(serde::de::Error::duplicate_field("urls")); + } + urls__ = Some(map_.next_value()?); + } + GeneratedField::WalletAddresses => { + if wallet_addresses__.is_some() { + return Err(serde::de::Error::duplicate_field("walletAddresses")); + } + wallet_addresses__ = Some(map_.next_value()?); + } + GeneratedField::Addresses => { + if addresses__.is_some() { + return Err(serde::de::Error::duplicate_field("addresses")); + } + addresses__ = Some(map_.next_value()?); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(ContactSave { + inbox_id: inbox_id__.unwrap_or_default(), + display_name: display_name__, + first_name: first_name__, + last_name: last_name__, + prefix: prefix__, + suffix: suffix__, + company: company__, + job_title: job_title__, + birthday: birthday__, + note: note__, + image_url: image_url__, + is_favorite: is_favorite__.unwrap_or_default(), + created_at_ns: created_at_ns__.unwrap_or_default(), + updated_at_ns: updated_at_ns__.unwrap_or_default(), + phone_numbers: phone_numbers__.unwrap_or_default(), + emails: emails__.unwrap_or_default(), + urls: urls__.unwrap_or_default(), + wallet_addresses: wallet_addresses__.unwrap_or_default(), + addresses: addresses__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("xmtp.device_sync.contact_backup.ContactSave", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for EmailSave { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.email.is_empty() { + len += 1; + } + if self.label.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.device_sync.contact_backup.EmailSave", len)?; + if !self.email.is_empty() { + struct_ser.serialize_field("email", &self.email)?; + } + if let Some(v) = self.label.as_ref() { + struct_ser.serialize_field("label", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for EmailSave { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "email", + "label", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Email, + Label, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "email" => Ok(GeneratedField::Email), + "label" => Ok(GeneratedField::Label), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = EmailSave; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.device_sync.contact_backup.EmailSave") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut email__ = None; + let mut label__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Email => { + if email__.is_some() { + return Err(serde::de::Error::duplicate_field("email")); + } + email__ = Some(map_.next_value()?); + } + GeneratedField::Label => { + if label__.is_some() { + return Err(serde::de::Error::duplicate_field("label")); + } + label__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(EmailSave { + email: email__.unwrap_or_default(), + label: label__, + }) + } + } + deserializer.deserialize_struct("xmtp.device_sync.contact_backup.EmailSave", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for PhoneNumberSave { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.phone_number.is_empty() { + len += 1; + } + if self.label.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.device_sync.contact_backup.PhoneNumberSave", len)?; + if !self.phone_number.is_empty() { + struct_ser.serialize_field("phone_number", &self.phone_number)?; + } + if let Some(v) = self.label.as_ref() { + struct_ser.serialize_field("label", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for PhoneNumberSave { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "phone_number", + "phoneNumber", + "label", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + PhoneNumber, + Label, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "phoneNumber" | "phone_number" => Ok(GeneratedField::PhoneNumber), + "label" => Ok(GeneratedField::Label), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = PhoneNumberSave; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.device_sync.contact_backup.PhoneNumberSave") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut phone_number__ = None; + let mut label__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::PhoneNumber => { + if phone_number__.is_some() { + return Err(serde::de::Error::duplicate_field("phoneNumber")); + } + phone_number__ = Some(map_.next_value()?); + } + GeneratedField::Label => { + if label__.is_some() { + return Err(serde::de::Error::duplicate_field("label")); + } + label__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(PhoneNumberSave { + phone_number: phone_number__.unwrap_or_default(), + label: label__, + }) + } + } + deserializer.deserialize_struct("xmtp.device_sync.contact_backup.PhoneNumberSave", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for UrlSave { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.url.is_empty() { + len += 1; + } + if self.label.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.device_sync.contact_backup.UrlSave", len)?; + if !self.url.is_empty() { + struct_ser.serialize_field("url", &self.url)?; + } + if let Some(v) = self.label.as_ref() { + struct_ser.serialize_field("label", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for UrlSave { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "url", + "label", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Url, + Label, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "url" => Ok(GeneratedField::Url), + "label" => Ok(GeneratedField::Label), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = UrlSave; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.device_sync.contact_backup.UrlSave") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut url__ = None; + let mut label__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Url => { + if url__.is_some() { + return Err(serde::de::Error::duplicate_field("url")); + } + url__ = Some(map_.next_value()?); + } + GeneratedField::Label => { + if label__.is_some() { + return Err(serde::de::Error::duplicate_field("label")); + } + label__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(UrlSave { + url: url__.unwrap_or_default(), + label: label__, + }) + } + } + deserializer.deserialize_struct("xmtp.device_sync.contact_backup.UrlSave", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for WalletAddressSave { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.wallet_address.is_empty() { + len += 1; + } + if self.label.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.device_sync.contact_backup.WalletAddressSave", len)?; + if !self.wallet_address.is_empty() { + struct_ser.serialize_field("wallet_address", &self.wallet_address)?; + } + if let Some(v) = self.label.as_ref() { + struct_ser.serialize_field("label", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for WalletAddressSave { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "wallet_address", + "walletAddress", + "label", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + WalletAddress, + Label, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "walletAddress" | "wallet_address" => Ok(GeneratedField::WalletAddress), + "label" => Ok(GeneratedField::Label), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = WalletAddressSave; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.device_sync.contact_backup.WalletAddressSave") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut wallet_address__ = None; + let mut label__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::WalletAddress => { + if wallet_address__.is_some() { + return Err(serde::de::Error::duplicate_field("walletAddress")); + } + wallet_address__ = Some(map_.next_value()?); + } + GeneratedField::Label => { + if label__.is_some() { + return Err(serde::de::Error::duplicate_field("label")); + } + label__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(WalletAddressSave { + wallet_address: wallet_address__.unwrap_or_default(), + label: label__, + }) + } + } + deserializer.deserialize_struct("xmtp.device_sync.contact_backup.WalletAddressSave", FIELDS, GeneratedVisitor) + } +} diff --git a/xmtp_proto/src/gen/xmtp.device_sync.rs b/xmtp_proto/src/gen/xmtp.device_sync.rs index e025bd1132..4afdc872f8 100644 --- a/xmtp_proto/src/gen/xmtp.device_sync.rs +++ b/xmtp_proto/src/gen/xmtp.device_sync.rs @@ -2,7 +2,7 @@ /// Union type representing everything that can be serialied and saved in a backup archive. #[derive(Clone, PartialEq, ::prost::Message)] pub struct BackupElement { - #[prost(oneof = "backup_element::Element", tags = "1, 2, 3, 4, 5")] + #[prost(oneof = "backup_element::Element", tags = "1, 2, 3, 4, 5, 6")] pub element: ::core::option::Option, } /// Nested message and enum types in `BackupElement`. @@ -19,6 +19,8 @@ pub mod backup_element { Consent(super::consent_backup::ConsentSave), #[prost(message, tag = "5")] Event(super::event_backup::EventSave), + #[prost(message, tag = "6")] + Contact(super::contact_backup::ContactSave), } } impl ::prost::Name for BackupElement { @@ -84,6 +86,7 @@ pub enum BackupElementSelection { Messages = 1, Consent = 2, Event = 3, + Contacts = 4, } impl BackupElementSelection { /// String value of the enum field names used in the ProtoBuf definition. @@ -96,6 +99,7 @@ impl BackupElementSelection { Self::Messages => "BACKUP_ELEMENT_SELECTION_MESSAGES", Self::Consent => "BACKUP_ELEMENT_SELECTION_CONSENT", Self::Event => "BACKUP_ELEMENT_SELECTION_EVENT", + Self::Contacts => "BACKUP_ELEMENT_SELECTION_CONTACTS", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -105,6 +109,7 @@ impl BackupElementSelection { "BACKUP_ELEMENT_SELECTION_MESSAGES" => Some(Self::Messages), "BACKUP_ELEMENT_SELECTION_CONSENT" => Some(Self::Consent), "BACKUP_ELEMENT_SELECTION_EVENT" => Some(Self::Event), + "BACKUP_ELEMENT_SELECTION_CONTACTS" => Some(Self::Contacts), _ => None, } } diff --git a/xmtp_proto/src/gen/xmtp.device_sync.serde.rs b/xmtp_proto/src/gen/xmtp.device_sync.serde.rs index dde3d21d87..de9cdceb59 100644 --- a/xmtp_proto/src/gen/xmtp.device_sync.serde.rs +++ b/xmtp_proto/src/gen/xmtp.device_sync.serde.rs @@ -27,6 +27,9 @@ impl serde::Serialize for BackupElement { backup_element::Element::Event(v) => { struct_ser.serialize_field("event", v)?; } + backup_element::Element::Contact(v) => { + struct_ser.serialize_field("contact", v)?; + } } } struct_ser.end() @@ -45,6 +48,7 @@ impl<'de> serde::Deserialize<'de> for BackupElement { "groupMessage", "consent", "event", + "contact", ]; #[allow(clippy::enum_variant_names)] @@ -54,6 +58,7 @@ impl<'de> serde::Deserialize<'de> for BackupElement { GroupMessage, Consent, Event, + Contact, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -81,6 +86,7 @@ impl<'de> serde::Deserialize<'de> for BackupElement { "groupMessage" | "group_message" => Ok(GeneratedField::GroupMessage), "consent" => Ok(GeneratedField::Consent), "event" => Ok(GeneratedField::Event), + "contact" => Ok(GeneratedField::Contact), _ => Ok(GeneratedField::__SkipField__), } } @@ -136,6 +142,13 @@ impl<'de> serde::Deserialize<'de> for BackupElement { return Err(serde::de::Error::duplicate_field("event")); } element__ = map_.next_value::<::std::option::Option<_>>()?.map(backup_element::Element::Event) +; + } + GeneratedField::Contact => { + if element__.is_some() { + return Err(serde::de::Error::duplicate_field("contact")); + } + element__ = map_.next_value::<::std::option::Option<_>>()?.map(backup_element::Element::Contact) ; } GeneratedField::__SkipField__ => { @@ -162,6 +175,7 @@ impl serde::Serialize for BackupElementSelection { Self::Messages => "BACKUP_ELEMENT_SELECTION_MESSAGES", Self::Consent => "BACKUP_ELEMENT_SELECTION_CONSENT", Self::Event => "BACKUP_ELEMENT_SELECTION_EVENT", + Self::Contacts => "BACKUP_ELEMENT_SELECTION_CONTACTS", }; serializer.serialize_str(variant) } @@ -177,6 +191,7 @@ impl<'de> serde::Deserialize<'de> for BackupElementSelection { "BACKUP_ELEMENT_SELECTION_MESSAGES", "BACKUP_ELEMENT_SELECTION_CONSENT", "BACKUP_ELEMENT_SELECTION_EVENT", + "BACKUP_ELEMENT_SELECTION_CONTACTS", ]; struct GeneratedVisitor; @@ -221,6 +236,7 @@ impl<'de> serde::Deserialize<'de> for BackupElementSelection { "BACKUP_ELEMENT_SELECTION_MESSAGES" => Ok(BackupElementSelection::Messages), "BACKUP_ELEMENT_SELECTION_CONSENT" => Ok(BackupElementSelection::Consent), "BACKUP_ELEMENT_SELECTION_EVENT" => Ok(BackupElementSelection::Event), + "BACKUP_ELEMENT_SELECTION_CONTACTS" => Ok(BackupElementSelection::Contacts), _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), } }