using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using MimeKit; using Serilog; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Contacts; using Wino.Services.Extensions; namespace Wino.Services; public class ContactService : BaseDatabaseService, IContactService { public ContactService(IDatabaseService databaseService) : base(databaseService) { } public async Task CreateNewContactAsync(string address, string displayName) { var contact = new AccountContact() { Address = address, Name = displayName }; await Connection.InsertAsync(contact, typeof(AccountContact)).ConfigureAwait(false); return contact; } public Task> GetAddressInformationAsync(string queryText) { if (queryText == null || queryText.Length < 2) return Task.FromResult>(null); const string query = "SELECT * FROM AccountContact WHERE Address LIKE ? OR Name LIKE ?"; var pattern = $"%{queryText}%"; return Connection.QueryAsync(query, pattern, pattern); } public Task GetAddressInformationByAddressAsync(string address) => Connection.Table().FirstOrDefaultAsync(a => a.Address == address); public async Task SaveAddressInformationAsync(MimeMessage message) { if (message == null) return; var contacts = message .GetRecipients(true) .Where(a => !string.IsNullOrWhiteSpace(a.Address)) .Select(a => new AccountContact { Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address : a.Name, Address = a.Address }); await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false); } public async Task SaveAddressInformationAsync(IEnumerable contacts) { if (contacts == null) return; await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false); } private async Task SaveAddressInformationInternalAsync(IEnumerable contacts) { var addressInformations = contacts .Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address)) .Select(a => new AccountContact { Address = a.Address.Trim(), Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address.Trim() : a.Name.Trim() }) .GroupBy(a => a.Address, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .ToList(); if (addressInformations.Count == 0) return; try { // Batch-fetch all existing contacts in one query. var addresses = addressInformations.Select(a => a.Address).ToList(); var placeholders = string.Join(",", addresses.Select((_, i) => "?")); var existingContacts = await Connection.QueryAsync( $"SELECT * FROM AccountContact WHERE Address IN ({placeholders})", addresses.Cast().ToArray() ).ConfigureAwait(false); var existingLookup = existingContacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase); var toInsert = new List(); var toUpdate = new List(); foreach (var info in addressInformations) { if (!existingLookup.TryGetValue(info.Address, out var existing)) { toInsert.Add(info); } else if (!existing.IsRootContact && !existing.IsOverridden) { // Only update if the new name is more informative (not just the email address) // and actually different from the current name. if (info.Name != info.Address && existing.Name != info.Name) { existing.Name = info.Name; toUpdate.Add(existing); } } } if (toInsert.Count > 0 || toUpdate.Count > 0) { await Connection.RunInTransactionAsync(conn => { if (toInsert.Count > 0) conn.InsertAll(toInsert, typeof(AccountContact)); foreach (var contact in toUpdate) conn.Update(contact, typeof(AccountContact)); }).ConfigureAwait(false); } } catch (Exception ex) { Log.Error(ex, "Failed to batch save contact information to the database."); } } public Task> GetAllContactsAsync() { return Connection.Table().ToListAsync(); } public Task> SearchContactsAsync(string searchQuery) { if (string.IsNullOrWhiteSpace(searchQuery)) return GetAllContactsAsync(); const string query = "SELECT * FROM AccountContact WHERE Address LIKE ? OR Name LIKE ?"; var pattern = $"%{searchQuery.Trim()}%"; return Connection.QueryAsync(query, pattern, pattern); } public async Task GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false) { offset = Math.Max(0, offset); pageSize = Math.Max(1, pageSize); var whereClauses = new List(); var parameters = new List(); if (excludeRootContacts) { whereClauses.Add("IsRootContact = 0"); } if (!string.IsNullOrWhiteSpace(searchQuery)) { var pattern = $"%{searchQuery.Trim()}%"; whereClauses.Add("(Address LIKE ? OR Name LIKE ?)"); parameters.Add(pattern); parameters.Add(pattern); } var whereSql = whereClauses.Count > 0 ? $" WHERE {string.Join(" AND ", whereClauses)}" : string.Empty; var countQuery = $"SELECT COUNT(*) FROM AccountContact{whereSql}"; var totalCount = await Connection.ExecuteScalarAsync(countQuery, parameters.ToArray()).ConfigureAwait(false); var pageParameters = new List(parameters) { pageSize, offset }; var pageQuery = $"SELECT * FROM AccountContact{whereSql} ORDER BY COALESCE(Name, Address) COLLATE NOCASE, Address COLLATE NOCASE LIMIT ? OFFSET ?"; var contacts = await Connection.QueryAsync(pageQuery, pageParameters.ToArray()).ConfigureAwait(false); var hasMore = offset + contacts.Count < totalCount; return new PagedContactsResult(contacts, totalCount, hasMore, offset, pageSize); } public async Task UpdateContactAsync(AccountContact contact) { // Mark the contact as overridden when manually updated contact.IsOverridden = true; await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false); return contact; } public async Task DeleteContactAsync(string address) { var contact = await GetAddressInformationByAddressAsync(address).ConfigureAwait(false); if (contact != null && !contact.IsRootContact) { await Connection.DeleteAsync(contact.Address).ConfigureAwait(false); } } public async Task DeleteContactsAsync(IEnumerable addresses) { var addressList = addresses.Where(a => !string.IsNullOrEmpty(a)).ToList(); if (addressList.Count == 0) return; var placeholders = string.Join(",", addressList.Select((_, i) => "?")); await Connection.ExecuteAsync( $"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0", addressList.Cast().ToArray() ).ConfigureAwait(false); } }