017f10e60b
- 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>
199 lines
7.6 KiB
C#
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();
|
|
}
|
|
}
|