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
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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()
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ 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) : Controller
|
||||
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services, IPushTemplateDefinitionRepository pushes) : Controller
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
||||
ModelState.AddModelError("", previousError);
|
||||
try
|
||||
{
|
||||
model.PushDefinitions = (await pushes.GetDefinitionsAsync(serviceId, ct))
|
||||
.Where(d => string.IsNullOrWhiteSpace(key) || d.Key == key).ToList();
|
||||
model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct))
|
||||
.Where(d => d.ServiceId == serviceId).ToList();
|
||||
}
|
||||
@@ -39,12 +41,12 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
||||
}
|
||||
|
||||
[HttpGet("create")]
|
||||
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct)
|
||||
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
|
||||
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter, Key = templateKey ?? "", LockKey = !string.IsNullOrEmpty(templateKey)
|
||||
}, service);
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
||||
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;
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@ public sealed class EmailTemplateEditViewModel
|
||||
public List<EmailTemplateLanguageEditViewModel> Languages { get; set; } = [];
|
||||
public int ActiveLanguageIndex { get; set; }
|
||||
public string? KeyFilter { get; set; }
|
||||
public bool LockKey { get; set; }
|
||||
public bool IsNew => Id == null;
|
||||
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
||||
public string PageTitle => IsNew ? "Create Template" : "Template: " + Key;
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public sealed class PushTemplateEditViewModel
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid ServiceId { get; set; }
|
||||
public Guid Revision { get; set; }
|
||||
public string ServiceName { get; set; } = "";
|
||||
[Required, StringLength(100)]
|
||||
public string Key { get; set; } = "";
|
||||
[Required]
|
||||
public string VariablesJson { get; set; } = "[]";
|
||||
public List<PushTemplateLanguageEditViewModel> Languages { get; set; } = [];
|
||||
public int ActiveLanguageIndex { get; set; }
|
||||
public string? KeyFilter { get; set; }
|
||||
public bool LockKey { get; set; }
|
||||
public bool ConfirmDelete { get; set; }
|
||||
public bool IsNew => Id is null;
|
||||
public string PageTitle => IsNew ? "Create Template" : "Template: " + Key;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public sealed class PushTemplateLanguageEditViewModel : IValidatableObject
|
||||
{
|
||||
[Required, StringLength(10)]
|
||||
public string LanguageCode { get; set; } = "";
|
||||
[DisplayFormat(ConvertEmptyStringToNull = false)]
|
||||
public string Title { get; set; } = "";
|
||||
[DisplayFormat(ConvertEmptyStringToNull = false)]
|
||||
public string Body { get; set; } = "";
|
||||
[Required]
|
||||
public string ActionsJson { get; set; } = "{}";
|
||||
[BindNever]
|
||||
public bool IsPresent { get; set; }
|
||||
public bool HasContent => !string.IsNullOrWhiteSpace(Title) || !string.IsNullOrWhiteSpace(Body) || HasActions();
|
||||
|
||||
private bool HasActions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var actions = DAL.Abstract.Templates.PushTemplateActionValidation.Parse(ActionsJson);
|
||||
return actions.DefaultAction is not null || actions.Buttons.Count > 0;
|
||||
}
|
||||
catch (DAL.Abstract.Templates.EmailTemplateWriteException) { return true; }
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (!HasContent) yield break;
|
||||
if (string.IsNullOrWhiteSpace(Title))
|
||||
yield return new ValidationResult("Title is required for a populated language.", [nameof(Title)]);
|
||||
if (string.IsNullOrWhiteSpace(Body))
|
||||
yield return new ValidationResult("Body is required for a populated language.", [nameof(Body)]);
|
||||
}
|
||||
}
|
||||
+1
@@ -6,5 +6,6 @@ public sealed class ServiceTemplatesViewModel
|
||||
{
|
||||
public required NotificationServiceInfo Service { get; init; }
|
||||
public string? KeyFilter { get; init; }
|
||||
public IReadOnlyList<PushTemplateDefinition> PushDefinitions { get; set; } = [];
|
||||
public IReadOnlyList<EmailTemplateDefinition> Definitions { get; set; } = [];
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public sealed class TemplateChannelNavigationViewModel
|
||||
{
|
||||
public Guid ServiceId { get; init; }
|
||||
public string ServiceName { get; init; } = "";
|
||||
public string Key { get; init; } = "";
|
||||
public string? KeyFilter { get; init; }
|
||||
public bool IsPush { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user