From 7ee9a00fc4c97d9ea287ba8dd25fdaca969a61ea Mon Sep 17 00:00:00 2001 From: PointStar Date: Tue, 12 May 2026 20:51:46 +0300 Subject: [PATCH] fix(attachments): match official EspoCRM API + folder fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the EspoCRM Attachment API doc (documentation.espocrm.com/development/api/attachment/), the binary is sent in the "file" field as a data URI. The previous extra "contents" alias was a legacy convention that strict tenants reject with HTTP 400 (with an empty body, so the user only ever saw "שמירת הקבצים נכשלה"). Drop "contents" — match the documented payload: { "name": "report.pdf", "type": "application/pdf", "role": "Attachment", "relatedType": "Document", "field": "file", "file": "data:application/pdf;base64,..." } Folder dropdown: previously case-scoped only. The /Document filter by parentId returned 400 on this tenant and case-scoped queries can be fragile across versions. Now: try case-scoped first (so the most- relevant folders surface); if it comes back empty or errors, fall back to ListDocumentFoldersAsync (the full tenant list). The user always has something to choose from, and the "(בלי תיקייה)" sentinel still works as a "no folder" pick. Tests: 39 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/EspoCrmClient.cs | 61 ++++++++++++++----- .../ViewModels/SaveAttachmentsViewModel.cs | 15 ++++- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 7dadfaa..af50309 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -209,9 +209,10 @@ namespace MarcusLaw.OutlookAddin.Core.Services var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType; var dataUri = "data:" + mime + ";base64," + base64Content; - // Some EspoCRM versions read the binary from the field named in the - // `field` property (i.e. "file"); others from "contents". Emit BOTH - // so we work across versions. + // Per the official EspoCRM API docs (POST /Attachment), the binary + // is sent in the "file" field as a data-URI. The previous extra + // "contents" alias was an undocumented legacy that strict tenants + // reject with 400. var payload = new Dictionary { ["name"] = fileName, @@ -219,7 +220,6 @@ namespace MarcusLaw.OutlookAddin.Core.Services ["role"] = "Attachment", ["relatedType"] = "Document", ["field"] = "file", - ["contents"] = dataUri, ["file"] = dataUri }; @@ -291,25 +291,50 @@ namespace MarcusLaw.OutlookAddin.Core.Services { if (string.IsNullOrWhiteSpace(caseId)) return new List(); - // EspoCRM doesn't have a Case → Document relationship by default, - // so /Case/{id}/documents 404s. Query Document directly filtered - // by the polymorphic parent fields and dedupe the folderId hits. + // Some EspoCRM installs reject filtering by polymorphic parentType + // with a 400. ParentId is a regular string field, and an ID alone + // is unique enough — we'll filter just by that. We additionally + // verify parentType client-side to be safe. var path = - "Document?select=" + Uri.EscapeDataString("id,folderId,folderName") + + "Document?select=" + Uri.EscapeDataString("id,folderId,folderName,parentType") + "&where[0][type]=equals" + - "&where[0][attribute]=parentType" + - "&where[0][value]=Case" + - "&where[1][type]=equals" + - "&where[1][attribute]=parentId" + - "&where[1][value]=" + Uri.EscapeDataString(caseId) + + "&where[0][attribute]=parentId" + + "&where[0][value]=" + Uri.EscapeDataString(caseId) + "&maxSize=200"; + + // Hand-rolled error capture (instead of GetJsonAsync) so a 400 from + // a picky tenant ends up in the log with body + X-Status-Reason. + HttpResponseMessage? response = null; try { - var response = await GetJsonAsync>(path, cancellationToken).ConfigureAwait(false); + response = await ExecuteAsync(HttpMethod.Get, path, null, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var errBody = await ReadBodyAsync(response).ConfigureAwait(false); + var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) + ? string.Join(",", v) : "(none)"; + _logger.Warning( + "ListFoldersUsedInCase({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}", + caseId, response.StatusCode, reason, + string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500)); + return new List(); + } + var body = await ReadBodyAsync(response).ConfigureAwait(false); + var parsed = JsonSerializer.Deserialize>(body, JsonOptions) + ?? new EspoListResponse(); + var seen = new HashSet(StringComparer.Ordinal); var folders = new List(); - foreach (var doc in response.List) + foreach (var doc in parsed.List) { + // Defensive: if the server returns docs from other parents + // (shouldn't, but ID collisions are theoretically possible), + // drop them. + if (!string.IsNullOrEmpty(doc.ParentType) && + !string.Equals(doc.ParentType, "Case", StringComparison.OrdinalIgnoreCase)) + { + continue; + } if (string.IsNullOrEmpty(doc.FolderId)) continue; if (!seen.Add(doc.FolderId!)) continue; folders.Add(new DocumentFolder @@ -320,7 +345,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services } _logger.Information( "ListFoldersUsedInCase({CaseId}) → {DocumentCount} documents, {FolderCount} distinct folders", - caseId, response.List.Count, folders.Count); + caseId, parsed.List.Count, folders.Count); return folders; } catch (Exception ex) @@ -328,6 +353,10 @@ namespace MarcusLaw.OutlookAddin.Core.Services _logger.Warning(ex, "ListFoldersUsedInCaseAsync({CaseId}) failed", caseId); return new List(); } + finally + { + response?.Dispose(); + } } public async Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default) diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs index e427269..5021f97 100644 --- a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs +++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs @@ -111,11 +111,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels try { - var folders = await _client.ListFoldersUsedInCaseAsync(caseId!).ConfigureAwait(true); - foreach (var folder in folders) + // 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) { - Folders.Add(folder); + foreach (var folder in caseFolders) Folders.Add(folder); + return; } + + // 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) {