fix(compose-from-case): use any contact with email + show number/status

Walk Case.contactsIds for the first contact with a non-empty emailAddress
(falling back to the Account's emailAddress) instead of blindly using
contactsIds[0] and giving up if it has no email — Outlook was opening
the new mail with no To recipient when the first linked contact was
e.g. opposing counsel or a witness without a stored address.

Also hydrate the "כתוב מתיק" picker rows with number/status/court#
through GetCaseAsync (same pattern as SaveAttachmentsViewModel) and
switch the dialog to the DataGrid layout so it matches the file-
attachments picker the user expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-26 14:56:18 +03:00
parent a8e947c825
commit 4556d2c1b3
3 changed files with 137 additions and 50 deletions
@@ -1,9 +1,10 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog" <Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="כתוב מייל מתיק" Title="כתוב מייל מתיק"
Height="480" Width="600" Height="520" Width="680"
MinHeight="320" MinWidth="500" MinHeight="360" MinWidth="540"
FlowDirection="RightToLeft" FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
ShowInTaskbar="False" ShowInTaskbar="False"
@@ -11,6 +12,7 @@
Background="#F8FAFC" Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial"> FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources> <Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" /> <SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" /> <SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" /> <SolidColorBrush x:Key="Border" Color="#E2E8F0" />
@@ -134,24 +136,30 @@
</Border> </Border>
<Border Grid.Row="1" Style="{StaticResource Card}"> <Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}" <DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}" SelectedItem="{Binding SelectedCase}"
BorderThickness="0" AutoGenerateColumns="False"
Background="Transparent" HeadersVisibility="Column"
ItemContainerStyle="{StaticResource ResultItem}" GridLinesVisibility="Horizontal"
Padding="4"> HorizontalGridLinesBrush="#E2E8F0"
<ListBox.ItemTemplate> RowBackground="White"
<DataTemplate> AlternatingRowBackground="#F8FAFC"
<StackPanel> BorderThickness="0"
<TextBlock FontSize="13"> CanUserAddRows="False"
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " /> CanUserDeleteRows="False"
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" /> CanUserResizeRows="False"
</TextBlock> CanUserReorderColumns="False"
<TextBlock Text="{Binding Status}" Foreground="{StaticResource Muted}" FontSize="11" /> SelectionMode="Single"
</StackPanel> SelectionUnit="FullRow"
</DataTemplate> IsReadOnly="True"
</ListBox.ItemTemplate> RowHeight="30">
</ListBox> <DataGrid.Columns>
<DataGridTextColumn Header="מס׳ תיק" Binding="{Binding Number}" Width="80" />
<DataGridTextColumn Header="שם" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="מס׳ בבית משפט" Binding="{Binding CourtCaseNumber}" Width="140" />
<DataGridTextColumn Header="סטטוס" Binding="{Binding Status, Converter={StaticResource CaseStatusHe}}" Width="110" />
</DataGrid.Columns>
</DataGrid>
</Border> </Border>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}" <TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -74,20 +75,36 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
Cases.Clear(); Cases.Clear();
int kept = 0; var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List) foreach (var hit in search.List)
{ {
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue; if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
Cases.Add(new CaseEntity caseHits.Add(hit);
{
Id = hit.Id,
Name = hit.Name,
});
kept++;
} }
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)" if (caseHits.Count == 0)
: $"נמצאו {kept} תיקים"; {
StatusMessage = "אין תוצאות מסוג תיק (Case)";
return;
}
// GlobalSearch only returns id+name. Hydrate each hit with
// number+status (and the Hebrew case name when present) so the
// picker shows the same columns the file-attachments picker
// does.
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
foreach (var hit in caseHits)
{
detailTasks.Add(LoadCaseRowAsync(hit, ct));
}
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
foreach (var row in details)
{
if (row != null) Cases.Add(row);
}
StatusMessage = $"נמצאו {Cases.Count} תיקים";
} }
catch (OperationCanceledException) { /* superseded */ } catch (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException) catch (EspoCrmAuthorizationException)
@@ -101,6 +118,27 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
} }
} }
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
{
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
if (string.IsNullOrEmpty(hit.Id)) return row;
try
{
var detail = await _client.GetCaseAsync(
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
row.Number = detail.Number;
row.Status = detail.Status;
row.CourtCaseNumber = detail.CourtCaseNumber;
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.Warning(ex, "ComposeFromCase: hydrating case {CaseId} failed", hit.Id);
}
return row;
}
[RelayCommand(CanExecute = nameof(CanSubmit))] [RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit() private void Submit()
{ {
+60 -19
View File
@@ -42,21 +42,7 @@ namespace OutlookAddin.Services
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId)); if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true); var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true);
ContactEntity? primaryContact = null; var recipientEmail = await ResolveRecipientEmailAsync(caseEntity, caseId).ConfigureAwait(true);
if (caseEntity.ContactsIds != null && caseEntity.ContactsIds.Count > 0)
{
try
{
primaryContact = await _client.GetContactAsync(
caseEntity.ContactsIds[0],
"id,name,emailAddress",
default).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load primary contact for case {CaseId}", caseId);
}
}
var guid = Guid.NewGuid().ToString("N"); var guid = Guid.NewGuid().ToString("N");
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id); StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
@@ -65,14 +51,14 @@ namespace OutlookAddin.Services
try try
{ {
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress)) if (!string.IsNullOrWhiteSpace(recipientEmail))
{ {
var existingTo = mail.To ?? string.Empty; var existingTo = mail.To ?? string.Empty;
if (!existingTo.Contains(primaryContact!.EmailAddress!)) if (!existingTo.Contains(recipientEmail!))
{ {
mail.To = string.IsNullOrEmpty(existingTo) mail.To = string.IsNullOrEmpty(existingTo)
? primaryContact.EmailAddress ? recipientEmail
: existingTo + "; " + primaryContact.EmailAddress; : existingTo + "; " + recipientEmail;
} }
} }
@@ -107,6 +93,61 @@ namespace OutlookAddin.Services
new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id)); new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id));
} }
/// <summary>
/// Find a usable recipient email for the case. EspoCRM's Case.contactsIds
/// is a many-to-many bag (primary + co-counsel + witnesses + …) and the
/// first entry isn't guaranteed to be the actual client — and even when
/// it is, the contact may have no emailAddress stored. So walk the list
/// and return the first non-empty address, then fall back to the
/// linked Account's email.
/// </summary>
private async Task<string?> ResolveRecipientEmailAsync(CaseEntity caseEntity, string caseId)
{
if (caseEntity.ContactsIds != null)
{
foreach (var contactId in caseEntity.ContactsIds)
{
if (string.IsNullOrWhiteSpace(contactId)) continue;
try
{
var contact = await _client.GetContactAsync(
contactId,
"id,name,emailAddress,emailAddressData",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(contact?.EmailAddress))
{
return contact!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load contact {ContactId} for case {CaseId}", contactId, caseId);
}
}
}
if (!string.IsNullOrWhiteSpace(caseEntity.AccountId))
{
try
{
var account = await _client.GetAccountAsync(
caseEntity.AccountId!,
"id,name,emailAddress",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(account?.EmailAddress))
{
return account!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load account {AccountId} for case {CaseId}", caseEntity.AccountId, caseId);
}
}
return null;
}
public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty); public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty);
public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty); public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty); public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);