diff --git a/src/OutlookAddin.Core/Models/AttachmentEntity.cs b/src/OutlookAddin.Core/Models/AttachmentEntity.cs new file mode 100644 index 0000000..36807a5 --- /dev/null +++ b/src/OutlookAddin.Core/Models/AttachmentEntity.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + /// + /// Response from POST /api/v1/Attachment — the server returns at least + /// the new attachment's id, which we then thread into a Document create. + /// + public sealed class AttachmentEntity + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/DocumentEntity.cs b/src/OutlookAddin.Core/Models/DocumentEntity.cs new file mode 100644 index 0000000..fbf85f9 --- /dev/null +++ b/src/OutlookAddin.Core/Models/DocumentEntity.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class DocumentEntity + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("fileId")] + public string? FileId { get; set; } + + [JsonPropertyName("folderId")] + public string? FolderId { get; set; } + + [JsonPropertyName("parentType")] + public string? ParentType { get; set; } + + [JsonPropertyName("parentId")] + public string? ParentId { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/DocumentFolder.cs b/src/OutlookAddin.Core/Models/DocumentFolder.cs new file mode 100644 index 0000000..c06a509 --- /dev/null +++ b/src/OutlookAddin.Core/Models/DocumentFolder.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + /// + /// A row from /api/v1/DocumentFolder — used to populate the folder + /// dropdown in the "Save attachments" dialog. EspoCRM document folders + /// are organizational labels independent of the document's parent link. + /// + public sealed class DocumentFolder + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("parentId")] + public string? ParentId { get; set; } + + [JsonPropertyName("order")] + public int? Order { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Services/AttachmentSaveService.cs b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs new file mode 100644 index 0000000..7648389 --- /dev/null +++ b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; +using Serilog; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public sealed class AttachmentSaveService : IAttachmentSaveService + { + private readonly IEspoCrmClient _client; + private readonly ILogger _logger; + + public AttachmentSaveService(IEspoCrmClient client, ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task SaveAsync( + IReadOnlyList attachments, + FilingTarget target, + string? folderId, + CancellationToken cancellationToken = default) + { + if (attachments == null) throw new ArgumentNullException(nameof(attachments)); + if (target == null) throw new ArgumentNullException(nameof(target)); + + var summary = new AttachmentSaveSummary(); + if (attachments.Count == 0) return summary; + + foreach (var att in attachments) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (summary.AuthRequired) + { + summary.Items.Add(new AttachmentSaveItemResult + { + FileName = att.Name, + Outcome = AttachmentSaveOutcome.AuthRequired, + ErrorMessage = "Skipped — earlier upload rejected by EspoCRM" + }); + continue; + } + + try + { + var uploaded = await _client.UploadAttachmentAsync( + att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false); + + var doc = await _client.CreateDocumentAsync( + att.Name, uploaded.Id, target.ParentType, target.ParentId, folderId, cancellationToken).ConfigureAwait(false); + + _logger.Information( + "Saved attachment {Name} → Document/{DocId} (parent={ParentType}/{ParentId}, folder={FolderId})", + att.Name, doc.Id, target.ParentType, target.ParentId, folderId ?? "(none)"); + + summary.Items.Add(new AttachmentSaveItemResult + { + FileName = att.Name, + Outcome = AttachmentSaveOutcome.Saved, + DocumentId = doc.Id + }); + } + catch (EspoCrmAuthorizationException ex) + { + _logger.Warning(ex, "Attachment save aborted: EspoCRM rejected credentials"); + summary.AuthRequired = true; + summary.Items.Add(new AttachmentSaveItemResult + { + FileName = att.Name, + Outcome = AttachmentSaveOutcome.AuthRequired, + ErrorMessage = ex.Message + }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Warning(ex, "Attachment save failed for {Name}", att.Name); + summary.Items.Add(new AttachmentSaveItemResult + { + FileName = att.Name, + Outcome = AttachmentSaveOutcome.Failed, + ErrorMessage = ex.Message + }); + } + } + + return summary; + } + } +} diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 96252ed..e2fc49f 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -202,6 +202,86 @@ namespace MarcusLaw.OutlookAddin.Core.Services public Task TestConnectionAsync(CancellationToken cancellationToken = default) => GetJsonAsync("App/user", cancellationToken); + public async Task UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default) + { + 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; + + var payload = new Dictionary + { + ["name"] = fileName, + ["type"] = mime, + ["contents"] = dataUri, + ["role"] = "Attachment", + ["relatedType"] = "Document", + ["field"] = "file" + }; + + var json = JsonSerializer.Serialize(payload, JsonOptions); + var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false); + try + { + await EnsureSuccessAsync(response).ConfigureAwait(false); + var body = await ReadBodyAsync(response).ConfigureAwait(false); + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM"); + } + finally + { + response.Dispose(); + } + } + + public async Task CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name)); + if (string.IsNullOrWhiteSpace(attachmentFileId)) throw new ArgumentException("attachmentFileId required", nameof(attachmentFileId)); + + var payload = new Dictionary + { + ["name"] = name, + ["fileId"] = attachmentFileId, + ["status"] = "Active" + }; + if (!string.IsNullOrWhiteSpace(parentType)) payload["parentType"] = parentType; + if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId; + if (!string.IsNullOrWhiteSpace(folderId)) payload["folderId"] = folderId; + + var json = JsonSerializer.Serialize(payload, JsonOptions); + var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false); + try + { + await EnsureSuccessAsync(response).ConfigureAwait(false); + var body = await ReadBodyAsync(response).ConfigureAwait(false); + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty DocumentEntity from EspoCRM"); + } + finally + { + response.Dispose(); + } + } + + public async Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default) + { + try + { + return await GetJsonAsync>( + "DocumentFolder?orderBy=name&order=asc&maxSize=200", + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warning(ex, "ListDocumentFoldersAsync failed; returning empty list"); + return new EspoListResponse(); + } + } + public async Task> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId)); diff --git a/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs new file mode 100644 index 0000000..dc214b8 --- /dev/null +++ b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public interface IAttachmentSaveService + { + /// + /// Uploads each attachment to EspoCRM and creates a Document linked + /// to the given target (typically a Case). Folder is optional — + /// pass null to leave the document at the root of the Documents + /// module. + /// + Task SaveAsync( + IReadOnlyList attachments, + FilingTarget target, + string? folderId, + CancellationToken cancellationToken = default); + } + + public sealed class AttachmentSaveSummary + { + public List Items { get; set; } = new List(); + + public bool AuthRequired { get; set; } + + public int SavedCount + { + get + { + var n = 0; + foreach (var it in Items) + { + if (it.Outcome == AttachmentSaveOutcome.Saved) n++; + } + return n; + } + } + + public int FailedCount + { + get + { + var n = 0; + foreach (var it in Items) + { + if (it.Outcome == AttachmentSaveOutcome.Failed) n++; + } + return n; + } + } + } + + public sealed class AttachmentSaveItemResult + { + public string FileName { get; set; } = string.Empty; + + public AttachmentSaveOutcome Outcome { get; set; } + + public string? DocumentId { get; set; } + + public string? ErrorMessage { get; set; } + } + + public enum AttachmentSaveOutcome + { + Saved, + Failed, + AuthRequired + } +} diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs index 6036296..9d2177e 100644 --- a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs @@ -15,5 +15,9 @@ namespace MarcusLaw.OutlookAddin.Core.Services Task CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default); Task> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default); Task TestConnectionAsync(CancellationToken cancellationToken = default); + + Task UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default); + Task CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default); + Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default); } } diff --git a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml new file mode 100644 index 0000000..c51296c --- /dev/null +++ b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +