e527aa80af
Core layer: - FilingService: orchestrates batch filing with 401-aborts-batch, 409-treats-as-success, transient errors fall to DiskRetryQueue - DiskRetryQueue: atomic write (tmp + rename), Hebrew UTF-8 round-trip, idempotent remove - Models: MailEnvelope (STA-extracted primitives), FilingTarget, FilingOutcome enum, FilingItemResult, FilingBatchSummary, RetryQueueItem - EspoCrmClient.CreateContactAsync for "create contact from sender" flow UI layer: - FileToCaseDialog (WPF, RTL, Hebrew labels) with grouped search results + recent sidebar - FileToCaseViewModel: 250ms debounced GlobalSearch, create-contact-from-sender command, DialogResult signaling Tests (38 passing): - FilingServiceTests: happy path, 409, 401 aborts batch, transient enqueues, queue failure → Failed, target propagation, null guards - DiskRetryQueueTests: id assignment, Hebrew round-trip, corrupt-file skip, idempotent remove, atomic overwrite VSTO host project (OutlookAddin.csproj) regenerated; ThisAddIn wiring in follow-up commits. gitignore: exclude .claude/settings.local.json (per-machine). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
4.8 KiB
C#
142 lines
4.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using MarcusLaw.OutlookAddin.Core.Models;
|
|
using Serilog;
|
|
|
|
namespace MarcusLaw.OutlookAddin.Core.Services
|
|
{
|
|
public sealed class DiskRetryQueue : IRetryQueue
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = false,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
|
|
|
|
private readonly string _directory;
|
|
private readonly ILogger _logger;
|
|
|
|
public DiskRetryQueue(ILogger logger)
|
|
: this(DefaultDirectory(), logger)
|
|
{
|
|
}
|
|
|
|
public DiskRetryQueue(string directory, ILogger logger)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("directory required", nameof(directory));
|
|
_directory = directory;
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public string Directory => _directory;
|
|
|
|
public Task<string> EnqueueAsync(RetryQueueItem item, CancellationToken cancellationToken = default)
|
|
{
|
|
if (item == null) throw new ArgumentNullException(nameof(item));
|
|
|
|
EnsureDirectory();
|
|
|
|
if (string.IsNullOrEmpty(item.Id))
|
|
{
|
|
item.Id = Guid.NewGuid().ToString("N");
|
|
}
|
|
if (item.EnqueuedAt == default)
|
|
{
|
|
item.EnqueuedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
var finalPath = Path.Combine(_directory, item.Id + ".json");
|
|
var tempPath = finalPath + ".tmp";
|
|
|
|
var json = JsonSerializer.Serialize(item, JsonOptions);
|
|
File.WriteAllText(tempPath, json, Utf8NoBom);
|
|
|
|
if (File.Exists(finalPath))
|
|
{
|
|
File.Delete(finalPath);
|
|
}
|
|
File.Move(tempPath, finalPath);
|
|
|
|
_logger.Information("Retry queue enqueued {Id} (messageId={MessageId})", item.Id, item.Request?.MessageId);
|
|
return Task.FromResult(item.Id);
|
|
}
|
|
|
|
public Task<IReadOnlyList<RetryQueueItem>> ListAllAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var result = new List<RetryQueueItem>();
|
|
if (!System.IO.Directory.Exists(_directory))
|
|
{
|
|
return Task.FromResult<IReadOnlyList<RetryQueueItem>>(result);
|
|
}
|
|
|
|
foreach (var path in System.IO.Directory.EnumerateFiles(_directory, "*.json"))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
try
|
|
{
|
|
var json = File.ReadAllText(path, Encoding.UTF8);
|
|
var item = JsonSerializer.Deserialize<RetryQueueItem>(json, JsonOptions);
|
|
if (item != null)
|
|
{
|
|
if (string.IsNullOrEmpty(item.Id))
|
|
{
|
|
item.Id = Path.GetFileNameWithoutExtension(path);
|
|
}
|
|
result.Add(item);
|
|
}
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.Warning(ex, "Retry queue: skipping corrupt file {Path}", path);
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
_logger.Warning(ex, "Retry queue: cannot read {Path}", path);
|
|
}
|
|
}
|
|
|
|
return Task.FromResult<IReadOnlyList<RetryQueueItem>>(result);
|
|
}
|
|
|
|
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("id required", nameof(id));
|
|
var path = Path.Combine(_directory, id + ".json");
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
_logger.Information("Retry queue removed {Id}", id);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private void EnsureDirectory()
|
|
{
|
|
if (!System.IO.Directory.Exists(_directory))
|
|
{
|
|
System.IO.Directory.CreateDirectory(_directory);
|
|
}
|
|
}
|
|
|
|
private static string DefaultDirectory()
|
|
{
|
|
return Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"MarcusLaw",
|
|
"OutlookAddin",
|
|
"retry-queue");
|
|
}
|
|
}
|
|
}
|