From af8cf134435312aae7ae96c0f062c0521136f5c5 Mon Sep 17 00:00:00 2001 From: PointStar Date: Tue, 12 May 2026 20:42:38 +0300 Subject: [PATCH] fix(attachments): folder query + verbose upload diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the save-attachments flow: 1. ListFoldersUsedInCaseAsync no longer hits /Case/{id}/documents — that related-records endpoint requires a Case-to-Document link that EspoCRM doesn't ship by default, so it returned an empty list on every install. Switch to a direct Document list filtered by the polymorphic parent fields: GET /Document?where[0]=parentType=Case&where[1]=parentId= That returns all documents linked to the case regardless of how the parent relationship is wired. 2. UploadAttachmentAsync now: - Emits BOTH "contents" and "file" payload fields with the data-URI base64 string. Different EspoCRM versions read from different keys; including both makes the call portable. - Logs the request shape (name, type, base64 length) before sending so server-side rejects are easier to attribute. - On a non-2xx response, logs the response body AND any X-Status-Reason header (EspoCRM convention for short error codes like "fileSizeIsTooLarge") so the user-visible exception finally includes a useful reason instead of an empty body. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/EspoCrmClient.cs | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index ef128ae..7dadfaa 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -207,26 +207,46 @@ namespace MarcusLaw.OutlookAddin.Core.Services if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName required", nameof(fileName)); if (string.IsNullOrWhiteSpace(base64Content)) throw new ArgumentException("base64Content required", nameof(base64Content)); - // EspoCRM accepts either raw base64 in `contents` or a data-URI with the - // mime prefix. The data-URI form is universally supported across versions. 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. var payload = new Dictionary { ["name"] = fileName, ["type"] = mime, - ["contents"] = dataUri, ["role"] = "Attachment", ["relatedType"] = "Document", - ["field"] = "file" + ["field"] = "file", + ["contents"] = dataUri, + ["file"] = dataUri }; var json = JsonSerializer.Serialize(payload, JsonOptions); + _logger.Information( + "UploadAttachment POST /Attachment (name={Name}, type={Type}, base64Length={Length})", + fileName, mime, base64Content.Length); + var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false); try { - await EnsureSuccessAsync(response).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var errBody = await ReadBodyAsync(response).ConfigureAwait(false); + var reason = string.Join(",", response.Headers.TryGetValues("X-Status-Reason", out var v) ? v : Array.Empty()); + _logger.Warning( + "UploadAttachment failed: {Status} (X-Status-Reason={Reason}) body={Body}", + response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500)); + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + { + throw new EspoCrmAuthorizationException( + $"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key."); + } + throw new EspoCrmHttpException(response.StatusCode, errBody, + $"EspoCRM Attachment upload returned {(int)response.StatusCode} {response.StatusCode}" + + (string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")")); + } var body = await ReadBodyAsync(response).ConfigureAwait(false); return JsonSerializer.Deserialize(body, JsonOptions) ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM"); @@ -271,15 +291,17 @@ namespace MarcusLaw.OutlookAddin.Core.Services { if (string.IsNullOrWhiteSpace(caseId)) return new List(); - // EspoCRM doesn't have a Case → DocumentFolder relationship by - // default — but it DOES expose all documents linked to the case - // via the related-records endpoint, and each document returns - // its folderId + folderName denormalized. We dedupe to surface - // the folders that are already in use for that case (the user's - // mental model: "תיקיות שיש בתיק"). + // 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. var path = - "Case/" + Uri.EscapeDataString(caseId) + "/documents" + - "?select=" + Uri.EscapeDataString("id,folderId,folderName") + + "Document?select=" + Uri.EscapeDataString("id,folderId,folderName") + + "&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) + "&maxSize=200"; try { @@ -296,7 +318,9 @@ namespace MarcusLaw.OutlookAddin.Core.Services Name = string.IsNullOrWhiteSpace(doc.FolderName) ? doc.FolderId! : doc.FolderName! }); } - _logger.Information("ListFoldersUsedInCase({CaseId}) → {Count} distinct folders", caseId, folders.Count); + _logger.Information( + "ListFoldersUsedInCase({CaseId}) → {DocumentCount} documents, {FolderCount} distinct folders", + caseId, response.List.Count, folders.Count); return folders; } catch (Exception ex)