From 5797b97bdc28b3400cd797622536c79fc2edeaea Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Mon, 9 Mar 2026 15:33:24 -0600 Subject: [PATCH 1/4] Base for Ches implementation --- .../backend/api/Controllers/ChesController.cs | 37 ++++++ .../api/Models/Ches/EmailAttachment.cs | 34 ++++++ .../backend/api/Models/Ches/EmailBodyType.cs | 44 +++++++ .../backend/api/Models/Ches/EmailContext.cs | 48 ++++++++ .../backend/api/Models/Ches/EmailEncoding.cs | 50 ++++++++ .../api/Models/Ches/EmailMergeRequest.cs | 67 +++++++++++ .../backend/api/Models/Ches/EmailPriority.cs | 47 ++++++++ .../backend/api/Models/Ches/EmailRequest.cs | 93 +++++++++++++++ .../backend/api/Models/Ches/EmailResponse.cs | 25 ++++ source/backend/api/Models/Ches/ErrorModel.cs | 22 ++++ .../backend/api/Models/Ches/ErrorResponse.cs | 41 +++++++ source/backend/api/Models/Ches/IAttachment.cs | 16 +++ .../backend/api/Models/Ches/IEmailContext.cs | 29 +++++ .../api/Models/Ches/MessageResponse.cs | 29 +++++ .../backend/api/Models/Ches/StatusHistory.cs | 21 ++++ .../backend/api/Models/Ches/StatusResponse.cs | 60 ++++++++++ .../Models/{Cdogs => Common}/JwtResponse.cs | 2 +- .../api/Models/Configuration/ChesConfig.cs | 15 +++ .../Repositories/Cdogs/CdogsAuthRepository.cs | 2 +- .../Repositories/Ches/ChesAuthRepository.cs | 103 ++++++++++++++++ .../Repositories/Ches/ChesBaseRepository.cs | 49 ++++++++ .../api/Repositories/Ches/ChesRepository.cs | 111 ++++++++++++++++++ .../Repositories/Ches/IEmailAuthRepository.cs | 12 ++ .../api/Repositories/Ches/IEmailRepository.cs | 11 ++ source/backend/api/Services/ChesService.cs | 43 +++++++ source/backend/api/Services/IEmailService.cs | 14 +++ source/backend/api/Startup.cs | 7 +- .../backend/api/appsettings.Development.json | 7 ++ source/backend/api/appsettings.Local.json | 7 ++ 29 files changed, 1043 insertions(+), 3 deletions(-) create mode 100644 source/backend/api/Controllers/ChesController.cs create mode 100644 source/backend/api/Models/Ches/EmailAttachment.cs create mode 100644 source/backend/api/Models/Ches/EmailBodyType.cs create mode 100644 source/backend/api/Models/Ches/EmailContext.cs create mode 100644 source/backend/api/Models/Ches/EmailEncoding.cs create mode 100644 source/backend/api/Models/Ches/EmailMergeRequest.cs create mode 100644 source/backend/api/Models/Ches/EmailPriority.cs create mode 100644 source/backend/api/Models/Ches/EmailRequest.cs create mode 100644 source/backend/api/Models/Ches/EmailResponse.cs create mode 100644 source/backend/api/Models/Ches/ErrorModel.cs create mode 100644 source/backend/api/Models/Ches/ErrorResponse.cs create mode 100644 source/backend/api/Models/Ches/IAttachment.cs create mode 100644 source/backend/api/Models/Ches/IEmailContext.cs create mode 100644 source/backend/api/Models/Ches/MessageResponse.cs create mode 100644 source/backend/api/Models/Ches/StatusHistory.cs create mode 100644 source/backend/api/Models/Ches/StatusResponse.cs rename source/backend/api/Models/{Cdogs => Common}/JwtResponse.cs (98%) create mode 100644 source/backend/api/Models/Configuration/ChesConfig.cs create mode 100644 source/backend/api/Repositories/Ches/ChesAuthRepository.cs create mode 100644 source/backend/api/Repositories/Ches/ChesBaseRepository.cs create mode 100644 source/backend/api/Repositories/Ches/ChesRepository.cs create mode 100644 source/backend/api/Repositories/Ches/IEmailAuthRepository.cs create mode 100644 source/backend/api/Repositories/Ches/IEmailRepository.cs create mode 100644 source/backend/api/Services/ChesService.cs create mode 100644 source/backend/api/Services/IEmailService.cs diff --git a/source/backend/api/Controllers/ChesController.cs b/source/backend/api/Controllers/ChesController.cs new file mode 100644 index 0000000000..893b6fd63c --- /dev/null +++ b/source/backend/api/Controllers/ChesController.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Pims.Api.Models.Ches; +using Pims.Api.Models.CodeTypes; +using Pims.Api.Services; + +namespace Pims.Api.Controllers +{ + [ApiController] + [ApiVersion("1.0")] + [Route("v{version:apiVersion}/ches/")] + [Route("/ches/")] + public class ChesController : ControllerBase + { + private readonly IEmailService _emailService; + + public ChesController(IEmailService emailService) + { + _emailService = emailService; + } + + /// + /// Send an email using CHES service. + /// + [HttpPost("email")] + [ProducesResponseType(typeof(EmailResponse), 200)] + public async Task SendEmail([FromBody] EmailRequest request) + { + var result = await _emailService.SendEmailAsync(request); + if (result.Status == ExternalResponseStatus.Error) + { + return StatusCode(500, new { error = result.Message, details = result.Payload }); + } + return Ok(result); + } + } +} diff --git a/source/backend/api/Models/Ches/EmailAttachment.cs b/source/backend/api/Models/Ches/EmailAttachment.cs new file mode 100644 index 0000000000..f82ff6403a --- /dev/null +++ b/source/backend/api/Models/Ches/EmailAttachment.cs @@ -0,0 +1,34 @@ + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +#nullable enable + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents an attachment for CHES email. + /// + public class EmailAttachment : IAttachment + { + /// + /// Buffer or a Stream contents for the attachment. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Optional content type for the attachment. + /// If not set, it will be derived from the filename property. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// Filename to be reported as the name of the attached file. + /// Use of unicode is allowed. + /// + [JsonPropertyName("filename")] + public string? Filename { get; set; } + } +} diff --git a/source/backend/api/Models/Ches/EmailBodyType.cs b/source/backend/api/Models/Ches/EmailBodyType.cs new file mode 100644 index 0000000000..27e4a67e4a --- /dev/null +++ b/source/backend/api/Models/Ches/EmailBodyType.cs @@ -0,0 +1,44 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailBodyType enum, provides email body type options for CHES. + /// + [JsonConverter(typeof(EmailBodyTypeJsonConverter))] + public enum EmailBodyType + { + Html = 0, + Text = 1, + } + + /// + /// Custom JsonConverter for EmailBodyType to serialize as lowercase strings. + /// + public class EmailBodyTypeJsonConverter : JsonConverter + { + public override EmailBodyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value?.ToLower() switch + { + "html" => EmailBodyType.Html, + "text" => EmailBodyType.Text, + _ => throw new JsonException($"Invalid bodyType value: {value}") + }; + } + + public override void Write(Utf8JsonWriter writer, EmailBodyType value, JsonSerializerOptions options) + { + var str = value switch + { + EmailBodyType.Html => "html", + EmailBodyType.Text => "text", + _ => throw new JsonException($"Invalid bodyType value: {value}") + }; + writer.WriteStringValue(str); + } + } +} diff --git a/source/backend/api/Models/Ches/EmailContext.cs b/source/backend/api/Models/Ches/EmailContext.cs new file mode 100644 index 0000000000..64676c12e1 --- /dev/null +++ b/source/backend/api/Models/Ches/EmailContext.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailContext provides the template variables for an email. + /// + public class EmailContext : IEmailContext + { + /// + /// An array of recipients email addresses that will appear on the To: field. + /// + [JsonPropertyName("to")] + public List To { get; set; } = new List(); + + /// + /// An array of recipients email addresses that will appear on the cc: field. + /// + [JsonPropertyName("cc")] + public List Cc { get; set; } = new List(); + + /// + /// An array of recipients email addresses that will appear on the bcc: field. + /// + [JsonPropertyName("bcc")] + public List Bcc { get; set; } = new List(); + + /// + /// A freeform JSON object of key-value pairs. + /// All keys must be alphanumeric or underscore. + /// + [JsonPropertyName("context")] + public Dictionary Context { get; set; } = new Dictionary(); + + /// + /// Desired UTC time for sending the message. 0 = Queue to send immediately + /// + [JsonPropertyName("delayTS")] + public long DelayTS { get; set; } + + /// + /// A unique string to be associated with the message + /// + [JsonPropertyName("tag")] + public string Tag { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/api/Models/Ches/EmailEncoding.cs b/source/backend/api/Models/Ches/EmailEncoding.cs new file mode 100644 index 0000000000..7affe88f13 --- /dev/null +++ b/source/backend/api/Models/Ches/EmailEncoding.cs @@ -0,0 +1,50 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailEncoding enum, provides encoding options for email content. + /// + [JsonConverter(typeof(EmailEncodingJsonConverter))] + public enum EmailEncoding + { + Utf8 = 0, + Base64 = 1, + Binary = 2, + Hex = 3, + } + + /// + /// Custom JsonConverter for EmailEncoding to serialize as lowercase strings. + /// + public class EmailEncodingJsonConverter : JsonConverter + { + public override EmailEncoding Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value?.ToLower() switch + { + "utf-8" => EmailEncoding.Utf8, + "base64" => EmailEncoding.Base64, + "binary" => EmailEncoding.Binary, + "hex" => EmailEncoding.Hex, + _ => throw new JsonException($"Invalid encoding value: {value}") + }; + } + + public override void Write(Utf8JsonWriter writer, EmailEncoding value, JsonSerializerOptions options) + { + var str = value switch + { + EmailEncoding.Utf8 => "utf-8", + EmailEncoding.Base64 => "base64", + EmailEncoding.Binary => "binary", + EmailEncoding.Hex => "hex", + _ => throw new JsonException($"Invalid encoding value: {value}") + }; + writer.WriteStringValue(str); + } + } +} diff --git a/source/backend/api/Models/Ches/EmailMergeRequest.cs b/source/backend/api/Models/Ches/EmailMergeRequest.cs new file mode 100644 index 0000000000..ff818a0e50 --- /dev/null +++ b/source/backend/api/Models/Ches/EmailMergeRequest.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + + /// + /// Represents a mail merge request for CHES. + /// + public class ChesMergeRequest + { + /// + /// The email address of the sender. + /// All email addresses can be plain 'sender@server.com' or formatted '"Sender Name" sender@server.com'. + /// + [JsonPropertyName("from")] + [System.ComponentModel.DataAnnotations.Required] + public string From { get; set; } = string.Empty; + + /// + /// The email subject. + /// + [JsonPropertyName("subject")] + [System.ComponentModel.DataAnnotations.Required] + public string Subject { get; set; } = string.Empty; + + /// + /// A body template of the message as an Unicode string. + /// Refer to https://mozilla.github.io/nunjucks/templating.html for template syntax. + /// + [JsonPropertyName("body")] + [System.ComponentModel.DataAnnotations.Required] + public string Body { get; set; } = string.Empty; + + /// + /// The email body type (html = content with html, text = plaintext). + /// + [JsonPropertyName("bodyType")] + [System.ComponentModel.DataAnnotations.Required] + public EmailBodyType BodyType { get; set; } = EmailBodyType.Html; + + /// + /// Identifies encoding for text/html strings (defaults to 'utf-8', other values are 'hex' and 'base64'). + /// + [JsonPropertyName("encoding")] + public EmailEncoding Encoding { get; set; } = EmailEncoding.Utf8; + + /// + /// Sets message importance headers, either 'high', 'normal' (default) or 'low'.. + /// + [JsonPropertyName("priority")] + public EmailPriority Priority { get; set; } = EmailPriority.Normal; + + /// + /// An array of Attachment objects. + /// + [JsonPropertyName("attachments")] + public List? Attachments { get; set; } + + /// + /// An array of context objects. + /// + [JsonPropertyName("contexts")] + [System.ComponentModel.DataAnnotations.Required] + public List Contexts { get; set; } = new(); + } +} \ No newline at end of file diff --git a/source/backend/api/Models/Ches/EmailPriority.cs b/source/backend/api/Models/Ches/EmailPriority.cs new file mode 100644 index 0000000000..ae9b42cd91 --- /dev/null +++ b/source/backend/api/Models/Ches/EmailPriority.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailPriority enum, provides email priority options for CHES. + /// + [JsonConverter(typeof(EmailPriorityJsonConverter))] + public enum EmailPriority + { + Low = 0, + Normal = 1, + High = 2, + } + + /// + /// Custom JsonConverter for EmailPriority to serialize as lowercase strings. + /// + public class EmailPriorityJsonConverter : JsonConverter + { + public override EmailPriority Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value?.ToLower() switch + { + "low" => EmailPriority.Low, + "normal" => EmailPriority.Normal, + "high" => EmailPriority.High, + _ => throw new JsonException($"Invalid priority value: {value}") + }; + } + + public override void Write(Utf8JsonWriter writer, EmailPriority value, JsonSerializerOptions options) + { + var str = value switch + { + EmailPriority.Low => "low", + EmailPriority.Normal => "normal", + EmailPriority.High => "high", + _ => throw new JsonException($"Invalid priority value: {value}") + }; + writer.WriteStringValue(str); + } + } +} diff --git a/source/backend/api/Models/Ches/EmailRequest.cs b/source/backend/api/Models/Ches/EmailRequest.cs new file mode 100644 index 0000000000..6d29794bf4 --- /dev/null +++ b/source/backend/api/Models/Ches/EmailRequest.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents a request to send an email via CHES. + /// + public class EmailRequest + { + /// + /// An array of recipients email addresses that will appear on the To: field. + /// + [JsonPropertyName("to")] + [System.ComponentModel.DataAnnotations.Required] + public List To { get; set; } = new(); + + /// + /// An array of recipients email addresses that will appear on the CC: field. + /// + [JsonPropertyName("cc")] + public List Cc { get; set; } = new(); + + /// + /// An array of recipients email addresses that will appear on the BCC: field. + /// + [JsonPropertyName("bcc")] + public List Bcc { get; set; } = new(); + + /// + /// The email address of the sender. + /// All email addresses can be plain 'sender@server.com' or formatted '"Sender Name" sender@server.com'. + /// + [JsonPropertyName("from")] + [System.ComponentModel.DataAnnotations.Required] + public string From { get; set; } + + /// + /// The email subject. + /// + [JsonPropertyName("subject")] + [System.ComponentModel.DataAnnotations.Required] + public string Subject { get; set; } = string.Empty; + + /// + /// The body of the message as an Unicode string. + /// + [JsonPropertyName("body")] + [System.ComponentModel.DataAnnotations.Required] + public string Body { get; set; } = string.Empty; + + /// + /// The email body type (html = content with html, text = plaintext). + /// + [JsonPropertyName("bodyType")] + [JsonConverter(typeof(EmailBodyTypeJsonConverter))] + [System.ComponentModel.DataAnnotations.Required] + public EmailBodyType BodyType { get; set; } = EmailBodyType.Html; + + /// + /// Identifies encoding for text/html strings (defaults to 'utf-8', other values are 'hex' and 'base64'). + /// + [JsonPropertyName("encoding")] + [JsonConverter(typeof(EmailEncodingJsonConverter))] + public EmailEncoding Encoding { get; set; } = EmailEncoding.Utf8; + + /// + /// Sets message importance headers, either 'high', 'normal' (default) or 'low'. + /// + [JsonPropertyName("priority")] + [JsonConverter(typeof(EmailPriorityJsonConverter))] + public EmailPriority Priority { get; set; } = EmailPriority.Normal; + + /// + /// A unique string which is associated with the message. + /// + [JsonPropertyName("tag")] + public string Tag { get; set; } + + /// + /// Desired UTC time for sending the message. 0 = Queue to send immediately. + /// + [JsonPropertyName("delayTS")] + public long? DelayTS { get; set; } + + /// + /// An array of Attachment objects. + /// + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = new List(); + } + +} diff --git a/source/backend/api/Models/Ches/EmailResponse.cs b/source/backend/api/Models/Ches/EmailResponse.cs new file mode 100644 index 0000000000..423b4518ac --- /dev/null +++ b/source/backend/api/Models/Ches/EmailResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using System.Collections.Generic; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents the response from CHES after sending an email. + /// + public class EmailResponse + { + /// + /// A corresponding transaction uuid. + /// + [JsonPropertyName("txId")] + public string TxId { get; set; } = string.Empty; + + /// + /// Array of objects. + /// Each object contains msgId, tag and to fields. + /// + [JsonPropertyName("messages")] + public List Messages { get; set; } = new(); + } + +} diff --git a/source/backend/api/Models/Ches/ErrorModel.cs b/source/backend/api/Models/Ches/ErrorModel.cs new file mode 100644 index 0000000000..33f9710d0b --- /dev/null +++ b/source/backend/api/Models/Ches/ErrorModel.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents an error response from the CHES service. + /// + public class ErrorModel + { + /// + /// The error message for the field. + /// + [JsonPropertyName("message")] + public string Message { get; set; } + + /// + /// Contents of the field that was in error. + /// + [JsonPropertyName("value")] + public object Value { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/api/Models/Ches/ErrorResponse.cs b/source/backend/api/Models/Ches/ErrorResponse.cs new file mode 100644 index 0000000000..74d619081f --- /dev/null +++ b/source/backend/api/Models/Ches/ErrorResponse.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// ErrorResponse class, provides a model that represents an error returned from CHES. + /// + public class ErrorResponse + { + /// + /// What type of problem, link to explanation of problem. + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Title of problem, generally the Http Status Code description. + /// + [JsonPropertyName("title")] + public string Title { get; set; } + + /// + /// The Http Status code. + /// + [JsonPropertyName("status")] + public long Status { get; set; } + + /// + /// Short description of why this problem was raised. + /// + [JsonPropertyName("detail")] + public string Detail { get; set; } + + /// + /// A list of errors associated with the response. + /// + [JsonPropertyName("errors")] + public List Errors { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/source/backend/api/Models/Ches/IAttachment.cs b/source/backend/api/Models/Ches/IAttachment.cs new file mode 100644 index 0000000000..2205d7a693 --- /dev/null +++ b/source/backend/api/Models/Ches/IAttachment.cs @@ -0,0 +1,16 @@ +#nullable enable + +namespace Pims.Api.Models.Ches +{ + /// + /// Defines the contract for an email attachment. + /// + public interface IAttachment + { + string? Content { get; set; } + + string? ContentType { get; set; } + + string? Filename { get; set; } + } +} diff --git a/source/backend/api/Models/Ches/IEmailContext.cs b/source/backend/api/Models/Ches/IEmailContext.cs new file mode 100644 index 0000000000..6fa23dd7c8 --- /dev/null +++ b/source/backend/api/Models/Ches/IEmailContext.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Defines the contract for a mail merge context object. + /// + public interface IEmailContext + { + [JsonPropertyName("to")] + List To { get; set; } + + [JsonPropertyName("cc")] + List Cc { get; set; } + + [JsonPropertyName("bcc")] + List Bcc { get; set; } + + [JsonPropertyName("context")] + Dictionary Context { get; set; } + + [JsonPropertyName("delayTS")] + long DelayTS { get; set; } + + [JsonPropertyName("tag")] + string Tag { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/api/Models/Ches/MessageResponse.cs b/source/backend/api/Models/Ches/MessageResponse.cs new file mode 100644 index 0000000000..3f8c84d861 --- /dev/null +++ b/source/backend/api/Models/Ches/MessageResponse.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// MessageResponse class, provides a model that represents the response when a message was added to the CHES queue. + /// + public class MessageResponse + { + /// + /// A corresponding message uuid. + /// + [JsonPropertyName("msgId")] + public string MsgId { get; set; } = string.Empty; + + /// + /// A unique string which is associated with the message. + /// + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + /// + /// An array of recipient email addresses that this message will go to. + /// + [JsonPropertyName("to")] + public List To { get; set; } = new(); + } +} diff --git a/source/backend/api/Models/Ches/StatusHistory.cs b/source/backend/api/Models/Ches/StatusHistory.cs new file mode 100644 index 0000000000..0eb116a1f2 --- /dev/null +++ b/source/backend/api/Models/Ches/StatusHistory.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + + /// + /// Represents a status history entry. + /// + public class ChesStatusHistory + { + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("timestamp")] + public long? Timestamp { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/api/Models/Ches/StatusResponse.cs b/source/backend/api/Models/Ches/StatusResponse.cs new file mode 100644 index 0000000000..7acd26c8b7 --- /dev/null +++ b/source/backend/api/Models/Ches/StatusResponse.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + + /// + /// Represents a status response for a message or transaction. + /// + public class ChesStatusResponse + { + /// + /// A corresponding transaction uuid. + /// + [JsonPropertyName("txId")] + public string? TxId { get; set; } + + /// + /// A corresponding message uuid. + /// + [JsonPropertyName("msgId")] + public string? MsgId { get; set; } + + /// + /// The current status of the message. + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// A unique string which is associated with the message. + /// + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + /// + /// UTC time this service first received this message queue request. + /// + [JsonPropertyName("createdTS")] + public long? CreatedTS { get; set; } + + /// + /// Desired UTC time for sending the message. 0 = Queue to send immediately. + /// + [JsonPropertyName("delayTS")] + public long? DelayTS { get; set; } + + /// + /// UTC time this message queue request was last updated. + /// + [JsonPropertyName("updatedTS")] + public long? UpdatedTS { get; set; } + + /// + /// A list of status changes to this message. + /// + [JsonPropertyName("statusHistory")] + public List? StatusHistory { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/api/Models/Cdogs/JwtResponse.cs b/source/backend/api/Models/Common/JwtResponse.cs similarity index 98% rename from source/backend/api/Models/Cdogs/JwtResponse.cs rename to source/backend/api/Models/Common/JwtResponse.cs index 55f33b5144..cc2be6a572 100644 --- a/source/backend/api/Models/Cdogs/JwtResponse.cs +++ b/source/backend/api/Models/Common/JwtResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Pims.Api.Models.Cdogs +namespace Pims.Api.Models.Common { /// /// Represents a JWT (JSON Web Token) response. diff --git a/source/backend/api/Models/Configuration/ChesConfig.cs b/source/backend/api/Models/Configuration/ChesConfig.cs new file mode 100644 index 0000000000..bee1d81f68 --- /dev/null +++ b/source/backend/api/Models/Configuration/ChesConfig.cs @@ -0,0 +1,15 @@ +using System; + +namespace Pims.Api.Models.Config +{ + public class ChesConfig + { + public Uri AuthEndpoint { get; set; } + + public Uri ChesHost { get; set; } + + public string ServiceClientId { get; set; } + + public string ServiceClientSecret { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs b/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs index dd2e87be8f..9a48fe3bd6 100644 --- a/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs +++ b/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs @@ -6,8 +6,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Pims.Api.Models.Cdogs; using Pims.Api.Models.CodeTypes; +using Pims.Api.Models.Common; using Pims.Api.Models.Requests.Http; using Pims.Core.Api.Exceptions; using Polly.Registry; diff --git a/source/backend/api/Repositories/Ches/ChesAuthRepository.cs b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs new file mode 100644 index 0000000000..b79f1dfadf --- /dev/null +++ b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Pims.Api.Models.CodeTypes; +using Pims.Api.Models.Common; +using Pims.Api.Models.Requests.Http; +using Pims.Core.Api.Exceptions; +using Polly.Registry; + +namespace Pims.Api.Repositories.Ches.Auth +{ + + public class ChesAuthRepository : ChesBaseRepository, IEmailAuthRepository + { + private JwtResponse _currentToken; + private DateTime _lastSuccessfulRequest; + + /// + /// Initializes a new instance of the class. + /// + /// Injected Logger Provider. + /// Injected Httpclient factory. + /// The injected configuration provider. + /// The jsonOptions. + /// The polly retry policy. + public ChesAuthRepository( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IOptions jsonOptions, + ResiliencePipelineProvider pollyPipelineProvider) + : base(logger, httpClientFactory, configuration, jsonOptions, pollyPipelineProvider) + { + _currentToken = null; + _lastSuccessfulRequest = DateTime.UnixEpoch; + } + + public async Task GetTokenAsync() + { + if (!IsValidToken()) + { + ExternalResponse tokenResult = await TryRequestToken(); + if (tokenResult.Status == ExternalResponseStatus.Error) + { + throw new AuthenticationException(tokenResult.Message); + } + + _lastSuccessfulRequest = DateTime.UtcNow; + _currentToken = tokenResult.Payload; + } + + return _currentToken.AccessToken; + } + + private bool IsValidToken() + { + if (_currentToken != null) + { + DateTime now = DateTime.UtcNow; + TimeSpan delta = now - _lastSuccessfulRequest; + if (delta.TotalSeconds >= _currentToken.ExpiresIn) + { + // Revoke token + _logger.LogDebug("Authentication Token has expired."); + _currentToken = null; + return false; + } + return true; + } + + return false; + } + + private async Task> TryRequestToken() + { + _logger.LogDebug("Getting authentication token..."); + + using HttpClient client = _httpClientFactory.CreateClient("Pims.Api.Logging"); + client.DefaultRequestHeaders.Accept.Clear(); + + var requestForm = new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", _config.ServiceClientId }, + { "client_secret", _config.ServiceClientSecret }, + }; + + using FormUrlEncodedContent content = new(requestForm); + content.Headers.Clear(); + content.Headers.Add("Content-Type", "application/x-www-form-urlencoded"); + + ExternalResponse result = await PostAsync(_config.AuthEndpoint, content); + _logger.LogDebug("Token endpoint response: {@Result}", result); + + return result; + } + } +} diff --git a/source/backend/api/Repositories/Ches/ChesBaseRepository.cs b/source/backend/api/Repositories/Ches/ChesBaseRepository.cs new file mode 100644 index 0000000000..e6a598cae5 --- /dev/null +++ b/source/backend/api/Repositories/Ches/ChesBaseRepository.cs @@ -0,0 +1,49 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Pims.Api.Models.Config; +using Pims.Core.Api.Repositories.Rest; +using Polly.Registry; + +namespace Pims.Api.Repositories.Ches +{ + /// + /// ChesBaseRepository provides common methods to interact with the Common Health Email Service (CHES) api. + /// + public abstract class ChesBaseRepository : BaseRestRepository + { + protected readonly ChesConfig _config; + private const string ChesConfigSectionKey = "Ches"; + + /// + /// Initializes a new instance of the class. + /// + /// Injected Logger Provider. + /// Injected Httpclient factory. + /// The injected configuration provider. + /// The json options. + /// The polly retry policy. + protected ChesBaseRepository( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IOptions jsonOptions, + ResiliencePipelineProvider pollyPipelineProvider) + : base(logger, httpClientFactory, jsonOptions, pollyPipelineProvider) + { + _config = new ChesConfig(); + configuration.Bind(ChesConfigSectionKey, _config); + } + + public override void AddAuthentication(HttpClient client, string authenticationToken = null) + { + if (!string.IsNullOrEmpty(authenticationToken)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authenticationToken); + } + } + } +} diff --git a/source/backend/api/Repositories/Ches/ChesRepository.cs b/source/backend/api/Repositories/Ches/ChesRepository.cs new file mode 100644 index 0000000000..5c74f11cf0 --- /dev/null +++ b/source/backend/api/Repositories/Ches/ChesRepository.cs @@ -0,0 +1,111 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Pims.Api.Models.Ches; +using Pims.Api.Models.CodeTypes; +using Pims.Api.Models.Requests.Http; +using Polly.Registry; + +namespace Pims.Api.Repositories.Ches +{ + /// + /// ChesRepository provides email access from the CHES API. + /// + public class ChesRepository : ChesBaseRepository, IEmailRepository + { + private readonly HttpClient _client; + private readonly IEmailAuthRepository _authRepository; + private readonly JsonSerializerOptions _serializeOptions; + + /// + /// Initializes a new instance of the class. + /// + /// Injected Logger Provider. + /// Injected Httpclient factory. + /// Injected repository that handles authentication. + /// The injected configuration provider. + /// The jsonOptions. + /// The polly retry policy. + public ChesRepository( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfiguration config, + IEmailAuthRepository authRepository, + IOptions jsonOptions, + ResiliencePipelineProvider pollyPipelineProvider) + : base(logger, httpClientFactory, config, jsonOptions, pollyPipelineProvider) + { + _client = httpClientFactory.CreateClient(); + _client.DefaultRequestHeaders.Accept.Clear(); + _client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + _authRepository = authRepository; + + _serializeOptions = new JsonSerializerOptions(jsonOptions.Value) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + } + + public async Task> SendEmailAsync(EmailRequest request) + { + _logger.LogDebug("Sending Email ..."); + ExternalResponse result = new ExternalResponse() + { + Status = ExternalResponseStatus.Error, + }; + + try + { + var token = await _authRepository.GetTokenAsync(); + + Uri endpoint = new(_config.ChesHost, "/api/v1/email"); + var jsonContent = JsonSerializer.Serialize(request, _serializeOptions); + + using var content = new StringContent(jsonContent); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + httpRequest.Content = content; + + var response = await _client.SendAsync(httpRequest); + if (response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + result.Status = ExternalResponseStatus.Success; + result.Payload = JsonSerializer.Deserialize(responseBody); + if (result.Payload == null) + { + result.Status = ExternalResponseStatus.Error; + result.Message = "CHES email send succeeded but response payload was null."; + _logger.LogError("CHES email send succeeded but response payload was null."); + } + } + else + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("CHES email send failed: {Status} {Reason} {Body}", response.StatusCode, response.ReasonPhrase, errorBody); + result.Message = $"CHES email send failed: {response.StatusCode} {response.ReasonPhrase}. Response body: {errorBody}"; + } + } + catch (Exception ex) + { + result.Status = ExternalResponseStatus.Error; + result.Message = $"Exception sending CHES email: {ex.Message}"; + result.Payload = null; + result.HttpStatusCode = System.Net.HttpStatusCode.InternalServerError; + _logger.LogError(ex, "Exception sending CHES email."); + } + _logger.LogDebug($"Finished sending email"); + return result; + } + } +} diff --git a/source/backend/api/Repositories/Ches/IEmailAuthRepository.cs b/source/backend/api/Repositories/Ches/IEmailAuthRepository.cs new file mode 100644 index 0000000000..33be37432d --- /dev/null +++ b/source/backend/api/Repositories/Ches/IEmailAuthRepository.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace Pims.Api.Repositories.Ches +{ + /// + /// IEmailAuthRepository interface, defines the functionality for a CHES email authentication repository. + /// + public interface IEmailAuthRepository + { + Task GetTokenAsync(); + } +} \ No newline at end of file diff --git a/source/backend/api/Repositories/Ches/IEmailRepository.cs b/source/backend/api/Repositories/Ches/IEmailRepository.cs new file mode 100644 index 0000000000..e3e2b3ca79 --- /dev/null +++ b/source/backend/api/Repositories/Ches/IEmailRepository.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Pims.Api.Models.Ches; +using Pims.Api.Models.Requests.Http; + +namespace Pims.Api.Repositories.Ches +{ + public interface IEmailRepository + { + Task> SendEmailAsync(EmailRequest request); + } +} \ No newline at end of file diff --git a/source/backend/api/Services/ChesService.cs b/source/backend/api/Services/ChesService.cs new file mode 100644 index 0000000000..754218788f --- /dev/null +++ b/source/backend/api/Services/ChesService.cs @@ -0,0 +1,43 @@ +#nullable enable +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Pims.Api.Models.Ches; +using Pims.Api.Models.CodeTypes; +using Pims.Api.Models.Requests.Http; +using Pims.Api.Repositories.Ches; + +namespace Pims.Api.Services + /// + /// Default implementation of IEmailService using CHES. + /// +{ + public class ChesService : IEmailService + { + private readonly IEmailRepository _chesRepository; + private readonly ILogger _logger; + + public ChesService(IEmailRepository chesRepository, ILogger logger) + { + _chesRepository = chesRepository; + _logger = logger; + } + + public async Task> SendEmailAsync(EmailRequest request) + { + _logger.LogInformation("Email send requested. Recipient count: {recipientCount}.", request.To?.Count ?? 0); + + ExternalResponse? response = await _chesRepository.SendEmailAsync(request); + + if (response == null || response.Payload == null) + { + return new ExternalResponse + { + Status = ExternalResponseStatus.Error, + Payload = new EmailResponse(), + Message = "Error sending email", + }; + } + return response; + } + } +} diff --git a/source/backend/api/Services/IEmailService.cs b/source/backend/api/Services/IEmailService.cs new file mode 100644 index 0000000000..143e11ce2d --- /dev/null +++ b/source/backend/api/Services/IEmailService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Pims.Api.Models.Ches; +using Pims.Api.Models.Requests.Http; + +namespace Pims.Api.Services +{ + /// + /// IEmailService interface, defines the functionality for email service. + /// + public interface IEmailService + { + Task> SendEmailAsync(EmailRequest request); + } +} \ No newline at end of file diff --git a/source/backend/api/Startup.cs b/source/backend/api/Startup.cs index cecbd2a0e4..d5653bba5c 100644 --- a/source/backend/api/Startup.cs +++ b/source/backend/api/Startup.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Microsoft.Data.SqlClient; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; @@ -27,6 +26,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Versioning; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -41,6 +41,8 @@ using Pims.Api.Helpers.Middleware; using Pims.Api.Models.Config; using Pims.Api.Repositories.Cdogs; +using Pims.Api.Repositories.Ches; +using Pims.Api.Repositories.Ches.Auth; using Pims.Api.Repositories.Mayan; using Pims.Api.Services; using Pims.Api.Services.Interfaces; @@ -521,6 +523,8 @@ private static void AddPimsApiRepositories(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSingleton(); } @@ -572,6 +576,7 @@ private static void AddPimsApiServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } /// diff --git a/source/backend/api/appsettings.Development.json b/source/backend/api/appsettings.Development.json index 82e81cc28e..bbd49d2e63 100644 --- a/source/backend/api/appsettings.Development.json +++ b/source/backend/api/appsettings.Development.json @@ -57,5 +57,12 @@ "Environment": "dev", "Integration": "4699" } + }, + "Ches": { + "ChesHost": "https://ches-dev.api.gov.bc.ca", + "ServiceClientId": "", + "ServiceClientSecret": "", + "Issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth", + "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token" } } diff --git a/source/backend/api/appsettings.Local.json b/source/backend/api/appsettings.Local.json index 58b8148b68..4f98da9e14 100644 --- a/source/backend/api/appsettings.Local.json +++ b/source/backend/api/appsettings.Local.json @@ -80,5 +80,12 @@ "ConnectionUser": "admin", "ConnectionPassword": "", "ExposeErrors": "true" + }, + "Ches": { + "ChesHost": "https://ches-dev.api.gov.bc.ca", + "ServiceClientId": "", + "ServiceClientSecret": "", + "Issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth", + "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token" } } From d2cd292915c2409990ff68680f636d95c210ba6b Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Thu, 12 Mar 2026 11:54:13 -0600 Subject: [PATCH 2/4] Health check for CHES --- .../Helpers/Healthchecks/ChesHealthCheck.cs | 46 +++++++++++++++++++ .../Repositories/Ches/ChesAuthRepository.cs | 3 -- .../api/Repositories/Ches/ChesRepository.cs | 13 ++++++ .../api/Repositories/Ches/IEmailRepository.cs | 3 ++ source/backend/api/Startup.cs | 12 ++++- .../backend/api/appsettings.Development.json | 4 ++ source/backend/api/appsettings.Local.json | 4 ++ source/backend/api/appsettings.Test.json | 4 ++ source/backend/api/appsettings.Uat.json | 4 ++ source/backend/api/appsettings.json | 10 ++++ .../dal/Options/AllHealthCheckOptions.cs | 2 + 11 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs diff --git a/source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs b/source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs new file mode 100644 index 0000000000..f09481c05f --- /dev/null +++ b/source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Pims.Api.Repositories.Ches; + +namespace Pims.Api.Helpers.Healthchecks +{ + /// + /// Health check for CHES service connectivity. + /// + public class ChesHealthCheck : IHealthCheck + { + private readonly IEmailRepository _repository; + + public ChesHealthCheck(IEmailRepository repository) + { + _repository = repository; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + const int maxJitterMilliseconds = 10000; + var jitter = Random.Shared.Next(0, maxJitterMilliseconds + 1); + if (jitter > 0) + { + await Task.Delay(TimeSpan.FromMilliseconds(jitter), cancellationToken); + } + + var response = await _repository.TryGetHealthAsync(); + if (response.StatusCode != System.Net.HttpStatusCode.OK) + { + return new HealthCheckResult(HealthStatus.Degraded, $"CHES health check returned status code: {response.StatusCode}"); + } + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, $"CHES health check failed with exception: {ex.Message}"); + } + return HealthCheckResult.Healthy(); + } + } +} diff --git a/source/backend/api/Repositories/Ches/ChesAuthRepository.cs b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs index b79f1dfadf..863994d0b5 100644 --- a/source/backend/api/Repositories/Ches/ChesAuthRepository.cs +++ b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs @@ -80,9 +80,6 @@ private async Task> TryRequestToken() { _logger.LogDebug("Getting authentication token..."); - using HttpClient client = _httpClientFactory.CreateClient("Pims.Api.Logging"); - client.DefaultRequestHeaders.Accept.Clear(); - var requestForm = new Dictionary { { "grant_type", "client_credentials" }, diff --git a/source/backend/api/Repositories/Ches/ChesRepository.cs b/source/backend/api/Repositories/Ches/ChesRepository.cs index 5c74f11cf0..5cdca65216 100644 --- a/source/backend/api/Repositories/Ches/ChesRepository.cs +++ b/source/backend/api/Repositories/Ches/ChesRepository.cs @@ -107,5 +107,18 @@ public async Task> SendEmailAsync(EmailRequest r _logger.LogDebug($"Finished sending email"); return result; } + + public async Task TryGetHealthAsync() + { + _logger.LogDebug("Checking health of CHES service"); + string authenticationToken = await _authRepository.GetTokenAsync(); + + Uri endpoint = new(this._config.ChesHost, "/api/v1/health"); + + Task result = GetRawAsync(endpoint, authenticationToken); + + _logger.LogDebug($"Finished checking health of CHES service"); + return await result; + } } } diff --git a/source/backend/api/Repositories/Ches/IEmailRepository.cs b/source/backend/api/Repositories/Ches/IEmailRepository.cs index e3e2b3ca79..86f6cac934 100644 --- a/source/backend/api/Repositories/Ches/IEmailRepository.cs +++ b/source/backend/api/Repositories/Ches/IEmailRepository.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Threading.Tasks; using Pims.Api.Models.Ches; using Pims.Api.Models.Requests.Http; @@ -7,5 +8,7 @@ namespace Pims.Api.Repositories.Ches public interface IEmailRepository { Task> SendEmailAsync(EmailRequest request); + + Task TryGetHealthAsync(); } } \ No newline at end of file diff --git a/source/backend/api/Startup.cs b/source/backend/api/Startup.cs index d5653bba5c..0f2a1f50cc 100644 --- a/source/backend/api/Startup.cs +++ b/source/backend/api/Startup.cs @@ -374,6 +374,16 @@ public void ConfigureServices(IServiceCollection services) { Period = TimeSpan.FromMinutes(allHealthCheckOptions.Cdogs.Period) }); } + if (allHealthCheckOptions.Ches.Enabled) + { + services.AddHealthChecks().Add(new HealthCheckRegistration( + "Ches", + sp => new ChesHealthCheck(sp.GetService()), + null, + new string[] { SERVICES, EXTERNAL, SYSTEMCHECK }) + { Period = TimeSpan.FromMinutes(allHealthCheckOptions.Ches.Period) }); + } + services.AddApiVersioning(options => { options.ReportApiVersions = true; @@ -576,7 +586,7 @@ private static void AddPimsApiServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); } /// diff --git a/source/backend/api/appsettings.Development.json b/source/backend/api/appsettings.Development.json index bbd49d2e63..617efc135c 100644 --- a/source/backend/api/appsettings.Development.json +++ b/source/backend/api/appsettings.Development.json @@ -16,6 +16,10 @@ "Ltsa": { "Enabled": false, "Period": 60 + }, + "Ches": { + "Enabled": false, + "Period": 60 } }, "Serilog": { diff --git a/source/backend/api/appsettings.Local.json b/source/backend/api/appsettings.Local.json index 4f98da9e14..626cb6cd6e 100644 --- a/source/backend/api/appsettings.Local.json +++ b/source/backend/api/appsettings.Local.json @@ -26,6 +26,10 @@ "Mayan": { "Period": 60, "Enabled": false + }, + "Ches": { + "Period": 60, + "Enabled": false } }, "Logging": { diff --git a/source/backend/api/appsettings.Test.json b/source/backend/api/appsettings.Test.json index 2d163caefb..536d92db4b 100644 --- a/source/backend/api/appsettings.Test.json +++ b/source/backend/api/appsettings.Test.json @@ -16,6 +16,10 @@ "Ltsa": { "Enabled": false, "Period": 15 + }, + "Ches": { + "Enabled": false, + "Period": 15 } }, "Serilog": { diff --git a/source/backend/api/appsettings.Uat.json b/source/backend/api/appsettings.Uat.json index 6b21492896..381955ae9a 100644 --- a/source/backend/api/appsettings.Uat.json +++ b/source/backend/api/appsettings.Uat.json @@ -16,6 +16,10 @@ "Ltsa": { "Enabled": false, "Period": 5 + }, + "Ches": { + "Enabled": false, + "Period": 5 } }, "Serilog": { diff --git a/source/backend/api/appsettings.json b/source/backend/api/appsettings.json index 24dab38bca..2d6a02e67e 100644 --- a/source/backend/api/appsettings.json +++ b/source/backend/api/appsettings.json @@ -41,6 +41,10 @@ "Cdogs": { "Enabled": true, "Period": 1 + }, + "Ches": { + "Enabled": true, + "Period": 1 } }, "Swagger": { @@ -153,6 +157,12 @@ "ServiceClientId": "[CLIENT_ID]", "ServiceClientSecret": "[CLIENT_SECRET]" }, + "Ches": { + "AuthEndpoint": "[AUTH_ENDPOINT]", + "ChesHost": "[CDOGS_HOST]", + "ServiceClientId": "[CLIENT_ID]", + "ServiceClientSecret": "[CLIENT_SECRET]" + }, "Polly": { "MaxRetries": 3, "DelayInSeconds": 1 diff --git a/source/backend/dal/Options/AllHealthCheckOptions.cs b/source/backend/dal/Options/AllHealthCheckOptions.cs index 8a61aaf0c2..6e70bf234d 100644 --- a/source/backend/dal/Options/AllHealthCheckOptions.cs +++ b/source/backend/dal/Options/AllHealthCheckOptions.cs @@ -30,6 +30,8 @@ public class AllHealthCheckOptions public PimsBaseHealthCheckOptions Cdogs { get; set; } public PimsBaseHealthCheckOptions Mayan { get; set; } + + public PimsBaseHealthCheckOptions Ches { get; set; } #endregion } } From fc18979e2d41b35f04a6693985793ddaa0689896 Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Fri, 13 Mar 2026 17:16:35 -0600 Subject: [PATCH 3/4] Move models to apimodels --- .../Models/{Common => Cdogs}/JwtResponse.cs | 2 +- .../Repositories/Cdogs/CdogsAuthRepository.cs | 2 +- .../Repositories/Ches/ChesAuthRepository.cs | 2 +- .../Models/Ches/EmailAttachment.cs | 0 .../Models/Ches/EmailBodyType.cs | 0 .../Models/Ches/EmailContext.cs | 0 .../Models/Ches/EmailEncoding.cs | 0 .../Models/Ches/EmailMergeRequest.cs | 0 .../Models/Ches/EmailPriority.cs | 0 .../Models/Ches/EmailRequest.cs | 0 .../Models/Ches/EmailResponse.cs | 0 .../Models/Ches/ErrorModel.cs | 0 .../Models/Ches/ErrorResponse.cs | 0 .../Models/Ches/IAttachment.cs | 0 .../Models/Ches/IEmailContext.cs | 0 .../apimodels/Models/Ches/JwtResponse.cs | 58 +++++++++++++++++++ .../Models/Ches/MessageResponse.cs | 0 .../Models/Ches/StatusHistory.cs | 0 .../Models/Ches/StatusResponse.cs | 0 19 files changed, 61 insertions(+), 3 deletions(-) rename source/backend/api/Models/{Common => Cdogs}/JwtResponse.cs (98%) rename source/backend/{api => apimodels}/Models/Ches/EmailAttachment.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/EmailBodyType.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/EmailContext.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/EmailEncoding.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/EmailMergeRequest.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/EmailPriority.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/EmailRequest.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/EmailResponse.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/ErrorModel.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/ErrorResponse.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/IAttachment.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/IEmailContext.cs (100%) create mode 100644 source/backend/apimodels/Models/Ches/JwtResponse.cs rename source/backend/{api => apimodels}/Models/Ches/MessageResponse.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/StatusHistory.cs (100%) rename source/backend/{api => apimodels}/Models/Ches/StatusResponse.cs (100%) diff --git a/source/backend/api/Models/Common/JwtResponse.cs b/source/backend/api/Models/Cdogs/JwtResponse.cs similarity index 98% rename from source/backend/api/Models/Common/JwtResponse.cs rename to source/backend/api/Models/Cdogs/JwtResponse.cs index cc2be6a572..55f33b5144 100644 --- a/source/backend/api/Models/Common/JwtResponse.cs +++ b/source/backend/api/Models/Cdogs/JwtResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Pims.Api.Models.Common +namespace Pims.Api.Models.Cdogs { /// /// Represents a JWT (JSON Web Token) response. diff --git a/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs b/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs index 9a48fe3bd6..dd2e87be8f 100644 --- a/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs +++ b/source/backend/api/Repositories/Cdogs/CdogsAuthRepository.cs @@ -6,8 +6,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Pims.Api.Models.Cdogs; using Pims.Api.Models.CodeTypes; -using Pims.Api.Models.Common; using Pims.Api.Models.Requests.Http; using Pims.Core.Api.Exceptions; using Polly.Registry; diff --git a/source/backend/api/Repositories/Ches/ChesAuthRepository.cs b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs index 863994d0b5..69742be482 100644 --- a/source/backend/api/Repositories/Ches/ChesAuthRepository.cs +++ b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Pims.Api.Models.CodeTypes; -using Pims.Api.Models.Common; +using Pims.Api.Models.Ches; using Pims.Api.Models.Requests.Http; using Pims.Core.Api.Exceptions; using Polly.Registry; diff --git a/source/backend/api/Models/Ches/EmailAttachment.cs b/source/backend/apimodels/Models/Ches/EmailAttachment.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailAttachment.cs rename to source/backend/apimodels/Models/Ches/EmailAttachment.cs diff --git a/source/backend/api/Models/Ches/EmailBodyType.cs b/source/backend/apimodels/Models/Ches/EmailBodyType.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailBodyType.cs rename to source/backend/apimodels/Models/Ches/EmailBodyType.cs diff --git a/source/backend/api/Models/Ches/EmailContext.cs b/source/backend/apimodels/Models/Ches/EmailContext.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailContext.cs rename to source/backend/apimodels/Models/Ches/EmailContext.cs diff --git a/source/backend/api/Models/Ches/EmailEncoding.cs b/source/backend/apimodels/Models/Ches/EmailEncoding.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailEncoding.cs rename to source/backend/apimodels/Models/Ches/EmailEncoding.cs diff --git a/source/backend/api/Models/Ches/EmailMergeRequest.cs b/source/backend/apimodels/Models/Ches/EmailMergeRequest.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailMergeRequest.cs rename to source/backend/apimodels/Models/Ches/EmailMergeRequest.cs diff --git a/source/backend/api/Models/Ches/EmailPriority.cs b/source/backend/apimodels/Models/Ches/EmailPriority.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailPriority.cs rename to source/backend/apimodels/Models/Ches/EmailPriority.cs diff --git a/source/backend/api/Models/Ches/EmailRequest.cs b/source/backend/apimodels/Models/Ches/EmailRequest.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailRequest.cs rename to source/backend/apimodels/Models/Ches/EmailRequest.cs diff --git a/source/backend/api/Models/Ches/EmailResponse.cs b/source/backend/apimodels/Models/Ches/EmailResponse.cs similarity index 100% rename from source/backend/api/Models/Ches/EmailResponse.cs rename to source/backend/apimodels/Models/Ches/EmailResponse.cs diff --git a/source/backend/api/Models/Ches/ErrorModel.cs b/source/backend/apimodels/Models/Ches/ErrorModel.cs similarity index 100% rename from source/backend/api/Models/Ches/ErrorModel.cs rename to source/backend/apimodels/Models/Ches/ErrorModel.cs diff --git a/source/backend/api/Models/Ches/ErrorResponse.cs b/source/backend/apimodels/Models/Ches/ErrorResponse.cs similarity index 100% rename from source/backend/api/Models/Ches/ErrorResponse.cs rename to source/backend/apimodels/Models/Ches/ErrorResponse.cs diff --git a/source/backend/api/Models/Ches/IAttachment.cs b/source/backend/apimodels/Models/Ches/IAttachment.cs similarity index 100% rename from source/backend/api/Models/Ches/IAttachment.cs rename to source/backend/apimodels/Models/Ches/IAttachment.cs diff --git a/source/backend/api/Models/Ches/IEmailContext.cs b/source/backend/apimodels/Models/Ches/IEmailContext.cs similarity index 100% rename from source/backend/api/Models/Ches/IEmailContext.cs rename to source/backend/apimodels/Models/Ches/IEmailContext.cs diff --git a/source/backend/apimodels/Models/Ches/JwtResponse.cs b/source/backend/apimodels/Models/Ches/JwtResponse.cs new file mode 100644 index 0000000000..0a7f793c96 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/JwtResponse.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents a JWT (JSON Web Token) response. + /// + public class JwtResponse + { + /// + /// get/set - The access token. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + /// + /// get/set - The token expiration in minutes. + /// + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + /// + /// get/set - The refresh token expiration in minutes. + /// + [JsonPropertyName("refresh_expires_in")] + public int RefreshExpiresIn { get; set; } + + /// + /// get/set - The refresh token. + /// + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + /// + /// get/set - The token type. + /// + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + /// + /// get/set - The not-before-policy. + /// + [JsonPropertyName("not-before-policy")] + public int NotBeforePolicy { get; set; } + + /// + /// get/set - The session state. + /// + [JsonPropertyName("session_state")] + public string SessionState { get; set; } + + /// + /// get/set - The scope. + /// + [JsonPropertyName("scope")] + public string Scope { get; set; } + } +} diff --git a/source/backend/api/Models/Ches/MessageResponse.cs b/source/backend/apimodels/Models/Ches/MessageResponse.cs similarity index 100% rename from source/backend/api/Models/Ches/MessageResponse.cs rename to source/backend/apimodels/Models/Ches/MessageResponse.cs diff --git a/source/backend/api/Models/Ches/StatusHistory.cs b/source/backend/apimodels/Models/Ches/StatusHistory.cs similarity index 100% rename from source/backend/api/Models/Ches/StatusHistory.cs rename to source/backend/apimodels/Models/Ches/StatusHistory.cs diff --git a/source/backend/api/Models/Ches/StatusResponse.cs b/source/backend/apimodels/Models/Ches/StatusResponse.cs similarity index 100% rename from source/backend/api/Models/Ches/StatusResponse.cs rename to source/backend/apimodels/Models/Ches/StatusResponse.cs From b8ef315e9cffafcb65de53797be74ad596186604 Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Fri, 13 Mar 2026 17:19:17 -0600 Subject: [PATCH 4/4] From email needs to be configured in environment variables --- .../api/Models/Configuration/ChesConfig.cs | 2 ++ source/backend/api/Services/ChesService.cs | 16 ++++++++++++---- source/backend/api/Startup.cs | 7 +++++++ source/backend/api/appsettings.Development.json | 3 ++- source/backend/api/appsettings.Local.json | 3 ++- source/backend/api/appsettings.json | 3 ++- .../apimodels/Models/Ches/EmailRequest.cs | 1 - 7 files changed, 27 insertions(+), 8 deletions(-) diff --git a/source/backend/api/Models/Configuration/ChesConfig.cs b/source/backend/api/Models/Configuration/ChesConfig.cs index bee1d81f68..a0db8fe797 100644 --- a/source/backend/api/Models/Configuration/ChesConfig.cs +++ b/source/backend/api/Models/Configuration/ChesConfig.cs @@ -11,5 +11,7 @@ public class ChesConfig public string ServiceClientId { get; set; } public string ServiceClientSecret { get; set; } + + public string FromEmail { get; set; } } } \ No newline at end of file diff --git a/source/backend/api/Services/ChesService.cs b/source/backend/api/Services/ChesService.cs index 754218788f..2f5d633710 100644 --- a/source/backend/api/Services/ChesService.cs +++ b/source/backend/api/Services/ChesService.cs @@ -3,29 +3,37 @@ using Microsoft.Extensions.Logging; using Pims.Api.Models.Ches; using Pims.Api.Models.CodeTypes; +using Pims.Api.Models.Config; using Pims.Api.Models.Requests.Http; using Pims.Api.Repositories.Ches; namespace Pims.Api.Services - /// - /// Default implementation of IEmailService using CHES. - /// +/// +/// Default implementation of IEmailService using CHES. +/// { public class ChesService : IEmailService { private readonly IEmailRepository _chesRepository; private readonly ILogger _logger; + private readonly ChesConfig _chesConfig; - public ChesService(IEmailRepository chesRepository, ILogger logger) + public ChesService(IEmailRepository chesRepository, ILogger logger, ChesConfig chesConfig) { _chesRepository = chesRepository; _logger = logger; + _chesConfig = chesConfig; } public async Task> SendEmailAsync(EmailRequest request) { _logger.LogInformation("Email send requested. Recipient count: {recipientCount}.", request.To?.Count ?? 0); + if (string.IsNullOrEmpty(request.From)) + { + request.From = _chesConfig.FromEmail; + } + ExternalResponse? response = await _chesRepository.SendEmailAsync(request); if (response == null || response.Payload == null) diff --git a/source/backend/api/Startup.cs b/source/backend/api/Startup.cs index 0f2a1f50cc..2276523adf 100644 --- a/source/backend/api/Startup.cs +++ b/source/backend/api/Startup.cs @@ -586,6 +586,13 @@ private static void AddPimsApiServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(sp => + { + var config = sp.GetRequiredService(); + var chesConfig = new ChesConfig(); + config.GetSection("Ches").Bind(chesConfig); + return chesConfig; + }); services.AddScoped(); } diff --git a/source/backend/api/appsettings.Development.json b/source/backend/api/appsettings.Development.json index 617efc135c..f924c3dbda 100644 --- a/source/backend/api/appsettings.Development.json +++ b/source/backend/api/appsettings.Development.json @@ -67,6 +67,7 @@ "ServiceClientId": "", "ServiceClientSecret": "", "Issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth", - "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token" + "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token", + "FromEmail": "" } } diff --git a/source/backend/api/appsettings.Local.json b/source/backend/api/appsettings.Local.json index 626cb6cd6e..6b9f0fc926 100644 --- a/source/backend/api/appsettings.Local.json +++ b/source/backend/api/appsettings.Local.json @@ -90,6 +90,7 @@ "ServiceClientId": "", "ServiceClientSecret": "", "Issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth", - "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token" + "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token", + "FromEmail": "" } } diff --git a/source/backend/api/appsettings.json b/source/backend/api/appsettings.json index 2d6a02e67e..1646f76200 100644 --- a/source/backend/api/appsettings.json +++ b/source/backend/api/appsettings.json @@ -161,7 +161,8 @@ "AuthEndpoint": "[AUTH_ENDPOINT]", "ChesHost": "[CDOGS_HOST]", "ServiceClientId": "[CLIENT_ID]", - "ServiceClientSecret": "[CLIENT_SECRET]" + "ServiceClientSecret": "[CLIENT_SECRET]", + "FromEmail": "[FROM_EMAIL]" }, "Polly": { "MaxRetries": 3, diff --git a/source/backend/apimodels/Models/Ches/EmailRequest.cs b/source/backend/apimodels/Models/Ches/EmailRequest.cs index 6d29794bf4..d0e6a86220 100644 --- a/source/backend/apimodels/Models/Ches/EmailRequest.cs +++ b/source/backend/apimodels/Models/Ches/EmailRequest.cs @@ -32,7 +32,6 @@ public class EmailRequest /// All email addresses can be plain 'sender@server.com' or formatted '"Sender Name" sender@server.com'. /// [JsonPropertyName("from")] - [System.ComponentModel.DataAnnotations.Required] public string From { get; set; } ///