fix(attachments): folder query + verbose upload diagnostics

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=<id>
   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) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-12 20:42:38 +03:00
parent 26322c92ff
commit af8cf13443
+39 -15
View File
@@ -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<string, object?>
{
["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<string>());
_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<AttachmentEntity>(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<DocumentFolder>();
// 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)