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:
2026-10-17 22:02:43 +03:00
parent 36810dc5f1
commit 81654c0d33
73 changed files with 4061 additions and 820 deletions
@@ -0,0 +1,125 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/services")]
public sealed class AdminServicesController(INotificationServiceRepository services, ILogger<AdminServicesController> logger) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index(CancellationToken ct) => View(await services.GetAllAsync(ct));
[HttpGet("create")]
public IActionResult Create() => View("Edit", new NotificationServiceEditViewModel());
[HttpGet("{id:guid}/edit")]
public async Task<IActionResult> Edit([FromRoute] Guid id, CancellationToken ct)
{
var service = await services.GetAsync(id, ct);
return service is null ? NotFound() : View(ToViewModel(service));
}
[HttpPost("save")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(NotificationServiceEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid)
return await ServiceEditorViewAsync(model, ct);
try
{
var service = new NotificationServiceInfo
{
Id = model.Id ?? Guid.Empty, Name = model.Name, Revision = model.Revision, Languages = model.Languages
};
await services.SaveAsync(service, model.IsNew, ct);
return RedirectToAction(nameof(AdminTemplatesController.Index), "AdminTemplates", new { serviceId = service.Id });
}
catch (EmailTemplateWriteException error)
{
ModelState.AddModelError("", error.Message);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to save notification service.");
ModelState.AddModelError("", "Could not save the service. Your entered values are retained; try again.");
}
return await ServiceEditorViewAsync(model, ct);
}
[HttpGet("{serviceId:guid}/languages/{languageId:guid}/delete")]
public async Task<IActionResult> ConfirmLanguageRemoval([FromRoute] Guid serviceId, [FromRoute] Guid languageId, CancellationToken ct)
{
var model = await RemovalModelAsync(serviceId, languageId, ct);
return model is null ? NotFound() : View("DeleteLanguage", model);
}
[HttpPost("{serviceId:guid}/languages/{languageId:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveLanguage([FromRoute] Guid serviceId, [FromRoute] Guid languageId, ServiceLanguageDeleteViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid || !model.ConfirmDelete)
return await RemovalErrorAsync(serviceId, languageId, "Confirm deletion of the language and all listed versions to continue.", ct);
try
{
await services.RemoveLanguageAsync(serviceId, languageId, model.VersionCount, model.Revision, ct);
return RedirectToAction(nameof(Edit), new { id = serviceId });
}
catch (EmailTemplateWriteException error)
{
return await RemovalErrorAsync(serviceId, languageId, error.Message, ct);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to remove service language.");
return await RemovalErrorAsync(serviceId, languageId, "Could not delete the language. No changes were saved; review the count and try again.", ct);
}
}
private async Task<IActionResult> ServiceEditorViewAsync(NotificationServiceEditViewModel model, CancellationToken ct)
{
if (model.Id is not Guid id)
return View("Edit", model);
try
{
var persisted = await services.GetAsync(id, ct);
var counts = persisted?.Languages.ToDictionary(l => l.Id, l => l.VersionCount) ?? [];
foreach (var language in model.Languages)
language.VersionCount = counts.GetValueOrDefault(language.Id);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to refresh service language version counts.");
ViewData["LanguageCountsUnavailable"] = true;
ModelState.AddModelError("", "Could not refresh language usage counts. Removal is disabled until the service is available.");
}
return View("Edit", model);
}
private async Task<IActionResult> RemovalErrorAsync(Guid serviceId, Guid languageId, string error, CancellationToken ct)
{
var model = await RemovalModelAsync(serviceId, languageId, ct);
if (model is null)
return NotFound();
ModelState.Clear();
ModelState.AddModelError("", error);
return View("DeleteLanguage", model);
}
private async Task<ServiceLanguageDeleteViewModel?> RemovalModelAsync(Guid serviceId, Guid languageId, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var language = service?.Languages.SingleOrDefault(l => l.Id == languageId);
return language is null ? null : new ServiceLanguageDeleteViewModel
{
ServiceId = serviceId, LanguageId = languageId, ServiceName = service!.Name, Revision = service.Revision,
Code = language.Code, VersionCount = language.VersionCount
};
}
private static NotificationServiceEditViewModel ToViewModel(NotificationServiceInfo service) => new()
{
Id = service.Id, Name = service.Name, Revision = service.Revision, Languages = service.Languages.ToList()
};
}
@@ -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()
};
}
@@ -0,0 +1,38 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/templates")]
public sealed class LegacyAdminTemplatesController(INotificationServiceRepository services,
IEmailTemplateDefinitionRepository definitions) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index([FromQuery] string? serviceName, [FromQuery] string? serviceNameFilter,
[FromQuery] string? key, [FromQuery] string? keyFilter, CancellationToken ct)
{
var name = serviceName ?? serviceNameFilter;
var service = (await services.GetAllAsync(ct)).SingleOrDefault(s => s.Name == name);
if (service is null)
return RedirectToAction("Index", "AdminServices");
return RedirectToAction("Index", "AdminTemplates", new { serviceId = service.Id, key = key ?? keyFilter });
}
[HttpGet("create")]
public async Task<IActionResult> Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter, CancellationToken ct)
{
var service = (await services.GetAllAsync(ct)).SingleOrDefault(s => s.Name == serviceNameFilter);
return service is null ? RedirectToAction("Index", "AdminServices")
: RedirectToAction("Create", "AdminTemplates", new { serviceId = service.Id, keyFilter });
}
[HttpGet("{serviceName}/{key}")]
[HttpGet("{serviceName}/{key}/{languageCode}")]
public async Task<IActionResult> Edit(string serviceName, string key, string? languageCode,
[FromQuery] string? keyFilter, CancellationToken ct)
{
var definition = await definitions.GetDefinitionAsync(serviceName, key, ct);
return definition is null ? NotFound() : RedirectToAction("Edit", "AdminTemplates",
new { serviceId = definition.ServiceId, definitionId = definition.Id, languageCode, keyFilter });
}
}
@@ -2,32 +2,20 @@ using System.ComponentModel.DataAnnotations;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public class EmailTemplateEditViewModel
public sealed class EmailTemplateEditViewModel
{
public Guid? Id { get; set; }
[Required]
public Guid ServiceId { get; set; }
public Guid Revision { get; set; }
[Required, StringLength(100)]
public string ServiceName { get; set; } = "";
[Required]
[Required, StringLength(100)]
public string Key { get; set; } = "";
[Required]
public string LanguageCode { get; set; } = "";
[Required]
public string Subject { get; set; } = "";
[Required]
public string HtmlBody { get; set; } = "";
public string TextBody { get; set; } = "";
// JSON array: [{"name":"UserName","required":true}, ...]
public string VariablesJson { get; set; } = "[]";
public string? ServiceNameFilter { get; set; }
public List<EmailTemplateLanguageEditViewModel> Languages { get; set; } = [];
public int ActiveLanguageIndex { get; set; }
public string? KeyFilter { get; set; }
public bool IsNew => Id == null;
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
}
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class EmailTemplateLanguageEditViewModel : IValidatableObject
{
[Required, StringLength(10)]
public string LanguageCode { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string Subject { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string HtmlBody { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string TextBody { get; set; } = "";
public bool HasContent => !string.IsNullOrWhiteSpace(Subject) || !string.IsNullOrWhiteSpace(HtmlBody) || !string.IsNullOrWhiteSpace(TextBody);
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!HasContent)
yield break;
if (string.IsNullOrWhiteSpace(Subject))
yield return new ValidationResult("The Subject field is required for a populated language.", [nameof(Subject)]);
if (string.IsNullOrWhiteSpace(HtmlBody))
yield return new ValidationResult("The HtmlBody field is required for a populated language.", [nameof(HtmlBody)]);
if (string.IsNullOrWhiteSpace(TextBody))
yield return new ValidationResult("The TextBody field is required for a populated language.", [nameof(TextBody)]);
}
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class NotificationServiceEditViewModel
{
public Guid? Id { get; set; }
public Guid Revision { get; set; }
[Required, StringLength(100)]
public string Name { get; set; } = "";
public List<ServiceLanguageInfo> Languages { get; set; } = [];
public bool IsNew => Id is null;
}
@@ -0,0 +1,12 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class ServiceLanguageDeleteViewModel
{
public Guid ServiceId { get; set; }
public Guid LanguageId { get; set; }
public string ServiceName { get; set; } = "";
public string Code { get; set; } = "";
public Guid Revision { get; set; }
public int VersionCount { get; set; }
public bool ConfirmDelete { get; set; }
}
@@ -0,0 +1,10 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class ServiceTemplatesViewModel
{
public required NotificationServiceInfo Service { get; init; }
public string? KeyFilter { get; init; }
public IReadOnlyList<EmailTemplateDefinition> Definitions { get; set; } = [];
}