diff --git a/src/OutlookAddin.Core/Json/FlexibleDateTimeOffsetConverter.cs b/src/OutlookAddin.Core/Json/FlexibleDateTimeOffsetConverter.cs new file mode 100644 index 0000000..03cad1b --- /dev/null +++ b/src/OutlookAddin.Core/Json/FlexibleDateTimeOffsetConverter.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Json +{ + /// + /// EspoCRM serializes date/datetime fields in one of these shapes: + /// * "YYYY-MM-DD HH:mm:ss" (datetime, naive — server is UTC by convention) + /// * "YYYY-MM-DD" (date only) + /// * ISO 8601 "YYYY-MM-DDTHH:mm:ss(+zz:zz|Z)" (some custom builds) + /// * empty string / null + /// .NET's default Utf8JsonReader.GetDateTimeOffset only accepts strict + /// ISO 8601 with a timezone, so any of the first two forms throws and + /// the whole list response fails to deserialize. This converter parses + /// all four and assumes UTC when no offset is present. + /// + public sealed class FlexibleDateTimeOffsetConverter : JsonConverter + { + private static readonly string[] Formats = new[] + { + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mm:ss.fffK", + "yyyy-MM-dd" + }; + + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.String: + var s = reader.GetString(); + if (string.IsNullOrWhiteSpace(s)) return null; + // Fast path — ISO with offset, what .NET accepts natively. + if (DateTimeOffset.TryParse( + s, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var dto)) + { + return dto; + } + foreach (var fmt in Formats) + { + if (DateTime.TryParseExact( + s, fmt, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var dt)) + { + return new DateTimeOffset(dt, TimeSpan.Zero); + } + } + return null; + case JsonTokenType.Number: + // Some installs emit Unix epoch seconds. + if (reader.TryGetInt64(out var epoch)) + { + return DateTimeOffset.FromUnixTimeSeconds(epoch); + } + return null; + default: + reader.Skip(); + return null; + } + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (value == null) writer.WriteNullValue(); + else writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + } + } +} diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 3be29d0..96252ed 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -20,12 +20,23 @@ namespace MarcusLaw.OutlookAddin.Core.Services { private const string ApiPrefix = "api/v1/"; - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + // EspoCRM emits dates as "YYYY-MM-DD HH:mm:ss" (no timezone) which + // .NET's built-in DateTimeOffset reader rejects. Register a global + // tolerant converter so every model that exposes a DateTimeOffset? + // works without per-property attributes. + options.Converters.Add(new MarcusLaw.OutlookAddin.Core.Json.FlexibleDateTimeOffsetConverter()); + return options; + } private readonly HttpClient _httpClient; private readonly ILogger _logger;