fix(attachments): match official EspoCRM API + folder fallback

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) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-12 20:51:46 +03:00
parent af8cf13443
commit 7ee9a00fc4
2 changed files with 57 additions and 19 deletions
+45 -16
View File
@@ -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<string, object?>
{
["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<DocumentFolder>();
// 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<EspoListResponse<DocumentEntity>>(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<DocumentFolder>();
}
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var parsed = JsonSerializer.Deserialize<EspoListResponse<DocumentEntity>>(body, JsonOptions)
?? new EspoListResponse<DocumentEntity>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var folders = new List<DocumentFolder>();
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<DocumentFolder>();
}
finally
{
response?.Dispose();
}
}
public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
@@ -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)
{