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