using HrynCo.NotificationService.Services.TemplateDefinitions; using System.Text.Json; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Services.EmailTemplates.Delete; using HrynCo.NotificationService.Services.EmailTemplates.SaveDefinition; using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; using MediatR; using Microsoft.AspNetCore.Mvc; namespace HrynCo.NotificationService.Web.Controllers.Admin; [Route("admin/services/{serviceId:guid}/templates")] public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions, ILogger logger, INotificationServiceRepository services, IPushTemplateDefinitionRepository pushes) : Controller { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; [HttpGet("/admin/services/{serviceId:guid}")] public async Task Index([FromRoute] Guid serviceId, [FromQuery] string? key, CancellationToken ct) { var service = await services.GetAsync(serviceId, ct); if (service is null) return NotFound(); var model = new ServiceTemplatesViewModel { Service = service, KeyFilter = key }; if (TempData["TemplateError"] is string previousError) ModelState.AddModelError("", previousError); try { var filter = key?.Trim() ?? ""; model.PushDefinitions = (await pushes.GetDefinitionsAsync(serviceId, ct)) .Where(d => d.Key.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList(); model.Definitions = (await definitions.GetDefinitionsAsync(serviceId, ct)) .Where(d => d.Key.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList(); } catch (Exception error) when (error is not OperationCanceledException) { logger.LogError(error, "Failed to load service template definitions."); ModelState.AddModelError("", "Could not load templates. Try again when the service is available."); } return View(model); } [HttpGet("create")] public async Task Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct, [FromQuery] string? templateKey = null) { var service = await services.GetAsync(serviceId, ct); return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel { ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter, Key = templateKey ?? "", LockKey = !string.IsNullOrEmpty(templateKey) }, service); } [HttpGet("{definitionId:guid}")] public async Task Edit([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, [FromQuery] string? languageCode, [FromQuery] string? keyFilter, CancellationToken ct) { var service = await services.GetAsync(serviceId, ct); var definition = await OwnedDefinitionAsync(service, definitionId, ct); if (definition is null) return NotFound(); var model = ToViewModel(definition); model.KeyFilter = keyFilter; model.ActiveLanguageIndex = Math.Max(0, model.Languages.FindIndex(l => l.LanguageCode == languageCode)); return EditorView(model, service!); } [HttpPost("save")] [ValidateAntiForgeryToken] public async Task Save([FromRoute] Guid serviceId, EmailTemplateEditViewModel model, CancellationToken ct) { var service = await services.GetAsync(serviceId, ct); if (service is null || model.ServiceId != serviceId || model.ServiceName != service.Name) return NotFound(); var persisted = model.Id is Guid definitionId ? await OwnedDefinitionAsync(service, definitionId, ct) : null; if (model.Id is not null && persisted is null) return NotFound(); model.LockKey = model.Id is not null || await pushes.GetAsync(serviceId, model.Key, ct) is not null; var presentCodes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal); foreach (var language in model.Languages) language.IsPresent = presentCodes?.Contains(language.LanguageCode) == true; ValidateLanguageCatalog(model, service); if (!ModelState.IsValid) return EditorView(model, service); var definition = ParseDefinition(model); if (definition is null) return EditorView(model, service); return await SaveDefinitionAsync(definition, model, service, ct); } [HttpPost("{definitionId:guid}/languages/{languageCode}/delete")] [ValidateAntiForgeryToken] public async Task Delete([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, [FromRoute] string languageCode, [FromForm] string? keyFilter, CancellationToken ct) { var service = await services.GetAsync(serviceId, ct); var definition = await OwnedDefinitionAsync(service, definitionId, ct); var language = definition?.Languages.SingleOrDefault(l => l.LanguageCode == languageCode); if (language is null) return NotFound(); var result = await mediator.Send(new DeleteEmailTemplateCommand(service!.Name, definition!.Key, languageCode, language.Id), ct); if (!result.IsSuccess) TempData["TemplateError"] = result.Error?.Message ?? "Failed to delete language version."; return RedirectToAction(nameof(Index), new { serviceId, key = keyFilter }); } [HttpGet("{definitionId:guid}/delete")] public async Task ConfirmDefinitionDelete([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, [FromQuery] string? keyFilter, CancellationToken ct) { var model = await DefinitionDeleteModelAsync(serviceId, definitionId, keyFilter, ct); return model is null ? NotFound() : View("DeleteDefinition", model); } [HttpPost("{definitionId:guid}/delete")] [ValidateAntiForgeryToken] public async Task DeleteDefinition([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, EmailTemplateDefinitionDeleteViewModel model, CancellationToken ct) { var current = await DefinitionDeleteModelAsync(serviceId, definitionId, model.KeyFilter, ct); if (current is null) return NotFound(); if (!ModelState.IsValid || !model.ConfirmDelete) return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, "Confirm deletion of this template and its translations to continue.", ct); try { var result = await mediator.Send(new DeleteEmailTemplateDefinitionCommand(serviceId, current.Key, model.Revision), ct); if (result.IsSuccess) return RedirectToAction(nameof(Index), new { serviceId, key = model.KeyFilter }); return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, result.Error!.Message, ct); } catch (Exception error) when (error is not OperationCanceledException) { logger.LogError(error, "Failed to delete email template definition."); return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, "Could not complete deletion. Review the current template before trying again.", ct); } } private async Task DefinitionDeleteModelAsync(Guid serviceId, Guid definitionId, string? keyFilter, CancellationToken ct) { var service = await services.GetAsync(serviceId, ct); var definition = await OwnedDefinitionAsync(service, definitionId, ct); return definition is null ? null : new EmailTemplateDefinitionDeleteViewModel { ServiceId = serviceId, DefinitionId = definition.Id, ServiceName = definition.ServiceName, Key = definition.Key, Revision = definition.Revision, KeyFilter = keyFilter, LanguageCodes = definition.Languages.Select(l => l.LanguageCode).ToList() }; } private async Task DefinitionDeleteErrorAsync(Guid serviceId, Guid definitionId, string? keyFilter, string error, CancellationToken ct) { var model = await DefinitionDeleteModelAsync(serviceId, definitionId, keyFilter, ct); if (model is null) return NotFound(); ModelState.Clear(); ModelState.AddModelError("", error); return View("DeleteDefinition", model); } private async Task SaveDefinitionAsync(EmailTemplateDefinition definition, EmailTemplateEditViewModel model, NotificationServiceInfo service, CancellationToken ct) { try { var result = await mediator.Send(new SaveEmailTemplateDefinitionCommand(definition, model.IsNew), ct); if (result.IsSuccess) return RedirectToAction(nameof(Index), new { serviceId = service.Id, key = model.KeyFilter }); ModelState.AddModelError("", result.Error?.Message ?? "Failed to save template."); } catch (Exception error) when (error is not OperationCanceledException) { logger.LogError(error, "Failed to save email template definition."); ModelState.AddModelError("", "Could not save the template. Your entered values are retained; try again when the service is available."); } return EditorView(model, service); } private async Task OwnedDefinitionAsync(NotificationServiceInfo? service, Guid definitionId, CancellationToken ct) { if (service is null) return null; return (await definitions.GetDefinitionsAsync(service.Id, ct)) .SingleOrDefault(d => d.Id == definitionId && d.ServiceId == service.Id); } private IActionResult EditorView(EmailTemplateEditViewModel model, NotificationServiceInfo service) { foreach (var language in service.Languages) if (!model.Languages.Any(l => l.LanguageCode == language.Code)) model.Languages.Add(new EmailTemplateLanguageEditViewModel { LanguageCode = language.Code }); return View("Edit", model); } private void ValidateLanguageCatalog(EmailTemplateEditViewModel model, NotificationServiceInfo service) { var codes = model.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal); if (codes.Count != model.Languages.Count) ModelState.AddModelError("", "Language codes must be unique within a template."); if (!codes.SetEquals(service.Languages.Select(l => l.Code))) ModelState.AddModelError("", "The service language catalog changed or the submitted tabs are invalid. Reload before saving."); } private EmailTemplateDefinition? ParseDefinition(EmailTemplateEditViewModel model) { try { return new EmailTemplateDefinition { Id = model.Id ?? Guid.NewGuid(), ServiceId = model.ServiceId, Revision = model.Revision, ServiceName = model.ServiceName, Key = model.Key, Variables = EmailTemplateValidation.ParseVariables(model.VariablesJson), Languages = model.Languages.Where(l => l.HasContent).Select(l => new EmailTemplate { ServiceName = model.ServiceName, Key = model.Key, LanguageCode = l.LanguageCode, Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody, Created = DateTimeOffset.UtcNow }).ToList() }; } catch (JsonException) { ModelState.AddModelError(nameof(model.VariablesJson), "Invalid variables JSON. Use an array of objects with name and required properties."); } catch (EmailTemplateWriteException error) { ModelState.AddModelError(nameof(model.VariablesJson), error.Message); } return null; } private static EmailTemplateEditViewModel ToViewModel(EmailTemplateDefinition definition) => new() { Id = definition.Id, ServiceId = definition.ServiceId, Revision = definition.Revision, ServiceName = definition.ServiceName, Key = definition.Key, VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions), Languages = definition.Languages.Select(l => new EmailTemplateLanguageEditViewModel { LanguageCode = l.LanguageCode, Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody, IsPresent = true }).ToList() }; }