From 017f10e60b34c34c1e52643783863569c2918112 Mon Sep 17 00:00:00 2001 From: PointStar Date: Wed, 20 May 2026 12:57:51 +0300 Subject: [PATCH] feat(sidebar): inline candidate picker, per-case file action, back navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ambiguous lookups (2+ contacts on one email) now dedupe by Contact Id and fetch each candidate's full record so the picker shows name + account + email instead of just the bare search payload. - Per-row "תייק" button on each linked case lets the user pick the correct case to file to when a contact has multiple cases; bottom "תייק לתיק הזה" button is hidden in that scenario. - "→ חזור לרשימת אנשי קשר" link restores the candidate list after drilling into one of them. - Contact name + case rows in the sidebar are now hyperlinks that open the entity in Klear. - AddInHost.LaunchFileToCaseAsync accepts an optional MatchResult and auto-files when the sidebar resolved a single linked case, skipping the manual search dialog. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/OutlookAddin.Core/Models/MatchResult.cs | 7 + .../Services/IMatchingService.cs | 9 + .../Services/MatchingService.cs | 160 +++++++++++++----- .../ViewModels/ReadingPaneViewModel.cs | 134 ++++++++++++++- .../Views/ReadingPaneView.xaml | 79 +++++++-- src/OutlookAddin/Services/AddInHost.cs | 93 ++++++---- .../Services/SidebarController.cs | 2 +- 7 files changed, 398 insertions(+), 86 deletions(-) diff --git a/src/OutlookAddin.Core/Models/MatchResult.cs b/src/OutlookAddin.Core/Models/MatchResult.cs index c5c04ff..12074fc 100644 --- a/src/OutlookAddin.Core/Models/MatchResult.cs +++ b/src/OutlookAddin.Core/Models/MatchResult.cs @@ -17,6 +17,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models public int CandidateCount { get; set; } + /// + /// When is true, holds the candidate + /// contacts that share the sender's email so the sidebar can show + /// a picker instead of bouncing the user into manual search. + /// + public List Candidates { get; set; } = new List(); + public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow; } } diff --git a/src/OutlookAddin.Core/Services/IMatchingService.cs b/src/OutlookAddin.Core/Services/IMatchingService.cs index 60d7182..137fd9b 100644 --- a/src/OutlookAddin.Core/Services/IMatchingService.cs +++ b/src/OutlookAddin.Core/Services/IMatchingService.cs @@ -13,6 +13,15 @@ namespace MarcusLaw.OutlookAddin.Core.Services /// Task LookupAsync(string senderEmail, CancellationToken cancellationToken = default); + /// + /// Loads the full contact + account + recent-cases bundle for a + /// specific contact id. Used when the sender's email matches + /// multiple contacts and the user picks one from the candidate + /// list — we then drill into that contact's cases without + /// dropping back to manual search. + /// + Task LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default); + void Invalidate(string senderEmail); } } diff --git a/src/OutlookAddin.Core/Services/MatchingService.cs b/src/OutlookAddin.Core/Services/MatchingService.cs index 37aa8d7..2706989 100644 --- a/src/OutlookAddin.Core/Services/MatchingService.cs +++ b/src/OutlookAddin.Core/Services/MatchingService.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using MarcusLaw.OutlookAddin.Core.Models; @@ -44,60 +45,51 @@ namespace MarcusLaw.OutlookAddin.Core.Services return null; } - if (matches.Count > 1) + // EspoCRM's /EmailAddress/search can return the same Contact + // more than once when the contact has the searched-for + // address registered under multiple emailAddressData rows. + // Dedupe by Id so the sidebar shows one row per unique person. + var distinctIds = matches + .Where(m => !string.IsNullOrWhiteSpace(m.Id)) + .Select(m => m.Id) + .Distinct(StringComparer.Ordinal) + .Take(20) + .ToList(); + + if (distinctIds.Count == 0) { - var ambiguous = new MatchResult - { - SenderEmail = senderEmail, - IsAmbiguous = true, - CandidateCount = matches.Count - }; - _cache.Set(key, ambiguous, CacheExpiry); - return ambiguous; + _cache.Set(key, (MatchResult?)null, CacheExpiry); + return null; } - var contactRef = matches[0]; - var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false); - - AccountEntity? account = null; - if (!string.IsNullOrWhiteSpace(contact.AccountId)) + if (distinctIds.Count == 1) { - 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 single = await LoadContactBundleAsync(distinctIds[0], senderEmail, cancellationToken).ConfigureAwait(false); + _cache.Set(key, single, CacheExpiry); + return single; } - var cases = new System.Collections.Generic.List(); - try - { - // Pull up to 50 cases per contact so the sidebar can show the - // whole list, not just the 5 most recent. 50 is well below the - // EspoCRM default page limit and large enough for any realistic - // attorney/contact relationship. - var list = await _client.ListCasesForContactAsync(contact.Id, 50, cancellationToken).ConfigureAwait(false); - cases.AddRange(list.List); - } - catch (Exception ex) - { - _logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id); - } + // Two or more genuinely distinct contacts. Fetch each one's + // full record (in parallel) so the picker can show real + // name + account, not just the bare email payload. + var fetched = await Task.WhenAll( + distinctIds.Select(id => SafeGetContactAsync(id, cancellationToken)) + ).ConfigureAwait(false); - var result = new MatchResult + var candidates = fetched + .Where(c => c != null && !string.IsNullOrWhiteSpace(c!.Id)) + .Select(c => c!) + .ToList(); + + var ambiguous = new MatchResult { SenderEmail = senderEmail, - Contact = contact, - Account = account, - RecentCases = cases, - CandidateCount = 1 + IsAmbiguous = true, + CandidateCount = candidates.Count, + Candidates = candidates }; - - _cache.Set(key, result, CacheExpiry); - return result; + _cache.Set(key, ambiguous, CacheExpiry); + return ambiguous; } catch (EspoCrmAuthorizationException) { @@ -114,6 +106,86 @@ namespace MarcusLaw.OutlookAddin.Core.Services } } + public async Task LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(contactId)) return null; + + try + { + return await LoadContactBundleAsync(contactId, senderEmail ?? string.Empty, cancellationToken).ConfigureAwait(false); + } + catch (EspoCrmAuthorizationException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Warning(ex, "MatchingService: LookupContactAsync failed for {ContactId}", contactId); + return null; + } + } + + private async Task LoadContactBundleAsync(string contactId, string senderEmail, CancellationToken cancellationToken) + { + var contact = await _client.GetContactAsync(contactId, 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 contact {ContactId}", contact.AccountId, contactId); + } + } + + var cases = new System.Collections.Generic.List(); + try + { + // Pull up to 50 cases per contact so the sidebar can show the + // whole list, not just the 5 most recent. 50 is well below the + // EspoCRM default page limit and large enough for any realistic + // attorney/contact relationship. + var list = await _client.ListCasesForContactAsync(contact.Id, 50, cancellationToken).ConfigureAwait(false); + cases.AddRange(list.List); + } + catch (Exception ex) + { + _logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id); + } + + return new MatchResult + { + SenderEmail = senderEmail, + Contact = contact, + Account = account, + RecentCases = cases, + CandidateCount = 1 + }; + } + + private async Task SafeGetContactAsync(string contactId, CancellationToken cancellationToken) + { + try + { + return await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (EspoCrmAuthorizationException) { throw; } + catch (Exception ex) + { + _logger.Warning(ex, "MatchingService: candidate contact fetch failed for {Id}", contactId); + return null; + } + } + public void Invalidate(string senderEmail) { if (string.IsNullOrWhiteSpace(senderEmail)) return; diff --git a/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs b/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs index a8143fe..3bfed47 100644 --- a/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs +++ b/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs @@ -30,6 +30,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels private readonly IMatchingService _matchingService; private readonly ILogger _logger; private CancellationTokenSource? _activeLookup; + // Stashes the ambiguous MatchResult while the user is drilling into + // one of its candidates, so the "→ חזור" button can restore it. + private MatchResult? _ambiguousReturnTarget; [ObservableProperty] private ReadingPaneState state = ReadingPaneState.Empty; @@ -51,6 +54,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels public ObservableCollection RecentCases { get; } = new ObservableCollection(); + public ObservableCollection Candidates { get; } = new ObservableCollection(); + public Visibility EmptyVisibility => Vis(State == ReadingPaneState.Empty); public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading); public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match); @@ -61,6 +66,22 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels public Visibility NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0); public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match); + /// + /// True when the matched contact has 2+ linked cases. The sidebar + /// uses this to hide the bottom "תייק לתיק הזה" button (which is + /// ambiguous in that scenario) and to surface the per-row "תייק" + /// buttons that let the user pick which case to file to. + /// + public Visibility MultiCaseHintVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count >= 2); + public Visibility SingleCaseFileVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 1); + + /// + /// "→ חזור לרשימת אנשי קשר" is only shown when the user drilled + /// into a candidate from the ambiguous picker; not in a normal + /// single-match lookup. + /// + public Visibility BackToCandidatesVisibility => Vis(State == ReadingPaneState.Match && _ambiguousReturnTarget != null); + /// /// Raised when the user clicks "File to this case" (carries the chosen /// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null). @@ -89,6 +110,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels OnPropertyChanged(nameof(ErrorVisibility)); OnPropertyChanged(nameof(ActionsVisibility)); OnPropertyChanged(nameof(NoCasesVisibility)); + OnPropertyChanged(nameof(MultiCaseHintVisibility)); + OnPropertyChanged(nameof(SingleCaseFileVisibility)); + OnPropertyChanged(nameof(BackToCandidatesVisibility)); FileToThisCommand.NotifyCanExecuteChanged(); FileElsewhereCommand.NotifyCanExecuteChanged(); OpenInEspoCrmCommand.NotifyCanExecuteChanged(); @@ -101,8 +125,16 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels { foreach (var c in value.RecentCases) RecentCases.Add(c); } + Candidates.Clear(); + if (value?.Candidates != null) + { + foreach (var c in value.Candidates) Candidates.Add(c); + } OnPropertyChanged(nameof(AccountVisibility)); OnPropertyChanged(nameof(NoCasesVisibility)); + OnPropertyChanged(nameof(MultiCaseHintVisibility)); + OnPropertyChanged(nameof(SingleCaseFileVisibility)); + OnPropertyChanged(nameof(BackToCandidatesVisibility)); } public void Reset() @@ -112,6 +144,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels Match = null; ErrorMessage = null; AmbiguousMessage = null; + _ambiguousReturnTarget = null; State = ReadingPaneState.Empty; } @@ -125,6 +158,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels } SenderEmail = senderEmail; + // New email selection — drop any stashed candidate-list state. + _ambiguousReturnTarget = null; State = ReadingPaneState.Loading; var cts = new CancellationTokenSource(); @@ -182,7 +217,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!)); } - private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null; + // Bottom "תייק לתיק הזה" only makes sense with exactly one linked + // case. 0 cases → nothing to file to; 2+ cases → ambiguous, the + // per-row "תייק" buttons handle the pick. + private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null && RecentCases.Count == 1; + + [RelayCommand] + private void FileToCase(CaseEntity? caseEntity) + { + if (caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id) || Match == null) return; + // Build a synthetic MatchResult so the host's + // TryResolveSidebarAutoTarget path sees exactly one case and + // auto-files without re-opening the search dialog. + var synthetic = new MatchResult + { + SenderEmail = Match.SenderEmail, + Contact = Match.Contact, + Account = Match.Account, + RecentCases = new System.Collections.Generic.List { caseEntity }, + CandidateCount = 1 + }; + FileRequested?.Invoke(this, new FileFromSidebarEventArgs(synthetic)); + } [RelayCommand(CanExecute = nameof(CanFileElsewhere))] private void FileElsewhere() @@ -202,12 +258,88 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null; + [RelayCommand] + private void OpenCaseInCrm(CaseEntity? caseEntity) + { + if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id)) return; + var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Case/view/" + caseEntity.Id; + OpenInBrowserRequested?.Invoke(this, url); + } + [RelayCommand] private void OpenManualSearch() { FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null)); } + [RelayCommand] + private void BackToCandidates() + { + if (_ambiguousReturnTarget == null) return; + var stash = _ambiguousReturnTarget; + // Clear stash before we re-enter Ambiguous so visibility refresh + // (triggered by OnMatchChanged) sees the cleared state and hides + // the back button until the user drills in again. + _ambiguousReturnTarget = null; + Match = stash; + AmbiguousMessage = $"נמצאו {stash.CandidateCount} אנשי קשר עם הכתובת הזו."; + State = ReadingPaneState.Ambiguous; + } + + /// + /// User picked one of the ambiguous candidates from the sidebar. + /// Load that contact's full bundle (account + cases) and transition + /// to the Match state so the user can pick a case or file. + /// + [RelayCommand] + private async Task SelectCandidateAsync(ContactEntity? candidate) + { + if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return; + + CancelInFlight(); + var cts = new CancellationTokenSource(); + _activeLookup = cts; + + var rememberedEmail = SenderEmail; + // Stash the ambiguous result so "→ חזור" can restore it. + if (Match?.IsAmbiguous == true) + { + _ambiguousReturnTarget = Match; + } + State = ReadingPaneState.Loading; + + try + { + var result = await _matchingService.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) return; + + if (result == null || result.Contact == null) + { + ErrorMessage = "טעינת איש הקשר נכשלה."; + State = ReadingPaneState.Error; + 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.SelectCandidateAsync failed for {ContactId}", candidate.Id); + ErrorMessage = "שגיאת התחברות לשרת."; + State = ReadingPaneState.Error; + } + } + private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed; } diff --git a/src/OutlookAddin.UI/Views/ReadingPaneView.xaml b/src/OutlookAddin.UI/Views/ReadingPaneView.xaml index 075304d..a821ccd 100644 --- a/src/OutlookAddin.UI/Views/ReadingPaneView.xaml +++ b/src/OutlookAddin.UI/Views/ReadingPaneView.xaml @@ -62,6 +62,29 @@ + + + + + + + + + +