diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f1dab9d..56fda29aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). - Application permission handling and administration - Emedia management - Promenade navigation management +- Card renewal requests ### Changed - Default to only logging Warnings for Microsoft and System namespaces diff --git a/ocuda.sln b/ocuda.sln index 36afd5ada..983002e77 100644 --- a/ocuda.sln +++ b/ocuda.sln @@ -89,6 +89,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlideUploader", "src\SlideU EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Models", "src\Models\Models.csproj", "{C01EBE34-9964-4CED-A775-4D2139443132}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolarisHelper", "src\PolarisHelper\PolarisHelper.csproj", "{E11E3598-BE6D-C9D8-D480-4EE48E7F4120}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -171,6 +173,10 @@ Global {C01EBE34-9964-4CED-A775-4D2139443132}.Debug|Any CPU.Build.0 = Debug|Any CPU {C01EBE34-9964-4CED-A775-4D2139443132}.Release|Any CPU.ActiveCfg = Release|Any CPU {C01EBE34-9964-4CED-A775-4D2139443132}.Release|Any CPU.Build.0 = Release|Any CPU + {E11E3598-BE6D-C9D8-D480-4EE48E7F4120}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E11E3598-BE6D-C9D8-D480-4EE48E7F4120}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E11E3598-BE6D-C9D8-D480-4EE48E7F4120}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E11E3598-BE6D-C9D8-D480-4EE48E7F4120}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -199,6 +205,7 @@ Global {28C9B95E-DD9B-458B-86EC-37B265D66181} = {1C0384E0-C8AD-46D9-9319-C75D67E6474E} {0DF031C8-95B2-48D1-ACD9-01E56B10F3D6} = {FC70E8FE-76E9-4D23-8D85-F219DD8BAC37} {C01EBE34-9964-4CED-A775-4D2139443132} = {FC70E8FE-76E9-4D23-8D85-F219DD8BAC37} + {E11E3598-BE6D-C9D8-D480-4EE48E7F4120} = {FC70E8FE-76E9-4D23-8D85-F219DD8BAC37} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E2592F73-781B-49DC-8020-540473F814F7} diff --git a/src/Models/AddressLookupResult.cs b/src/Models/AddressLookupResult.cs new file mode 100644 index 000000000..f408da017 --- /dev/null +++ b/src/Models/AddressLookupResult.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Ocuda.Models +{ + public class AddressLookupResult + { + public string Error { get; set; } + public IEnumerable Residents { get; set; } + public string PostalCode { get; set; } + public string StreetAddress1 { get; set; } + public bool Success { get; set; } + } +} diff --git a/src/Models/Customer.cs b/src/Models/Customer.cs index aff02ee52..326d98f98 100644 --- a/src/Models/Customer.cs +++ b/src/Models/Customer.cs @@ -9,6 +9,7 @@ public class Customer public DateTime? AddressVerificationDate { get; set; } public DateTime? BirthDate { get; set; } public string BlockingNotes { get; set; } + public double ChargeBalance { get; set; } public int CustomerCodeId { get; set; } public string CustomerIdNumber { get; set; } public string EmailAddress { get; set; } diff --git a/src/Models/CustomerBlock.cs b/src/Models/CustomerBlock.cs new file mode 100644 index 000000000..ebd64a3f7 --- /dev/null +++ b/src/Models/CustomerBlock.cs @@ -0,0 +1,8 @@ +namespace Ocuda.Models +{ + public class CustomerBlock + { + public int? BlockId { get; set; } + public string Description { get; set; } + } +} diff --git a/src/Ops.Controllers/ApiController.cs b/src/Ops.Controllers/ApiController.cs index 8ff0b41d5..6447f4f29 100644 --- a/src/Ops.Controllers/ApiController.cs +++ b/src/Ops.Controllers/ApiController.cs @@ -1,8 +1,13 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Text.Json; using System.Threading.Tasks; +using System.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Ocuda.Models; using Ocuda.Ops.Controllers.Areas.ContentManagement; using Ocuda.Ops.Models; using Ocuda.Ops.Models.Entities; @@ -20,36 +25,47 @@ public class ApiController : Controller private readonly IApiKeyService _apiKeyService; private readonly IAuthorizationService _authorizationService; private readonly IDigitalDisplayService _digitalDisplayService; + private readonly HttpClient _httpClient; private readonly ILdapService _ldapService; private readonly ILogger _logger; private readonly IPermissionGroupService _permissionGroupService; + private readonly ISiteSettingService _siteSettingService; private readonly IUserService _userService; public ApiController(IApiKeyService apiKeyService, IAuthorizationService authorizationService, IDigitalDisplayService digitalDisplayService, + HttpClient httpClient, ILdapService ldapService, ILogger logger, IPermissionGroupService permissionGroupService, + ISiteSettingService siteSettingService, IUserService userService) { ArgumentNullException.ThrowIfNull(apiKeyService); ArgumentNullException.ThrowIfNull(authorizationService); ArgumentNullException.ThrowIfNull(digitalDisplayService); + ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(ldapService); ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(permissionGroupService); + ArgumentNullException.ThrowIfNull(siteSettingService); ArgumentNullException.ThrowIfNull(userService); _apiKeyService = apiKeyService; _authorizationService = authorizationService; _digitalDisplayService = digitalDisplayService; + _httpClient = httpClient; _ldapService = ldapService; _logger = logger; _permissionGroupService = permissionGroupService; + _siteSettingService = siteSettingService; _userService = userService; } + public static string Name + { get { return "Api"; } } + private static JsonResponse ErrorJobResult(string message) { return new JsonResponse @@ -60,6 +76,53 @@ private static JsonResponse ErrorJobResult(string message) }; } + public async Task AddressLookup(string address, string zip) + { + var addressLookupPath = await _siteSettingService.GetSettingStringAsync(Models + .Keys + .SiteSetting + .CardRenewal + .AddressLookupUrl); + + var queryParams = new Dictionary + { + { nameof(address), HttpUtility.UrlEncode(address) }, + { nameof(zip), HttpUtility.UrlEncode(zip) } + }; + var parameterString = string.Join('&', queryParams.Select(_ => $"{_.Key}={_.Value}")); + + var queryUri = new UriBuilder(addressLookupPath) { Query = parameterString }.Uri; + + using var response = await _httpClient.GetAsync(queryUri); + + if (response.IsSuccessStatusCode) + { + using var responseStream = await response.Content.ReadAsStreamAsync(); + + try + { + return await JsonSerializer.DeserializeAsync( + responseStream, + new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + catch (JsonException jex) + { + _logger.LogError(jex, "Error decoding JSON: {ErrorMessage}", jex.Message); + } + } + else + { + _logger.LogError("Address lookup returned status code {StatusCode} for parameters {Parameters}", + response.StatusCode, + parameterString); + } + + return null; + } + #region Slide Upload [HttpPost("[action]")] diff --git a/src/Ops.Controllers/Areas/ContentManagement/CardRenewalController.cs b/src/Ops.Controllers/Areas/ContentManagement/CardRenewalController.cs new file mode 100644 index 000000000..0f100c557 --- /dev/null +++ b/src/Ops.Controllers/Areas/ContentManagement/CardRenewalController.cs @@ -0,0 +1,249 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Controllers.Abstract; +using Ocuda.Ops.Controllers.Areas.ContentManagement.ViewModels.CardRenewal; +using Ocuda.Ops.Controllers.Filters; +using Ocuda.Ops.Models; +using Ocuda.Ops.Models.Keys; +using Ocuda.Ops.Service.Interfaces.Ops.Services; +using Ocuda.Ops.Service.Interfaces.Promenade.Services; +using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; +using Ocuda.Utility.Keys; + +namespace Ocuda.Ops.Controllers.Areas.ContentManagement +{ + [Area("ContentManagement")] + [Route("[area]/[controller]")] + public class CardRenewalController : BaseController + { + private readonly ICardRenewalService _cardRenewalService; + private readonly IEmailService _emailService; + private readonly ILanguageService _languageService; + private readonly IPermissionGroupService _permissionGroupService; + + public CardRenewalController(ServiceFacades.Controller context, + ICardRenewalService cardRenewalService, + IEmailService emailService, + ILanguageService languageService, + IPermissionGroupService permissionGroupService) + : base(context) + { + ArgumentNullException.ThrowIfNull(cardRenewalService); + ArgumentNullException.ThrowIfNull(emailService); + ArgumentNullException.ThrowIfNull(languageService); + ArgumentNullException.ThrowIfNull(permissionGroupService); + + _cardRenewalService = cardRenewalService; + _emailService = emailService; + _languageService = languageService; + _permissionGroupService = permissionGroupService; + } + + public static string Area + { get { return "ContentManagement"; } } + + public static string Name + { get { return "CardRenewal"; } } + + [HttpPost("[action]")] + public async Task ChangeResponseSort(int id, bool increase) + { + JsonResponse response; + + if (await HasCardRenewalManagementRightsAsync()) + { + try + { + await _cardRenewalService.UpdateResponseSortOrderAsync(id, increase); + response = new JsonResponse + { + Success = true + }; + } + catch (OcudaException ex) + { + response = new JsonResponse + { + Message = ex.Message, + Success = false + }; + } + } + else + { + response = new JsonResponse + { + Message = "Unauthorized", + Success = false + }; + } + + return Json(response); + } + + [HttpPost("[action]")] + public async Task CreateResponse(ResponsesViewModel viewModel) + { + if (!await HasCardRenewalManagementRightsAsync()) + { + return RedirectToUnauthorized(); + } + + ArgumentNullException.ThrowIfNull(viewModel); + + var response = await _cardRenewalService.CreateResponseAsync(viewModel.Response); + + return RedirectToAction(nameof(Response), new { id = response.Id }); + } + + [HttpPost("[action]")] + public async Task DeleteResponse(ResponsesViewModel viewModel) + { + if (!await HasCardRenewalManagementRightsAsync()) + { + return RedirectToUnauthorized(); + } + + ArgumentNullException.ThrowIfNull(viewModel); + + await _cardRenewalService.DeleteResponseAsync(viewModel.Response.Id); + + ShowAlertSuccess($"Deleted response: {viewModel.Response.Name}"); + + return RedirectToAction(nameof(Responses)); + } + + [HttpGet("[action]")] + public async Task GetEmailSetupText(int emailSetupId, string languageName) + { + JsonResponse response; + + if (await HasCardRenewalManagementRightsAsync()) + { + try + { + var emailSetupText = await _emailService.GetSetupTextByLanguageAsync( + emailSetupId, languageName); + + return Json(new + { + success = true, + emailSetupText?.Subject, + emailSetupText?.Preview, + emailSetupText?.BodyText + }); + } + catch (OcudaException ex) + { + response = new JsonResponse + { + Message = ex.Message, + Success = false + }; + } + } + else + { + response = new JsonResponse + { + Message = "Unauthorized", + Success = false + }; + } + + return Json(response); + } + + [HttpGet("[action]/{id}")] + [RestoreModelState] + public async Task Response(int id) + { + if (!await HasCardRenewalManagementRightsAsync()) + { + return RedirectToUnauthorized(); + } + + var response = await _cardRenewalService.GetResponseAsync(id); + if (response == null) + { + ShowAlertDanger($"Could not find Response with ID: {id}"); + return RedirectToAction(nameof(Responses)); + } + + var emailSetups = await _emailService.GetEmailSetupsAsync(); + + var viewModel = new ResponseViewModel + { + Response = response, + EmailSetups = new SelectList(emailSetups, "Key", "Value"), + Languages = await _languageService.GetActiveAsync() + }; + + if (response.EmailSetupId.HasValue && viewModel.Languages.Count > 0) + { + viewModel.EmailSetupText = await _emailService.GetSetupTextByLanguageAsync( + response.EmailSetupId.Value, + viewModel.Languages.FirstOrDefault()?.Name); + } + + return View(viewModel); + } + + [HttpPost("[action]/{id}")] + [SaveModelState] + public async Task Response(ResponseViewModel viewModel) + { + if (!await HasCardRenewalManagementRightsAsync()) + { + return RedirectToUnauthorized(); + } + + ArgumentNullException.ThrowIfNull(viewModel); + + if (ModelState.IsValid) + { + try + { + await _cardRenewalService.UpdateResponseAsync(viewModel.Response); + ShowAlertSuccess("Updated response"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating card renewal response: {Message}", + ex.Message); + ShowAlertDanger("Error updating response"); + } + } + + return RedirectToAction(nameof(Response), new { id = viewModel.Response.Id }); + } + + [HttpGet("[action]")] + public async Task Responses() + { + if (!await HasCardRenewalManagementRightsAsync()) + { + return RedirectToUnauthorized(); + } + + var viewModel = new ResponsesViewModel + { + Responses = await _cardRenewalService.GetResponsesAsync() + }; + + return View(viewModel); + } + + private async Task HasCardRenewalManagementRightsAsync() + { + return !string.IsNullOrEmpty(UserClaim(ClaimType.SiteManager)) + || await HasAppPermissionAsync(_permissionGroupService, + ApplicationPermission.CardRenewalManagement); + } + } +} diff --git a/src/Ops.Controllers/Areas/ContentManagement/DigitalDisplaysController.cs b/src/Ops.Controllers/Areas/ContentManagement/DigitalDisplaysController.cs index 1f9152bfb..e821b82fb 100644 --- a/src/Ops.Controllers/Areas/ContentManagement/DigitalDisplaysController.cs +++ b/src/Ops.Controllers/Areas/ContentManagement/DigitalDisplaysController.cs @@ -17,6 +17,7 @@ using Ocuda.Ops.Service.Interfaces.Ops.Services; using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; diff --git a/src/Ops.Controllers/Areas/ContentManagement/HomeController.cs b/src/Ops.Controllers/Areas/ContentManagement/HomeController.cs index 01cfef5e3..4418526e9 100644 --- a/src/Ops.Controllers/Areas/ContentManagement/HomeController.cs +++ b/src/Ops.Controllers/Areas/ContentManagement/HomeController.cs @@ -44,6 +44,8 @@ public async Task Index() var viewModel = new IndexViewModel { IsSiteManager = !string.IsNullOrEmpty(UserClaim(ClaimType.SiteManager)), + HasCardRenewalPermissions = await HasAppPermissionAsync(_permissionGroupService, + ApplicationPermission.CardRenewalManagement), HasRosterPermissions = await HasAppPermissionAsync(_permissionGroupService, ApplicationPermission.RosterManagement), HasUserSyncPermissions = await HasAppPermissionAsync(_permissionGroupService, diff --git a/src/Ops.Controllers/Areas/ContentManagement/IdentityProvidersController.cs b/src/Ops.Controllers/Areas/ContentManagement/IdentityProvidersController.cs index ddd29ad31..e6ec7e301 100644 --- a/src/Ops.Controllers/Areas/ContentManagement/IdentityProvidersController.cs +++ b/src/Ops.Controllers/Areas/ContentManagement/IdentityProvidersController.cs @@ -9,6 +9,7 @@ using Ocuda.Ops.Service.Filters; using Ocuda.Ops.Service.Interfaces.Ops.Services; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; namespace Ocuda.Ops.Controllers.Areas.ContentManagement diff --git a/src/Ops.Controllers/Areas/ContentManagement/RosterController.cs b/src/Ops.Controllers/Areas/ContentManagement/RosterController.cs index a0e1835d0..336acd62f 100644 --- a/src/Ops.Controllers/Areas/ContentManagement/RosterController.cs +++ b/src/Ops.Controllers/Areas/ContentManagement/RosterController.cs @@ -14,6 +14,7 @@ using Ocuda.Ops.Service.Filters; using Ocuda.Ops.Service.Interfaces.Ops.Services; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; namespace Ocuda.Ops.Controllers.Areas.ContentManagement diff --git a/src/Ops.Controllers/Areas/ContentManagement/SectionController.cs b/src/Ops.Controllers/Areas/ContentManagement/SectionController.cs index b72fba5f6..58b2116d0 100644 --- a/src/Ops.Controllers/Areas/ContentManagement/SectionController.cs +++ b/src/Ops.Controllers/Areas/ContentManagement/SectionController.cs @@ -15,6 +15,7 @@ using Ocuda.Ops.Service.Interfaces.Ops.Services; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Extensions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; using Ocuda.Utility.Services.Interfaces; diff --git a/src/Ops.Controllers/Areas/ContentManagement/ViewModels/CardRenewal/ResponseViewModel.cs b/src/Ops.Controllers/Areas/ContentManagement/ViewModels/CardRenewal/ResponseViewModel.cs new file mode 100644 index 000000000..48e589595 --- /dev/null +++ b/src/Ops.Controllers/Areas/ContentManagement/ViewModels/CardRenewal/ResponseViewModel.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Ocuda.Ops.Models.Entities; +using Ocuda.Promenade.Models.Entities; + +namespace Ocuda.Ops.Controllers.Areas.ContentManagement.ViewModels.CardRenewal +{ + public class ResponseViewModel + { + public SelectList EmailSetups { get; set; } + public EmailSetupText EmailSetupText { get; set; } + public ICollection Languages { get; set; } + public CardRenewalResponse Response { get; set; } + } +} diff --git a/src/Ops.Controllers/Areas/ContentManagement/ViewModels/CardRenewal/ResponsesViewModel.cs b/src/Ops.Controllers/Areas/ContentManagement/ViewModels/CardRenewal/ResponsesViewModel.cs new file mode 100644 index 000000000..f7569c34b --- /dev/null +++ b/src/Ops.Controllers/Areas/ContentManagement/ViewModels/CardRenewal/ResponsesViewModel.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Ocuda.Ops.Models.Entities; + +namespace Ocuda.Ops.Controllers.Areas.ContentManagement.ViewModels.CardRenewal +{ + public class ResponsesViewModel + { + public IEnumerable Responses { get; set; } + public CardRenewalResponse Response { get; set; } + } +} diff --git a/src/Ops.Controllers/Areas/ContentManagement/ViewModels/Home/IndexViewModel.cs b/src/Ops.Controllers/Areas/ContentManagement/ViewModels/Home/IndexViewModel.cs index 46a9fdd7b..5801db119 100644 --- a/src/Ops.Controllers/Areas/ContentManagement/ViewModels/Home/IndexViewModel.cs +++ b/src/Ops.Controllers/Areas/ContentManagement/ViewModels/Home/IndexViewModel.cs @@ -13,6 +13,7 @@ public bool HasPermissions } } + public bool HasCardRenewalPermissions { get; set; } public bool HasRosterPermissions { get; set; } public bool HasSectionManagerPermissions { get; set; } public bool HasUserSyncPermissions { get; set; } diff --git a/src/Ops.Controllers/Areas/Incident/HomeController.cs b/src/Ops.Controllers/Areas/Incident/HomeController.cs index c2f8a5de0..43d4af017 100644 --- a/src/Ops.Controllers/Areas/Incident/HomeController.cs +++ b/src/Ops.Controllers/Areas/Incident/HomeController.cs @@ -19,6 +19,7 @@ using Ocuda.Ops.Service.Interfaces.Ops.Services; using Ocuda.Utility.Abstract; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Services.Interfaces; diff --git a/src/Ops.Controllers/Areas/Services/CardRenewalController.cs b/src/Ops.Controllers/Areas/Services/CardRenewalController.cs new file mode 100644 index 000000000..d44c974a7 --- /dev/null +++ b/src/Ops.Controllers/Areas/Services/CardRenewalController.cs @@ -0,0 +1,338 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Controllers.Abstract; +using Ocuda.Ops.Controllers.Areas.Services.ViewModels.CardRenewal; +using Ocuda.Ops.Controllers.Filters; +using Ocuda.Ops.Models; +using Ocuda.Ops.Models.Entities; +using Ocuda.Ops.Service.Filters; +using Ocuda.Ops.Service.Interfaces.Ops.Services; +using Ocuda.Ops.Service.Interfaces.Promenade.Services; +using Ocuda.PolarisHelper; +using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; + +namespace Ocuda.Ops.Controllers.Areas.Services +{ + [Area("Services")] + [Route("[area]/[controller]")] + public class CardRenewalController : BaseController + { + private const string leapPatronRecordsPath = "/record"; + + private readonly ICardRenewalRequestService _cardRenewalRequestService; + private readonly ICardRenewalService _cardRenewalService; + private readonly ILanguageService _languageService; + private readonly IPolarisHelper _polarisHelper; + + public CardRenewalController(ServiceFacades.Controller context, + ICardRenewalRequestService cardRenewalRequestService, + ICardRenewalService cardRenewalService, + ILanguageService languageService, + IPolarisHelper polarisHelper) + : base(context) + { + ArgumentNullException.ThrowIfNull(cardRenewalRequestService); + ArgumentNullException.ThrowIfNull(cardRenewalService); + ArgumentNullException.ThrowIfNull(languageService); + ArgumentNullException.ThrowIfNull(polarisHelper); + + _cardRenewalRequestService = cardRenewalRequestService; + _cardRenewalService = cardRenewalService; + _languageService = languageService; + _polarisHelper = polarisHelper; + } + + public static string Name + { get { return "CardRenewal"; } } + + [HttpGet("[action]/{id}")] + [RestoreModelState] + public async Task Details(int id) + { + if (!_polarisHelper.IsConfigured) + { + return RedirectToAction(nameof(Index)); + } + + var request = await _cardRenewalRequestService.GetRequestAsync(id); + + if (request == null) + { + return RedirectToAction(nameof(Index)); + } + + request.Language = await _languageService.GetActiveByIdAsync(request.LanguageId); + + var customer = _polarisHelper.GetCustomerDataOverride(request.Barcode); + + var viewModel = new DetailsViewModel + { + AddressLookupUrlSet = !string.IsNullOrWhiteSpace( + await _siteSettingService.GetSettingStringAsync(Models + .Keys + .SiteSetting + .CardRenewal + .AddressLookupUrl)), + AssessorLookupUrl = await _siteSettingService.GetSettingStringAsync(Models + .Keys + .SiteSetting + .CardRenewal + .AssessorLookupUrl), + Customer = customer, + CustomerCode = await _polarisHelper + .GetCustomerCodeNameAsync(customer.CustomerCodeId), + CustomerName = $"{customer.NameFirst} {customer.NameLast}", + Request = request + }; + + if (!request.ProcessedAt.HasValue) + { + var responses = await _cardRenewalService.GetAvailableResponsesAsync(); + viewModel.ResponseList = responses.Select(_ => new SelectListItem + { + Text = _.Name, + Value = _.Id.ToString(CultureInfo.InvariantCulture) + }); + } + else + { + var result = await _cardRenewalService.GetResultForRequestAsync(request.Id); + result.ResponseText = CommonMark.CommonMarkConverter.Convert(result.ResponseText); + viewModel.Result = result; + } + + var blocks = await _polarisHelper.GetCustomerBlocksAsync(customer.Id); + if (blocks.Count > 0) + { + var ignoredBlocks = await _siteSettingService.GetSettingStringAsync(Models + .Keys + .SiteSetting + .CardRenewal + .IgnoredBlockIds); + if (!string.IsNullOrWhiteSpace(ignoredBlocks)) + { + var ignoredBlockIdList = ignoredBlocks + .Split(',', StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .ToList(); + + foreach (var ignoredBlock in ignoredBlockIdList) + { + if (int.TryParse(ignoredBlock, out int ignoredBlockId)) + { + blocks.RemoveAll(_ => _.BlockId == ignoredBlockId); + } + else + { + _logger.LogError($"Invalid ignored block id '{ignoredBlock}'"); + } + } + } + + viewModel.CustomerBlocks = blocks; + } + + var acceptedCounty = await _siteSettingService.GetSettingStringAsync(Models + .Keys + .SiteSetting + .CardRenewal + .AcceptedCounty); + if (!string.IsNullOrWhiteSpace(acceptedCounty)) + { + viewModel.AcceptedCounty = acceptedCounty; + viewModel.InCounty = customer.Addresses.Any(_ => + string.Equals(_.County, acceptedCounty, StringComparison.OrdinalIgnoreCase)); + } + + var chargeLimit = await _siteSettingService.GetSettingDoubleAsync(Models + .Keys + .SiteSetting + .CardRenewal + .ChargesLimit); + if (chargeLimit >= 0 && customer.ChargeBalance >= chargeLimit) + { + + viewModel.OverChargesLimit = true; + } + + if (!string.IsNullOrWhiteSpace(request.GuardianName) && customer.BirthDate.HasValue + && customer.BirthDate.Value != DateTime.MinValue) + { + DateTime today = DateTime.Today; + var age = today.Year - customer.BirthDate.Value.Year; + if (customer.BirthDate.Value > today.AddYears(-age)) + { + age--; + } + viewModel.CustomerAge = age; + } + + var leapPatronUrl = await _siteSettingService.GetSettingStringAsync(Models + .Keys + .SiteSetting + .CardRenewal + .LeapPatronUrl); + if (!string.IsNullOrWhiteSpace(leapPatronUrl)) + { + viewModel.LeapPath = leapPatronUrl + request.CustomerId + leapPatronRecordsPath; + } + + return View(viewModel); + } + + [HttpPost("[action]/{id}")] + [SaveModelState] + public async Task Details(DetailsViewModel viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + + if (!_polarisHelper.IsConfigured) + { + return RedirectToAction(nameof(Index)); + } + + if (ModelState.IsValid) + { + try + { + var processResult = await _cardRenewalService.ProcessRequestAsync( + viewModel.RequestId, + viewModel.ResponseId.Value, + viewModel.ResponseText, + viewModel.CustomerName); + + if (!processResult.EmailSent) + { + ShowAlertDanger($"There was an error sending the email for request {viewModel.RequestId}"); + } + + if (processResult.Type == CardRenewalResponse.ResponseType.Accept) + { + ShowAlertSuccess($"Request {viewModel.RequestId} has been successfully processed and the record has been updated in Polaris!"); + + if (processResult.EmailNotUpdated) + { + ShowAlertWarning("Email was not able to be updated"); + return RedirectToAction(nameof(Details), new { viewModel.RequestId }); + } + } + else if (processResult.Type == CardRenewalResponse.ResponseType.Partial) + { + ShowAlertSuccess($"Request {viewModel.RequestId} has been successfully processed, be sure to update the record in Polaris!"); + } + else + { + ShowAlertSuccess($"Request {viewModel.RequestId} has been successfully processed"); + } + return RedirectToAction(nameof(Index)); + } + catch (OcudaException ex) + { + ShowAlertDanger($"Unable to process card renewal request: {ex.Message}"); + } + } + + return RedirectToAction(nameof(Details), new + { + id = viewModel.RequestId + }); + } + + [HttpPost("[action]")] + public async Task Discard(int requestId) + { + try + { + await _cardRenewalService.DiscardRequestAsync(requestId); + ShowAlertSuccess($"Request {requestId} has been discarded"); + } + catch (OcudaException ex) + { + ShowAlertDanger($"Unable to discard request: {ex.Message}"); + return RedirectToAction(nameof(Details), new { requestId }); + } + + return RedirectToAction(nameof(Index)); + } + + [HttpGet("[action]")] + public async Task GetResponseText(int responseId, int languageId) + { + try + { + var response = await _cardRenewalService + .GetResponseTextAsync(responseId, languageId); + + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() } + }; + + return Json(new { success = true, response.Text, response.Type }, options); + } + catch (OcudaException ex) + { + var response = new JsonResponse + { + Message = ex.Message, + Success = false + }; + return Json(response); + } + } + + [HttpGet("[action]")] + public async Task Index(int? page, bool processed) + { + if (!_polarisHelper.IsConfigured) + { + _logger.LogError("Card renewal API settings are not configured"); + } + + page ??= 1; + + var filter = new RequestFilter(page.Value) + { + IsProcessed = processed + }; + + var requests = await _cardRenewalRequestService.GetRequestsAsync(filter); + + var viewModel = new IndexViewModel + { + APIConfigured = _polarisHelper.IsConfigured, + CardRequests = requests.Data, + CurrentPage = page.Value, + IsProcessed = processed, + ItemCount = requests.Count, + ItemsPerPage = filter.Take.Value + }; + + if (processed) + { + viewModel.PendingCount = await _cardRenewalRequestService.GetRequestCountAsync(false); + viewModel.ProcessedCount = viewModel.ItemCount; + + foreach (var request in viewModel.CardRequests) + { + request.Accepted = await _cardRenewalService.IsRequestAccepted(request.Id); + } + } + else + { + viewModel.PendingCount = viewModel.ItemCount; + viewModel.ProcessedCount = await _cardRenewalRequestService.GetRequestCountAsync(true); + } + + return View(viewModel); + } + } +} diff --git a/src/Ops.Controllers/Areas/Services/ViewModels/CardRenewal/DetailsViewModel.cs b/src/Ops.Controllers/Areas/Services/ViewModels/CardRenewal/DetailsViewModel.cs new file mode 100644 index 000000000..e5b359291 --- /dev/null +++ b/src/Ops.Controllers/Areas/Services/ViewModels/CardRenewal/DetailsViewModel.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; +using Ocuda.Models; +using Ocuda.Ops.Models.Entities; +using Ocuda.Promenade.Models.Entities; + +namespace Ocuda.Ops.Controllers.Areas.Services.ViewModels.CardRenewal +{ + public class DetailsViewModel + { + private const int _maxNotesDisplayLength = 400; + + public CardRenewalRequest Request { get; set; } + public CardRenewalResult Result { get; set; } + public List CustomerBlocks { get; set; } + public Customer Customer { get; set; } + public string AcceptedCounty { get; set; } + public bool AddressLookupUrlSet { get; set; } + public string AssessorLookupUrl { get; set; } + public int? CustomerAge { get; set; } + public string CustomerCode { get; set; } + public string CustomerName { get; set; } + public bool InCounty { get; set; } + public string LeapPath { get; set; } + public bool OverChargesLimit { get; set; } + public IEnumerable ResponseList { get; set; } + public int RequestId { get; set; } + + [DisplayName("Response")] + [Required] + public int? ResponseId { get; set; } + + [DisplayName("Email Text")] + [Required] + public string ResponseText { get; set; } + + public int MaxNotesDisplayLength + { + get { return _maxNotesDisplayLength; } + } + } +} diff --git a/src/Ops.Controllers/Areas/Services/ViewModels/CardRenewal/IndexViewModel.cs b/src/Ops.Controllers/Areas/Services/ViewModels/CardRenewal/IndexViewModel.cs new file mode 100644 index 000000000..6f4e9cd4f --- /dev/null +++ b/src/Ops.Controllers/Areas/Services/ViewModels/CardRenewal/IndexViewModel.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility.Models; + +namespace Ocuda.Ops.Controllers.Areas.Services.ViewModels.CardRenewal +{ + public class IndexViewModel : PaginateModel + { + public ICollection CardRequests { get; set; } + public bool APIConfigured { get; set; } + public bool IsProcessed { get; set; } + public int PendingCount { get; set; } + public int ProcessedCount { get; set; } + } +} diff --git a/src/Ops.Controllers/Areas/SiteManagement/CarouselsController.cs b/src/Ops.Controllers/Areas/SiteManagement/CarouselsController.cs index 2e1e3b44d..74ea10976 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/CarouselsController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/CarouselsController.cs @@ -17,6 +17,7 @@ using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; diff --git a/src/Ops.Controllers/Areas/SiteManagement/CategoriesController.cs b/src/Ops.Controllers/Areas/SiteManagement/CategoriesController.cs index b2256b777..dca0602cd 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/CategoriesController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/CategoriesController.cs @@ -13,6 +13,7 @@ using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; diff --git a/src/Ops.Controllers/Areas/SiteManagement/EmediaController.cs b/src/Ops.Controllers/Areas/SiteManagement/EmediaController.cs index 0807da6e6..220f9b828 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/EmediaController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/EmediaController.cs @@ -15,6 +15,7 @@ using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Models; namespace Ocuda.Ops.Controllers.Areas.SiteManagement diff --git a/src/Ops.Controllers/Areas/SiteManagement/FeaturesController.cs b/src/Ops.Controllers/Areas/SiteManagement/FeaturesController.cs index a7e8bb119..35134f5f2 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/FeaturesController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/FeaturesController.cs @@ -16,6 +16,7 @@ using Ocuda.Utility.Email; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Extensions; +using Ocuda.Utility.Filters; namespace Ocuda.Ops.Controllers.Areas.SiteManagement { diff --git a/src/Ops.Controllers/Areas/SiteManagement/GroupsController.cs b/src/Ops.Controllers/Areas/SiteManagement/GroupsController.cs index 4c56f02fd..ea9580e95 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/GroupsController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/GroupsController.cs @@ -11,6 +11,7 @@ using Ocuda.Ops.Service.Interfaces.Ops.Services; using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; diff --git a/src/Ops.Controllers/Areas/SiteManagement/ImageFeaturesController.cs b/src/Ops.Controllers/Areas/SiteManagement/ImageFeaturesController.cs index ae9298c71..bb2d398e9 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/ImageFeaturesController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/ImageFeaturesController.cs @@ -19,6 +19,7 @@ using Ocuda.Utility.Abstract; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Extensions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using SixLabors.ImageSharp; diff --git a/src/Ops.Controllers/Areas/SiteManagement/LocationsController.cs b/src/Ops.Controllers/Areas/SiteManagement/LocationsController.cs index b9061a71f..00bc6d664 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/LocationsController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/LocationsController.cs @@ -16,6 +16,7 @@ using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; using Ocuda.Utility.Services.Interfaces; diff --git a/src/Ops.Controllers/Areas/SiteManagement/NavigationsController.cs b/src/Ops.Controllers/Areas/SiteManagement/NavigationsController.cs index 2164e5037..07eb904f2 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/NavigationsController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/NavigationsController.cs @@ -20,6 +20,7 @@ using Ocuda.Utility.Abstract; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Extensions; +using Ocuda.Utility.Filters; namespace Ocuda.Ops.Controllers.Areas.SiteManagement { diff --git a/src/Ops.Controllers/Areas/SiteManagement/PagesController.cs b/src/Ops.Controllers/Areas/SiteManagement/PagesController.cs index 68ae25b79..0facd2efa 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/PagesController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/PagesController.cs @@ -20,6 +20,7 @@ using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Extensions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; diff --git a/src/Ops.Controllers/Areas/SiteManagement/ScheduleController.cs b/src/Ops.Controllers/Areas/SiteManagement/ScheduleController.cs index 28efabc51..22c4fd273 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/ScheduleController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/ScheduleController.cs @@ -10,6 +10,7 @@ using Ocuda.Ops.Controllers.Filters; using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; namespace Ocuda.Ops.Controllers.Areas.SiteManagement diff --git a/src/Ops.Controllers/Areas/SiteManagement/SegmentWrapsController.cs b/src/Ops.Controllers/Areas/SiteManagement/SegmentWrapsController.cs index f45d50a1f..125df0dfd 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/SegmentWrapsController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/SegmentWrapsController.cs @@ -8,6 +8,7 @@ using Ocuda.Ops.Controllers.ServiceFacades; using Ocuda.Ops.Service.Filters; using Ocuda.Ops.Service.Interfaces.Promenade.Services; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; namespace Ocuda.Ops.Controllers.Areas.SiteManagement diff --git a/src/Ops.Controllers/Areas/SiteManagement/SegmentsController.cs b/src/Ops.Controllers/Areas/SiteManagement/SegmentsController.cs index be3de9a3d..add530168 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/SegmentsController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/SegmentsController.cs @@ -22,6 +22,7 @@ using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Extensions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; diff --git a/src/Ops.Controllers/Areas/SiteManagement/SocialCardsController.cs b/src/Ops.Controllers/Areas/SiteManagement/SocialCardsController.cs index 32eea5dac..30dbfec1e 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/SocialCardsController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/SocialCardsController.cs @@ -10,6 +10,7 @@ using Ocuda.Ops.Service.Interfaces.Ops.Services; using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; using Ocuda.Utility.Models; diff --git a/src/Ops.Controllers/Areas/SiteManagement/VolunteerController.cs b/src/Ops.Controllers/Areas/SiteManagement/VolunteerController.cs index 6f0d7ffd7..2320db5fe 100644 --- a/src/Ops.Controllers/Areas/SiteManagement/VolunteerController.cs +++ b/src/Ops.Controllers/Areas/SiteManagement/VolunteerController.cs @@ -15,6 +15,7 @@ using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; namespace Ocuda.Ops.Controllers.Areas.SiteManagement diff --git a/src/Ops.Controllers/Filters/RestoreModelStateAttribute.cs b/src/Ops.Controllers/Filters/RestoreModelStateAttribute.cs index b00c0aca9..565090690 100644 --- a/src/Ops.Controllers/Filters/RestoreModelStateAttribute.cs +++ b/src/Ops.Controllers/Filters/RestoreModelStateAttribute.cs @@ -1,60 +1,21 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Logging; using Ocuda.Ops.Service.Interfaces.Ops.Services; -using Ocuda.Utility.Helpers; +using Ocuda.Utility.Filters; namespace Ocuda.Ops.Controllers.Filters { - public class RestoreModelStateAttribute : ActionFilterAttribute + public sealed class RestoreModelStateAttribute : RestoreModelStateAttributeBase { - public string Key { get; set; } - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - var resultContext = await next(); - - var controller = context.Controller as Controller; - - string modelStateKey; - if (!string.IsNullOrWhiteSpace(Key)) - { - modelStateKey = ModelStateHelper.GetModelStateKey(Key); - } - else - { - modelStateKey = ModelStateHelper.GetModelStateKey(context.RouteData.Values); - } - - if (controller?.TempData[modelStateKey] is string modelStateStorage) - { - var (modelState, time) = ModelStateHelper.DeserializeModelState(modelStateStorage); - var timeDifference = DateTimeOffset.Now.ToUnixTimeSeconds() - time; - - var _siteSettingService = (ISiteSettingService)context.HttpContext.RequestServices - .GetService(typeof(ISiteSettingService)); - var modelstateTimeOut = await _siteSettingService - .GetSettingIntAsync(Models.Keys.SiteSetting.UserInterface.ModelStateTimeoutMinutes); - if (TimeSpan.FromSeconds(timeDifference).Minutes < modelstateTimeOut - || modelstateTimeOut < 1) - { - //Only Import if we are viewing - if (resultContext.Result is ViewResult) - { - context.ModelState.Merge(modelState); - } - } - else - { - var _logger = (ILogger)context.HttpContext - .RequestServices.GetService(typeof(ILogger)); - _logger.LogError("ModelState timed out for key {ModelStateKey}", - modelStateKey); - } - } + var _siteSettingService = (ISiteSettingService)context.HttpContext.RequestServices + .GetService(typeof(ISiteSettingService)); + ModelStateTimeOut = await _siteSettingService + .GetSettingIntAsync(Models.Keys.SiteSetting.UserInterface.ModelStateTimeoutMinutes); + + await base.OnActionExecutionAsync(context, next); } } } diff --git a/src/Ops.Controllers/LocationsController.cs b/src/Ops.Controllers/LocationsController.cs index 71347ef58..deaba6f16 100644 --- a/src/Ops.Controllers/LocationsController.cs +++ b/src/Ops.Controllers/LocationsController.cs @@ -22,6 +22,7 @@ using Ocuda.Promenade.Models.Entities; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Extensions; +using Ocuda.Utility.Filters; using Ocuda.Utility.Keys; namespace Ocuda.Ops.Controllers diff --git a/src/Ops.Controllers/Ops.Controllers.csproj b/src/Ops.Controllers/Ops.Controllers.csproj index fe6cedc47..3dc985c13 100644 --- a/src/Ops.Controllers/Ops.Controllers.csproj +++ b/src/Ops.Controllers/Ops.Controllers.csproj @@ -27,7 +27,9 @@ + + diff --git a/src/Ops.Data/Ops/CardRenewalResponseRepository.cs b/src/Ops.Data/Ops/CardRenewalResponseRepository.cs new file mode 100644 index 000000000..db237deb3 --- /dev/null +++ b/src/Ops.Data/Ops/CardRenewalResponseRepository.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Models.Entities; +using Ocuda.Ops.Service.Interfaces.Ops.Repositories; + +namespace Ocuda.Ops.Data.Ops +{ + public class CardRenewalResponseRepository + : OpsRepository, + ICardRenewalResponseRepository + { + public CardRenewalResponseRepository(ServiceFacade.Repository repositoryFacade, + ILogger logger) : base(repositoryFacade, logger) + { + } + + public override async Task FindAsync(int id) + { + return await DbSet + .AsNoTracking() + .Where(_ => !_.IsDeleted && _.Id == id) + .SingleOrDefaultAsync(); + } + + public async Task GetBySortOrderAsync(int sortOrder) + { + return await DbSet + .AsNoTracking() + .Where(_ => !_.IsDeleted && _.SortOrder == sortOrder) + .FirstOrDefaultAsync(); + } + + public async Task GetMaxSortOrderAsync() + { + return await DbSet + .AsNoTracking() + .Where(_ => !_.IsDeleted) + .MaxAsync(_ => (int?)_.SortOrder); + } + + public async Task> GetAllAsync() + { + return await DbSet + .AsNoTracking() + .Include(_ => _.EmailSetup) + .Where(_ => !_.IsDeleted) + .OrderBy(_ => _.SortOrder) + .ToListAsync(); + } + + public async Task> GetAvailableAsync() + { + return await DbSet + .AsNoTracking() + .Where(_ => !_.IsDeleted && _.EmailSetupId.HasValue) + .OrderBy(_ => _.SortOrder) + .ToListAsync(); + } + + public async Task> GetSubsequentAsync(int sortOrder) + { + return await DbSet + .AsNoTracking() + .Where(_ => !_.IsDeleted && _.SortOrder > sortOrder) + .ToListAsync(); + } + } +} diff --git a/src/Ops.Data/Ops/CardRenewalResultRepository.cs b/src/Ops.Data/Ops/CardRenewalResultRepository.cs new file mode 100644 index 000000000..c5d1e9cff --- /dev/null +++ b/src/Ops.Data/Ops/CardRenewalResultRepository.cs @@ -0,0 +1,36 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Models.Entities; +using Ocuda.Ops.Service.Interfaces.Ops.Repositories; + +namespace Ocuda.Ops.Data.Ops +{ + public class CardRenewalResultRepository : OpsRepository, + ICardRenewalResultRepository + { + public CardRenewalResultRepository(ServiceFacade.Repository repositoryFacade, + ILogger logger) : base(repositoryFacade, logger) + { + } + + public async Task GetForRequestAsync(int requestId) + { + return await DbSet + .AsNoTracking() + .Include(_ => _.CreatedByUser) + .Where(_ => _.CardRenewalRequestId == requestId) + .SingleOrDefaultAsync(); + } + + public async Task GetRequestResponseTypeAsync( + int requestId) + { + return await DbSet.AsNoTracking() + .Where(_ => _.CardRenewalRequestId == requestId) + .Select(_ => _.CardRenewalResponse.Type) + .SingleOrDefaultAsync(); + } + } +} diff --git a/src/Ops.Data/Ops/EmailSetupRepository.cs b/src/Ops.Data/Ops/EmailSetupRepository.cs new file mode 100644 index 000000000..f9d9a3219 --- /dev/null +++ b/src/Ops.Data/Ops/EmailSetupRepository.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Models.Entities; +using Ocuda.Ops.Service.Interfaces.Ops.Repositories; + +namespace Ocuda.Ops.Data.Ops +{ + public class EmailSetupRepository + : GenericRepository, IEmailSetupRepository + { + public EmailSetupRepository(ServiceFacade.Repository repositoryFacade, + ILogger logger) : base(repositoryFacade, logger) + { + } + + public async Task> GetAllAsync() + { + return await DbSet + .AsNoTracking() + .OrderBy(_ => _.Description) + .ToListAsync(); + } + } +} diff --git a/src/Ops.Data/OpsContext.cs b/src/Ops.Data/OpsContext.cs index 9fcdc2ff5..b0ae22713 100644 --- a/src/Ops.Data/OpsContext.cs +++ b/src/Ops.Data/OpsContext.cs @@ -16,6 +16,8 @@ protected OpsContext(DbContextOptions options) : base(options) } public DbSet ApiKeys { get; set; } + public DbSet CardRenewalResponses { get; set; } + public DbSet CardRenewalResults { get; set; } public DbSet Categories { get; set; } public DbSet ClaimGroups { get; set; } public DbSet CoverIssueDetails { get; set; } diff --git a/src/Ops.Data/Promenade/CardRenewalRequestRepository.cs b/src/Ops.Data/Promenade/CardRenewalRequestRepository.cs new file mode 100644 index 000000000..061d57ee6 --- /dev/null +++ b/src/Ops.Data/Promenade/CardRenewalRequestRepository.cs @@ -0,0 +1,75 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Data.Extensions; +using Ocuda.Ops.Data.ServiceFacade; +using Ocuda.Ops.Service.Filters; +using Ocuda.Ops.Service.Interfaces.Promenade.Repositories; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility.Models; + +namespace Ocuda.Ops.Data.Promenade +{ + public class CardRenewalRequestRepository : + GenericRepository, ICardRenewalRequestRepository + { + public CardRenewalRequestRepository(Repository repositoryFacade, + ILogger logger) + : base(repositoryFacade, logger) + { + } + + public async Task GetByIdAsync(int id) + { + return await DbSet + .AsNoTracking() + .Where(_ => _.Id == id && !_.IsDiscarded) + .SingleOrDefaultAsync(); + } + + public async Task GetCountAsync(bool? isProcessed) + { + var query = DbSet + .AsNoTracking() + .Where(_ => !_.IsDiscarded); + + if (isProcessed.HasValue) + { + query = query.Where(_ => _.ProcessedAt.HasValue == isProcessed); + } + + return await query.CountAsync(); + } + + public async Task> GetPaginatedAsync( + RequestFilter filter) + { + var query = DbSet + .AsNoTracking() + .Where(_ => !_.IsDiscarded); + + if (filter?.IsProcessed.HasValue == true) + { + query = query.Where(_ => _.ProcessedAt.HasValue == filter.IsProcessed.Value); + } + + if (filter?.IsProcessed == true) + { + query = query.OrderByDescending(_ => _.ProcessedAt); + } + else + { + query = query.OrderBy(_ => _.SubmittedAt); + } + + return new CollectionWithCount + { + Count = await query.CountAsync(), + Data = await query + .ApplyPagination(filter) + .ToListAsync() + }; + } + } +} diff --git a/src/Ops.Data/Promenade/LanguageRepository.cs b/src/Ops.Data/Promenade/LanguageRepository.cs index 0b1ab8d13..5a2200220 100644 --- a/src/Ops.Data/Promenade/LanguageRepository.cs +++ b/src/Ops.Data/Promenade/LanguageRepository.cs @@ -60,6 +60,15 @@ public async Task GetDefaultLanguageId() .SingleOrDefaultAsync(); } + public async Task GetDefaultLanguageNameAsync() + { + return await DbSet + .AsNoTracking() + .Where(_ => _.IsActive && _.IsDefault) + .Select(_ => _.Name) + .SingleOrDefaultAsync(); + } + public async Task GetLanguageId(string culture) { return await DbSet diff --git a/src/Ops.Data/PromenadeContext.cs b/src/Ops.Data/PromenadeContext.cs index be5d518ce..fc2729157 100644 --- a/src/Ops.Data/PromenadeContext.cs +++ b/src/Ops.Data/PromenadeContext.cs @@ -14,6 +14,7 @@ protected PromenadeContext(DbContextOptions options) : base(options) } public DbSet CardDetails { get; set; } + public DbSet CardRenewalRequests { get; set; } public DbSet Cards { get; set; } public DbSet CarouselButtonLabels { get; set; } diff --git a/src/Ops.Models/Defaults/SiteSettings.cs b/src/Ops.Models/Defaults/SiteSettings.cs index 40741b47d..f627eac70 100644 --- a/src/Ops.Models/Defaults/SiteSettings.cs +++ b/src/Ops.Models/Defaults/SiteSettings.cs @@ -8,6 +8,66 @@ public static class SiteSettings { public static IEnumerable Get { get; } = new[] { + #region CardRenewal + + new SiteSetting + { + Id = Keys.SiteSetting.CardRenewal.AcceptedCounty, + Name = "Accepted county", + Description = "Accepted county for card renewal addresses", + Category = "CardRenewal", + Value = "", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Id = Keys.SiteSetting.CardRenewal.AddressLookupUrl, + Name = "Address lookup url", + Description = "Address lookup url with scheme, host and path", + Category = "CardRenewal", + Value = "", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Id = Keys.SiteSetting.CardRenewal.AssessorLookupUrl, + Name = "Assessor lookup url", + Description = "Assessor lookup with scheme, host and path", + Category = "CardRenewal", + Value = "", + Type= SiteSettingType.StringNullable + }, + new SiteSetting + { + Id = Keys.SiteSetting.CardRenewal.ChargesLimit, + Name = "Charges Limit", + Description = "Charges amount when a warning starts being shown", + Category = "CardRenewal", + Value = "-1", + Type = SiteSettingType.Double + }, + new SiteSetting + { + Id = Keys.SiteSetting.CardRenewal.IgnoredBlockIds, + Name = "Ignored block ids", + Description = "Ids of blocks that won't be included, comma delimited", + Category = "CardRenewal", + Value = "", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Id = Keys.SiteSetting.CardRenewal.LeapPatronUrl, + Name = "Leap patron records url", + Description = "Leap patron records url with scheme, host and path", + Category = "CardRenewal", + Value = "", + Type = SiteSettingType.StringNullable + }, + + + #endregion CardRenwal + #region Carousel new SiteSetting diff --git a/src/Ops.Models/Definitions/ApplicationPermissionDefinitions.cs b/src/Ops.Models/Definitions/ApplicationPermissionDefinitions.cs index c3d7c316b..37a0001ae 100644 --- a/src/Ops.Models/Definitions/ApplicationPermissionDefinitions.cs +++ b/src/Ops.Models/Definitions/ApplicationPermissionDefinitions.cs @@ -7,6 +7,12 @@ public static class ApplicationPermissionDefinitions { public static readonly ApplicationPermissionDefinition[] ApplicationPermissions = { + new() + { + Id = ApplicationPermission.CardRenewalManagement, + Info = "Manage settings related to card renewal.", + Name = "Card Renewal Management" + }, new() { Id = ApplicationPermission.CoverIssueManagement, Info = "Able to mark cover issues as resolved.", diff --git a/src/Ops.Models/Entities/CardRenewalResponse.cs b/src/Ops.Models/Entities/CardRenewalResponse.cs new file mode 100644 index 000000000..ccd08f720 --- /dev/null +++ b/src/Ops.Models/Entities/CardRenewalResponse.cs @@ -0,0 +1,32 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Ocuda.Ops.Models.Entities +{ + public class CardRenewalResponse : Abstract.BaseEntity + { + [DisplayName("Email")] + public int? EmailSetupId { get; set; } + public EmailSetup EmailSetup { get; set; } + + public bool IsDeleted { get; set; } + + [Required] + [MaxLength(255)] + public string Name { get; set; } + + [NotMapped] + public string Text { get; set; } + + public ResponseType Type { get; set; } + public int SortOrder { get; set; } + + public enum ResponseType + { + Accept, + Deny, + Partial + } + } +} diff --git a/src/Ops.Models/Entities/CardRenewalResult.cs b/src/Ops.Models/Entities/CardRenewalResult.cs new file mode 100644 index 000000000..1143b5de1 --- /dev/null +++ b/src/Ops.Models/Entities/CardRenewalResult.cs @@ -0,0 +1,29 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Ocuda.Ops.Models.Entities +{ + public class CardRenewalResult + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + [Required] + public int CardRenewalRequestId { get; set; } + + public int? CardRenewalResponseId { get; set; } + public CardRenewalResponse CardRenewalResponse { get; set; } + + public DateTime CreatedAt { get; set; } + + [ForeignKey(nameof(CreatedByUser))] + public int CreatedBy { get; set; } + public User CreatedByUser { get; set; } + + public bool IsDiscarded { get; set; } + + [DisplayName("Email Text")] + public string ResponseText { get; set; } + } +} diff --git a/src/Ops.Models/Entities/EmailSetupText.cs b/src/Ops.Models/Entities/EmailSetupText.cs index 46bc173a1..23e27dd9b 100644 --- a/src/Ops.Models/Entities/EmailSetupText.cs +++ b/src/Ops.Models/Entities/EmailSetupText.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; namespace Ocuda.Ops.Models.Entities { @@ -20,6 +21,7 @@ public class EmailSetupText [MaxLength(255)] public string Preview { get; set; } + [DisplayName("Text")] public string BodyText { get; set; } public string BodyHtml { get; set; } diff --git a/src/Ops.Models/Keys/ApplicationPermission.cs b/src/Ops.Models/Keys/ApplicationPermission.cs index cdd7005de..a1bb6da92 100644 --- a/src/Ops.Models/Keys/ApplicationPermission.cs +++ b/src/Ops.Models/Keys/ApplicationPermission.cs @@ -5,6 +5,7 @@ Justification = "Descriptive key name")] public static class ApplicationPermission { + public static readonly string CardRenewalManagement = nameof(CardRenewalManagement); public static readonly string CoverIssueManagement = nameof(CoverIssueManagement); public static readonly string DigitalDisplayContentManagement = nameof(DigitalDisplayContentManagement); public static readonly string EmediaManagement = nameof(EmediaManagement); diff --git a/src/Ops.Models/Keys/SiteSetting.cs b/src/Ops.Models/Keys/SiteSetting.cs index ac80aaff3..a6c5d7a8d 100644 --- a/src/Ops.Models/Keys/SiteSetting.cs +++ b/src/Ops.Models/Keys/SiteSetting.cs @@ -2,6 +2,16 @@ { namespace SiteSetting { + public static class CardRenewal + { + public static readonly string AcceptedCounty = "CardRenewal.AcceptedCounty"; + public static readonly string AddressLookupUrl = "CardRenewal.AddressLookupUrl"; + public static readonly string AssessorLookupUrl = "CardRenewal.AssessorLookupUrl"; + public static readonly string ChargesLimit = "CardRenewal.ChargesLimit"; + public static readonly string IgnoredBlockIds = "CardRenewal.IgnoredBlockIds"; + public static readonly string LeapPatronUrl = "CardRenewal.LeapPatronUrl"; + } + public static class Carousel { public static readonly string ImageRestrictToDomains = "Carousel.ImageRestricToDomains"; diff --git a/src/Ops.Service/CardRenewalRequestService.cs b/src/Ops.Service/CardRenewalRequestService.cs new file mode 100644 index 000000000..dd9d0617b --- /dev/null +++ b/src/Ops.Service/CardRenewalRequestService.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Service.Abstract; +using Ocuda.Ops.Service.Filters; +using Ocuda.Ops.Service.Interfaces.Promenade.Repositories; +using Ocuda.Ops.Service.Interfaces.Promenade.Services; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility.Models; + +namespace Ocuda.Ops.Service +{ + public class CardRenewalRequestService : BaseService, + ICardRenewalRequestService + { + private readonly ICardRenewalRequestRepository _cardRenewalRequestRepository; + + public CardRenewalRequestService(ILogger logger, + IHttpContextAccessor httpContext, + ICardRenewalRequestRepository cardRenewalRequestRepository) + : base(logger, httpContext) + { + ArgumentNullException.ThrowIfNull(cardRenewalRequestRepository); + + _cardRenewalRequestRepository = cardRenewalRequestRepository; + } + + public async Task GetRequestAsync(int id) + { + return await _cardRenewalRequestRepository.GetByIdAsync(id); + } + + public async Task GetRequestCountAsync(bool? isProcessed) + { + return await _cardRenewalRequestRepository.GetCountAsync(isProcessed); + } + + public async Task> GetRequestsAsync( + RequestFilter filter) + { + return await _cardRenewalRequestRepository.GetPaginatedAsync(filter); + } + } +} diff --git a/src/Ops.Service/CardRenewalService.cs b/src/Ops.Service/CardRenewalService.cs new file mode 100644 index 000000000..c049d5a10 --- /dev/null +++ b/src/Ops.Service/CardRenewalService.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Ocuda.Ops.Models.Entities; +using Ocuda.Ops.Service.Abstract; +using Ocuda.Ops.Service.Interfaces.Ops.Repositories; +using Ocuda.Ops.Service.Interfaces.Ops.Services; +using Ocuda.Ops.Service.Interfaces.Promenade.Repositories; +using Ocuda.Ops.Service.Interfaces.Promenade.Services; +using Ocuda.Ops.Service.Models.CardRenewal; +using Ocuda.PolarisHelper; +using Ocuda.Utility.Abstract; +using Ocuda.Utility.Exceptions; + +namespace Ocuda.Ops.Service +{ + public class CardRenewalService : BaseService, + ICardRenewalService + { + private readonly ICardRenewalRequestRepository _cardRenewalRequestRepository; + private readonly ICardRenewalResponseRepository _cardRenewalResponseRepository; + private readonly ICardRenewalResultRepository _cardRenewalResultRepository; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly IEmailService _emailService; + private readonly ILanguageService _languageService; + private readonly IPolarisHelper _polarisHelper; + + public CardRenewalService(ILogger logger, + IHttpContextAccessor httpContext, + ICardRenewalRequestRepository cardRenewalRequestRepository, + ICardRenewalResponseRepository cardRenewalResponseRepository, + ICardRenewalResultRepository cardRenewalResultRepository, + IDateTimeProvider dateTimeProvider, + IEmailService emailService, + ILanguageService languageService, + IPolarisHelper polarisHelper) + : base(logger, httpContext) + { + ArgumentNullException.ThrowIfNull(cardRenewalRequestRepository); + ArgumentNullException.ThrowIfNull(cardRenewalResponseRepository); + ArgumentNullException.ThrowIfNull(cardRenewalResultRepository); + ArgumentNullException.ThrowIfNull(dateTimeProvider); + ArgumentNullException.ThrowIfNull(emailService); + ArgumentNullException.ThrowIfNull(languageService); + ArgumentNullException.ThrowIfNull(polarisHelper); + + _cardRenewalRequestRepository = cardRenewalRequestRepository; + _cardRenewalResponseRepository = cardRenewalResponseRepository; + _cardRenewalResultRepository = cardRenewalResultRepository; + _dateTimeProvider = dateTimeProvider; + _emailService = emailService; + _languageService = languageService; + _polarisHelper = polarisHelper; + } + + public async Task CreateResponseAsync(CardRenewalResponse response) + { + ArgumentNullException.ThrowIfNull(response); + + response.CreatedAt = _dateTimeProvider.Now; + response.CreatedBy = GetCurrentUserId(); + response.Name = response.Name.Trim(); + + var maxSortOrder = await _cardRenewalResponseRepository.GetMaxSortOrderAsync(); + if (maxSortOrder.HasValue) + { + response.SortOrder = maxSortOrder.Value + 1; + } + + await _cardRenewalResponseRepository.AddAsync(response); + await _cardRenewalResponseRepository.SaveAsync(); + + return response; + } + + public async Task DeleteResponseAsync(int id) + { + var response = await _cardRenewalResponseRepository.FindAsync(id); + + var subsequentResponses = await _cardRenewalResponseRepository + .GetSubsequentAsync(response.SortOrder); + if (subsequentResponses.Count > 0) + { + subsequentResponses.ForEach(_ => _.SortOrder--); + _cardRenewalResponseRepository.UpdateRange(subsequentResponses); + } + + response.IsDeleted = true; + _cardRenewalResponseRepository.Update(response); + await _cardRenewalResponseRepository.SaveAsync(); + } + + public async Task DiscardRequestAsync(int id) + { + var request = await _cardRenewalRequestRepository.GetByIdAsync(id); + + if (request == null) + { + throw new OcudaException("Request does not exist"); + } + else if (request.ProcessedAt.HasValue) + { + throw new OcudaException("Request has already been processed"); + } + + var now = _dateTimeProvider.Now; + + var result = new CardRenewalResult + { + CardRenewalRequestId = request.Id, + CreatedAt = now, + CreatedBy = GetCurrentUserId(), + IsDiscarded = true + }; + await _cardRenewalResultRepository.AddAsync(result); + + + request.IsDiscarded = true; + request.ProcessedAt = now; + _cardRenewalRequestRepository.Update(request); + + await _cardRenewalResultRepository.SaveAsync(); + await _cardRenewalRequestRepository.SaveAsync(); + } + + public async Task> GetAvailableResponsesAsync() + { + return await _cardRenewalResponseRepository.GetAvailableAsync(); + } + + public async Task GetResponseAsync(int id) + { + return await _cardRenewalResponseRepository.FindAsync(id); + } + + public async Task GetResponseTextAsync(int responseId, int languageId) + { + var response = await _cardRenewalResponseRepository.FindAsync(responseId); + if (!response.EmailSetupId.HasValue) + { + _logger.LogError($"Invalid card renewal response '{responseId}': no email setup set."); + throw new OcudaException("Invalid response"); + } + + var language = await _languageService.GetActiveByIdAsync(languageId); + + var emailSetupText = await _emailService.GetSetupTextByLanguageAsync( + response.EmailSetupId.Value, + language.Name); + + if (emailSetupText == null) + { + var defaultLanguage = await _languageService.GetDefaultLanguageNameAsync(); + + emailSetupText = await _emailService.GetSetupTextByLanguageAsync( + response.EmailSetupId.Value, + defaultLanguage); + if (emailSetupText == null) + { + _logger.LogError($"Invalid card renewal response '{responseId}': email setup {response.EmailSetupId} is missing text."); + throw new OcudaException("Invalid response"); + } + } + + response.Text = emailSetupText.BodyText; + + return response; + } + + public async Task> GetResponsesAsync() + { + return await _cardRenewalResponseRepository.GetAllAsync(); + } + + public async Task GetResultForRequestAsync(int requestId) + { + return await _cardRenewalResultRepository.GetForRequestAsync(requestId); + } + + public async Task IsRequestAccepted(int requestId) + { + var responseType = await _cardRenewalResultRepository + .GetRequestResponseTypeAsync(requestId); + + return responseType == CardRenewalResponse.ResponseType.Accept; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", + "CA1031:Do not catch general exception types", + Justification = "Show end user error message rather than exception")] + public async Task ProcessRequestAsync(int requestId, + int responseId, + string responseText, + string customerName) + { + var request = await _cardRenewalRequestRepository + .GetByIdAsync(requestId); + if (request.ProcessedAt.HasValue) + { + throw new OcudaException("Request has already been processed."); + } + + var response = await _cardRenewalResponseRepository.FindAsync(responseId); + + var processResult = new ProcessResult + { + Type = response.Type + }; + + if (response.Type == CardRenewalResponse.ResponseType.Accept) + { + var renewResult = _polarisHelper.RenewCustomerRegistration( + request.Barcode, + request.Email); + + if (!renewResult.Success) + { + throw new OcudaException("Unable to update the record in Polaris."); + } + + processResult.EmailNotUpdated = renewResult.EmailNotUpdated; + } + + var language = await _languageService.GetActiveByIdAsync(request.LanguageId); + var tags = new Dictionary + { + { Keys.CardRenewal.CustomerBarcode, request.Barcode }, + { Keys.CardRenewal.CustomerName, customerName } + }; + + var emailDetails = await _emailService.GetDetailsAsync(response.EmailSetupId.Value, + language.Name, + tags, + responseText); + + var now = _dateTimeProvider.Now; + + request.ProcessedAt = now; + _cardRenewalRequestRepository.Update(request); + + var result = new CardRenewalResult + { + CardRenewalRequestId = request.Id, + CardRenewalResponseId = response.Id, + CreatedAt = now, + CreatedBy = GetCurrentUserId(), + ResponseText = emailDetails.BodyText + }; + + await _cardRenewalResultRepository.AddAsync(result); + await _cardRenewalResultRepository.SaveAsync(); + await _cardRenewalRequestRepository.SaveAsync(); + + emailDetails.ToEmailAddress = request.Email; + emailDetails.ToName = customerName; + + try + { + var sentEmail = await _emailService.SendAsync(emailDetails); + if (sentEmail != null) + { + processResult.EmailSent = true; + } + else + { + _logger.LogWarning("Card renewal email (setup {EmailSetupId}) failed sending to {EmailTo}", + response.EmailSetupId.Value, + request.Email); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error sending email setup {EmailSetupId} to {EmailTo}: {ErrorMessage}", + response.EmailSetupId.Value, + emailDetails.ToEmailAddress, + ex.Message); + } + + return processResult; + } + + public async Task UpdateResponseAsync(CardRenewalResponse response) + { + ArgumentNullException.ThrowIfNull(response); + + var currentResponse = await _cardRenewalResponseRepository.FindAsync(response.Id); + + currentResponse.EmailSetupId = response.EmailSetupId; + currentResponse.Name = response.Name.Trim(); + + _cardRenewalResponseRepository.Update(currentResponse); + await _cardRenewalResponseRepository.SaveAsync(); + } + + public async Task UpdateResponseSortOrderAsync(int id, bool increase) + { + var response = await _cardRenewalResponseRepository.FindAsync(id); + + int newSortOrder; + if (increase) + { + newSortOrder = response.SortOrder + 1; + } + else + { + if (response.SortOrder == 0) + { + throw new OcudaException("Response is already in the first position."); + } + newSortOrder = response.SortOrder - 1; + } + + var responseInPosition = await _cardRenewalResponseRepository.GetBySortOrderAsync( + newSortOrder) + ?? throw new OcudaException("Response is already in the last position."); + + responseInPosition.SortOrder = response.SortOrder; + response.SortOrder = newSortOrder; + + _cardRenewalResponseRepository.Update(response); + _cardRenewalResponseRepository.Update(responseInPosition); + await _cardRenewalResponseRepository.SaveAsync(); + } + } +} diff --git a/src/Ops.Service/EmailService.cs b/src/Ops.Service/EmailService.cs index dbcfede5f..aefc94205 100644 --- a/src/Ops.Service/EmailService.cs +++ b/src/Ops.Service/EmailService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Threading.Tasks; using CommonMark; using Microsoft.AspNetCore.Http; @@ -21,6 +22,7 @@ public class EmailService : BaseService, IEmailService private readonly IOcudaCache _cache; private readonly IEmailRecordRepository _emailRecordRepository; + private readonly IEmailSetupRepository _emailSetupRepository; private readonly IEmailSetupTextRepository _emailSetupTextRepository; private readonly IEmailTemplateTextRepository _emailTemplateTextRepository; private readonly Utility.Email.Sender _sender; @@ -28,6 +30,7 @@ public class EmailService : BaseService, IEmailService public EmailService(ILogger logger, IEmailRecordRepository emailRecordRepository, + IEmailSetupRepository emailSetupRepository, IEmailSetupTextRepository emailSetupTextRepository, IEmailTemplateTextRepository emailTemplateTextRepository, IHttpContextAccessor httpContextAccessor, @@ -38,6 +41,7 @@ public EmailService(ILogger logger, { ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(emailRecordRepository); + ArgumentNullException.ThrowIfNull(emailSetupRepository); ArgumentNullException.ThrowIfNull(emailSetupTextRepository); ArgumentNullException.ThrowIfNull(emailTemplateTextRepository); ArgumentNullException.ThrowIfNull(sender); @@ -45,6 +49,7 @@ public EmailService(ILogger logger, _cache = cache; _emailRecordRepository = emailRecordRepository; + _emailSetupRepository = emailSetupRepository; _emailSetupTextRepository = emailSetupTextRepository; _emailTemplateTextRepository = emailTemplateTextRepository; _sender = sender; @@ -53,11 +58,18 @@ public EmailService(ILogger logger, public async Task GetDetailsAsync(int emailSetupId, string languageName, - IDictionary tags) + IDictionary tags, + string overrideText = null) { var emailSetupText = await GetEmailSetupAsync(emailSetupId, languageName) ?? throw new OcudaEmailException($"Unable to find email setup {emailSetupId} in the requested or default language."); + if (!string.IsNullOrWhiteSpace(overrideText)) + { + emailSetupText.BodyHtml = null; + emailSetupText.BodyText = overrideText; + } + var emailTemplateText = await GetEmailTemplateAsync(emailSetupText.EmailSetup.EmailTemplateId, languageName) @@ -69,7 +81,8 @@ var emailTemplateText { try { - emailSetupText.BodyHtml = CommonMarkConverter.Convert(emailSetupText.BodyText); + emailSetupText.BodyHtml = CommonMarkConverter.Convert(overrideText + ?? emailSetupText.BodyText); } catch (CommonMarkException cmex) { @@ -100,6 +113,18 @@ var emailTemplateText }; } + public async Task> GetEmailSetupsAsync() + { + var emailSetups = await _emailSetupRepository.GetAllAsync(); + return emailSetups.ToDictionary(_ => _.Id, _ => _.Description); + } + + public async Task GetSetupTextByLanguageAsync(int emailSetupId, + string languageName) + { + return await _emailSetupTextRepository.GetByIdLanguageAsync(emailSetupId, languageName); + } + public async Task SendAsync(Utility.Email.Details emailDetails) { var record = await _sender.SendEmailAsync(emailDetails); diff --git a/src/Ops.Service/Filters/RequestFilter.cs b/src/Ops.Service/Filters/RequestFilter.cs new file mode 100644 index 000000000..0f4b28563 --- /dev/null +++ b/src/Ops.Service/Filters/RequestFilter.cs @@ -0,0 +1,11 @@ +namespace Ocuda.Ops.Service.Filters +{ + public class RequestFilter : BaseFilter + { + public RequestFilter(int page) : base(page) + { + } + + public bool? IsProcessed { get; set; } + } +} diff --git a/src/Ops.Service/Interfaces/Ops/Repositories/ICardRenewalResponseRepository.cs b/src/Ops.Service/Interfaces/Ops/Repositories/ICardRenewalResponseRepository.cs new file mode 100644 index 000000000..0be78bb00 --- /dev/null +++ b/src/Ops.Service/Interfaces/Ops/Repositories/ICardRenewalResponseRepository.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Ocuda.Ops.Models.Entities; + +namespace Ocuda.Ops.Service.Interfaces.Ops.Repositories +{ + public interface ICardRenewalResponseRepository : IOpsRepository + { + Task GetBySortOrderAsync(int sortOrder); + Task GetMaxSortOrderAsync(); + Task> GetAllAsync(); + Task> GetAvailableAsync(); + Task> GetSubsequentAsync(int sortOrder); + } +} diff --git a/src/Ops.Service/Interfaces/Ops/Repositories/ICardRenewalResultRepository.cs b/src/Ops.Service/Interfaces/Ops/Repositories/ICardRenewalResultRepository.cs new file mode 100644 index 000000000..b81c9c499 --- /dev/null +++ b/src/Ops.Service/Interfaces/Ops/Repositories/ICardRenewalResultRepository.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Ocuda.Ops.Models.Entities; + +namespace Ocuda.Ops.Service.Interfaces.Ops.Repositories +{ + public interface ICardRenewalResultRepository : IOpsRepository + { + Task GetForRequestAsync(int requestId); + Task GetRequestResponseTypeAsync(int requestId); + } +} diff --git a/src/Ops.Service/Interfaces/Ops/Repositories/IEmailSetupRepository.cs b/src/Ops.Service/Interfaces/Ops/Repositories/IEmailSetupRepository.cs new file mode 100644 index 000000000..4e8f776df --- /dev/null +++ b/src/Ops.Service/Interfaces/Ops/Repositories/IEmailSetupRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Ocuda.Ops.Models.Entities; + +namespace Ocuda.Ops.Service.Interfaces.Ops.Repositories +{ + public interface IEmailSetupRepository : IGenericRepository + { + Task> GetAllAsync(); + } +} diff --git a/src/Ops.Service/Interfaces/Ops/Services/ICardRenewalService.cs b/src/Ops.Service/Interfaces/Ops/Services/ICardRenewalService.cs new file mode 100644 index 000000000..d3073dfe4 --- /dev/null +++ b/src/Ops.Service/Interfaces/Ops/Services/ICardRenewalService.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Ocuda.Ops.Models.Entities; +using Ocuda.Ops.Service.Models.CardRenewal; + +namespace Ocuda.Ops.Service.Interfaces.Ops.Services +{ + public interface ICardRenewalService + { + Task CreateResponseAsync(CardRenewalResponse response); + Task DeleteResponseAsync(int id); + Task DiscardRequestAsync(int id); + Task> GetAvailableResponsesAsync(); + Task GetResponseAsync(int id); + Task> GetResponsesAsync(); + Task GetResponseTextAsync(int responseId, int languageId); + Task GetResultForRequestAsync(int requestId); + Task IsRequestAccepted(int requestId); + Task ProcessRequestAsync(int requestId, + int responseId, + string responseText, + string customerName); + Task UpdateResponseAsync(CardRenewalResponse response); + Task UpdateResponseSortOrderAsync(int id, bool increase); + } +} diff --git a/src/Ops.Service/Interfaces/Ops/Services/IEmailService.cs b/src/Ops.Service/Interfaces/Ops/Services/IEmailService.cs index 8b6a82dcc..ce828d7d4 100644 --- a/src/Ops.Service/Interfaces/Ops/Services/IEmailService.cs +++ b/src/Ops.Service/Interfaces/Ops/Services/IEmailService.cs @@ -7,9 +7,12 @@ namespace Ocuda.Ops.Service.Interfaces.Ops.Services { public interface IEmailService { - Task
GetDetailsAsync(int emailSetupId, string languageName, - IDictionary tags); - + Task
GetDetailsAsync(int emailSetupId, + string languageName, + IDictionary tags, + string overrideText = null); + Task> GetEmailSetupsAsync(); + Task GetSetupTextByLanguageAsync(int emailSetupId, string languageName); Task SendAsync(Details emailDetails); } } \ No newline at end of file diff --git a/src/Ops.Service/Interfaces/Ops/Services/ISiteSettingService.cs b/src/Ops.Service/Interfaces/Ops/Services/ISiteSettingService.cs index fae8fcf5d..381f0d4cb 100644 --- a/src/Ops.Service/Interfaces/Ops/Services/ISiteSettingService.cs +++ b/src/Ops.Service/Interfaces/Ops/Services/ISiteSettingService.cs @@ -9,6 +9,7 @@ public interface ISiteSettingService Task EnsureSiteSettingsExistAsync(int sysadminId); Task> GetAllAsync(); Task GetSettingBoolAsync(string key); + Task GetSettingDoubleAsync(string key); Task GetSettingIntAsync(string key); Task GetSettingStringAsync(string key); Task UpdateAsync(string key, string value); diff --git a/src/Ops.Service/Interfaces/Promenade/Repositories/ICardRenewalRequestRepository.cs b/src/Ops.Service/Interfaces/Promenade/Repositories/ICardRenewalRequestRepository.cs new file mode 100644 index 000000000..bf245d978 --- /dev/null +++ b/src/Ops.Service/Interfaces/Promenade/Repositories/ICardRenewalRequestRepository.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Ocuda.Ops.Service.Filters; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility.Models; + +namespace Ocuda.Ops.Service.Interfaces.Promenade.Repositories +{ + public interface ICardRenewalRequestRepository : IGenericRepository + { + Task GetByIdAsync(int id); + Task GetCountAsync(bool? isProcessed); + Task> GetPaginatedAsync(RequestFilter filter); + } +} diff --git a/src/Ops.Service/Interfaces/Promenade/Repositories/ILanguageRepository.cs b/src/Ops.Service/Interfaces/Promenade/Repositories/ILanguageRepository.cs index ced6a2e9f..cd765c304 100644 --- a/src/Ops.Service/Interfaces/Promenade/Repositories/ILanguageRepository.cs +++ b/src/Ops.Service/Interfaces/Promenade/Repositories/ILanguageRepository.cs @@ -16,6 +16,8 @@ public interface ILanguageRepository : IGenericRepository Task GetDefaultLanguageId(); + Task GetDefaultLanguageNameAsync(); + Task GetLanguageId(string culture); } } \ No newline at end of file diff --git a/src/Ops.Service/Interfaces/Promenade/Services/ICardRenewalRequestService.cs b/src/Ops.Service/Interfaces/Promenade/Services/ICardRenewalRequestService.cs new file mode 100644 index 000000000..cc88586a7 --- /dev/null +++ b/src/Ops.Service/Interfaces/Promenade/Services/ICardRenewalRequestService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Ocuda.Ops.Service.Filters; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility.Models; + +namespace Ocuda.Ops.Service.Interfaces.Promenade.Services +{ + public interface ICardRenewalRequestService + { + Task GetRequestAsync(int id); + Task GetRequestCountAsync(bool? isProcessed); + Task> GetRequestsAsync(RequestFilter filter); + } +} diff --git a/src/Ops.Service/Interfaces/Promenade/Services/ILanguageService.cs b/src/Ops.Service/Interfaces/Promenade/Services/ILanguageService.cs index 775a51f3e..66284f444 100644 --- a/src/Ops.Service/Interfaces/Promenade/Services/ILanguageService.cs +++ b/src/Ops.Service/Interfaces/Promenade/Services/ILanguageService.cs @@ -13,5 +13,7 @@ public interface ILanguageService Task> GetActiveNamesAsync(); Task GetDefaultLanguageId(); + + Task GetDefaultLanguageNameAsync(); } } \ No newline at end of file diff --git a/src/Ops.Service/Keys/CardRenewal.cs b/src/Ops.Service/Keys/CardRenewal.cs new file mode 100644 index 000000000..bb6bc40b9 --- /dev/null +++ b/src/Ops.Service/Keys/CardRenewal.cs @@ -0,0 +1,8 @@ +namespace Ocuda.Ops.Service.Keys +{ + public class CardRenewal + { + public static readonly string CustomerBarcode = "CustomerBarcode"; + public static readonly string CustomerName = "CustomerName"; + } +} diff --git a/src/Ops.Service/LanguageService.cs b/src/Ops.Service/LanguageService.cs index effbc0b1f..1fc9a3276 100644 --- a/src/Ops.Service/LanguageService.cs +++ b/src/Ops.Service/LanguageService.cs @@ -36,5 +36,10 @@ public async Task GetDefaultLanguageId() { return await _languageRepository.GetDefaultLanguageId(); } + + public async Task GetDefaultLanguageNameAsync() + { + return await _languageRepository.GetDefaultLanguageNameAsync(); + } } } \ No newline at end of file diff --git a/src/Ops.Service/Models/CardRenewal/ProcessResult.cs b/src/Ops.Service/Models/CardRenewal/ProcessResult.cs new file mode 100644 index 000000000..9e3b6329f --- /dev/null +++ b/src/Ops.Service/Models/CardRenewal/ProcessResult.cs @@ -0,0 +1,11 @@ +using static Ocuda.Ops.Models.Entities.CardRenewalResponse; + +namespace Ocuda.Ops.Service.Models.CardRenewal +{ + public class ProcessResult + { + public bool EmailNotUpdated { get; set; } + public bool EmailSent { get; set; } + public ResponseType Type { get; set; } + } +} diff --git a/src/Ops.Service/Ops.Service.csproj b/src/Ops.Service/Ops.Service.csproj index ff4b386c2..1a525fd87 100644 --- a/src/Ops.Service/Ops.Service.csproj +++ b/src/Ops.Service/Ops.Service.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Ops.Service/SiteSettingService.cs b/src/Ops.Service/SiteSettingService.cs index 60c68fd3f..9372d844f 100644 --- a/src/Ops.Service/SiteSettingService.cs +++ b/src/Ops.Service/SiteSettingService.cs @@ -90,6 +90,20 @@ public async Task GetSettingBoolAsync(string key) } } + public async Task GetSettingDoubleAsync(string key) + { + var settingValue = await GetSettingValueAsync(key); + + if (double.TryParse(settingValue, out double result)) + { + return result; + } + else + { + throw new OcudaException($"Invalid value for double setting {key}: {settingValue}"); + } + } + public async Task GetSettingIntAsync(string key) { var settingValue = await GetSettingValueAsync(key); diff --git a/src/Ops.Web/Areas/ContentManagement/Views/CardRenewal/Response.cshtml b/src/Ops.Web/Areas/ContentManagement/Views/CardRenewal/Response.cshtml new file mode 100644 index 000000000..c75c5c6fd --- /dev/null +++ b/src/Ops.Web/Areas/ContentManagement/Views/CardRenewal/Response.cshtml @@ -0,0 +1,118 @@ +@model Ocuda.Ops.Controllers.Areas.ContentManagement.ViewModels.CardRenewal.ResponseViewModel + +
+

+ Card Renewal Response + @Model.Response.Name +

+
+ Back +
+
+ + + +
+ + + + + + +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+

+ Email Text Preview + +

+
+ @foreach (var language in Model.Languages) + { + + } +
+
+
+ + + + + +
+
+ Replacement Keys – {{@Ocuda.Ops.Service.Keys.CardRenewal.CustomerBarcode}}, {{@Ocuda.Ops.Service.Keys.CardRenewal.CustomerName}} +
+
+ + +@section Scripts { + +} \ No newline at end of file diff --git a/src/Ops.Web/Areas/ContentManagement/Views/CardRenewal/Responses.cshtml b/src/Ops.Web/Areas/ContentManagement/Views/CardRenewal/Responses.cshtml new file mode 100644 index 000000000..c3cd48544 --- /dev/null +++ b/src/Ops.Web/Areas/ContentManagement/Views/CardRenewal/Responses.cshtml @@ -0,0 +1,170 @@ +@model Ocuda.Ops.Controllers.Areas.ContentManagement.ViewModels.CardRenewal.ResponsesViewModel + +
+

+ Card Renewal Responses +

+
+ +
+
+ + + + + + + + + + + + @if (Model.Responses.Count() == 0) + { + + + + } + else + { + @foreach (var response in Model.Responses) + { + + + + + + + } + } + +
TypeNameEmail 
+ No responses found. +
@response.Type + + @response.Name + + @response.EmailSetup?.Description + + + +
+ +
+
+ + +
+
+ +
+ + + +
+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/src/Ops.Web/Areas/ContentManagement/Views/Home/Index.cshtml b/src/Ops.Web/Areas/ContentManagement/Views/Home/Index.cshtml index a904aee80..66b78aab5 100644 --- a/src/Ops.Web/Areas/ContentManagement/Views/Home/Index.cshtml +++ b/src/Ops.Web/Areas/ContentManagement/Views/Home/Index.cshtml @@ -4,6 +4,22 @@

Intranet management

+ @if (Model.IsSiteManager || Model.HasCardRenewalPermissions) + { +
+ +
+ }
diff --git a/src/Ops.Web/Areas/Services/Views/CardRenewal/Details.cshtml b/src/Ops.Web/Areas/Services/Views/CardRenewal/Details.cshtml new file mode 100644 index 000000000..9cb27fdb8 --- /dev/null +++ b/src/Ops.Web/Areas/Services/Views/CardRenewal/Details.cshtml @@ -0,0 +1,515 @@ + @model Ocuda.Ops.Controllers.Areas.Services.ViewModels.CardRenewal.DetailsViewModel + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if (!string.IsNullOrWhiteSpace(Model.AcceptedCounty)) + { + + + + + } + + + + +
Record id + @Model.Request.CustomerId + @if (!string.IsNullOrEmpty(Model.LeapPath)) + { + + + + } +
Request barcode@Model.Request.Barcode
Request language@Model.Request.Language.Description
First name@Model.Customer.NameFirst
Last name@Model.Customer.NameLast
Email address@Model.Request.Email
Expiration date@Model.Customer.ExpirationDate?.ToShortDateString()
Address check date@Model.Customer.AddressVerificationDate?.ToShortDateString()
At the same address@(Model.Request.SameAddress ? "Yes" : "No")
In @Model.AcceptedCounty County@(Model.InCounty ? "Yes" : "No")
Patron code@Model.CustomerCode
+ + @if (!string.IsNullOrWhiteSpace(Model.Request.GuardianName)) + { + + @if (Model.CustomerAge.HasValue) + { + + + + + } + + + + + + + + + + + + + + + + +
Age@Model.CustomerAge
Request guardian's name@Model.Request.GuardianName
Request guardian's barcode@Model.Request.GuardianBarcode
Polaris guardian's name@Model.Customer.UserDefinedField3
Polaris guardian's barcode@Model.Customer.UserDefinedField2
+ } +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Charges@Model.Customer.ChargeBalance.ToString("C")
Has system blocks@(Model.Customer.IsBlocked ? "Yes" : "No")
Blocks + @if (Model.CustomerBlocks?.Count > 0) + { + foreach (var block in Model.CustomerBlocks) + { +
@block.Description
+ } + } + else + { + @:None + } +
+ Blocking notes + @if (Model.Customer.BlockingNotes?.Length > Model.MaxNotesDisplayLength) + { + + } + + @if (!string.IsNullOrWhiteSpace(Model.Customer.BlockingNotes)) + { + @(new string(Model.Customer.BlockingNotes.Take(Model.MaxNotesDisplayLength).ToArray())) + } + else + { + @:None + } +
+ Non-blocking notes + @if (Model.Customer.Notes?.Length > Model.MaxNotesDisplayLength) + { + + } + + @if (!string.IsNullOrWhiteSpace(Model.Customer.Notes)) + { + @(new string(Model.Customer.Notes.Take(Model.MaxNotesDisplayLength).ToArray())) + } + else + { + @:None + } +
Proof of address@Model.Customer.UserDefinedField4
+ @if (Model.Request.ProcessedAt.HasValue) + { + + + + + + + + + +
Processed by + @if (Model.Result.CreatedByUser.IsDeleted == false) + { + @Model.Result.CreatedByUser.Name + } + else + { + @Model.Result.CreatedByUser.Name + } +
Processed at@Model.Request.ProcessedAt
+ } +
+
+ +
+
+
+
+

+ +

+
+
+
+
+
Address(es) in Polaris:
+ @foreach (var address in Model.Customer.Addresses) + { +
+ @address.AddressType + @if (Model.AddressLookupUrlSet || !string.IsNullOrWhiteSpace(Model.AssessorLookupUrl)) + { + + (Look up) + + + } +
+
+ @address.StreetAddressOne
+ @if (!string.IsNullOrWhiteSpace(address.StreetAddressTwo)) + { + @address.StreetAddressTwo +
+ } + @address.City @address.State, @address.PostalCode
+ County: @address.County +
+ } +
+ @if (Model.AddressLookupUrlSet || !string.IsNullOrWhiteSpace(Model.AssessorLookupUrl)) + { +
+ @if (Model.AddressLookupUrlSet) + { +
+ } + @if (!string.IsNullOrWhiteSpace(Model.AssessorLookupUrl)) + { +
+ Assessor results: + +
+ } +
+ } +
+
+
+
+
+
+
+ +@if (!Model.Request.ProcessedAt.HasValue) +{ +
+ + + +
+
+
+ + +
+ +
+ + +
+ +
+
+ Replacement Keys – {{@Ocuda.Ops.Service.Keys.CardRenewal.CustomerBarcode}}, {{@Ocuda.Ops.Service.Keys.CardRenewal.CustomerName}} +
+
+ +
+ + + Back +
+
+
+
+ +
+ + + +
+} +else +{ +
+
+
+ E-Mail response sent to customer: +
+
+
+ @Html.Raw(Model.Result.ResponseText) +
+
+
+ Back +
+
+
+} + +@if (Model.Customer.BlockingNotes?.Length > Model.MaxNotesDisplayLength) +{ +
+ @Model.Customer.BlockingNotes +
+} + +@if (Model.Customer.Notes?.Length > Model.MaxNotesDisplayLength) +{ +
+ @Model.Customer.Notes +
+} + +@section scripts { + + + @if (Model.AddressLookupUrlSet) + { + + } + + @if (!string.IsNullOrWhiteSpace(Model.AssessorLookupUrl)) + { + + } +} \ No newline at end of file diff --git a/src/Ops.Web/Areas/Services/Views/CardRenewal/Index.cshtml b/src/Ops.Web/Areas/Services/Views/CardRenewal/Index.cshtml new file mode 100644 index 000000000..8e481f48d --- /dev/null +++ b/src/Ops.Web/Areas/Services/Views/CardRenewal/Index.cshtml @@ -0,0 +1,110 @@ +@model Ocuda.Ops.Controllers.Areas.Services.ViewModels.CardRenewal.IndexViewModel + +
+
+

+ Card Renewal Requests + @(Model.IsProcessed ? "Processed" : "Pending") +

+
+
+ +@if (!Model.APIConfigured) +{ +
+
+
+ Please set API/DB configurations to enable processing card renewals +
+
+
+} + + + + + + + + + + @if (Model.IsProcessed) + { + + } + + + + @if (Model.ItemCount == 0) + { + + + + } + else + { + @foreach (var request in Model.CardRequests) + { + + + + + @if (Model.IsProcessed) + { + + } + + } + } + +
@(Model.IsProcessed ? "Processed" : "Submitted")BarcodeSame Address?Accepted?
+ No requests found. +
+ @if (Model.APIConfigured) + { + + @(Model.IsProcessed? request.ProcessedAt: request.SubmittedAt) + + } + else + { + @(Model.IsProcessed? request.ProcessedAt: request.SubmittedAt) + } + @request.Barcode@(request.SameAddress ? "Yes" : "No") + @if (request.Accepted == true) + { + + } + else + { + + } +
+ + \ No newline at end of file diff --git a/src/Ops.Web/Areas/Services/Views/_ViewImports.cshtml b/src/Ops.Web/Areas/Services/Views/_ViewImports.cshtml new file mode 100644 index 000000000..e74331b16 --- /dev/null +++ b/src/Ops.Web/Areas/Services/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using Ocuda.Ops.Web +@using Ocuda.Ops.Controllers.Areas.Services +@using Ocuda.Utility.TagHelpers +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Ocuda.Utility \ No newline at end of file diff --git a/src/Ops.Web/Areas/Services/Views/_ViewStart.cshtml b/src/Ops.Web/Areas/Services/Views/_ViewStart.cshtml new file mode 100644 index 000000000..2de62418c --- /dev/null +++ b/src/Ops.Web/Areas/Services/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "~/Views/Shared/_Layout.cshtml"; +} diff --git a/src/Ops.Web/Startup.cs b/src/Ops.Web/Startup.cs index e291dad61..b2657c209 100644 --- a/src/Ops.Web/Startup.cs +++ b/src/Ops.Web/Startup.cs @@ -22,6 +22,7 @@ using Ocuda.Ops.Service.Interfaces.Promenade.Services; using Ocuda.Ops.Web.JobScheduling; using Ocuda.Ops.Web.StartupHelper; +using Ocuda.PolarisHelper; using Ocuda.Utility.Abstract; using Ocuda.Utility.Exceptions; using Ocuda.Utility.Keys; @@ -163,12 +164,17 @@ string cacheDiscriminator string promCs = _config.GetConnectionString("Promenade") ?? throw new OcudaException("ConnectionString:Promenade not configured."); + string polarisCs = _config.GetConnectionString("Polaris"); + if (_config[Configuration.OpsDatabaseProvider]?.ToUpperInvariant() == "SQLSERVER") { services.AddDbContextPool(_ => _.UseSqlServer(opsCs)); services.AddDbContextPool(_ => _.UseSqlServer(promCs)); + + services.AddDbContextPool(_ => _.UseSqlServer(polarisCs)); + services.AddHealthChecks(); } else @@ -334,10 +340,15 @@ string cacheDiscriminator // helpers services.AddScoped(); services.AddScoped(); + services.AddScoped(); // repositories services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Ops.Web/Views/Shared/_Layout.cshtml b/src/Ops.Web/Views/Shared/_Layout.cshtml index 341a280cc..d160c0585 100644 --- a/src/Ops.Web/Views/Shared/_Layout.cshtml +++ b/src/Ops.Web/Views/Shared/_Layout.cshtml @@ -1,357 +1,364 @@ @{ - var navigationRows = Context.Items[ItemKey.NavColumn] as List; - var locations = Context.Items[ItemKey.Locations] as IDictionary; + var navigationRows = Context.Items[ItemKey.NavColumn] as List; + var locations = Context.Items[ItemKey.Locations] as IDictionary; } - - - @ViewData[Ocuda.Ops.Controllers.ViewDataKey.Title] - - - @if (Context.Items.ContainsKey(ItemKey.ExternalCSS)) - { - foreach (var cssUrl in (List)Context.Items[ItemKey.ExternalCSS]) - { - - } - } - @RenderSection("Styles", required: false) + + + @ViewData[Ocuda.Ops.Controllers.ViewDataKey.Title] + + + @if (Context.Items.ContainsKey(ItemKey.ExternalCSS)) + { + foreach (var cssUrl in (List)Context.Items[ItemKey.ExternalCSS]) + { + + } + } + @RenderSection("Styles", required: false) - @if (User?.Identity?.IsAuthenticated != true) - { - - } - else - { - + } +
+ @if (navigationRows?.Count > 0) + { + + + } + @RenderSection("Header", required: false) + @if (TempData[Ocuda.Ops.Controllers.TempDataKey.AlertDanger] != null) + { +
+
+
@Html.Raw(TempData[Ocuda.Ops.Controllers.TempDataKey.AlertDanger])
+
+
+ } + @if (TempData[Ocuda.Ops.Controllers.TempDataKey.AlertWarning] != null) + { +
+
+
@Html.Raw(TempData[Ocuda.Ops.Controllers.TempDataKey.AlertWarning])
+
+
+ } + @if (TempData[Ocuda.Ops.Controllers.TempDataKey.AlertSuccess] != null) + { +
+
+
@Html.Raw(TempData[Ocuda.Ops.Controllers.TempDataKey.AlertSuccess])
+
+
+ } + @if (TempData[Ocuda.Ops.Controllers.TempDataKey.AlertInfo] != null) + { +
+
+
@Html.Raw(TempData[Ocuda.Ops.Controllers.TempDataKey.AlertInfo])
+
+
+ } + @RenderBody() +
+ + + @if (Context.Items.ContainsKey(ItemKey.ExternalJS)) + { + foreach (var jsUrl in (List)Context.Items[ItemKey.ExternalJS]) + { + + } + } + @RenderSection("Scripts", required: false) \ No newline at end of file diff --git a/src/PolarisHelper/IPolarisHelper.cs b/src/PolarisHelper/IPolarisHelper.cs new file mode 100644 index 000000000..b04b2c775 --- /dev/null +++ b/src/PolarisHelper/IPolarisHelper.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Ocuda.Models; +using Ocuda.PolarisHelper.Models; + +namespace Ocuda.PolarisHelper +{ + public interface IPolarisHelper + { + bool IsConfigured { get; } + bool AuthenticateCustomer(string barcode, string password); + Task> GetCustomerBlocksAsync(int customerId); + Task GetCustomerCodeNameAsync(int customerCodeId); + Customer GetCustomerData(string barcode, string password); + Customer GetCustomerDataOverride(string barcode); + RenewRegistrationResult RenewCustomerRegistration(string barcode, string email); + } +} diff --git a/src/PolarisHelper/Models/RenewRegistrationResult.cs b/src/PolarisHelper/Models/RenewRegistrationResult.cs new file mode 100644 index 000000000..7e8fd849d --- /dev/null +++ b/src/PolarisHelper/Models/RenewRegistrationResult.cs @@ -0,0 +1,8 @@ +namespace Ocuda.PolarisHelper.Models +{ + public class RenewRegistrationResult + { + public bool EmailNotUpdated { get; set; } + public bool Success { get; set; } + } +} diff --git a/src/PolarisHelper/PolarisContext.cs b/src/PolarisHelper/PolarisContext.cs new file mode 100644 index 000000000..433328c95 --- /dev/null +++ b/src/PolarisHelper/PolarisContext.cs @@ -0,0 +1,9 @@ +using Microsoft.EntityFrameworkCore; + +namespace Ocuda.PolarisHelper +{ + public class PolarisContext : DbContext + { + public PolarisContext(DbContextOptions options) : base(options) { } + } +} diff --git a/src/PolarisHelper/PolarisHelper.cs b/src/PolarisHelper/PolarisHelper.cs new file mode 100644 index 000000000..4c9ea508e --- /dev/null +++ b/src/PolarisHelper/PolarisHelper.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Clc.Polaris.Api; +using Clc.Polaris.Api.Configuration; +using Clc.Polaris.Api.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Ocuda.Models; +using Ocuda.PolarisHelper.Models; +using Ocuda.Utility.Keys; +using Ocuda.Utility.Services.Interfaces; + +namespace Ocuda.PolarisHelper +{ + public class PolarisHelper : IPolarisHelper + { + public bool IsConfigured { get; } + + private const int CacheCodesHours = 1; + private const int PAPIInvalidEmailErrorCode = -3518; + + private readonly IOcudaCache _cache; + private readonly IConfiguration _config; + private readonly PolarisContext _context; + private readonly ILogger _logger; + private readonly IPapiClient _papiClient; + + public PolarisHelper(IOcudaCache cache, + IConfiguration config, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(logger); + + _cache = cache; + _config = config; + _logger = logger; + + var settings = new PapiSettings(); + _config.GetSection(PapiSettings.SECTION_NAME).Bind(settings); + _papiClient = new PapiClient(settings); + _papiClient.AllowStaffOverrideRequests = false; + + IsConfigured = ValidateConfiguration(); + } + + public PolarisHelper(IOcudaCache cache, + IConfiguration config, + PolarisContext context, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(logger); + + _cache = cache; + _config = config; + _context = context; + _logger = logger; + + var settings = new PapiSettings(); + _config.GetSection(PapiSettings.SECTION_NAME).Bind(settings); + _papiClient = new PapiClient(settings); + + IsConfigured = ValidateConfiguration(); + } + + public bool AuthenticateCustomer(string barcode, string password) + { + var validateResult = _papiClient.PatronValidate(barcode, password)?.Data; + + return validateResult != null; + } + + public async Task> GetCustomerBlocksAsync(int customerId) + { + var blocks = await _context.Database + .SqlQuery(@$"SELECT PS.PatronStopID AS BlockId, PSD.Description + FROM PatronStops as PS + INNER JOIN PatronStopDescriptions as PSD + on PS.PatronStopId = PSD.PatronStopId + WHERE PS.PatronID = {customerId}") + .ToListAsync(); + + var freeTextBlocks = await _context.Database + .SqlQuery(@$"SELECT NULL AS BlockId, FreeTextBlock AS Description + FROM PatronFreeTextBlocks + WHERE PatronID = {customerId}") + .ToListAsync(); + + blocks.AddRange(freeTextBlocks); + + return blocks; + } + + public async Task GetCustomerCodeNameAsync(int customerCodeId) + { + var patronCodes = await _cache + .GetObjectFromCacheAsync>(Cache.PolarisPatronCodes); + + if (patronCodes == null) + { + patronCodes = _papiClient.PatronCodesGet().Data.PatronCodesRows; + await _cache.SaveToCacheAsync(Cache.PolarisPatronCodes, + patronCodes, + CacheCodesHours); + } + + return patronCodes + .Where(_ => _.PatronCodeID == customerCodeId) + .Select(_ => _.Description) + .SingleOrDefault(); + } + + public Customer GetCustomerData(string barcode, string password) + { + var patronData = _papiClient.PatronBasicDataGet(barcode, password, addresses: true) + .Data + .PatronBasicData; + + return GetCustomerInfo(patronData); + } + + public Customer GetCustomerDataOverride(string barcode) + { + var patronData = _papiClient.PatronBasicDataGet(barcode, addresses: true, notes: true) + .Data + .PatronBasicData; + + return GetCustomerInfo(patronData); + } + + public RenewRegistrationResult RenewCustomerRegistration(string barcode, string email) + { + var date = DateTime.Now.AddYears(1); + var updateParams = new PatronUpdateParams + { + BranchId = _papiClient.OrganizationId, + UserId = _papiClient.UserId, + LogonWorkstationId = _papiClient.WorkstationId, + ExpirationDate = date, + AddrCheckDate = date, + EmailAddress = email + }; + + var renewResult = new RenewRegistrationResult(); + + var updateResults = _papiClient.PatronUpdate(barcode, updateParams); + + if (updateResults.Exception != null) + { + _logger.LogError("PAPI call was not successful: {ErrorMessage}", + updateResults.Exception.Message); + } + else if (!updateResults.Response.IsSuccessStatusCode) + { + _logger.LogError("PAPI call was not successful after {Elapsed} ms: {StatusCode}", + updateResults.ResponseTime, + updateResults.Response.StatusCode); + } + else + { + if (updateResults.Data.PAPIErrorCode == PAPIInvalidEmailErrorCode) + { + renewResult.EmailNotUpdated = true; + updateParams.EmailAddress = null; + + updateResults = _papiClient.PatronUpdate(barcode, updateParams); + } + + if (updateResults.Data.PAPIErrorCode != 0) + { + _logger.LogError("PAPI error after {Elapsed} ms: {PAPIErrorCode}", + updateResults.ResponseTime, + updateResults.Data.PAPIErrorCode); + } + else if (updateResults.Data.PAPIErrorCode == 0) + { + renewResult.Success = true; + if (renewResult.EmailNotUpdated) + { + _logger.LogWarning("Unable to update email to {EmailAddress} for barcode {Barcode}", + email, + barcode); + } + } + } + + return renewResult; + } + + private static Customer GetCustomerInfo(PatronData patronData) + { + var customer = new Customer + { + AddressVerificationDate = patronData.AddrCheckDate, + BirthDate = patronData.BirthDate, + BlockingNotes = patronData.PatronNotes?.BlockingStatusNotes, + ChargeBalance = patronData.ChargeBalance, + CustomerCodeId = patronData.PatronCodeID, + CustomerIdNumber = patronData.Barcode, + EmailAddress = patronData.EmailAddress, + ExpirationDate = patronData.ExpirationDate, + Id = patronData.PatronID, + IsBlocked = patronData.PatronSystemBlocks.Any(), + NameFirst = patronData.NameFirst, + NameLast = patronData.NameLast, + Notes = patronData.PatronNotes?.NonBlockingStatusNotes, + UserDefinedField1 = patronData.User1, + UserDefinedField2 = patronData.User2, + UserDefinedField3 = patronData.User3, + UserDefinedField4 = patronData.User4, + UserDefinedField5 = patronData.User5 + }; + + var addressList = new List(); + foreach (var address in patronData.PatronAddresses) + { + addressList.Add(new CustomerAddress + { + AddressType = address.FreeTextLabel, + AddressTypeId = address.AddressTypeID, + City = address.City, + Country = address.Country, + CountryId = address.CountryID, + County = address.County, + Id = address.AddressId, + PostalCode = address.PostalCode, + State = address.State, + StreetAddressOne = address.StreetOne, + StreetAddressTwo = address.StreetTwo, + ZipPlusFour = address.ZipPlusFour, + }); + } + + customer.Addresses = addressList; + + return customer; + } + + private bool ValidateConfiguration() + { + var validConfiguration = !string.IsNullOrEmpty(_papiClient.AccessID) + && !string.IsNullOrWhiteSpace(_papiClient.AccessKey) + && !string.IsNullOrWhiteSpace(_papiClient.Hostname); + + if (!validConfiguration) + { + _logger.LogError("PAPI not configured"); + } + else if (_papiClient.AllowStaffOverrideRequests) + { + validConfiguration = _papiClient.OrganizationId != 0 + && _papiClient.UserId != 0 + && _papiClient.WorkstationId != 0 + && _papiClient.StaffOverrideAccount != null + && !string.IsNullOrWhiteSpace(_context.Database.GetConnectionString()); + + if (!validConfiguration) + { + _logger.LogError("PAPI/PolarisDB not configured for staff requests"); + } + } + + return validConfiguration; + } + } +} diff --git a/src/PolarisHelper/PolarisHelper.csproj b/src/PolarisHelper/PolarisHelper.csproj new file mode 100644 index 000000000..bf7390b6a --- /dev/null +++ b/src/PolarisHelper/PolarisHelper.csproj @@ -0,0 +1,27 @@ + + + + Ocuda.PolarisHelper + Maricopa County Library District Web developers + ../../OcudaRuleSet.ruleset + Maricopa County Library District + Copyright 2018 Maricopa County Library District + https://github.com/MCLD/ocuda/blob/develop/LICENSE + Ocuda + Git + https://github.com/MCLD/ocuda/ + Ocuda.PolarisHelper + net8.0 + + + + + + + + + + + + + diff --git a/src/Promenade.Controllers/CardRenewalController.cs b/src/Promenade.Controllers/CardRenewalController.cs new file mode 100644 index 000000000..f6e4b9912 --- /dev/null +++ b/src/Promenade.Controllers/CardRenewalController.cs @@ -0,0 +1,636 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using CommonMark; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Ocuda.Models; +using Ocuda.PolarisHelper; +using Ocuda.Promenade.Controllers.Abstract; +using Ocuda.Promenade.Controllers.Filters; +using Ocuda.Promenade.Controllers.ViewModels.CardRenewal; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Promenade.Service; +using Ocuda.Utility.Abstract; +using Ocuda.Utility.Filters; + +namespace Ocuda.Promenade.Controllers +{ + [Route("[Controller]")] + [Route("{culture:cultureConstraint?}/[Controller]")] + public class CardRenewalController : BaseController + { + private const int AgeOfMajority = 18; + + private const string TempDataRequest = "TempData.Request"; + private const string TempDataTimeout = "TempData.Timeout"; + private const string TempDataUnableToRenew = "TempData.UnableToRenew"; + + private readonly CardRenewalService _cardRenewalService; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly IPolarisHelper _polarisHelper; + private readonly SegmentService _segmentService; + + public CardRenewalController(ServiceFacades.Controller context, + CardRenewalService cardRenewalService, + IDateTimeProvider dateTimeProvider, + IPolarisHelper polarisHelper, + SegmentService segmentService) + : base(context) + { + ArgumentNullException.ThrowIfNull(cardRenewalService); + ArgumentNullException.ThrowIfNull(dateTimeProvider); + ArgumentNullException.ThrowIfNull(polarisHelper); + ArgumentNullException.ThrowIfNull(segmentService); + + _cardRenewalService = cardRenewalService; + _dateTimeProvider = dateTimeProvider; + _polarisHelper = polarisHelper; + _segmentService = segmentService; + } + + public static string Name + { get { return "CardRenewal"; } } + + [HttpGet] + [RestoreModelState] + public async Task Index(string cardNumber) + { + var forceReload = HttpContext.Items[ItemKey.ForceReload] as bool? ?? false; + + if (!_polarisHelper.IsConfigured) + { + _logger.LogError("Card renewal API settings are not configured"); + + var notConfiguredSegmentId = await _siteSettingService.GetSettingIntAsync( + Models.Keys.SiteSetting.CardRenewal.NotConfiguredSegment, + forceReload); + + SegmentText notConfiguredSegmentText = null; + if (notConfiguredSegmentId > 0) + { + notConfiguredSegmentText = await _segmentService.GetSegmentTextBySegmentIdAsync( + notConfiguredSegmentId, + forceReload); + if (notConfiguredSegmentText == null) + { + _logger.LogError($"Card renewal 'Not configured' segment id '{notConfiguredSegmentId}' not found"); + } + else if (!string.IsNullOrWhiteSpace(notConfiguredSegmentText.Text)) + { + notConfiguredSegmentText.Text = CommonMarkConverter.Convert( + notConfiguredSegmentText.Text); + } + } + + return View("NotConfigured", notConfiguredSegmentText); + } + + if (TempData.ContainsKey(TempDataTimeout)) + { + ModelState.AddModelError(nameof(IndexViewModel.Invalid), + _localizer[i18n.Keys.Promenade.CardRenewalSessionTimeout]); + TempData.Remove(TempDataTimeout); + } + + var viewModel = new IndexViewModel() + { + Barcode = cardNumber, + ForgotPasswordLink = await _siteSettingService.GetSettingStringAsync( + Models.Keys.SiteSetting.Site.ForgotPasswordLink, + forceReload) + }; + + var cardRenewalSegmentId = await _siteSettingService.GetSettingIntAsync( + Models.Keys.SiteSetting.CardRenewal.HomeSegment, + forceReload); + if (cardRenewalSegmentId > 0) + { + viewModel.SegmentText = await _segmentService.GetSegmentTextBySegmentIdAsync( + cardRenewalSegmentId, + forceReload); + if (viewModel.SegmentText == null) + { + _logger.LogError($"Card renewal 'Home' segment id '{cardRenewalSegmentId}' not found"); + } + else if (!string.IsNullOrWhiteSpace(viewModel.SegmentText.Text)) + { + viewModel.SegmentText.Text = CommonMarkConverter.Convert( + viewModel.SegmentText.Text); + } + } + + return View(viewModel); + } + + [HttpPost] + [SaveModelState] + public async Task Index(IndexViewModel viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + + if (!_polarisHelper.IsConfigured) + { + return RedirectToAction(nameof(Index)); + } + + if (ModelState.IsValid) + { + var barcode = string.Concat(viewModel.Barcode.Where(_ => !char.IsWhiteSpace(_))); + var password = viewModel.Password.Trim(); + + var validateResult = _polarisHelper.AuthenticateCustomer(barcode, password); + + if (!validateResult) + { + _logger.LogInformation($"Invalid card number or password for Barcode '{barcode}'"); + ModelState.AddModelError(nameof(viewModel.Invalid), + _localizer[i18n.Keys.Promenade.CardRenewalInvalidLogin]); + return RedirectToAction(nameof(Index)); + } + + var customer = _polarisHelper.GetCustomerData(barcode, password); + + // Handle accounts with a pending request + var pendingRequest = await _cardRenewalService + .GetPendingRequestAsync(customer.Id); + if (pendingRequest != null) + { + ModelState.AddModelError(nameof(viewModel.Invalid), + _localizer[i18n.Keys.Promenade.CardRenewalPendingRequest, + pendingRequest.SubmittedAt.ToString("d", CultureInfo.CurrentCulture), + pendingRequest.SubmittedAt.ToString("t", CultureInfo.CurrentCulture)]); + return RedirectToAction(nameof(Index)); + } + + // Handle accounts that aren't expiring soon + var cutoffDays = await _siteSettingService.GetSettingIntAsync( + Models.Keys.SiteSetting.CardRenewal.ExpirationCutoffDays); + if (cutoffDays > -1) + { + var renewalAllowedDate = customer + .ExpirationDate.Value.AddDays(-cutoffDays).Date; + if (renewalAllowedDate > _dateTimeProvider.Now.Date) + { + ModelState.AddModelError(nameof(viewModel.Invalid), + _localizer[i18n.Keys.Promenade.CardRenewalInvalidCutoff, + cutoffDays, + renewalAllowedDate.ToShortDateString()]); + return RedirectToAction(nameof(Index)); + } + } + + // Handle accounts belonging to staff + var staffCustomerCodes = await _siteSettingService.GetSettingStringAsync( + Models.Keys.SiteSetting.CardRenewal.StaffCustomerCodes); + if (!string.IsNullOrWhiteSpace(staffCustomerCodes)) + { + var customerCodeList = staffCustomerCodes + .Split(",", StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .ToList(); + + foreach (var customerCode in customerCodeList) + { + if (int.TryParse(customerCode, out int customerCodeId)) + { + if (customer.CustomerCodeId == customerCodeId) + { + ModelState.AddModelError(nameof(viewModel.Invalid), + _localizer[i18n.Keys.Promenade.CardRenewalInvalidStaff]); + return RedirectToAction(nameof(Index)); + } + } + else + { + _logger.LogError($"Invalid staff customer code id '{customerCode}'"); + } + } + } + + // Handle accounts belonging to nonresidents + var nonresidentCustomerCodes = await _siteSettingService.GetSettingStringAsync( + Models.Keys.SiteSetting.CardRenewal.NonresidentCustomerCodes); + if (!string.IsNullOrWhiteSpace(nonresidentCustomerCodes)) + { + var customerCodeList = nonresidentCustomerCodes + .Split(",", StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .ToList(); + + foreach (var customerCode in customerCodeList) + { + if (int.TryParse(customerCode, out int customerCodeId)) + { + if (customer.CustomerCodeId == customerCodeId) + { + var nonresidentSegmentId = await _siteSettingService + .GetSettingIntAsync(Models.Keys.SiteSetting.CardRenewal + .NonresidentSegment); + if (nonresidentSegmentId <= 0) + { + _logger.LogError("'Nonresident' segment not set for card renewal"); + } + + TempData[TempDataUnableToRenew] = nonresidentSegmentId; + + return RedirectToAction(nameof(UnableToRenew)); + } + } + else + { + _logger.LogError($"Invalid nonresident customer code id '{customerCode}'"); + } + } + } + + // Handle accounts that have aged out of their code + var ageCheckCustomerCodes = await _siteSettingService.GetSettingStringAsync( + Models.Keys.SiteSetting.CardRenewal.AgeCheckCustomerCodes); + if (!string.IsNullOrWhiteSpace(ageCheckCustomerCodes)) + { + var customerCodeList = ageCheckCustomerCodes + .Split(",", StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .ToList(); + + foreach (var customerCode in customerCodeList) + { + if (int.TryParse(customerCode, out int customerCodeId)) + { + if (customer.CustomerCodeId == customerCodeId) + { + if (customer.BirthDate.HasValue + && customer.BirthDate.Value != DateTime.MinValue) + { + DateTime today = DateTime.Today; + var age = today.Year - customer.BirthDate.Value.Year; + if (customer.BirthDate.Value > today.AddYears(-age)) + { + age--; + } + + if (age >= AgeOfMajority) + { + var ageCheckSegmentId = await _siteSettingService + .GetSettingIntAsync(Models.Keys.SiteSetting.CardRenewal + .AgeCheckSegment); + if (ageCheckSegmentId <= 0) + { + _logger.LogError("'Age check' segment not set for card renewal"); + } + + TempData[TempDataUnableToRenew] = ageCheckSegmentId; + + return RedirectToAction(nameof(UnableToRenew)); + } + } + + break; + } + } + else + { + _logger.LogError($"Invalid age check customer code id '{customerCode}'"); + } + } + } + + IEnumerable addresses = customer.Addresses; + + var acceptedCounties = await _siteSettingService + .GetSettingStringAsync(Models.Keys.SiteSetting.CardRenewal.AcceptedCounties); + if (!string.IsNullOrWhiteSpace(acceptedCounties)) + { + var counties = acceptedCounties.Split(",", + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + addresses = addresses.Where(_ => counties.Contains(_.County, + StringComparer.OrdinalIgnoreCase)); + } + addresses = addresses + .DistinctBy(_ => new + { + _.StreetAddressOne, + _.StreetAddressTwo, + _.City, + _.PostalCode + }); + + var request = new CardRenewalRequest + { + Addresses = addresses, + Barcode = barcode, + CustomerId = customer.Id, + Email = customer.EmailAddress?.Trim() + }; + + var juvenileCustomerCodes = await _siteSettingService.GetSettingStringAsync( + Models.Keys.SiteSetting.CardRenewal.JuvenileCustomerCodes); + if (!string.IsNullOrWhiteSpace(juvenileCustomerCodes)) + { + var customerCodeList = juvenileCustomerCodes + .Split(",", StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .ToList(); + + foreach (var customerCode in customerCodeList) + { + if (int.TryParse(customerCode, out int customerCodeId)) + { + if (customer.CustomerCodeId == customerCodeId) + { + request.IsJuvenile = true; + break; + } + } + else + { + _logger.LogError($"Invalid juvenile customer code id '{customerCode}'"); + } + } + } + + TempData[TempDataRequest] = JsonSerializer.Serialize(request); + + if (request.IsJuvenile) + { + return RedirectToAction(nameof(Juvenile)); + } + + return RedirectToAction(nameof(VerifyAddress)); + } + return RedirectToAction(nameof(Index)); + } + + [HttpGet("[action]")] + [RestoreModelState] + public async Task Juvenile() + { + if (!TempData.ContainsKey(TempDataRequest) || !_polarisHelper.IsConfigured) + { + if (!TempData.ContainsKey(TempDataRequest)) + { + TempData[TempDataTimeout] = true; + } + return RedirectToAction(nameof(Index)); + } + + var request = JsonSerializer.Deserialize( + (string)TempData.Peek(TempDataRequest)); + + if (!request.IsJuvenile) + { + return RedirectToAction(nameof(VerifyAddress)); + } + + var forceReload = HttpContext.Items[ItemKey.ForceReload] as bool? ?? false; + + var viewModel = new JuvenileViewModel(); + + var segmentId = await _siteSettingService.GetSettingIntAsync( + Models.Keys.SiteSetting.CardRenewal.JuvenileSegment, + forceReload); + + if (segmentId > 0) + { + viewModel.SegmentText = await _segmentService.GetSegmentTextBySegmentIdAsync( + segmentId, + forceReload); + if (viewModel.SegmentText == null) + { + _logger.LogError($"Card renewal 'Juvenile' segment id '{segmentId}' not found"); + } + else if (!string.IsNullOrWhiteSpace(viewModel.SegmentText.Text)) + { + viewModel.SegmentText.Text = CommonMarkConverter.Convert( + viewModel.SegmentText.Text); + } + } + + return View(viewModel); + } + + [HttpPost("[action]")] + [SaveModelState] + public IActionResult Juvenile(JuvenileViewModel viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + + if (!TempData.ContainsKey(TempDataRequest) || !_polarisHelper.IsConfigured) + { + if (!TempData.ContainsKey(TempDataRequest)) + { + TempData[TempDataTimeout] = true; + } + return RedirectToAction(nameof(Index)); + } + + var request = JsonSerializer.Deserialize( + (string)TempData.Peek(TempDataRequest)); + + if (!request.IsJuvenile) + { + return RedirectToAction(nameof(VerifyAddress)); + } + + if (ModelState.IsValid) + { + request.GuardianBarcode = string.Concat(viewModel.GuardianBarcode + .Where(_ => !char.IsWhiteSpace(_))); + request.GuardianName = viewModel.GuardianName.Trim(); + TempData[TempDataRequest] = JsonSerializer.Serialize(request); + + return RedirectToAction(nameof(VerifyAddress)); + } + + return RedirectToAction(nameof(Juvenile)); + } + + [HttpGet("[action]")] + public async Task Submitted() + { + if (!_polarisHelper.IsConfigured) + { + return RedirectToAction(nameof(Index)); + } + + var forceReload = HttpContext.Items[ItemKey.ForceReload] as bool? ?? false; + + var segmentId = await _siteSettingService.GetSettingIntAsync( + Models.Keys.SiteSetting.CardRenewal.SubmittedSegment, + forceReload); + + SegmentText segmentText = null; + if (segmentId > 0) + { + segmentText = await _segmentService + .GetSegmentTextBySegmentIdAsync(segmentId, forceReload); + + if (segmentText == null) + { + _logger.LogError($"Card renewal 'Submitted' segment id '{segmentId}' not found"); + } + else if (!string.IsNullOrWhiteSpace(segmentText.Text)) + { + segmentText.Text = CommonMarkConverter.Convert(segmentText.Text); + } + } + + return View(segmentText); + } + + [HttpGet("[action]")] + public async Task UnableToRenew() + { + if (!TempData.ContainsKey(TempDataUnableToRenew) || !_polarisHelper.IsConfigured) + { + if (!TempData.ContainsKey(TempDataUnableToRenew)) + { + TempData[TempDataTimeout] = true; + } + return RedirectToAction(nameof(Index)); + } + + var segmentId = (int)TempData.Peek(TempDataUnableToRenew); + + SegmentText segmentText = null; + if (segmentId > 0) + { + var forceReload = HttpContext.Items[ItemKey.ForceReload] as bool? ?? false; + + segmentText = await _segmentService.GetSegmentTextBySegmentIdAsync( + segmentId, + forceReload); + + if (segmentText == null) + { + _logger.LogError($"Card renewal segment id '{segmentId}' not found for unable to renew response"); + } + else if (!string.IsNullOrWhiteSpace(segmentText.Text)) + { + segmentText.Text = CommonMarkConverter.Convert(segmentText.Text); + } + } + + return View(segmentText); + } + + [HttpGet("[action]")] + [RestoreModelState] + public async Task VerifyAddress() + { + if (!TempData.ContainsKey(TempDataRequest) || !_polarisHelper.IsConfigured) + { + if (!TempData.ContainsKey(TempDataRequest)) + { + TempData[TempDataTimeout] = true; + } + return RedirectToAction(nameof(Index)); + } + + var request = JsonSerializer.Deserialize( + (string)TempData.Peek(TempDataRequest)); + + if (request.IsJuvenile && string.IsNullOrWhiteSpace(request.GuardianName)) + { + return RedirectToAction(nameof(Juvenile)); + } + + var forceReload = HttpContext.Items[ItemKey.ForceReload] as bool? ?? false; + + var viewModel = new VerifyAddressViewModel() + { + Addresses = request.Addresses, + Email = request.Email + }; + + var verifyAddressSegmentId = await _siteSettingService.GetSettingIntAsync( + Models.Keys.SiteSetting.CardRenewal.VerifyAddressSegment, + forceReload); + if (verifyAddressSegmentId > 0) + { + viewModel.HeaderSegmentText = await _segmentService + .GetSegmentTextBySegmentIdAsync(verifyAddressSegmentId, forceReload); + + if (viewModel.HeaderSegmentText == null) + { + _logger.LogError($"Card renewal 'Verify address' segment id '{verifyAddressSegmentId}' not found"); + } + else if (!string.IsNullOrWhiteSpace(viewModel.HeaderSegmentText.Text)) + { + viewModel.HeaderSegmentText.Text = CommonMarkConverter.Convert( + viewModel.HeaderSegmentText.Text); + } + } + + if (!request.Addresses.Any()) + { + var noAddressSegmentId = await _siteSettingService.GetSettingIntAsync( + Models.Keys.SiteSetting.CardRenewal.NoAddressSegment, + forceReload); + if (noAddressSegmentId > 0) + { + viewModel.NoAddressSegmentText = await _segmentService + .GetSegmentTextBySegmentIdAsync(noAddressSegmentId, forceReload); + + if (viewModel.NoAddressSegmentText == null) + { + _logger.LogError($"Card renewal 'No address' segment id '{noAddressSegmentId}' not found"); + } + else if (!string.IsNullOrWhiteSpace(viewModel.NoAddressSegmentText.Text)) + { + viewModel.NoAddressSegmentText.Text = CommonMarkConverter.Convert( + viewModel.NoAddressSegmentText.Text); + } + } + } + + return View(viewModel); + } + + [HttpPost("[action]")] + [SaveModelState] + public async Task VerifyAddress(VerifyAddressViewModel viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + + if (!TempData.ContainsKey(TempDataRequest) || !_polarisHelper.IsConfigured) + { + if (!TempData.ContainsKey(TempDataRequest)) + { + TempData[TempDataTimeout] = true; + } + return RedirectToAction(nameof(Index)); + } + + var request = JsonSerializer.Deserialize( + (string)TempData.Peek(TempDataRequest)); + + if (request.IsJuvenile && string.IsNullOrWhiteSpace(request.GuardianName)) + { + return RedirectToAction(nameof(Juvenile)); + } + + if (ModelState.IsValid) + { + request.Email = viewModel.Email.Trim(); + + if (viewModel.SameAddress && request.Addresses.Any()) + { + request.SameAddress = true; + } + + await _cardRenewalService.CreateRequestAsync(request); + + TempData.Remove(TempDataRequest); + + return RedirectToAction(nameof(Submitted)); + } + + return RedirectToAction(nameof(VerifyAddress)); + } + } +} diff --git a/src/Promenade.Controllers/Filters/RestoreModelStateAttribute.cs b/src/Promenade.Controllers/Filters/RestoreModelStateAttribute.cs new file mode 100644 index 000000000..2c0bff65b --- /dev/null +++ b/src/Promenade.Controllers/Filters/RestoreModelStateAttribute.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; +using Ocuda.Utility.Filters; + +namespace Ocuda.Promenade.Controllers.Filters +{ + public sealed class RestoreModelStateAttribute : RestoreModelStateAttributeBase + { + public const int TimeOutMinutes = 2; + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, + ActionExecutionDelegate next) + { + ModelStateTimeOut = TimeOutMinutes; + await base.OnActionExecutionAsync(context, next); + } + } +} diff --git a/src/Promenade.Controllers/Promenade.Controllers.csproj b/src/Promenade.Controllers/Promenade.Controllers.csproj index f883e7a9d..a76926e79 100644 --- a/src/Promenade.Controllers/Promenade.Controllers.csproj +++ b/src/Promenade.Controllers/Promenade.Controllers.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Promenade.Controllers/ViewModels/CardRenewal/IndexViewModel.cs b/src/Promenade.Controllers/ViewModels/CardRenewal/IndexViewModel.cs new file mode 100644 index 000000000..22cbfabb0 --- /dev/null +++ b/src/Promenade.Controllers/ViewModels/CardRenewal/IndexViewModel.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility; + +namespace Ocuda.Promenade.Controllers.ViewModels.CardRenewal +{ + public class IndexViewModel + { + [Required(ErrorMessage = ErrorMessage.FieldRequired)] + [DisplayName(i18n.Keys.Promenade.PromptLibraryCardNumber)] + public string Barcode { get; set; } + + public bool Invalid { get; set; } + + public string ForgotPasswordLink { get; set; } + + [Required(ErrorMessage = ErrorMessage.FieldRequired)] + [DisplayName(i18n.Keys.Promenade.PromptPassword)] + public string Password { get; set; } + + public SegmentText SegmentText { get; set; } + } +} diff --git a/src/Promenade.Controllers/ViewModels/CardRenewal/JuvenileViewModel.cs b/src/Promenade.Controllers/ViewModels/CardRenewal/JuvenileViewModel.cs new file mode 100644 index 000000000..5778f571d --- /dev/null +++ b/src/Promenade.Controllers/ViewModels/CardRenewal/JuvenileViewModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility; + +namespace Ocuda.Promenade.Controllers.ViewModels.CardRenewal +{ + public class JuvenileViewModel + { + [Required(ErrorMessage = ErrorMessage.FieldRequired)] + [DisplayName(i18n.Keys.Promenade.PromptGuardianBarcode)] + public string GuardianBarcode { get; set; } + + [Required(ErrorMessage = ErrorMessage.FieldRequired)] + [DisplayName(i18n.Keys.Promenade.PromptGuardianName)] + public string GuardianName { get; set; } + + public SegmentText SegmentText { get; set; } + } +} diff --git a/src/Promenade.Controllers/ViewModels/CardRenewal/VerifyAddressViewModel.cs b/src/Promenade.Controllers/ViewModels/CardRenewal/VerifyAddressViewModel.cs new file mode 100644 index 000000000..2d6bf0a7c --- /dev/null +++ b/src/Promenade.Controllers/ViewModels/CardRenewal/VerifyAddressViewModel.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Ocuda.Models; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Utility; + +namespace Ocuda.Promenade.Controllers.ViewModels.CardRenewal +{ + public class VerifyAddressViewModel + { + public IEnumerable Addresses { get; set; } + + [Required(ErrorMessage = ErrorMessage.FieldRequired)] + [DisplayName(i18n.Keys.Promenade.PromptEmail)] + public string Email { get; set; } + + public SegmentText HeaderSegmentText { get; set; } + public SegmentText NoAddressSegmentText { get; set; } + public bool SameAddress { get; set; } + } +} diff --git a/src/Promenade.Data/Promenade/CardRenewalRequestRepository.cs b/src/Promenade.Data/Promenade/CardRenewalRequestRepository.cs new file mode 100644 index 000000000..8c252b41e --- /dev/null +++ b/src/Promenade.Data/Promenade/CardRenewalRequestRepository.cs @@ -0,0 +1,34 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Ocuda.Promenade.Data.ServiceFacade; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Promenade.Service.Interfaces.Repositories; + +namespace Ocuda.Promenade.Data.Promenade +{ + public class CardRenewalRequestRepository + : GenericRepository, ICardRenewalRequestRepository + { + public CardRenewalRequestRepository(Repository repositoryFacade, + ILogger logger) : base(repositoryFacade, logger) + { + } + + public async Task AddSaveAsync(CardRenewalRequest request) + { + await DbSet.AddAsync(request); + await _context.SaveChangesAsync(); + } + + public async Task GetPendingRequestAsync(int customerId) + { + return await DbSet + .AsNoTracking() + .Where(_ => _.CustomerId == customerId && !_.IsDiscarded && !_.ProcessedAt.HasValue) + .OrderByDescending(_ => _.SubmittedAt) + .FirstOrDefaultAsync(); + } + } +} diff --git a/src/Promenade.Data/PromenadeContext.cs b/src/Promenade.Data/PromenadeContext.cs index 89432fbf5..068e545cc 100644 --- a/src/Promenade.Data/PromenadeContext.cs +++ b/src/Promenade.Data/PromenadeContext.cs @@ -11,6 +11,7 @@ protected PromenadeContext(DbContextOptions options) : base(options) } public DbSet CardDetails { get; } + public DbSet CardRenewalRequests { get; set; } public DbSet Cards { get; } public DbSet CarouselButtonLabels { get; } public DbSet CarouselButtonLabelTexts { get; } diff --git a/src/Promenade.Models/Defaults/SiteSetting.cs b/src/Promenade.Models/Defaults/SiteSetting.cs index f31015cf8..bd7035d73 100644 --- a/src/Promenade.Models/Defaults/SiteSetting.cs +++ b/src/Promenade.Models/Defaults/SiteSetting.cs @@ -8,6 +8,132 @@ public static class SiteSettings { public static IEnumerable Get { get; } = new[] { + #region CardRenewal + + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Accepted counties for card renewal addresses, comma delimited", + Id = Keys.SiteSetting.CardRenewal.AcceptedCounties, + Name = "Accepted counties", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Customer codes ids to check for becoming 18 years old, comma delimited", + Id = Keys.SiteSetting.CardRenewal.AgeCheckCustomerCodes, + Name = "Age check customer codes", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Category = nameof (Keys.SiteSetting.CardRenewal), + Description = "Segment to show to age check customers who have become 18", + Id = Keys.SiteSetting.CardRenewal.AgeCheckSegment, + Name = "Age check segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Number of days before a cards expiration that it's eligible for online renewal", + Id = Keys.SiteSetting.CardRenewal.ExpirationCutoffDays, + Name = "Card renewal expiration cutoff days", + Type= SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Segment to show on the card renewal home page", + Id = Keys.SiteSetting.CardRenewal.HomeSegment, + Name = "Home segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Juvenile customer code ids, comma delimited", + Id = Keys.SiteSetting.CardRenewal.JuvenileCustomerCodes, + Name = "Juvenile customer codes", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Segment to show on the juvenile page", + Id = Keys.SiteSetting.CardRenewal.JuvenileSegment, + Name = "Juvenile segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Segment to show on the verify address page when there's no valid addresses", + Id = Keys.SiteSetting.CardRenewal.NoAddressSegment, + Name = "No address segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Nonresident customer code ids, comma delimited", + Id = Keys.SiteSetting.CardRenewal.NonresidentCustomerCodes, + Name = "Nonresident customer codes", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Segment to show when a customer can't renew their card due to being a nonresident", + Id = Keys.SiteSetting.CardRenewal.NonresidentSegment, + Name = "Nonresident segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Segment to show when api settings aren't configured", + Id = Keys.SiteSetting.CardRenewal.NotConfiguredSegment, + Name = "Not configured segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Staff customer code ids, comma delimited", + Id = Keys.SiteSetting.CardRenewal.StaffCustomerCodes, + Name = "Staff customer codes", + Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Segment to show on the submitted page", + Id = Keys.SiteSetting.CardRenewal.SubmittedSegment, + Name = "Submitted segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.CardRenewal), + Description = "Segment to show on the verify address page", + Id = Keys.SiteSetting.CardRenewal.VerifyAddressSegment, + Name = "Verify address segment", + Type = SiteSettingType.Int, + Value = "-1" + }, + + #endregion CardRenewal + #region Contact new SiteSetting @@ -156,6 +282,14 @@ public static class SiteSettings Id = Keys.SiteSetting.Site.FooterImageAlt, Name = "Footer Image Alt", Type = SiteSettingType.StringNullable + }, + new SiteSetting + { + Category = nameof(Keys.SiteSetting.Site), + Description = "Link to the forgot password page", + Id = Keys.SiteSetting.Site.ForgotPasswordLink, + Name = "Forgot Password link", + Type = SiteSettingType.StringNullable }, new SiteSetting { diff --git a/src/Promenade.Models/Entities/CardRenewalRequest.cs b/src/Promenade.Models/Entities/CardRenewalRequest.cs new file mode 100644 index 000000000..3c04be0ed --- /dev/null +++ b/src/Promenade.Models/Entities/CardRenewalRequest.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Ocuda.Models; + +namespace Ocuda.Promenade.Models.Entities +{ + public class CardRenewalRequest + { + [NotMapped] + public bool Accepted { get; set; } + + [NotMapped] + public IEnumerable Addresses { get; set; } + + [Required] + public string Barcode { get; set; } + + public int CustomerId { get; set; } + + [Required] + public string Email { get; set; } + + public string GuardianBarcode { get; set; } + public string GuardianName { get; set; } + + [Key] + [Required] + public int Id { get; set; } + + public bool IsDiscarded { get; set; } + + [NotMapped] + public bool IsJuvenile { get; set; } + + public int LanguageId { get; set; } + public Language Language { get; set; } + + public DateTime? ProcessedAt { get; set; } + public bool SameAddress { get; set; } + public DateTime SubmittedAt { get; set; } + } +} diff --git a/src/Promenade.Models/Keys/SiteSetting.cs b/src/Promenade.Models/Keys/SiteSetting.cs index b9234ed6a..4b5d6798d 100644 --- a/src/Promenade.Models/Keys/SiteSetting.cs +++ b/src/Promenade.Models/Keys/SiteSetting.cs @@ -2,6 +2,27 @@ { namespace SiteSetting { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", + "CA1815:Override equals and operator equals on value types", + Justification = "No reason to compare these site setting keys")] + public struct CardRenewal + { + public const string AcceptedCounties = "CardRenewal.AcceptedCounties"; + public const string AgeCheckCustomerCodes = "CardRenewal.AgeCheckCustomerCodes"; + public const string AgeCheckSegment = "CardRenewal.AgeCheckSegment"; + public const string HomeSegment = "CardRenewal.HomeSegment"; + public const string ExpirationCutoffDays = "CardRenewal.ExpirationCutoffDays"; + public const string JuvenileCustomerCodes = "CardRenewal.JuvenileCustomerCodes"; + public const string JuvenileSegment = "CardRenewal.JuvenileSegment"; + public const string NoAddressSegment = "CardRenewal.NoAddressSegment"; + public const string NonresidentCustomerCodes = "CardRenewal.NonresidentCustomerCodes"; + public const string NonresidentSegment = "CardRenewal.NonresidentSegment"; + public const string NotConfiguredSegment = "CardRenewal.NotConfiguredSegment"; + public const string StaffCustomerCodes = "CardRenewal.StaffCustomerCodes"; + public const string SubmittedSegment = "CardRenewal.SubmittedSegment"; + public const string VerifyAddressSegment = "CardRenewal.VerifyAddressSegment"; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "No reason to compare these site setting keys")] @@ -37,6 +58,7 @@ public struct Site public const string CatalogSearchLink = "Site.CatalogSearchLink"; public const string FooterImage = "Site.FooterImage"; public const string FooterImageAlt = "Site.FooterImageAlt"; + public const string ForgotPasswordLink = "Site.ForgotPasswordLink"; public const string GoogleTrackingCode = "Site.GoogleTrackingCode"; public const string IsTLS = "Site.IsTLS"; public const string NavigationIdFooter = "Site.NavigationIdFooter"; diff --git a/src/Promenade.Models/Promenade.Models.csproj b/src/Promenade.Models/Promenade.Models.csproj index c4c2fef3c..8a26d89eb 100644 --- a/src/Promenade.Models/Promenade.Models.csproj +++ b/src/Promenade.Models/Promenade.Models.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Promenade.Service/CardRenewalService.cs b/src/Promenade.Service/CardRenewalService.cs new file mode 100644 index 000000000..ee1cc077b --- /dev/null +++ b/src/Promenade.Service/CardRenewalService.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Ocuda.Promenade.Models.Entities; +using Ocuda.Promenade.Service.Abstract; +using Ocuda.Promenade.Service.Interfaces.Repositories; +using Ocuda.Utility.Abstract; + +namespace Ocuda.Promenade.Service +{ + public class CardRenewalService : BaseService + { + private ICardRenewalRequestRepository _cardRenewalRequestRepository; + private LanguageService _languageService; + + public CardRenewalService(ILogger logger, + IDateTimeProvider dateTimeProvider, + ICardRenewalRequestRepository cardRenewalRequestRepository, + LanguageService languageService) + : base(logger, dateTimeProvider) + { + ArgumentNullException.ThrowIfNull(cardRenewalRequestRepository); + ArgumentNullException.ThrowIfNull(languageService); + + _cardRenewalRequestRepository = cardRenewalRequestRepository; + _languageService = languageService; + } + + public async Task CreateRequestAsync(CardRenewalRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + request.LanguageId = await _languageService.GetLanguageIdAsync( + CultureInfo.CurrentCulture.Name); + request.SubmittedAt = _dateTimeProvider.Now; + await _cardRenewalRequestRepository.AddSaveAsync(request); + } + + public async Task GetPendingRequestAsync(int customerId) + { + return await _cardRenewalRequestRepository.GetPendingRequestAsync(customerId); + } + } +} diff --git a/src/Promenade.Service/Interfaces/Repositories/ICardRenewalRequestRepository.cs b/src/Promenade.Service/Interfaces/Repositories/ICardRenewalRequestRepository.cs new file mode 100644 index 000000000..c818e1b8a --- /dev/null +++ b/src/Promenade.Service/Interfaces/Repositories/ICardRenewalRequestRepository.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Ocuda.Promenade.Models.Entities; + +namespace Ocuda.Promenade.Service.Interfaces.Repositories +{ + public interface ICardRenewalRequestRepository : IGenericRepository + { + Task AddSaveAsync(CardRenewalRequest request); + Task GetPendingRequestAsync(int customerId); + } +} diff --git a/src/Promenade.Web/Startup.cs b/src/Promenade.Web/Startup.cs index 4c42166a7..e3b5d9acd 100644 --- a/src/Promenade.Web/Startup.cs +++ b/src/Promenade.Web/Startup.cs @@ -20,6 +20,7 @@ using Microsoft.Net.Http.Headers; using Ocuda.i18n; using Ocuda.i18n.RouteConstraint; +using Ocuda.PolarisHelper; using Ocuda.Promenade.Controllers; using Ocuda.Promenade.Data; using Ocuda.Promenade.Service; @@ -382,6 +383,7 @@ string cacheDiscriminator services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHttpClient(); @@ -392,6 +394,8 @@ string cacheDiscriminator // repositories services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); - // promenade servicews + // promenade services + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Promenade.Web/Views/CardRenewal/Index.cshtml b/src/Promenade.Web/Views/CardRenewal/Index.cshtml new file mode 100644 index 000000000..61396ea8a --- /dev/null +++ b/src/Promenade.Web/Views/CardRenewal/Index.cshtml @@ -0,0 +1,62 @@ +@model Ocuda.Promenade.Controllers.ViewModels.CardRenewal.IndexViewModel + +@if (Model?.SegmentText != null) +{ + if (!string.IsNullOrEmpty(Model.SegmentText.Header)) + { +

