diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 7dadfaa..af50309 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -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 { ["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(); - // 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>(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(); + } + var body = await ReadBodyAsync(response).ConfigureAwait(false); + var parsed = JsonSerializer.Deserialize>(body, JsonOptions) + ?? new EspoListResponse(); + var seen = new HashSet(StringComparer.Ordinal); var folders = new List(); - 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(); } + finally + { + response?.Dispose(); + } } public async Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default) diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs index e427269..5021f97 100644 --- a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs +++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs @@ -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) {