feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage. Ref: IT-1601
This commit is contained in:
@@ -1,159 +1,178 @@
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Create;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Get;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Update;
|
||||
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/templates")]
|
||||
public class AdminTemplatesController : Controller
|
||||
[Route("admin/services/{serviceId:guid}/templates")]
|
||||
public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions,
|
||||
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services) : Controller
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
|
||||
public AdminTemplatesController(IMediator mediator)
|
||||
[HttpGet("/admin/services/{serviceId:guid}")]
|
||||
public async Task<IActionResult> Index([FromRoute] Guid serviceId, [FromQuery] string? key, CancellationToken ct)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
// GET /admin/templates
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken ct)
|
||||
{
|
||||
ViewData["ServiceNameFilter"] = serviceName;
|
||||
ViewData["KeyFilter"] = key;
|
||||
|
||||
var result = await _mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load templates.");
|
||||
return View(Array.Empty<EmailTemplate>());
|
||||
}
|
||||
|
||||
return View(result.Result);
|
||||
}
|
||||
|
||||
// GET /admin/templates/create
|
||||
[HttpGet("create")]
|
||||
public IActionResult Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter)
|
||||
{
|
||||
return View("Edit", new EmailTemplateEditViewModel
|
||||
{
|
||||
ServiceNameFilter = serviceNameFilter,
|
||||
KeyFilter = keyFilter
|
||||
});
|
||||
}
|
||||
|
||||
// GET /admin/templates/{serviceName}/{key}/{languageCode}
|
||||
[HttpGet("{serviceName}/{key}/{languageCode}")]
|
||||
public async Task<IActionResult> Edit(
|
||||
string serviceName,
|
||||
string key,
|
||||
string languageCode,
|
||||
[FromQuery] string? serviceNameFilter,
|
||||
[FromQuery] string? keyFilter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _mediator.Send(new GetEmailTemplateQuery(serviceName, key, languageCode), ct);
|
||||
if (!result.IsSuccess || result.Result is null)
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
if (service is null)
|
||||
return NotFound();
|
||||
|
||||
var template = result.Result;
|
||||
var vm = new EmailTemplateEditViewModel
|
||||
{
|
||||
Id = template.Id,
|
||||
ServiceName = template.ServiceName,
|
||||
Key = template.Key,
|
||||
LanguageCode = template.LanguageCode,
|
||||
Subject = template.Subject,
|
||||
HtmlBody = template.HtmlBody,
|
||||
TextBody = template.TextBody,
|
||||
VariablesJson = JsonSerializer.Serialize(template.Variables),
|
||||
ServiceNameFilter = serviceNameFilter,
|
||||
KeyFilter = keyFilter
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
// POST /admin/templates/save
|
||||
[HttpPost("save")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Save(EmailTemplateEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return View("Edit", model);
|
||||
|
||||
List<EmailTemplateVariable> variables;
|
||||
var model = new ServiceTemplatesViewModel { Service = service, KeyFilter = key };
|
||||
if (TempData["TemplateError"] is string previousError)
|
||||
ModelState.AddModelError("", previousError);
|
||||
try
|
||||
{
|
||||
variables = JsonSerializer.Deserialize<List<EmailTemplateVariable>>(model.VariablesJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? [];
|
||||
model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct))
|
||||
.Where(d => d.ServiceId == serviceId).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<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel
|
||||
{
|
||||
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter
|
||||
}, service);
|
||||
}
|
||||
|
||||
[HttpGet("{definitionId:guid}")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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();
|
||||
if (model.Id is Guid definitionId && await OwnedDefinitionAsync(service, definitionId, ct) is null)
|
||||
return NotFound();
|
||||
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<IActionResult> 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 });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> 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<EmailTemplateDefinition?> OwnedDefinitionAsync(NotificationServiceInfo? service, Guid definitionId, CancellationToken ct)
|
||||
{
|
||||
if (service is null)
|
||||
return null;
|
||||
return (await definitions.GetDefinitionsAsync(service.Name, ct: 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 JSON format for variables.");
|
||||
return View("Edit", model);
|
||||
ModelState.AddModelError(nameof(model.VariablesJson), "Invalid variables JSON. Use an array of objects with name and required properties.");
|
||||
}
|
||||
|
||||
if (model.IsNew)
|
||||
catch (EmailTemplateWriteException error)
|
||||
{
|
||||
var command = new CreateEmailTemplateCommand(
|
||||
model.ServiceName,
|
||||
model.Key,
|
||||
model.LanguageCode,
|
||||
model.Subject,
|
||||
model.HtmlBody,
|
||||
model.TextBody,
|
||||
variables);
|
||||
|
||||
var result = await _mediator.Send(command, ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create template.");
|
||||
return View("Edit", model);
|
||||
}
|
||||
ModelState.AddModelError(nameof(model.VariablesJson), error.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
var command = new UpdateEmailTemplateCommand(
|
||||
model.ServiceName,
|
||||
model.Key,
|
||||
model.LanguageCode,
|
||||
model.Subject,
|
||||
model.HtmlBody,
|
||||
model.TextBody,
|
||||
variables);
|
||||
|
||||
var result = await _mediator.Send(command, ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update template.");
|
||||
return View("Edit", model);
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index), new { serviceName = model.ServiceNameFilter, key = model.KeyFilter });
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST /admin/templates/{serviceName}/{key}/{languageCode}/delete
|
||||
[HttpPost("{serviceName}/{key}/{languageCode}/delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(
|
||||
string serviceName,
|
||||
string key,
|
||||
string languageCode,
|
||||
[FromForm] string? serviceNameFilter,
|
||||
[FromForm] string? keyFilter,
|
||||
CancellationToken ct)
|
||||
private static EmailTemplateEditViewModel ToViewModel(EmailTemplateDefinition definition) => new()
|
||||
{
|
||||
await _mediator.Send(new DeleteEmailTemplateCommand(serviceName, key, languageCode), ct);
|
||||
return RedirectToAction(nameof(Index), new { serviceName = serviceNameFilter, key = keyFilter });
|
||||
}
|
||||
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
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user