From 5242abb585bb81ec0655c590a66fd2be656f9a81 Mon Sep 17 00:00:00 2001 From: PointStar Date: Tue, 12 May 2026 21:42:22 +0300 Subject: [PATCH] feat(attachments): rewire to Marcus-Law NetworkStorageIntegration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After reading the NetworkStorageIntegration repo I realized the previous "save attachments" flow was wrong on every level — Marcus-Law doesn't use EspoCRM's stock Document/DocumentFolder model. Folders are real filesystem paths under the network share, named after the Case (format "-") with four standard subfolders: מסמכים, אסמכתאות, התכתבויות, כללי. Rewire the save pipeline against the integration's real endpoints (documented in routes.json on the server repo): Core — three new IEspoCrmClient methods: - EnsureCaseSubfoldersAsync(caseId) POST /NetworkStorage/action/createSubfolders Idempotent — creates case folder + defaults if missing. - ListNetworkStorageFolderAsync(path) GET /NetworkStorage/folderContents?path=… Returns folders + files; we filter to type=folder for the dropdown. - UploadAttachmentToNetworkStorageAsync(attachmentId, targetPath) POST /NetworkStorage/action/upload Moves the staged Attachment binary onto the share at /. CaseEntity gets a new `networkStorageFolderPath` field (set by the integration's hook on first Document save; empty until then). NetworkStorageItem DTO captures the folder-listing rows. AttachmentSaveService rewritten — no more Document creation, no more DocumentFolder picker. The new contract is: SaveAsync(attachments, targetFolderPath) Where targetFolderPath = "[/subfolder]". For each attachment: upload binary via standard /Attachment, then move via NetworkStorage upload. UI — SaveAttachmentsViewModel: - Subfolder dropdown switched from DocumentFolder objects to strings. - Static defaults loaded up-front: "(שורש התיק)" + 4 server-side defaults. - When the user picks a case: 1) hydrate Case (number + networkStorageFolderPath) via GetCase 2) call EnsureCaseSubfoldersAsync to materialise standard folders 3) listFolder at the case path to merge any custom subfolders into the dropdown. - ResolveCaseFolderPath: use Case.networkStorageFolderPath if set, otherwise compute "-" with a client-side SanitizeFolderSegment that mirrors LocalFilesystemClient::sanitizeFileName on the server (geresh/gershayim/quotes removed, illegal chars → hyphen). AddInHost.LaunchSaveAttachmentsAsync: - Composes [/subfolder] from the VM choice. - Pre-flight ensureCaseSubfolders as a safety net (in case the user submitted before the dialog's lazy refresh completed). - Summary MessageBox now shows the resolved storage path instead of a bare entity name. Tests: 39 passing. The build is clean across Core / UI / Tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/OutlookAddin.Core/Models/CaseEntity.cs | 7 + .../Models/NetworkStorageItem.cs | 27 +++ .../Services/AttachmentSaveService.cs | 22 ++- .../Services/EspoCrmClient.cs | 111 +++++++++++++ .../Services/IAttachmentSaveService.cs | 20 ++- .../Services/IEspoCrmClient.cs | 6 + .../Dialogs/SaveAttachmentsDialog.xaml | 5 +- .../ViewModels/SaveAttachmentsViewModel.cs | 154 +++++++++++++----- src/OutlookAddin/Services/AddInHost.cs | 41 +++-- 9 files changed, 325 insertions(+), 68 deletions(-) create mode 100644 src/OutlookAddin.Core/Models/NetworkStorageItem.cs diff --git a/src/OutlookAddin.Core/Models/CaseEntity.cs b/src/OutlookAddin.Core/Models/CaseEntity.cs index 80f014c..c73995b 100644 --- a/src/OutlookAddin.Core/Models/CaseEntity.cs +++ b/src/OutlookAddin.Core/Models/CaseEntity.cs @@ -31,5 +31,12 @@ namespace MarcusLaw.OutlookAddin.Core.Models [JsonPropertyName("createdAt")] public DateTimeOffset? CreatedAt { get; set; } + + // Set by Marcus-Law's NetworkStorageIntegration hook on first + // Document save. Empty until a document has been written through + // EspoCRM. When empty we compute it client-side from number + + // primary contact name. + [JsonPropertyName("networkStorageFolderPath")] + public string? NetworkStorageFolderPath { get; set; } } } diff --git a/src/OutlookAddin.Core/Models/NetworkStorageItem.cs b/src/OutlookAddin.Core/Models/NetworkStorageItem.cs new file mode 100644 index 0000000..9789c65 --- /dev/null +++ b/src/OutlookAddin.Core/Models/NetworkStorageItem.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + /// + /// One row from GET /NetworkStorage/folderContents — folder or file. + /// + public sealed class NetworkStorageItem + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; // "folder" or "file" + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + public bool IsFolder => string.Equals(Type, "folder", System.StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OutlookAddin.Core/Services/AttachmentSaveService.cs b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs index 7648389..3c5ccaa 100644 --- a/src/OutlookAddin.Core/Services/AttachmentSaveService.cs +++ b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs @@ -20,16 +20,17 @@ namespace MarcusLaw.OutlookAddin.Core.Services public async Task SaveAsync( IReadOnlyList attachments, - FilingTarget target, - string? folderId, + string targetFolderPath, CancellationToken cancellationToken = default) { if (attachments == null) throw new ArgumentNullException(nameof(attachments)); - if (target == null) throw new ArgumentNullException(nameof(target)); + if (string.IsNullOrWhiteSpace(targetFolderPath)) throw new ArgumentException("targetFolderPath required", nameof(targetFolderPath)); var summary = new AttachmentSaveSummary(); if (attachments.Count == 0) return summary; + var trimmedFolder = targetFolderPath.TrimEnd('/'); + foreach (var att in attachments) { cancellationToken.ThrowIfCancellationRequested(); @@ -47,21 +48,26 @@ namespace MarcusLaw.OutlookAddin.Core.Services try { + // Step 1: create the EspoCRM Attachment (binary stored + // in data/upload/; not yet on the network share). var uploaded = await _client.UploadAttachmentAsync( att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false); - var doc = await _client.CreateDocumentAsync( - att.Name, uploaded.Id, target.ParentType, target.ParentId, folderId, cancellationToken).ConfigureAwait(false); + // Step 2: ask Marcus-Law's NetworkStorageIntegration + // to place the uploaded blob under . + // The integration appends the filename to the path. + await _client.UploadAttachmentToNetworkStorageAsync( + uploaded.Id, trimmedFolder, cancellationToken).ConfigureAwait(false); _logger.Information( - "Saved attachment {Name} → Document/{DocId} (parent={ParentType}/{ParentId}, folder={FolderId})", - att.Name, doc.Id, target.ParentType, target.ParentId, folderId ?? "(none)"); + "Saved attachment {Name} → {Path} (attachmentId={AttachmentId})", + att.Name, trimmedFolder, uploaded.Id); summary.Items.Add(new AttachmentSaveItemResult { FileName = att.Name, Outcome = AttachmentSaveOutcome.Saved, - DocumentId = doc.Id + AttachmentId = uploaded.Id }); } catch (EspoCrmAuthorizationException ex) diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 11b3076..51a2a29 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -386,6 +386,117 @@ namespace MarcusLaw.OutlookAddin.Core.Services } } + public async Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId)); + var payload = new Dictionary + { + ["entityType"] = "Case", + ["entityId"] = caseId + }; + var json = JsonSerializer.Serialize(payload, JsonOptions); + var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/createSubfolders", json, cancellationToken).ConfigureAwait(false); + try + { + if (!response.IsSuccessStatusCode) + { + var body = await ReadBodyAsync(response).ConfigureAwait(false); + var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)"; + _logger.Warning( + "EnsureCaseSubfolders({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}", + caseId, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500)); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key."); + } + // Don't throw — folder creation is best-effort. Upload will fail + // with its own clear error if the parent really isn't there. + return; + } + _logger.Information("EnsureCaseSubfolders({CaseId}) OK", caseId); + } + finally + { + response.Dispose(); + } + } + + public async Task> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default) + { + var query = "NetworkStorage/folderContents"; + if (!string.IsNullOrEmpty(path)) + { + query += "?path=" + Uri.EscapeDataString(path); + } + HttpResponseMessage? response = null; + try + { + response = await ExecuteAsync(HttpMethod.Get, query, null, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var body = await ReadBodyAsync(response).ConfigureAwait(false); + var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)"; + _logger.Warning( + "ListNetworkStorageFolder({Path}): {Status} (X-Status-Reason={Reason}) body={Body}", + path, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500)); + return new List(); + } + var json = await ReadBodyAsync(response).ConfigureAwait(false); + var items = JsonSerializer.Deserialize>(json, JsonOptions); + _logger.Information("ListNetworkStorageFolder({Path}) → {Count} entries", path, items?.Count ?? 0); + return items ?? new List(); + } + catch (Exception ex) + { + _logger.Warning(ex, "ListNetworkStorageFolderAsync({Path}) failed", path); + return new List(); + } + finally + { + response?.Dispose(); + } + } + + public async Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(attachmentId)) throw new ArgumentException("attachmentId required", nameof(attachmentId)); + if (string.IsNullOrWhiteSpace(targetPath)) throw new ArgumentException("targetPath required", nameof(targetPath)); + + var payload = new Dictionary + { + ["attachmentId"] = attachmentId, + ["path"] = targetPath + }; + var json = JsonSerializer.Serialize(payload, JsonOptions); + _logger.Information( + "UploadToNetworkStorage POST /NetworkStorage/action/upload (attachmentId={AttachmentId}, path={Path})", + attachmentId, targetPath); + + var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/upload", json, cancellationToken).ConfigureAwait(false); + try + { + if (!response.IsSuccessStatusCode) + { + var body = await ReadBodyAsync(response).ConfigureAwait(false); + var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)"; + _logger.Warning( + "UploadToNetworkStorage failed: {Status} (X-Status-Reason={Reason}) body={Body}", + response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500)); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key."); + } + throw new EspoCrmHttpException(response.StatusCode, body, + $"NetworkStorage upload returned {(int)response.StatusCode} {response.StatusCode}" + + (string.IsNullOrEmpty(reason) || reason == "(none)" ? "" : " (reason: " + reason + ")")); + } + } + finally + { + response.Dispose(); + } + } + public async Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default) { try diff --git a/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs index dc214b8..33440ec 100644 --- a/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs +++ b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs @@ -8,15 +8,21 @@ namespace MarcusLaw.OutlookAddin.Core.Services public interface IAttachmentSaveService { /// - /// Uploads each attachment to EspoCRM and creates a Document linked - /// to the given target (typically a Case). Folder is optional — - /// pass null to leave the document at the root of the Documents - /// module. + /// Uploads each attachment as an EspoCRM Attachment, then asks + /// Marcus-Law's NetworkStorage integration to place the file under + /// on the shared storage. No + /// Document entity is created — these are raw files dropped into + /// the case's filesystem folder. /// + /// files to save + /// + /// Full relative folder path on storage, e.g. + /// "30-מרדכי שמביקו/אסמכתאות". The case folder + optional + /// subfolder. Filename is appended internally. + /// Task SaveAsync( IReadOnlyList attachments, - FilingTarget target, - string? folderId, + string targetFolderPath, CancellationToken cancellationToken = default); } @@ -59,7 +65,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services public AttachmentSaveOutcome Outcome { get; set; } - public string? DocumentId { get; set; } + public string? AttachmentId { get; set; } public string? ErrorMessage { get; set; } } diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs index 73585cd..b504ec2 100644 --- a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs @@ -20,5 +20,11 @@ namespace MarcusLaw.OutlookAddin.Core.Services Task CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default); Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default); Task> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default); + + // Marcus-Law NetworkStorageIntegration endpoints (filesystem-based + // folder structure under the case folder). + Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default); + Task> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default); + Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default); } } diff --git a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml index c51296c..e199c7e 100644 --- a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml +++ b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml @@ -153,9 +153,8 @@ - diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs index 5021f97..129f3a2 100644 --- a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs +++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs @@ -11,8 +11,24 @@ using Serilog; namespace MarcusLaw.OutlookAddin.UI.ViewModels { + /// + /// Picks a Case + optional subfolder (filesystem-style, via Marcus-Law's + /// NetworkStorageIntegration) for saving raw attachment files. + /// public sealed partial class SaveAttachmentsViewModel : ObservableObject { + // Default subfolders the integration creates under each Case folder. + // Mirrors Resources/metadata/app/networkStorage.json on the server. + private static readonly string[] DefaultCaseSubfolders = + { + "מסמכים", + "אסמכתאות", + "התכתבויות", + "כללי" + }; + + private const string RootSentinel = "(שורש התיק)"; + private readonly IEspoCrmClient _client; private readonly ILogger _logger; private CancellationTokenSource? _searchCts; @@ -24,14 +40,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels private CaseEntity? selectedCase; [ObservableProperty] - private DocumentFolder? selectedFolder; + private string? selectedSubfolder; [ObservableProperty] private string? statusMessage; public ObservableCollection Cases { get; } = new ObservableCollection(); - public ObservableCollection Folders { get; } = new ObservableCollection(); + public ObservableCollection Subfolders { get; } = new ObservableCollection(); public ObservableCollection Attachments { get; } = new ObservableCollection(); @@ -50,19 +66,17 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels } } - public FilingTarget? Target - { - get - { - if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return null; - return new FilingTarget("Case", SelectedCase.Id, SelectedCase.Name ?? SelectedCase.Id); - } - } - - public string? FolderId => - SelectedFolder == null || string.IsNullOrWhiteSpace(SelectedFolder.Id) + /// + /// Subfolder string the user picked, or null to save to the case + /// folder root. + /// + public string? ChosenSubfolderName => + string.IsNullOrEmpty(SelectedSubfolder) || + string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal) ? null - : SelectedFolder.Id; + : SelectedSubfolder; + + public CaseEntity? PickedCase => SelectedCase; public event EventHandler? RequestClose; @@ -74,10 +88,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels _client = client ?? throw new ArgumentNullException(nameof(client)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - // Sentinel "no folder" row at the top — Id stays null so the - // service skips emitting folderId in the payload. - Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" }); - SelectedFolder = Folders[0]; + ResetSubfolders(); foreach (var att in attachments) { @@ -85,6 +96,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels } } + private void ResetSubfolders() + { + Subfolders.Clear(); + Subfolders.Add(RootSentinel); + foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f); + SelectedSubfolder = Subfolders[0]; + } + partial void OnSearchTextChanged(string value) { _searchCts?.Cancel(); @@ -95,43 +114,95 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels partial void OnSelectedCaseChanged(CaseEntity? value) { SubmitCommand.NotifyCanExecuteChanged(); - // Folders are scoped to the chosen case — refresh whenever the - // case selection changes. - _ = LoadFoldersForCaseAsync(value?.Id); + _ = RefreshSubfoldersForSelectedCaseAsync(); } - public async Task LoadFoldersForCaseAsync(string? caseId) + /// + /// Refreshes the subfolder dropdown to merge static defaults with + /// the actual folders that already exist on the network share for + /// the chosen case. Triggers when the case selection changes. + /// + public async Task RefreshSubfoldersForSelectedCaseAsync() { - // Reset to the sentinel row first; we'll append folders below. - Folders.Clear(); - Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" }); - SelectedFolder = Folders[0]; + ResetSubfolders(); + var c = SelectedCase; + if (c == null || string.IsNullOrEmpty(c.Id)) return; - if (string.IsNullOrWhiteSpace(caseId)) return; + // Best-effort: ensure standard subfolders physically exist on + // disk before the user picks one. + try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); } + catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); } + + // Fetch the actual case folder path from the server (the + // integration's hook sets it on Case.networkStorageFolderPath + // after the first document save). + try + { + var detail = await _client.GetCaseAsync( + c.Id, "id,name,number,networkStorageFolderPath", default).ConfigureAwait(true); + c.Number = detail.Number; + c.Name = detail.Name; + c.NetworkStorageFolderPath = detail.NetworkStorageFolderPath; + } + catch (Exception ex) { _logger.Warning(ex, "GetCase for subfolder list failed"); } + + var folderPath = ResolveCaseFolderPath(c); + if (string.IsNullOrEmpty(folderPath)) return; try { - // First try: folders that are already in use for THIS case - // (the user's mental model — most relevant suggestions). - var caseFolders = await _client.ListFoldersUsedInCaseAsync(caseId!).ConfigureAwait(true); - if (caseFolders.Count > 0) + var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true); + foreach (var item in items) { - foreach (var folder in caseFolders) Folders.Add(folder); - return; + if (!item.IsFolder) continue; + if (Subfolders.Contains(item.Name)) continue; // already in defaults + if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue; + Subfolders.Add(item.Name); } - - // Fallback: list every DocumentFolder in the tenant, so the - // user still has somewhere to put the file even on the very - // first upload to a case. - var global = await _client.ListDocumentFoldersAsync().ConfigureAwait(true); - foreach (var folder in global.List) Folders.Add(folder); } catch (Exception ex) { - _logger.Warning(ex, "SaveAttachmentsViewModel.LoadFoldersForCaseAsync failed for {CaseId}", caseId); + _logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id); } } + public static string ResolveCaseFolderPath(CaseEntity c) + { + if (c == null) return string.Empty; + if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath)) + { + return c.NetworkStorageFolderPath!.TrimEnd('/'); + } + // Best-effort match to NetworkDocumentService::buildEntityFolderName: + // "-", sanitized. + var contactName = c.Name ?? string.Empty; + var raw = !string.IsNullOrWhiteSpace(c.Number) + ? $"{c.Number}-{contactName}" + : contactName; + return SanitizeFolderSegment(raw); + } + + /// + /// Mirrors LocalFilesystemClient::sanitizeFileName on the server side + /// so client-computed paths match what the integration writes. + /// + public static string SanitizeFolderSegment(string name) + { + if (string.IsNullOrWhiteSpace(name)) return string.Empty; + foreach (var c in new[] { '׳', '״', '\'', '"', '‘', '’', '“', '”' }) + { + name = name.Replace(c.ToString(), string.Empty); + } + foreach (var c in new[] { '/', '\\', ':', '*', '?', '<', '>', '|' }) + { + name = name.Replace(c, '-'); + } + while (name.Contains("--")) name = name.Replace("--", "-"); + while (name.Contains(" ")) name = name.Replace(" ", " "); + name = name.Replace("-.", "."); + return name.Trim(' ', '-'); + } + private async Task SearchAsync(string query, CancellationToken ct) { try @@ -154,6 +225,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels foreach (var hit in search.List) { if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue; + // Bare id+name in the list; the full Case (with number + + // networkStorageFolderPath) is hydrated when the user + // picks one. Cases.Add(new CaseEntity { Id = hit.Id, diff --git a/src/OutlookAddin/Services/AddInHost.cs b/src/OutlookAddin/Services/AddInHost.cs index a389c72..238d46f 100644 --- a/src/OutlookAddin/Services/AddInHost.cs +++ b/src/OutlookAddin/Services/AddInHost.cs @@ -482,26 +482,47 @@ namespace OutlookAddin.Services } var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments); - // Folders are loaded lazily when the user picks a case — the - // sentinel "(בלי תיקייה)" row is already present. + // Subfolders are populated lazily once the user picks a case; + // the default static set is already pre-loaded. var dialog = new SaveAttachmentsDialog(vm); AttachOwnerToOutlook(dialog); var ok = dialog.ShowDialog(); if (ok != true) return; - var target = vm.Target; + var picked = vm.PickedCase; var selected = vm.SelectedAttachments; - if (target == null || selected.Count == 0) return; + if (picked == null || string.IsNullOrEmpty(picked.Id) || selected.Count == 0) return; + + var caseFolder = SaveAttachmentsViewModel.ResolveCaseFolderPath(picked); + if (string.IsNullOrEmpty(caseFolder)) + { + MessageBox.Show( + "לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.", + "Marcus-Law OutlookAddin", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + var subfolder = vm.ChosenSubfolderName; + var targetFolderPath = string.IsNullOrEmpty(subfolder) + ? caseFolder + : caseFolder + "/" + subfolder; try { Logger.Information( - "Saving {Count} attachment(s) to {Type}/{Id} ({Name}), folder={FolderId}", - selected.Count, target.ParentType, target.ParentId, target.ParentName, vm.FolderId ?? "(none)"); + "Saving {Count} attachment(s) to Case/{Id} ({Name}) → {Path}", + selected.Count, picked.Id, picked.Name, targetFolderPath); - var summary = await AttachmentSaveService!.SaveAsync(selected, target, vm.FolderId).ConfigureAwait(true); - ShowAttachmentSummary(summary, target); + // Safety net: ensure the case folder + defaults exist on + // disk before the per-file uploads (the dialog may have + // been submitted before the prior ensure call finished). + try { await CrmClient!.EnsureCaseSubfoldersAsync(picked.Id).ConfigureAwait(true); } + catch (Exception ex) { Logger.Warning(ex, "Pre-upload ensure failed; continuing anyway"); } + + var summary = await AttachmentSaveService!.SaveAsync(selected, targetFolderPath).ConfigureAwait(true); + ShowAttachmentSummary(summary, picked.Name ?? picked.Id, targetFolderPath); } catch (Exception ex) { @@ -514,7 +535,7 @@ namespace OutlookAddin.Services } } - private static void ShowAttachmentSummary(AttachmentSaveSummary summary, FilingTarget target) + private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath) { string message; MessageBoxImage icon; @@ -526,7 +547,7 @@ namespace OutlookAddin.Services else { var parts = new List(); - if (summary.SavedCount > 0) parts.Add($"נשמרו {summary.SavedCount} קבצים אל \"{target.ParentName}\""); + if (summary.SavedCount > 0) parts.Add($"נשמרו {summary.SavedCount} קבצים אל \"{caseLabel}\" ({targetPath})"); if (summary.FailedCount > 0) parts.Add($"{summary.FailedCount} נכשלו"); message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts); icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;