Files
hrynco-notification-service/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs
T
agrynco ed4293d852 fix(admin): filter template keys by substring
Match both channels case-insensitively while keeping API and delivery lookups exact.

Ref: IT-1627
2026-10-31 10:14:48 +02:00

242 lines
12 KiB
C#

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<AdminTemplatesController> logger, INotificationServiceRepository services, IPushTemplateDefinitionRepository pushes) : Controller
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
[HttpGet("/admin/services/{serviceId:guid}")]
public async Task<IActionResult> 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<IActionResult> 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<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();
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<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 });
}
[HttpGet("{definitionId:guid}/delete")]
public async Task<IActionResult> 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<IActionResult> 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<EmailTemplateDefinitionDeleteViewModel?> 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<IActionResult> 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<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.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()
};
}