diff --git a/src/OutlookAddin.Core/Models/EspoListResponse.cs b/src/OutlookAddin.Core/Models/EspoListResponse.cs new file mode 100644 index 0000000..c4cf6e1 --- /dev/null +++ b/src/OutlookAddin.Core/Models/EspoListResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class EspoListResponse + { + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("list")] + public List List { get; set; } = new List(); + } +} diff --git a/src/OutlookAddin.Core/Models/MatchResult.cs b/src/OutlookAddin.Core/Models/MatchResult.cs new file mode 100644 index 0000000..c5c04ff --- /dev/null +++ b/src/OutlookAddin.Core/Models/MatchResult.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class MatchResult + { + public string SenderEmail { get; set; } = string.Empty; + + public ContactEntity? Contact { get; set; } + + public AccountEntity? Account { get; set; } + + public List RecentCases { get; set; } = new List(); + + public bool IsAmbiguous { get; set; } + + public int CandidateCount { get; set; } + + public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow; + } +} diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 8b28f8f..57b7b6e 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -155,6 +155,20 @@ namespace MarcusLaw.OutlookAddin.Core.Services public Task TestConnectionAsync(CancellationToken cancellationToken = default) => GetJsonAsync("App/user", cancellationToken); + public Task> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId)); + if (maxSize <= 0) maxSize = 5; + var path = + "Case?select=" + Uri.EscapeDataString("id,name,number,status,createdAt,accountId") + + "&where[0][type]=linkedWith" + + "&where[0][attribute]=contacts" + + "&where[0][value]=" + Uri.EscapeDataString(contactId) + + "&orderBy=createdAt&order=desc" + + "&maxSize=" + maxSize; + return GetJsonAsync>(path, cancellationToken); + } + public async Task CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name)); diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs index 37d3207..7726955 100644 --- a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs @@ -13,6 +13,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services Task GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default); Task GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default); Task CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default); + Task> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default); Task TestConnectionAsync(CancellationToken cancellationToken = default); } } diff --git a/src/OutlookAddin.Core/Services/IMatchingService.cs b/src/OutlookAddin.Core/Services/IMatchingService.cs new file mode 100644 index 0000000..60d7182 --- /dev/null +++ b/src/OutlookAddin.Core/Services/IMatchingService.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public interface IMatchingService + { + /// + /// Looks up the contact/account/recent-cases bundle for a sender + /// email address. Returns null when there is no unique Contact match + /// (0 or 2+ candidates). Results are cached for 10 minutes. + /// + Task LookupAsync(string senderEmail, CancellationToken cancellationToken = default); + + void Invalidate(string senderEmail); + } +} diff --git a/src/OutlookAddin.Core/Services/MatchingService.cs b/src/OutlookAddin.Core/Services/MatchingService.cs new file mode 100644 index 0000000..e0cfe1a --- /dev/null +++ b/src/OutlookAddin.Core/Services/MatchingService.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; +using Microsoft.Extensions.Caching.Memory; +using Serilog; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public sealed class MatchingService : IMatchingService + { + private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(10); + private const string ContactSelect = "id,name,emailAddress,accountId,accountName"; + + private readonly IEspoCrmClient _client; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public MatchingService(IEspoCrmClient client, IMemoryCache cache, ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task LookupAsync(string senderEmail, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(senderEmail)) return null; + if (!senderEmail.Contains("@")) return null; + + var key = CacheKey(senderEmail); + if (_cache.TryGetValue(key, out var cached)) + { + return cached; + } + + try + { + var search = await _client.EmailAddressSearchAsync(senderEmail, "Contact", cancellationToken).ConfigureAwait(false); + + if (search.List.Count == 0) + { + _cache.Set(key, (MatchResult?)null, CacheExpiry); + return null; + } + + if (search.List.Count > 1) + { + var ambiguous = new MatchResult + { + SenderEmail = senderEmail, + IsAmbiguous = true, + CandidateCount = search.List.Count + }; + _cache.Set(key, ambiguous, CacheExpiry); + return ambiguous; + } + + var contactRef = search.List[0]; + var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false); + + AccountEntity? account = null; + if (!string.IsNullOrWhiteSpace(contact.AccountId)) + { + try + { + account = await _client.GetAccountAsync(contact.AccountId!, "id,name,emailAddress", cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warning(ex, "MatchingService: failed to load account {AccountId} for {Email}", contact.AccountId, senderEmail); + } + } + + var cases = new System.Collections.Generic.List(); + try + { + var list = await _client.ListCasesForContactAsync(contact.Id, 5, cancellationToken).ConfigureAwait(false); + cases.AddRange(list.List); + } + catch (Exception ex) + { + _logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id); + } + + var result = new MatchResult + { + SenderEmail = senderEmail, + Contact = contact, + Account = account, + RecentCases = cases, + CandidateCount = 1 + }; + + _cache.Set(key, result, CacheExpiry); + return result; + } + catch (EspoCrmAuthorizationException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Warning(ex, "MatchingService lookup failed for {Email}", senderEmail); + return null; + } + } + + public void Invalidate(string senderEmail) + { + if (string.IsNullOrWhiteSpace(senderEmail)) return; + _cache.Remove(CacheKey(senderEmail)); + } + + private static string CacheKey(string senderEmail) + => "match:" + senderEmail.Trim().ToLowerInvariant(); + } +} diff --git a/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs b/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs new file mode 100644 index 0000000..a8143fe --- /dev/null +++ b/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarcusLaw.OutlookAddin.Core.Models; +using MarcusLaw.OutlookAddin.Core.Services; +using Serilog; + +namespace MarcusLaw.OutlookAddin.UI.ViewModels +{ + /// + /// Possible states of the sidebar. Each maps to a Visibility binding + /// in ReadingPaneView.xaml. + /// + public enum ReadingPaneState + { + Empty, + Loading, + Match, + NoMatch, + Ambiguous, + Error + } + + public sealed partial class ReadingPaneViewModel : ObservableObject + { + private readonly IMatchingService _matchingService; + private readonly ILogger _logger; + private CancellationTokenSource? _activeLookup; + + [ObservableProperty] + private ReadingPaneState state = ReadingPaneState.Empty; + + [ObservableProperty] + private string senderEmail = string.Empty; + + [ObservableProperty] + private MatchResult? match; + + [ObservableProperty] + private string? errorMessage; + + [ObservableProperty] + private string? ambiguousMessage; + + [ObservableProperty] + private string? espoCrmBaseUrl; + + public ObservableCollection RecentCases { get; } = new ObservableCollection(); + + public Visibility EmptyVisibility => Vis(State == ReadingPaneState.Empty); + public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading); + public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match); + public Visibility NoMatchVisibility => Vis(State == ReadingPaneState.NoMatch); + public Visibility AmbiguousVisibility => Vis(State == ReadingPaneState.Ambiguous); + public Visibility ErrorVisibility => Vis(State == ReadingPaneState.Error); + public Visibility AccountVisibility => Vis(Match?.Account != null); + public Visibility NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0); + public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match); + + /// + /// Raised when the user clicks "File to this case" (carries the chosen + /// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null). + /// Host wires this to AddInHost.LaunchFileToCaseAsync. + /// + public event EventHandler? FileRequested; + + /// + /// Raised when the user clicks "Open in EspoCRM". The host opens the URL. + /// + public event EventHandler? OpenInBrowserRequested; + + public ReadingPaneViewModel(IMatchingService matchingService, ILogger logger) + { + _matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + partial void OnStateChanged(ReadingPaneState value) + { + OnPropertyChanged(nameof(EmptyVisibility)); + OnPropertyChanged(nameof(LoadingVisibility)); + OnPropertyChanged(nameof(MatchVisibility)); + OnPropertyChanged(nameof(NoMatchVisibility)); + OnPropertyChanged(nameof(AmbiguousVisibility)); + OnPropertyChanged(nameof(ErrorVisibility)); + OnPropertyChanged(nameof(ActionsVisibility)); + OnPropertyChanged(nameof(NoCasesVisibility)); + FileToThisCommand.NotifyCanExecuteChanged(); + FileElsewhereCommand.NotifyCanExecuteChanged(); + OpenInEspoCrmCommand.NotifyCanExecuteChanged(); + } + + partial void OnMatchChanged(MatchResult? value) + { + RecentCases.Clear(); + if (value?.RecentCases != null) + { + foreach (var c in value.RecentCases) RecentCases.Add(c); + } + OnPropertyChanged(nameof(AccountVisibility)); + OnPropertyChanged(nameof(NoCasesVisibility)); + } + + public void Reset() + { + CancelInFlight(); + SenderEmail = string.Empty; + Match = null; + ErrorMessage = null; + AmbiguousMessage = null; + State = ReadingPaneState.Empty; + } + + public async Task LoadAsync(string senderEmail) + { + CancelInFlight(); + if (string.IsNullOrWhiteSpace(senderEmail)) + { + Reset(); + return; + } + + SenderEmail = senderEmail; + State = ReadingPaneState.Loading; + + var cts = new CancellationTokenSource(); + _activeLookup = cts; + + try + { + var result = await _matchingService.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) return; + + if (result == null) + { + Match = null; + State = ReadingPaneState.NoMatch; + return; + } + + if (result.IsAmbiguous) + { + Match = result; + AmbiguousMessage = $"נמצאו {result.CandidateCount} אנשי קשר עם הכתובת הזו — נדרשת בחירה ידנית."; + State = ReadingPaneState.Ambiguous; + return; + } + + Match = result; + State = ReadingPaneState.Match; + } + catch (OperationCanceledException) + { + // Selection changed before lookup finished — ignore. + } + catch (EspoCrmAuthorizationException) + { + ErrorMessage = "מפתח ה-API נדחה. עדכן בהגדרות."; + State = ReadingPaneState.Error; + } + catch (Exception ex) + { + _logger.Warning(ex, "ReadingPaneViewModel.LoadAsync failed for {Email}", senderEmail); + ErrorMessage = "שגיאת התחברות לשרת."; + State = ReadingPaneState.Error; + } + } + + private void CancelInFlight() + { + try { _activeLookup?.Cancel(); } catch { /* ignore */ } + _activeLookup = null; + } + + [RelayCommand(CanExecute = nameof(CanFileToThis))] + private void FileToThis() + { + FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!)); + } + + private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null; + + [RelayCommand(CanExecute = nameof(CanFileElsewhere))] + private void FileElsewhere() + { + FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null)); + } + + private bool CanFileElsewhere() => true; + + [RelayCommand(CanExecute = nameof(CanOpenInCrm))] + private void OpenInEspoCrm() + { + if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || Match?.Contact == null) return; + var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Contact/view/" + Match.Contact.Id; + OpenInBrowserRequested?.Invoke(this, url); + } + + private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null; + + [RelayCommand] + private void OpenManualSearch() + { + FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null)); + } + + private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed; + } + + public sealed class FileFromSidebarEventArgs : EventArgs + { + public MatchResult? PreselectedMatch { get; } + + public FileFromSidebarEventArgs(MatchResult? preselected) + { + PreselectedMatch = preselected; + } + } +} diff --git a/src/OutlookAddin.UI/Views/ReadingPaneView.xaml b/src/OutlookAddin.UI/Views/ReadingPaneView.xaml new file mode 100644 index 0000000..239b5e2 --- /dev/null +++ b/src/OutlookAddin.UI/Views/ReadingPaneView.xaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +