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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user