Files
hrynco-notification-service/HrynCo.NotificationService.Web/Controllers/Admin/AdminPushTemplatesController.cs
T
agrynco aba4173827 feat(templates): add localized push templates and actions
Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery.

Ref: IT-1627
2026-10-31 09:38:05 +02:00

163 lines
8.6 KiB
C#

using System.Text.Json;
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/{serviceId:guid}/push-templates")]
public sealed class AdminPushTemplatesController(IPushTemplateDefinitionRepository pushes,
IEmailTemplateDefinitionRepository emails, INotificationServiceRepository services,
ILogger<AdminPushTemplatesController> logger) : Controller
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
[HttpGet("email")]
public async Task<IActionResult> Email([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null) return NotFound();
if (string.IsNullOrEmpty(key))
return RedirectToAction("Create", "AdminTemplates", new { serviceId, keyFilter });
var email = await emails.GetDefinitionAsync(serviceId, key, ct);
if (email is not null)
return RedirectToAction("Edit", "AdminTemplates", new { serviceId, definitionId = email.Id, keyFilter });
return Missing(service, key, keyFilter, false);
}
[HttpGet("edit")]
public async Task<IActionResult> Edit([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null) return NotFound();
if (string.IsNullOrEmpty(key))
return RedirectToAction(nameof(Create), new { serviceId, keyFilter });
var definition = await pushes.GetAsync(serviceId, key, ct);
return definition is null ? Missing(service, key, keyFilter, true)
: Editor(ToModel(definition, service, keyFilter), service);
}
[HttpGet("create")]
public async Task<IActionResult> Create([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null) return NotFound();
if (key is not null && await pushes.GetAsync(serviceId, key, ct) is not null)
return RedirectToAction(nameof(Edit), new { serviceId, key, keyFilter });
return Editor(new PushTemplateEditViewModel
{
ServiceId = serviceId, ServiceName = service.Name, Key = key ?? "", KeyFilter = keyFilter,
LockKey = key is not null && await emails.GetDefinitionAsync(serviceId, key, ct) is not null
}, service);
}
[HttpPost("save"), ValidateAntiForgeryToken]
public async Task<IActionResult> Save([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null || serviceId != model.ServiceId) return NotFound();
model.ServiceName = service.Name;
var persisted = await pushes.GetAsync(serviceId, model.Key, ct);
if (model.Id is not null && persisted?.Id != model.Id) return NotFound();
model.LockKey = model.Id is not null || await emails.GetDefinitionAsync(serviceId, model.Key, ct) is not null;
SetSavedStatuses(model, persisted);
ValidateCatalog(model, service);
if (!ModelState.IsValid) return Editor(model, service);
return await SaveModelAsync(model, service, ct);
}
private async Task<IActionResult> SaveModelAsync(PushTemplateEditViewModel model, NotificationServiceInfo service, CancellationToken ct)
{
try
{
await pushes.SaveAsync(new PushTemplateDefinition
{
Id = model.Id ?? Guid.Empty, ServiceId = service.Id, Key = model.Key, Revision = model.Revision,
Variables = EmailTemplateValidation.ParseVariables(model.VariablesJson),
Languages = model.Languages.Where(l => l.HasContent).Select(l => new PushTemplateTranslation
{
LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson
}).ToList()
}, model.IsNew, ct);
return RedirectToAction("Index", "AdminTemplates", new { serviceId = service.Id, key = model.KeyFilter });
}
catch (JsonException) { ModelState.AddModelError("VariablesJson", "Invalid variables JSON. Use name and required properties."); }
catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); }
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to save push template definition");
ModelState.AddModelError("", "Could not save the push template. Your entered values are retained; try again.");
}
return Editor(model, service);
}
[HttpGet("delete")]
public async Task<IActionResult> ConfirmDelete([FromRoute] Guid serviceId, string key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await pushes.GetAsync(serviceId, key, ct);
return service is null || definition is null ? NotFound() : View("Delete", ToModel(definition, service, keyFilter));
}
[HttpPost("delete"), ValidateAntiForgeryToken]
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await pushes.GetAsync(serviceId, model.Key, ct);
if (service is null || definition is null) return NotFound();
if (!model.ConfirmDelete || !ModelState.IsValid)
ModelState.AddModelError("", "Confirm deletion of this push template to continue.");
else
{
try
{
await pushes.DeleteAsync(serviceId, model.Key, model.Revision, ct);
return RedirectToAction("Index", "AdminTemplates", new { serviceId, key = model.KeyFilter });
}
catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); }
}
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList();
ModelState.Clear();
foreach (var error in errors) ModelState.AddModelError("", error);
return View("Delete", ToModel(definition, service, model.KeyFilter));
}
private IActionResult Missing(NotificationServiceInfo service, string key, string? keyFilter, bool isPush) => View("Missing", new TemplateChannelNavigationViewModel
{
ServiceId = service.Id, ServiceName = service.Name, Key = key, KeyFilter = keyFilter, IsPush = isPush
});
private IActionResult Editor(PushTemplateEditViewModel model, NotificationServiceInfo service)
{
foreach (var language in service.Languages)
if (!model.Languages.Any(l => l.LanguageCode == language.Code))
model.Languages.Add(new PushTemplateLanguageEditViewModel { LanguageCode = language.Code });
return View("Edit", model);
}
private void ValidateCatalog(PushTemplateEditViewModel model, NotificationServiceInfo service)
{
var codes = model.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
if (codes.Count != model.Languages.Count || !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 static void SetSavedStatuses(PushTemplateEditViewModel model, PushTemplateDefinition? persisted)
{
var codes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal) ?? [];
foreach (var language in model.Languages) language.IsPresent = codes.Contains(language.LanguageCode);
}
private static PushTemplateEditViewModel ToModel(PushTemplateDefinition definition, NotificationServiceInfo service, string? keyFilter) => new()
{
Id = definition.Id, ServiceId = service.Id, ServiceName = service.Name, Key = definition.Key,
Revision = definition.Revision, KeyFilter = keyFilter, LockKey = true,
VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions),
Languages = definition.Languages.Select(l => new PushTemplateLanguageEditViewModel
{
LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson, IsPresent = true
}).ToList()
};
}