This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
OutlookAddin/src/OutlookAddin.Core/Services/MatchingService.cs
T
PointStar 017f10e60b feat(sidebar): inline candidate picker, per-case file action, back navigation
- 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) <noreply@anthropic.com>
2026-05-20 12:57:51 +03:00

199 lines
7.6 KiB
C#

using System;
using System.Linq;
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<MatchResult?> LookupAsync(string senderEmail, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(senderEmail)) return null;
if (!senderEmail.Contains("@")) return null;
var key = CacheKey(senderEmail);
if (_cache.TryGetValue<MatchResult?>(key, out var cached))
{
return cached;
}
try
{
var matches = await _client.EmailAddressSearchAsync(senderEmail, "Contact", cancellationToken).ConfigureAwait(false);
if (matches.Count == 0)
{
_cache.Set(key, (MatchResult?)null, CacheExpiry);
return null;
}
// 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)
{
_cache.Set(key, (MatchResult?)null, CacheExpiry);
return null;
}
if (distinctIds.Count == 1)
{
var single = await LoadContactBundleAsync(distinctIds[0], senderEmail, cancellationToken).ConfigureAwait(false);
_cache.Set<MatchResult?>(key, single, CacheExpiry);
return single;
}
// 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 candidates = fetched
.Where(c => c != null && !string.IsNullOrWhiteSpace(c!.Id))
.Select(c => c!)
.ToList();
var ambiguous = new MatchResult
{
SenderEmail = senderEmail,
IsAmbiguous = true,
CandidateCount = candidates.Count,
Candidates = candidates
};
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
return ambiguous;
}
catch (EspoCrmAuthorizationException)
{
throw;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService lookup failed for {Email}", senderEmail);
return null;
}
}
public async Task<MatchResult?> 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<MatchResult> 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<CaseEntity>();
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<ContactEntity?> 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;
_cache.Remove(CacheKey(senderEmail));
}
private static string CacheKey(string senderEmail)
=> "match:" + senderEmail.Trim().ToLowerInvariant();
}
}