diff --git a/Hippo.Core/Services/EmailService.cs b/Hippo.Core/Services/EmailService.cs index 933d7ce6..27c7fe02 100644 --- a/Hippo.Core/Services/EmailService.cs +++ b/Hippo.Core/Services/EmailService.cs @@ -52,10 +52,12 @@ public async Task SendEmail(EmailModel emailModel) { message.To.Add(new MailAddress(email, email)); } - - foreach (var ccEmail in emailModel.CcEmails) + if (emailModel.CcEmails != null) { - message.CC.Add(new MailAddress(ccEmail)); + foreach (var ccEmail in emailModel.CcEmails) + { + message.CC.Add(new MailAddress(ccEmail)); + } } if (!string.IsNullOrWhiteSpace(_emailSettings.BccEmail)) diff --git a/Hippo.Core/Services/NotificationService.cs b/Hippo.Core/Services/NotificationService.cs index b927f920..d4a77e9f 100644 --- a/Hippo.Core/Services/NotificationService.cs +++ b/Hippo.Core/Services/NotificationService.cs @@ -20,9 +20,13 @@ namespace Hippo.Core.Services public interface INotificationService { Task AccountRequest(Request request); - Task AccountDecision(Request request, bool isApproved, string overrideSponsor = null, string reason = null); + Task AccountDecision(Request request, bool isApproved, string decidedBy , string reason = null); Task AdminOverrideDecision(Request request, bool isApproved, User adminUser, string reason = null); Task SimpleNotification(SimpleNotificationModel simpleNotificationModel, string[] emails, string[] ccEmails = null); + + Task AdminPaymentFailureNotification(string[] emails, string clusterName, int[] orderIds); + Task SponsorPaymentFailureNotification(string[] emails, Order order); //Could possibly just pass the order Id, but there might be more order info we want to include + Task OrderNotification(SimpleNotificationModel simpleNotificationModel, Order order, string[] emails, string[] ccEmails = null); } public class NotificationService : INotificationService @@ -30,33 +34,22 @@ public class NotificationService : INotificationService private readonly AppDbContext _dbContext; private readonly IEmailService _emailService; private readonly EmailSettings _emailSettings; - private readonly IUserService _userService; private readonly IMjmlRenderer _mjmlRenderer; public NotificationService(AppDbContext dbContext, IEmailService emailService, - IOptions emailSettings, IUserService userService, IMjmlRenderer mjmlRenderer) + IOptions emailSettings, IMjmlRenderer mjmlRenderer) { _dbContext = dbContext; _emailService = emailService; _emailSettings = emailSettings.Value; - _userService = userService; _mjmlRenderer = mjmlRenderer; } - public async Task AccountDecision(Request request, bool isApproved, string overrideDecidedBy = null, string details = "") + public async Task AccountDecision(Request request, bool isApproved, string decidedBy, string details = "") { try { - var decidedBy = String.Empty; - if (!string.IsNullOrWhiteSpace(overrideDecidedBy)) - { - decidedBy = overrideDecidedBy; - } - else - { - decidedBy = (await _userService.GetCurrentUser()).Name; - } var requestUrl = $"{_emailSettings.BaseUrl}/{request.Cluster.Name}"; //TODO: Only have button if approved? var emailTo = request.Requester.Email; @@ -237,5 +230,121 @@ private async Task GetGroupAdminEmails(Group group) .ToArrayAsync(); return groupAdminEmails; } + + public async Task AdminPaymentFailureNotification(string[] emails, string clusterName, int[] orderIds) + { + try + { + var message = "The payment for one or more orders in hippo have failed."; + + var model = new OrderNotificationModel() + { + UcdLogoUrl = $"{_emailSettings.BaseUrl}/media/caes-logo-gray.png", + Subject = "Payment failed", + Header = "Order Payment Failed", + Paragraphs = new List(), + }; + foreach (var orderId in orderIds) + { + model.Paragraphs.Add($"{_emailSettings.BaseUrl}/{clusterName}/order/details/{orderId}"); + + } + + var htmlBody = await _mjmlRenderer.RenderView("/Views/Emails/OrderAdminPaymentFail_mjml.cshtml", model); + + await _emailService.SendEmail(new EmailModel + { + Emails = emails, + CcEmails = null, + HtmlBody = htmlBody, + TextBody = message, + Subject = model.Subject, + }); + + return true; + } + catch (Exception ex) + { + Log.Error("Error emailing Sponsor Payment Failure Notification", ex); + return false; + } + } + + public async Task SponsorPaymentFailureNotification(string[] emails, Order order) + { + try + { + var message = "The payment for the following order has failed. Please update your billing information in Hippo."; + + var model = new OrderNotificationModel() + { + UcdLogoUrl = $"{_emailSettings.BaseUrl}/media/caes-logo-gray.png", + ButtonUrl = $"{_emailSettings.BaseUrl}/{order.Cluster.Name}/order/details/{order.Id}", + Subject = "Payment failed", + Header = "Order Payment Failed", + Paragraphs = new List(), + }; + model.Paragraphs.Add($"Order: {order.Name}"); + model.Paragraphs.Add($"Order Id: {order.Id}"); + model.Paragraphs.Add("The payment for this order has failed."); + model.Paragraphs.Add("This is most likely due to a Aggie Enterprise Chart String which is no longer valid."); + model.Paragraphs.Add("The order details will have the validation message from Aggie Enterprise."); + model.Paragraphs.Add("Please update your billing information in Hippo."); + + var htmlBody = await _mjmlRenderer.RenderView("/Views/Emails/OrderNotification_mjml.cshtml", model); + + await _emailService.SendEmail(new EmailModel + { + Emails = emails, + CcEmails = null, + HtmlBody = htmlBody, + TextBody = message, + Subject = model.Subject, + }); + + return true; + } + catch (Exception ex) + { + Log.Error("Error emailing Sponsor Payment Failure Notification", ex); + return false; + } + } + + public async Task OrderNotification(SimpleNotificationModel simpleNotificationModel, Order order, string[] emails, string[] ccEmails = null) + { + try + { + var message = simpleNotificationModel.Paragraphs.FirstOrDefault(); + + var model = new OrderNotificationModel() + { + UcdLogoUrl = $"{_emailSettings.BaseUrl}/media/caes-logo-gray.png", + ButtonUrl = $"{_emailSettings.BaseUrl}/{order.Cluster.Name}/order/details/{order.Id}", + Subject = simpleNotificationModel.Subject, + Header = simpleNotificationModel.Header, + Paragraphs = simpleNotificationModel.Paragraphs, + }; + + + var htmlBody = await _mjmlRenderer.RenderView("/Views/Emails/OrderNotification_mjml.cshtml", model); + + await _emailService.SendEmail(new EmailModel + { + Emails = emails, + CcEmails = ccEmails, + HtmlBody = htmlBody, + TextBody = message, + Subject = simpleNotificationModel.Subject, + }); + + return true; + } + catch (Exception ex) + { + Log.Error("Error emailing Order Notification", ex); + return false; + } + } } } diff --git a/Hippo.Core/Services/PaymentsService.cs b/Hippo.Core/Services/PaymentsService.cs index 0ae22a63..7a871871 100644 --- a/Hippo.Core/Services/PaymentsService.cs +++ b/Hippo.Core/Services/PaymentsService.cs @@ -24,12 +24,14 @@ public class PaymentsService : IPaymentsService private readonly AppDbContext _dbContext; private readonly IHistoryService _historyService; private readonly IAggieEnterpriseService _aggieEnterpriseService; + private readonly INotificationService _notificationService; - public PaymentsService(AppDbContext dbContext, IHistoryService historyService, IAggieEnterpriseService aggieEnterpriseService) + public PaymentsService(AppDbContext dbContext, IHistoryService historyService, IAggieEnterpriseService aggieEnterpriseService, INotificationService notificationService) { _dbContext = dbContext; _historyService = historyService; _aggieEnterpriseService = aggieEnterpriseService; + _notificationService = notificationService; } public async Task CreatePayments() { @@ -164,8 +166,17 @@ public async Task NotifyAboutFailedPayments() } if (invalidChartStrings) { + Log.Error("Order {0} has an invalid chart string", order.Id); //TODO: Notify the sponsor //Remember to add notification/email service to job + try + { + await _notificationService.SponsorPaymentFailureNotification(new string[] { order.PrincipalInvestigator.Email }, order); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to notify sponsor for order {0}", order.Id); + } invalidOrderIdsInCluster.Add(order.Id); @@ -177,9 +188,18 @@ public async Task NotifyAboutFailedPayments() //Send email to cluster admins with list of orders that have failed payments //https://localhost:44371/caesfarm/order/details/46 - var clusterAdmins = await _dbContext.Users.AsNoTracking().Where(u => u.Permissions.Any(p => p.Cluster.Id == orderGroup.Key && p.Role.Name == Role.Codes.ClusterAdmin)).OrderBy(u => u.LastName).ThenBy(u => u.FirstName).ToArrayAsync(); + var clusterAdmins = await _dbContext.Users.AsNoTracking().Where(u => u.Permissions.Any(p => p.Cluster.Id == orderGroup.Key && p.Role.Name == Role.Codes.ClusterAdmin)).Select(a => a.Email).ToArrayAsync(); //TODO: Notify the cluster admins with a single email + try + { + await _notificationService.AdminPaymentFailureNotification(clusterAdmins, cluster.Name, invalidOrderIdsInCluster.ToArray()); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to notify cluster admins for cluster {0}", cluster.Id); + } + } } diff --git a/Hippo.Email/Hippo.Email.csproj b/Hippo.Email/Hippo.Email.csproj index 515f8339..31be7eac 100644 --- a/Hippo.Email/Hippo.Email.csproj +++ b/Hippo.Email/Hippo.Email.csproj @@ -7,7 +7,7 @@ enable - + diff --git a/Hippo.Email/Models/OrderNotificationModel.cs b/Hippo.Email/Models/OrderNotificationModel.cs new file mode 100644 index 00000000..0f11c645 --- /dev/null +++ b/Hippo.Email/Models/OrderNotificationModel.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Hippo.Email.Models +{ + public class OrderNotificationModel + { + + public string UcdLogoUrl { get; set; } = String.Empty; + + public string ButtonText { get; set; } = "View Order"; + public string ButtonUrl { get; set; } = ""; + + public string Subject { get; set; } = ""; + public string Header { get; set; } = ""; + public List Paragraphs { get; set; } = new(); + + } +} diff --git a/Hippo.Email/Views/Emails/OrderAdminPaymentFail_mjml.cshtml b/Hippo.Email/Views/Emails/OrderAdminPaymentFail_mjml.cshtml new file mode 100644 index 00000000..c918288a --- /dev/null +++ b/Hippo.Email/Views/Emails/OrderAdminPaymentFail_mjml.cshtml @@ -0,0 +1,43 @@ +@using Hippo.Email.Models +@model Hippo.Email.Models.OrderNotificationModel + +@{ + ViewData["EmailTitle"] = "Order Notification"; + Layout = "_EmailLayout_mjml"; +} + + + + + +

@Model.Header

+
+ +

There are one or more orders that have failing payments.

+

The Sponsor has been notified, and until the billing is corrected the payment can't go through.

+
+
+
+ + + + + +

Orders With Failing Payments

+
+
+
+ + + + @foreach (var paragraph in @Model.Paragraphs) + { +

@paragraph

+ } +
+
+
+ diff --git a/Hippo.Email/Views/Emails/OrderNotification_mjml.cshtml b/Hippo.Email/Views/Emails/OrderNotification_mjml.cshtml new file mode 100644 index 00000000..df7a67fc --- /dev/null +++ b/Hippo.Email/Views/Emails/OrderNotification_mjml.cshtml @@ -0,0 +1,27 @@ +@using Hippo.Email.Models +@model Hippo.Email.Models.OrderNotificationModel + +@{ + ViewData["EmailTitle"] = "Order Notification"; + Layout = "_EmailLayout_mjml"; +} + + + + + +

@Model.Header

+
+ + @foreach (var paragraph in @Model.Paragraphs) + { +

@paragraph

+ } +
+
+
+ +@await Html.PartialAsync("_EmailButton_mjml", new EmailButtonModel(@Model!.ButtonText, @Model!.ButtonUrl)) + + diff --git a/Hippo.Jobs.OrderProcess/Program.cs b/Hippo.Jobs.OrderProcess/Program.cs index 728b5e85..125ca558 100644 --- a/Hippo.Jobs.OrderProcess/Program.cs +++ b/Hippo.Jobs.OrderProcess/Program.cs @@ -9,6 +9,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Mjml.Net; using Serilog; @@ -120,6 +121,7 @@ private static ServiceProvider ConfigureServices() services.Configure(Configuration.GetSection("Azure")); services.Configure(Configuration.GetSection("Sloth")); services.Configure(Configuration.GetSection("AggieEnterprise")); + services.Configure(Configuration.GetSection("Email")); services.AddScoped(); services.AddTransient(); @@ -127,7 +129,10 @@ private static ServiceProvider ConfigureServices() services.AddHttpClient(); services.AddSingleton(); services.AddScoped(); - //TODO: This will probably need the notification service as well. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services.BuildServiceProvider(); diff --git a/Hippo.Jobs.OrderProcess/appsettings.json b/Hippo.Jobs.OrderProcess/appsettings.json index a859e825..d797ab13 100644 --- a/Hippo.Jobs.OrderProcess/appsettings.json +++ b/Hippo.Jobs.OrderProcess/appsettings.json @@ -30,6 +30,16 @@ "ApiUrl": "https://sloth-api-test.azurewebsites.net/v2/", "HippoBaseUrl": "https://hippo-test.azurewebsites.net/" }, + "Email": { + "ApiKey": "[External]", + "UserName": "[External]", + "Password": "[External]", + "Host": "[External]", + "Port": 1, + "FromEmail": "hippo@notify.ucdavis.edu", + "FromName": "Hippo Notification", + "BaseUrl": "https://localhost:44371" + }, "ConnectionStrings": { "DefaultConnection": "[External]" } diff --git a/Hippo.Web/Controllers/OrderController.cs b/Hippo.Web/Controllers/OrderController.cs index 723065b1..1203c47a 100644 --- a/Hippo.Web/Controllers/OrderController.cs +++ b/Hippo.Web/Controllers/OrderController.cs @@ -10,6 +10,9 @@ using Microsoft.EntityFrameworkCore; using static Hippo.Core.Domain.Product; using static Hippo.Core.Models.SlothModels.TransferViewModel; +using Serilog; +using Hippo.Email.Models; +using System.Runtime.CompilerServices; namespace Hippo.Web.Controllers { @@ -21,14 +24,15 @@ public class OrderController : SuperController private readonly IAggieEnterpriseService _aggieEnterpriseService; private readonly IUserService _userService; private readonly IHistoryService _historyService; + private readonly INotificationService _notificationService; - - public OrderController(AppDbContext dbContext, IAggieEnterpriseService aggieEnterpriseService, IUserService userService, IHistoryService historyService) + public OrderController(AppDbContext dbContext, IAggieEnterpriseService aggieEnterpriseService, IUserService userService, IHistoryService historyService, INotificationService notificationService) { _dbContext = dbContext; _aggieEnterpriseService = aggieEnterpriseService; _userService = userService; _historyService = historyService; + _notificationService = notificationService; } @@ -219,6 +223,7 @@ public async Task Save([FromBody] OrderPostModel model) { return BadRequest("Invalid Installment Type Detected."); } + var result = new ProcessingResult(); if (model.Id == 0) { @@ -228,6 +233,7 @@ public async Task Save([FromBody] OrderPostModel model) return BadRequest(processingResult.Message); } orderToReturn = processingResult.Order; + result = processingResult; } else { @@ -238,13 +244,26 @@ public async Task Save([FromBody] OrderPostModel model) } orderToReturn = processingResult.Order; - + result = processingResult; } await _dbContext.SaveChangesAsync(); + if(result.NotificationMethod != null && orderToReturn != null) + { + switch (result.NotificationMethod) + { + case "NotifyAdminOrderSubmitted": + await NotifyAdminOrderSubmitted(orderToReturn); + break; + case "NotifySponsorOrderCreatedByAdmin": + await NotifySponsorOrderCreatedByAdmin(orderToReturn); + break; + } + } + return Ok(orderToReturn); } @@ -334,6 +353,7 @@ public async Task ChangeStatus(int id, string expectedStatus) existingOrder.Status = Order.Statuses.Submitted; await _historyService.OrderSnapshot(existingOrder, currentUser, History.OrderActions.Updated); await _historyService.OrderUpdated(existingOrder, currentUser, "Order Submitted."); + await NotifyAdminOrderSubmitted(existingOrder); break; case Order.Statuses.Submitted: @@ -348,6 +368,7 @@ public async Task ChangeStatus(int id, string expectedStatus) existingOrder.Status = Order.Statuses.Processing; await _historyService.OrderSnapshot(existingOrder, currentUser, History.OrderActions.Updated); await _historyService.OrderUpdated(existingOrder, currentUser, "Order Processing."); + await NotifySponsorOrderStatusChange(existingOrder); break; case Order.Statuses.Processing: @@ -389,6 +410,8 @@ public async Task ChangeStatus(int id, string expectedStatus) await _historyService.OrderSnapshot(existingOrder, currentUser, History.OrderActions.Updated); await _historyService.OrderUpdated(existingOrder, currentUser, "Order Activated."); + await NotifySponsorOrderStatusChange(existingOrder); + break; default: return BadRequest("You cannot change the status of an order in the current status."); @@ -633,10 +656,98 @@ private async Task SaveNewOrder(OrderPostModel model, Account rtValue.Success = true; rtValue.Order = order; + + + if(order.Status == Order.Statuses.Created) + { + rtValue.NotificationMethod = "NotifySponsorOrderCreatedByAdmin"; + } + if(order.Status == Order.Statuses.Submitted) + { + rtValue.NotificationMethod = "NotifyAdminOrderSubmitted"; + } return rtValue; } + private async Task NotifyAdminOrderSubmitted(Order order) + { + try + { + var clusterAdmins = await _dbContext.Users.AsNoTracking().Where(u => u.Permissions.Any(p => p.Cluster.Id == order.ClusterId && p.Role.Name == Role.Codes.ClusterAdmin)).Select(a => a.Email).ToArrayAsync(); + var emailModel = new SimpleNotificationModel + { + Subject = "New Order Submitted", + Header = "A new order has been submitted.", + Paragraphs = new List + { + $"A new order has been submitted by {order.PrincipalInvestigator.Owner.FirstName} {order.PrincipalInvestigator.Owner.LastName}.", + } + }; + + await _notificationService.OrderNotification(emailModel, order, clusterAdmins); + } + catch (Exception ex) + { + Log.Error(ex, "Error sending email to admins for new order submission."); + } + } + + private async Task NotifySponsorOrderCreatedByAdmin(Order order) + { + try + { + var emailModel = new SimpleNotificationModel + { + Subject = "New Order Created", + Header = "A new order has been created for you.", + Paragraphs = new List + { + "A new order has been created for you. Please enter the billing information and approve it for processing.", + "If you believe this was done in error, please contact the cluster admins before canceleing it." + } + }; + + await _notificationService.OrderNotification(emailModel, order, new string[] {order.PrincipalInvestigator.Email}); + } + catch (Exception ex) + { + Log.Error(ex, "Error sending email to admins for new order submission."); + } + } + + private async Task NotifySponsorOrderStatusChange(Order order) + { + try + { + var emailModel = new SimpleNotificationModel + { + Subject = "Order Updated", + Header = "Order Status has changed", + Paragraphs = new List(), + }; + + if(order.Status == Order.Statuses.Processing) + { + emailModel.Paragraphs.Add("We have begun processing your order."); + } + if(order.Status == Order.Statuses.Active) + { + emailModel.Paragraphs.Add("Your order has been activated. Automatic billing will commence. You may also make out of cycle payments on your order."); + } + if(order.Status == Order.Statuses.Rejected) + { + emailModel.Paragraphs.Add("Your order has been rejected."); + } + + await _notificationService.OrderNotification(emailModel, order, new string[] {order.PrincipalInvestigator.Email}); + } + catch (Exception ex) + { + Log.Error(ex, "Error sending email to admins for new order submission."); + } + } + private async Task UpdateExistingOrder(OrderPostModel model, bool isClusterOrSystemAdmin, User currentUser) { var rtValue = new ProcessingResult(); @@ -907,6 +1018,8 @@ private class ProcessingResult public string? Message { get; set; } public Order? Order { get; set; } + + public string? NotificationMethod { get; set; } } } } diff --git a/Hippo.Web/Controllers/RequestController.cs b/Hippo.Web/Controllers/RequestController.cs index 46c0d6eb..00774ba6 100644 --- a/Hippo.Web/Controllers/RequestController.cs +++ b/Hippo.Web/Controllers/RequestController.cs @@ -128,7 +128,7 @@ private async Task ApproveCreateAccount(AccountRequest request) request.Status = AccountRequest.Statuses.Processing; request.UpdatedOn = DateTime.UtcNow; - var success = await _notificationService.AccountDecision(request, true, + var success = await _notificationService.AccountDecision(request, true, decidedBy: currentUser.Name, reason: "Your account request has been approved. You will receive another email with more details once your " + $"account is created on {Cluster}. You can check the \"My Account\" tab of Hippo to see what " + "resources you have access to."); @@ -183,7 +183,7 @@ private async Task ApproveAddAccountToGroup(AccountRequest request request.Status = AccountRequest.Statuses.Processing; request.UpdatedOn = DateTime.UtcNow; - var success = await _notificationService.AccountDecision(request, true); + var success = await _notificationService.AccountDecision(request, true, currentUser.Name); if (!success) { Log.Error("Error creating Account Decision email"); @@ -240,7 +240,7 @@ public async Task Reject(int id, [FromBody] RequestRejectionModel request.Status = AccountRequest.Statuses.Rejected; request.UpdatedOn = DateTime.UtcNow; - var success = await _notificationService.AccountDecision(request, false, reason: model.Reason); + var success = await _notificationService.AccountDecision(request, false, decidedBy: currentUser.Name, reason: model.Reason); if (!success) { Log.Error("Error creating Account Decision email"); diff --git a/Hippo.Web/appsettings.json b/Hippo.Web/appsettings.json index 073924f2..e88ce515 100644 --- a/Hippo.Web/appsettings.json +++ b/Hippo.Web/appsettings.json @@ -20,7 +20,6 @@ }, "Email": { "ApiKey": "[External]", - "DisableSend": "Yes", "UserName": "[External]", "Password": "[External]", "Host": "[External]",