@Model.SegmentText.Header

+ } + @Html.Raw(Model.SegmentText.Text) +} +else +{ +

@Localizer[Promenade.RenewYourLibraryCard]

+} + +@if (ViewData.ModelState[nameof(Model.Invalid)]?.ValidationState + == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) +{ +
+
+
+ + +
+
+
+} + +
+
+
+ +
+ +
+ + + @if (!string.IsNullOrWhiteSpace(Model.ForgotPasswordLink)) + { + + } +
+
+
+ +
+
+
+
diff --git a/src/Promenade.Web/Views/CardRenewal/Juvenile.cshtml b/src/Promenade.Web/Views/CardRenewal/Juvenile.cshtml new file mode 100644 index 000000000..468ba5f07 --- /dev/null +++ b/src/Promenade.Web/Views/CardRenewal/Juvenile.cshtml @@ -0,0 +1,35 @@ +@model Ocuda.Promenade.Controllers.ViewModels.CardRenewal.JuvenileViewModel + +@if (Model?.SegmentText != null) +{ + if (!string.IsNullOrEmpty(Model.SegmentText.Header)) + { +

@Model.SegmentText.Header

+ } + @Html.Raw(Model.SegmentText.Text) +} +else +{ +

@Localizer[Promenade.RenewYourLibraryCard]

+

@Localizer[Promenade.CardRenewalJuvenile]

+} + +
+
+
+ + +
+ +
+
+
+
diff --git a/src/Promenade.Web/Views/CardRenewal/NotConfigured.cshtml b/src/Promenade.Web/Views/CardRenewal/NotConfigured.cshtml new file mode 100644 index 000000000..95e3f79b2 --- /dev/null +++ b/src/Promenade.Web/Views/CardRenewal/NotConfigured.cshtml @@ -0,0 +1,15 @@ +@model Ocuda.Promenade.Models.Entities.SegmentText + +@if (Model != null) +{ + if (!string.IsNullOrEmpty(Model.Header)) + { +

@Model.Header

+ } + @Html.Raw(Model.Text) +} +else +{ +

@Localizer[Promenade.RenewYourLibraryCard]

+

@HtmlLocalizer[Promenade.CardRenewalNotConfigured, Url.Action(nameof(LocationsController.Find), LocationsController.Name)]

+} diff --git a/src/Promenade.Web/Views/CardRenewal/Submitted.cshtml b/src/Promenade.Web/Views/CardRenewal/Submitted.cshtml new file mode 100644 index 000000000..d83d71750 --- /dev/null +++ b/src/Promenade.Web/Views/CardRenewal/Submitted.cshtml @@ -0,0 +1,15 @@ +@model Ocuda.Promenade.Models.Entities.SegmentText + +@if (Model != null) +{ + if (!string.IsNullOrEmpty(Model.Header)) + { +

@Model.Header

+ } + @Html.Raw(Model.Text) +} +else +{ +

@Localizer[Promenade.RenewYourLibraryCard]

+

@Localizer[Promenade.CardRenewalSubmitted]

+} \ No newline at end of file diff --git a/src/Promenade.Web/Views/CardRenewal/UnableToRenew.cshtml b/src/Promenade.Web/Views/CardRenewal/UnableToRenew.cshtml new file mode 100644 index 000000000..f40caf53e --- /dev/null +++ b/src/Promenade.Web/Views/CardRenewal/UnableToRenew.cshtml @@ -0,0 +1,15 @@ +@model Ocuda.Promenade.Models.Entities.SegmentText + +@if (Model != null) +{ + if (!string.IsNullOrEmpty(Model.Header)) + { +

@Model.Header

+ } + @Html.Raw(Model.Text) +} +else +{ +

@Localizer[Promenade.RenewYourLibraryCard]

+

@HtmlLocalizer[Promenade.CardRenewalUnableToRenew, Url.Action(nameof(LocationsController.Find), LocationsController.Name)]

+} diff --git a/src/Promenade.Web/Views/CardRenewal/VerifyAddress.cshtml b/src/Promenade.Web/Views/CardRenewal/VerifyAddress.cshtml new file mode 100644 index 000000000..ef02e2fe0 --- /dev/null +++ b/src/Promenade.Web/Views/CardRenewal/VerifyAddress.cshtml @@ -0,0 +1,83 @@ +@model Ocuda.Promenade.Controllers.ViewModels.CardRenewal.VerifyAddressViewModel + +@if (Model?.HeaderSegmentText != null) +{ + if (!string.IsNullOrEmpty(Model.HeaderSegmentText.Header)) + { +

@Model.HeaderSegmentText.Header

+ } + @Html.Raw(Model.HeaderSegmentText.Text) +} +else +{ +

@Localizer[Promenade.RenewYourLibraryCard]

+} + +
+
+
+ +
+
+ @if (Model.Addresses.Any()) + { +
+ @Localizer[Promenade.CardRenewalSameAddress] + @foreach (var address in Model.Addresses) + { +
+
@address.StreetAddressOne
+ @if (!string.IsNullOrWhiteSpace(address.StreetAddressTwo)) + { +
@address.StreetAddressTwo
+ } + @address.City + @address.PostalCode +
County: @address.County
+
+ } +
+
+ + +
+ } + else + { + @if (Model?.NoAddressSegmentText != null) + { + @Html.Raw(Model.NoAddressSegmentText.Text) + } + else + { +

@Localizer[Promenade.CardRenewalNoAddress]

+ } +
+ +
+ } +
+
+
+
+
\ No newline at end of file diff --git a/src/Utility/Filters/RestoreModelStateAttributeBase.cs b/src/Utility/Filters/RestoreModelStateAttributeBase.cs new file mode 100644 index 000000000..a06593610 --- /dev/null +++ b/src/Utility/Filters/RestoreModelStateAttributeBase.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; +using Ocuda.Utility.Helpers; + +namespace Ocuda.Utility.Filters +{ + /// + /// Load model state items from temp data and merge them into the model state when redirected to + /// after a model state error. Key can be set manually or generated from route values. + /// + public abstract class RestoreModelStateAttributeBase : ActionFilterAttribute + { + public string Key { get; set; } + public int ModelStateTimeOut { get; set; } + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, + ActionExecutionDelegate next) + { + var resultContext = await next(); + + var controller = context.Controller as Controller; + + string modelStateKey; + if (!string.IsNullOrWhiteSpace(Key)) + { + modelStateKey = ModelStateHelper.GetModelStateKey(Key); + } + else + { + modelStateKey = ModelStateHelper.GetModelStateKey(context.RouteData.Values); + } + + if (controller?.TempData[modelStateKey] is string modelStateStorage) + { + var (modelState, time) = ModelStateHelper.DeserializeModelState(modelStateStorage); + var timeDifference = DateTimeOffset.Now.ToUnixTimeSeconds() - time; + + if (TimeSpan.FromSeconds(timeDifference).Minutes < ModelStateTimeOut + || ModelStateTimeOut < 1) + { + //Only Import if we are viewing + if (resultContext.Result is ViewResult) + { + context.ModelState.Merge(modelState); + } + } + else + { + var _logger = (ILogger)context.HttpContext + .RequestServices.GetService(typeof(ILogger)); + _logger.LogError("ModelState timed out for key {ModelStateKey}", + modelStateKey); + } + } + } + } +} diff --git a/src/Ops.Controllers/Filters/SaveModelStateAttribute.cs b/src/Utility/Filters/SaveModelStateAttribute.cs similarity index 86% rename from src/Ops.Controllers/Filters/SaveModelStateAttribute.cs rename to src/Utility/Filters/SaveModelStateAttribute.cs index f8b45abe0..2d37e87ce 100644 --- a/src/Ops.Controllers/Filters/SaveModelStateAttribute.cs +++ b/src/Utility/Filters/SaveModelStateAttribute.cs @@ -2,8 +2,12 @@ using Microsoft.AspNetCore.Mvc.Filters; using Ocuda.Utility.Helpers; -namespace Ocuda.Ops.Controllers.Filters +namespace Ocuda.Utility.Filters { + /// + /// Save model state items to temp data when redirecting after a model state error. Key can be + /// set manually or generated from route values. + /// public class SaveModelStateAttribute : ActionFilterAttribute { public string Key { get; set; } diff --git a/src/Utility/Keys/Cache.cs b/src/Utility/Keys/Cache.cs index 28b2b3f24..11f1f3be5 100644 --- a/src/Utility/Keys/Cache.cs +++ b/src/Utility/Keys/Cache.cs @@ -2,6 +2,11 @@ { public static class Cache { + /// + /// Cached Polaris patron codes + /// + public static readonly string PolarisPatronCodes = "patroncodes"; + /// /// Date and time of last expired slide purge /// diff --git a/src/i18n/Keys/Promenade.cs b/src/i18n/Keys/Promenade.cs index 4b363108f..1f35cf56c 100644 --- a/src/i18n/Keys/Promenade.cs +++ b/src/i18n/Keys/Promenade.cs @@ -13,6 +13,19 @@ public static class Promenade public const string ButtonSubmit = "Submit"; public const string ButtonSubscribe = "Subscribe"; public const string ButtonUseMyLocation = "Use my location"; + public const string CardRenewalInvalidCutoff = "Thanks for your submission! Your card has more than {0} days until it expires, please come back to renew it any time after {1}"; + public const string CardRenewalInvalidLogin = "You have entered an invalid card number or password"; + public const string CardRenewalInvalidStaff = "It appears that the barcode you've entered belongs to a staff account. Please speak to your branch manager or supervisor for instructions on how to renew your account."; + public const string CardRenewalJuvenile = "It looks like you're trying to renew a juvenile account, please have a parent or guardian complete the rest of the process."; + public const string CardRenewalNoAddress = "With no valid address on file your renewal request may not be eligible for a full renewal."; + public const string CardRenewalNotConfigured = "Please contact your library to renew your library card."; + public const string CardRenewalPendingRequest = "It looks like we are still working on a request you submitted on {0} at {1}. You'll be hearing from us soon."; + public const string CardRenewalSameAddress = "Do you still live at an address listed below?"; + public const string CardRenewalSameAddressNo = "No, I have moved"; + public const string CardRenewalSameAddressYes = "Yes, this is my address"; + public const string CardRenewalSessionTimeout = "Unfortunately your session has timed out. Please resubmit your barcode and password."; + public const string CardRenewalSubmitted = "Thank you for using our online renewal service!"; + public const string CardRenewalUnableToRenew = "We are unable to renew your card at this time. Please contact your library to renew your library card."; public const string ConnectSocialMedia = "Connect with us on social media!"; public const string ConnectSocialOn = "Connect with us on {0}"; public const string ContactInformation = "Contact Information"; @@ -37,6 +50,7 @@ public static class Promenade public const string ErrorVolunteerNotAcceptingAdult = "This location is not accepting adult volunteer applications at this time."; public const string ErrorVolunteerNotAcceptingTeen = "This location is not accepting teen volunteer applications at this time."; public const string ErrorZipCode = "Please enter a 5 digit numeric ZIP code."; + public const string ForgotPassword = "Forgot your password?"; public const string HelpItem = "{0} help"; public const string HowCanWeHelp = "How can we help?"; public const string KeywordsItem = "Keywords: {0}"; @@ -85,12 +99,15 @@ public static class Promenade public const string PromptAreYouTeenVolunteer = "Are you a teen looking to volunteer?"; public const string PromptEmail = "Email"; public const string PromptExperience = "Why do you want to volunteer? Describe any previous volunteer experience."; + public const string PromptGuardianBarcode = "Parent/Guardian Barcode"; public const string PromptGuardianEmail = "Parent/Guardian Email"; public const string PromptGuardianName = "Parent/Guardian Name"; public const string PromptGuardianPhone = "Parent/Guardian Phone"; public const string PromptLanguage = "Language"; + public const string PromptLibraryCardNumber = "Library Card Number"; public const string PromptName = "Name"; public const string PromptNotes = "Notes"; + public const string PromptPassword = "Password"; public const string PromptPhone = "Phone"; public const string PromptRequestedDate = "Requested date"; public const string PromptRequestedDateAndTime = "Requested date and time"; @@ -98,6 +115,7 @@ public static class Promenade public const string PromptSubject = "Subject"; public const string PromptVolunteerRegularity = "Are you interested in regular volunteer work or certain number of hours?"; public const string PromptWeeklyAvailability = "Weekly Availability"; + public const string RenewYourLibraryCard = "Renew your library card"; public const string RequiredField = "You must supply a value for: {0}"; public const string RequiredFieldItem = "The {0} field is required."; public const string ScheduleAppointmentDetails = "Here are the details of your appointment:"; diff --git a/src/i18n/Resources/Shared.en.resx b/src/i18n/Resources/Shared.en.resx index c8b7e4883..e619e260b 100644 --- a/src/i18n/Resources/Shared.en.resx +++ b/src/i18n/Resources/Shared.en.resx @@ -589,4 +589,76 @@ Search Hover text to display on the search button + + Thanks for your submission! Your card has more than {0} days until it expires, please come back to renew it any time after {1} + Error message: the library card is not near expiration; {0} is how many days before expiration, {1} is the earliest date + + + You have entered an invalid card number or password + Error message: invalid login information + + + It appears that the barcode you've entered belongs to a staff account. Please speak to your branch manager or supervisor for instructions on how to renew your account. + Error message: account belongs to a staff member + + + It looks like you're trying to renew a juvenile account, please have a parent or guardian complete the rest of the process. + Label indicating how to proceed with the form + + + With no valid address on file your renewal request may not be eligible for a full renewal. + Label informing how the request may be processed + + + Please contact <a href=\"{0}\">your library</a> to renew your library card. + Message informing where to renew a library card; {0} is replaced with a link to the locations + + + It looks like we are still working on a request you submitted on {0} at {1}. You'll be hearing from us soon. + Error message: there's a pending request for the account; {0} is replace with the date of the request, {1} with the time + + + Do you still live at an address listed below? + Label asking if a customer lives at an address in the list + + + No, I have moved + Button: responding in the negative to the question of if the customer still lives at a listed address + + + Yes, this is my address + Button: responding in the affirmative to the question of if the customer still lives at a listed address + + + Unfortunately your session has timed out. Please resubmit your barcode and password. + Error message: too long has elapsed while the user was viewing a form and their session expired. + + + Thank you for using our online renewal service! + Default follow-up shown to customer after submitting card renewal form. + + + We are unable to renew your card at this time. Please contact <a href=\"{0}\">your library</a> to renew your library card. + Message informing the card cannot be renewed and how to proceed; {0} is replaced with a link to the locations + + + Forgot your password? + Link for a customer to click if they've forgotten their password + + + Parent/Guardian Barcode + Label prompting a customer to entier the parent or guardian's barcode number + + + Library Card Number + Label prompting a customer to enter their library card number + + + Password + Label prompting a customer to enter their password + + + Renew your library card + Default header for card renewal request pages + \ No newline at end of file