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:
2026-10-31 09:38:05 +02:00
parent d4ee8fc0c8
commit aba4173827
79 changed files with 3906 additions and 104 deletions
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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)]);
}
}
@@ -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; } = [];
}
@@ -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; }
}
@@ -73,10 +73,26 @@
</div>
<div class="modal-body">
<p class="text-muted small">Tests the current form settings without saving. Blank credentials use the saved credentials only when the Firebase project is unchanged.</p>
<p class="text-muted small">Sends a neutral notification directly through Firebase. This tests the channel, not Invemory account binding, navigation or background handling. Use your own test device only.</p>
<label for="pushTestVersion" class="form-label fw-semibold">Test format</label>
<select id="pushTestVersion" class="form-select mb-2" aria-describedby="pushTestFormatHelp">
<option value="1">Legacy — simple notification</option>
<option value="2">Version 2 — notification with a button</option>
</select>
<p id="pushTestFormatHelp" class="text-muted small">Legacy sends the existing neutral notification. Version 2 tests the action payload and requires a client that supports buttons. Firebase acceptance alone does not confirm display or navigation. Use your own test device only.</p>
<label for="pushRecipientToken" class="form-label fw-semibold">Recipient token</label>
<textarea id="pushRecipientToken" class="form-control font-monospace" rows="3" maxlength="4096" autocomplete="off" spellcheck="false"></textarea>
<div class="form-text">Not saved as channel configuration. Cleared after the test or when this dialog closes.</div>
<fieldset id="pushButtonTestFields" class="mt-3" hidden>
<legend class="fs-6">Button test</legend>
<label for="pushTestBindingId" class="form-label">Current device binding ID</label>
<input id="pushTestBindingId" class="form-control mb-2" autocomplete="off" spellcheck="false" />
<div class="form-text mb-2">Use the actual binding of the recipient installation. An invented or outdated binding will be rejected by the app.</div>
<label for="pushTestActionKey" class="form-label">Section action key</label>
<input id="pushTestActionKey" class="form-control mb-2" maxlength="100" placeholder="Action supported by your app" spellcheck="false" />
<label for="pushTestButtonLabel" class="form-label">Button label</label>
<input id="pushTestButtonLabel" class="form-control" maxlength="100" value="Перейти в розділ" />
<div class="form-text">The button and notification tap use the same action. No object ID is needed for a section.</div>
</fieldset>
<div id="pushSendResult" class="alert d-none mt-3" role="status" aria-live="polite"></div>
</div>
<div class="modal-footer">
@@ -101,6 +117,8 @@
const authorizationButton = document.getElementById('pushAuthorizationBtn');
const sendButton = document.getElementById('pushSendBtn');
const modalButton = document.getElementById('pushTestModalBtn');
const version = document.getElementById('pushTestVersion');
const binding = document.getElementById('pushTestBindingId');
let busy = false;
function showResult(target, success, message) {
@@ -114,7 +132,11 @@
channelId: id === '00000000-0000-0000-0000-000000000000' ? null : id,
firebaseProjectId: form.querySelector('[name="FirebaseProjectId"]').value,
credentialsJson: form.querySelector('[name="CredentialsJson"]').value,
recipientToken: send ? token.value.trim() : null
recipientToken: send ? token.value.trim() : null,
version: Number(version.value),
bindingId: send && version.value === '2' ? binding.value.trim() || null : null,
actionKey: document.getElementById('pushTestActionKey').value,
buttonLabel: document.getElementById('pushTestButtonLabel').value
};
}
@@ -152,10 +174,14 @@
authorizationButton.addEventListener('click', () => runTest(false));
sendButton.addEventListener('click', () => runTest(true));
version.addEventListener('change', () => {
document.getElementById('pushButtonTestFields').hidden = version.value !== '2';
document.getElementById('pushSendResult').className = 'alert d-none';
});
modalButton.addEventListener('click', () => {
document.getElementById('pushSendResult').className = 'alert d-none';
bootstrap.Modal.getOrCreateInstance(modal).show();
});
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; });
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; binding.value = ''; });
});
</script>
@@ -0,0 +1,12 @@
@model HrynCo.NotificationService.Web.Controllers.Admin.ViewModels.PushTemplateEditViewModel
@{ ViewData["Title"] = "Delete Push Template"; }
<h1 class="h3">Delete Push Template</h1>
<p>Delete the Push template <strong>@Model.Key</strong> and its @Model.Languages.Count language versions from <strong>@Model.ServiceName</strong>? The Email template is retained.</p>
<form method="post" asp-action="Delete" asp-route-serviceId="@Model.ServiceId">
@Html.AntiForgeryToken()
<input asp-for="Key" type="hidden" /><input asp-for="Revision" type="hidden" /><input asp-for="KeyFilter" type="hidden" />
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
<div class="form-check my-3"><input asp-for="ConfirmDelete" class="form-check-input" /><label asp-for="ConfirmDelete" class="form-check-label">Confirm deletion of this Push template and its translations</label></div>
<button class="btn btn-danger" type="submit">Delete Push Template</button>
<a class="btn btn-secondary" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })">Cancel</a>
</form>
@@ -0,0 +1,111 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model PushTemplateEditViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = Model.PageTitle;
ViewData["EditorTitle"] = Model.PageTitle;
var active = Math.Clamp(Model.ActiveLanguageIndex, 0, Math.Max(0, Model.Languages.Count - 1));
}
@section Breadcrumbs {
<nav aria-label="Breadcrumb" class="template-editor-breadcrumbs"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="/admin/services/@Model.ServiceId">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">@Model.PageTitle</li></ol></nav>
}
<form id="templateForm" asp-action="Save" asp-controller="AdminPushTemplates" asp-route-serviceId="@Model.ServiceId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
<input asp-for="Revision" type="hidden" />
<input asp-for="ServiceId" type="hidden" /><input asp-for="ServiceName" type="hidden" />
<input asp-for="KeyFilter" type="hidden" />
<input type="hidden" id="ActiveLanguageIndex" name="ActiveLanguageIndex" value="@active" />
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<dl class="template-service-context">
<dt>Service</dt>
<dd>@Model.ServiceName</dd>
</dl>
<div class="form-text"><a href="/admin/services/@Model.ServiceId/edit" class="template-service-edit-link">Edit Service and language codes</a></div>
</div>
<div class="col-md-6">
<label asp-for="Key" class="form-label fw-semibold">Template Key</label>
<input asp-for="Key" class="form-control" readonly="@(Model.LockKey)" />
</div>
</div>
<partial name="_TemplateChannelNavigation" model="@(new TemplateChannelNavigationViewModel { ServiceId = Model.ServiceId, Key = Model.Key, KeyFilter = Model.KeyFilter, IsPush = true })" />
<div class="mb-3">
<label asp-for="VariablesJson" class="form-label fw-semibold">Shared variables (JSON)</label>
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="14"></textarea>
<div class="form-text">Push variables, shared across its languages: <code>[{"name":"UserName","required":true}]</code></div>
</div>
<p class="text-muted">Every configured service language has a tab. Present and Missing show saved versions. To remove a language version, clear Title and Body and set Actions to {}. Shared variables and the template remain.</p>
@if (Model.Languages.Count == 0) { <p>No service languages configured. You can save the key and shared variables now, then add language codes in Edit Service.</p> }
<div class="template-language-sheet mb-3" hidden="@(Model.Languages.Count == 0)">
<div class="nav nav-tabs" role="tablist" aria-label="Template languages" id="languageTabs">
@for (var i = 0; i < Model.Languages.Count; i++)
{
var language = Model.Languages[i];
<button type="button" role="tab" id="language-tab-@i" aria-controls="language-pane-@i"
aria-selected="@(i == active ? "true" : "false")" tabindex="@(i == active ? 0 : -1)"
class="nav-link @(i == active ? "active" : "")" data-pane="language-pane-@i">
<span>@language.LanguageCode</span>
<span class="template-language-status @(language.IsPresent ? "template-language-present" : "template-language-missing")">@(language.IsPresent ? "Present" : "Missing")</span>
</button>
}
</div>
<div id="languagePanes">
@for (var i = 0; i < Model.Languages.Count; i++)
{
<section class="language-pane" id="language-pane-@i" role="tabpanel" aria-labelledby="language-tab-@i" hidden="@(i != active)">
<input asp-for="Languages[i].LanguageCode" type="hidden" class="language-code" />
<div class="mb-3">
<label asp-for="Languages[i].Title" class="form-label fw-semibold">Title</label>
<input asp-for="Languages[i].Title" class="form-control" data-content="title" />
</div>
<div class="mb-3">
<label asp-for="Languages[i].Body" class="form-label fw-semibold">Body</label>
<textarea asp-for="Languages[i].Body" class="form-control font-monospace" rows="8" data-content="body"></textarea>
</div>
<div class="mb-3">
<label asp-for="Languages[i].ActionsJson" class="form-label fw-semibold">Actions (JSON)</label>
<textarea asp-for="Languages[i].ActionsJson" class="form-control font-monospace" rows="6" data-content="actions"></textarea>
<div class="form-text">Labels use this language. Action keys must match the message metadata; object IDs are optional for section navigation. Up to three buttons. Example: <code>{"defaultAction":"openSection","buttons":[{"action":"openSection","label":"Перейти в розділ"}]}</code>. Use <code>{}</code> for no actions.</div>
</div>
</section>
}
</div>
</div>
<div class="template-preview-panel mb-3">
<div class="template-preview-panel-header">
<div>
<div class="template-preview-title">Selected language preview: <span id="previewLanguage"></span></div>
<div class="template-preview-subtitle">Preview uses the Push variables. Content is plain text; this does not send a notification.</div>
</div>
<span id="previewStatus" class="badge text-bg-secondary" role="status">Ready</span>
</div>
<div class="template-preview-body">
<div class="template-preview-source template-preview-section-block">
<div class="template-preview-section-title">Sample values</div>
<div id="previewVariables" class="template-preview-variables"></div>
</div>
<div class="template-preview-output">
<div class="template-preview-section-title">Rendered title</div>
<div id="previewTitle" class="template-preview-subject"></div>
<div class="template-preview-section-title mt-3">Rendered body</div>
<pre id="previewBody" class="template-preview-text mb-0"></pre>
<div class="template-preview-section-title mt-3">Buttons</div>
<div id="previewActions" class="d-flex flex-wrap gap-2" aria-label="Button preview"></div>
<div id="previewDefaultAction" class="form-text"></div>
</div>
</div>
</div>
</form>
@section FormActions {
<button type="submit" form="templateForm" class="btn btn-primary"><i class="bi bi-floppy me-1"></i> Save</button>
<a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a>
}
@section Scripts {
<script src="~/js/template-channel-navigation.js" asp-append-version="true"></script>
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
}
@@ -0,0 +1,23 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model TemplateChannelNavigationViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = "Template: " + Model.Key;
ViewData["EditorTitle"] = ViewData["Title"];
var channel = Model.IsPush ? "Push" : "Email";
}
@section Breadcrumbs {
<nav aria-label="Breadcrumb" class="template-editor-breadcrumbs"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="/admin/services/@Model.ServiceId">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">Template</li></ol></nav>
}
<dl class="template-service-context"><dt>Service</dt><dd>@Model.ServiceName</dd><dt>Template Key</dt><dd>@Model.Key</dd></dl>
<partial name="_TemplateChannelNavigation" model="Model" />
<div class="empty-state"><p>@channel template has not been created for this key.</p>
@if (Model.IsPush) {
<a class="btn btn-primary" asp-action="Create" asp-route-serviceId="@Model.ServiceId" asp-route-key="@Model.Key" asp-route-keyFilter="@Model.KeyFilter">Create Push Template</a>
} else {
<a class="btn btn-primary" asp-action="Create" asp-controller="AdminTemplates" asp-route-serviceId="@Model.ServiceId" asp-route-templateKey="@Model.Key" asp-route-keyFilter="@Model.KeyFilter">Create Email Template</a>
}
</div>
@section FormActions {
<a href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Back to Templates</a>
}
@@ -2,16 +2,16 @@
@model EmailTemplateDefinitionDeleteViewModel
@{ ViewData["Title"] = "Delete email template"; var returnUrl = Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter }); }
<div class="admin-delete-confirmation">
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="@returnUrl">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">Delete template</li></ol></nav>
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="@returnUrl">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">Delete Email template</li></ol></nav>
<h2>Delete template @Model.Key?</h2>
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
<div class="alert alert-danger">This permanently deletes the definition, shared variables and <strong>@Model.LanguageCodes.Count translation(s)</strong> from service @Model.ServiceName.</div>
<div class="alert alert-danger">This permanently deletes the Email definition, its shared variables and <strong>@Model.LanguageCodes.Count translation(s)</strong> from service @Model.ServiceName.</div>
<p>Languages: @(Model.LanguageCodes.Count == 0 ? "No translations" : string.Join(", ", Model.LanguageCodes))</p>
<p>The service, its language catalog and email channels are retained.</p>
<p>The Push template, service, language catalog and delivery channels are retained.</p>
<form asp-action="DeleteDefinition" asp-route-serviceId="@Model.ServiceId" asp-route-definitionId="@Model.DefinitionId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Revision" type="hidden" /><input asp-for="KeyFilter" type="hidden" />
<div class="form-check mb-3"><input asp-for="ConfirmDelete" class="form-check-input" /><label asp-for="ConfirmDelete" class="form-check-label">I confirm deletion of @Model.Key and its @Model.LanguageCodes.Count translation(s).</label></div>
<div class="form-check mb-3"><input asp-for="ConfirmDelete" class="form-check-input" /><label asp-for="ConfirmDelete" class="form-check-label">I confirm deletion of the Email template @Model.Key and its @Model.LanguageCodes.Count translation(s).</label></div>
<div class="d-flex flex-wrap gap-2"><button type="submit" class="btn btn-danger">Delete template</button><a class="btn btn-secondary" href="@returnUrl">Cancel</a></div>
</form>
</div>
@@ -29,13 +29,14 @@
</div>
<div class="col-md-6">
<label asp-for="Key" class="form-label fw-semibold">Template Key</label>
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew)" />
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew || Model.LockKey)" />
</div>
</div>
<partial name="_TemplateChannelNavigation" model="@(new TemplateChannelNavigationViewModel { ServiceId = Model.ServiceId, Key = Model.Key, KeyFilter = Model.KeyFilter })" />
<div class="mb-3">
<label asp-for="VariablesJson" class="form-label fw-semibold">Shared variables (JSON)</label>
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="14"></textarea>
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
<div class="form-text">Email variables, shared across its languages: <code>[{"name":"UserName","required":true}]</code></div>
</div>
<p class="text-muted">Every configured service language has a tab. Present and Missing show saved versions. Leave all three content fields blank for an unused language; clear them to remove an existing version. Shared variables and the template remain.</p>
@if (Model.Languages.Count == 0) { <p>No service languages configured. You can save the key and shared variables now, then add language codes in Edit Service.</p> }
@@ -102,6 +103,7 @@
<a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a>
}
@section Scripts {
<script src="~/js/template-channel-navigation.js" asp-append-version="true"></script>
<script src="~/js/email-template-preview.js" asp-append-version="true"></script>
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
}
@@ -25,7 +25,7 @@
}
</div>
<div class="page-header">
<h3 class="h4">Email Templates</h3>
<h3 class="h4">Templates</h3>
<a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a>
</div>
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
@@ -40,12 +40,13 @@
</div>
</form>
</th>
<th scope="col">Languages</th>
<th scope="col">Email</th>
<th scope="col">Push</th>
<th scope="col" class="template-actions">Action</th>
</tr></thead><tbody>
@if (Model.Definitions.Count == 0)
@if (Model.Definitions.Count == 0 && Model.PushDefinitions.Count == 0)
{
<tr><td colspan="3"><div class="empty-state">
<tr><td colspan="4"><div class="empty-state">
@if (!ViewData.ModelState.IsValid)
{
<p class="mb-0">Templates are unavailable. Review the error and try again.</p>
@@ -56,18 +57,21 @@
}
else
{
<i class="bi bi-envelope" aria-hidden="true"></i><p class="mt-2 mb-0">No email templates for this service yet.</p><a asp-action="Create" asp-route-serviceId="@serviceId" class="btn btn-primary btn-sm mt-3">Create First Template</a>
<i class="bi bi-envelope" aria-hidden="true"></i><p class="mt-2 mb-0">No templates for this service yet.</p><a asp-action="Create" asp-route-serviceId="@serviceId" class="btn btn-primary btn-sm mt-3">Create First Template</a>
}
</div></td></tr>
}
@foreach (var template in Model.Definitions)
@foreach (var templateKey in Model.Definitions.Select(d => d.Key).Concat(Model.PushDefinitions.Select(d => d.Key)).Distinct(StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal))
{
var editUrl = Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = template.Id, keyFilter });
var presentCodes = template.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
var email = Model.Definitions.SingleOrDefault(d => d.Key == templateKey);
var push = Model.PushDefinitions.SingleOrDefault(d => d.Key == templateKey);
var editUrl = email is null ? Url.Action("Edit", "AdminPushTemplates", new { serviceId, key = templateKey, keyFilter }) : Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = email.Id, keyFilter });
var presentCodes = email?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal) ?? [];
<tr>
<td>@template.Key</td>
<td>@templateKey</td>
<td>
@if (Model.Service.Languages.Count == 0)
@if (email is null) { <span>Not configured</span> }
else if (Model.Service.Languages.Count == 0)
{
<span>No configured languages</span>
}
@@ -84,7 +88,22 @@
</ul>
}
</td>
<td class="template-actions"><div class="list-action-links"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @template.Key">Edit</a><a asp-action="ConfirmDefinitionDelete" asp-route-serviceId="@serviceId" asp-route-definitionId="@template.Id" asp-route-keyFilter="@keyFilter" class="btn btn-outline-danger btn-sm" aria-label="Delete template @template.Key">Delete</a></div></td>
<td>
@if (push is null) { <span>Not configured</span> }
else if (Model.Service.Languages.Count == 0) { <span>No configured languages</span> }
else {
var pushCodes = push.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
<ul class="template-language-statuses">
@foreach (var language in Model.Service.Languages) {
var present = pushCodes.Contains(language.Code);
<li class="template-language-status @(present ? "template-language-present" : "template-language-missing")" data-language-code="@language.Code" data-state="@(present ? "present" : "missing")"><span>@language.Code:</span><span>@(present ? "Present" : "Missing")</span></li>
}
</ul>
}
</td>
<td class="template-actions"><div class="list-action-links"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @templateKey">Edit</a>@if (email is not null) { <a asp-action="ConfirmDefinitionDelete" asp-route-serviceId="@serviceId" asp-route-definitionId="@email.Id" asp-route-keyFilter="@keyFilter" class="btn btn-outline-danger btn-sm" aria-label="Delete Email template @templateKey">Delete</a> }
@if (push is not null) { <a asp-action="ConfirmDelete" asp-controller="AdminPushTemplates" asp-route-serviceId="@serviceId" asp-route-key="@templateKey" asp-route-keyFilter="@keyFilter" class="btn btn-outline-danger btn-sm">Delete Push</a> }
</div></td>
</tr>
}
</tbody></table></div>
@@ -14,7 +14,7 @@
<link href="/lib/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet" />
<link href="/css/admin.css" rel="stylesheet" />
<link href="~/css/admin.css" rel="stylesheet" asp-append-version="true" />
</head>
<body>
<nav class="navbar navbar-dark bg-dark admin-navbar">
@@ -0,0 +1,7 @@
@model HrynCo.NotificationService.Web.Controllers.Admin.ViewModels.TemplateChannelNavigationViewModel
<nav class="nav nav-tabs mb-3 template-channel-tabs" aria-label="Template channels">
<a class="nav-link @(!Model.IsPush ? "active" : "")" aria-current="@(!Model.IsPush ? "page" : null)" data-template-channel
href="@Url.Action("Email", "AdminPushTemplates", new { serviceId = Model.ServiceId, key = Model.Key, keyFilter = Model.KeyFilter })">Email</a>
<a class="nav-link @(Model.IsPush ? "active" : "")" aria-current="@(Model.IsPush ? "page" : null)" data-template-channel
href="@Url.Action("Edit", "AdminPushTemplates", new { serviceId = Model.ServiceId, key = Model.Key, keyFilter = Model.KeyFilter })">Push</a>
</nav>
@@ -578,3 +578,11 @@ body {
}
textarea.form-control { resize: vertical; }
/* Keep channel/language comparisons readable inside the responsive wrapper. */
.service-template-grid { min-width: 960px; }
.service-template-grid .template-language-status { white-space: nowrap; }
.template-channel-tabs .nav-link { min-width: 80px; text-align: center; }
.template-unsaved-dialog { border: 1px solid #cfd8e3; border-radius: .5rem; padding: 1.5rem; max-width: min(440px, calc(100vw - 32px)); }
.template-unsaved-dialog::backdrop { background: rgb(0 0 0 / 40%); }
@@ -5,7 +5,7 @@
const activeField = document.getElementById('ActiveLanguageIndex');
const variablesField = document.getElementById('VariablesJson');
const samples = document.getElementById('previewVariables');
let dirty = false;
let dirty = !!form.querySelector('.validation-summary-errors');
const sampleValues = new Map();
function escapeHtml(value) {
@@ -77,29 +77,52 @@
}
function interpolate(text, html = false) {
let result = text;
samples.querySelectorAll('input').forEach(input => {
const name = input.dataset.variableName;
const value = html ? escapeHtml(input.value) : input.value;
result = result.split(`{{${name}}}`).join(value);
return text.replace(/\{\{([^{}]+)\}\}/g, (placeholder, name) => {
if (!sampleValues.has(name)) return placeholder;
const value = sampleValues.get(name);
return html ? escapeHtml(value) : value;
});
return result;
}
function updatePreview() {
const pane = activePane();
if (!pane) {
document.getElementById('previewLanguage').textContent = '(no versions)';
document.getElementById('previewSubject').textContent = '';
document.getElementById('previewText').textContent = '';
document.getElementById('previewHtmlFrame').srcdoc = '';
return;
const push = !!document.getElementById('previewTitle');
document.getElementById('previewLanguage').textContent = pane?.querySelector('.language-code').value ?? '(no versions)';
const heading = pane?.querySelector(push ? '[data-content="title"]' : '[data-content="subject"]')?.value ?? '';
const body = pane?.querySelector(push ? '[data-content="body"]' : '[data-content="text"]')?.value ?? '';
document.getElementById(push ? 'previewTitle' : 'previewSubject').textContent = interpolate(heading);
document.getElementById(push ? 'previewBody' : 'previewText').textContent = interpolate(body);
updateHtmlPreview(pane);
updateActionsPreview(pane);
}
function updateActionsPreview(pane) {
const container = document.getElementById('previewActions');
if (!container) return;
container.replaceChildren();
const hint = document.getElementById('previewDefaultAction');
try {
const actions = JSON.parse(pane?.querySelector('[data-content="actions"]')?.value || '{}');
if (!actions || (actions.buttons !== undefined && !Array.isArray(actions.buttons))) throw new Error();
(actions.buttons ?? []).forEach(item => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-outline-primary btn-sm';
button.textContent = interpolate(item.label);
button.title = `Action: ${item.action}. Preview only.`;
container.append(button);
});
hint.textContent = actions.defaultAction ? `Notification tap: ${actions.defaultAction}` : 'No notification tap action configured.';
} catch {
hint.textContent = 'Invalid actions JSON. Use defaultAction and buttons with action and label.';
}
document.getElementById('previewLanguage').textContent = pane.querySelector('.language-code').value;
document.getElementById('previewSubject').textContent = interpolate(pane.querySelector('[data-content="subject"]').value);
document.getElementById('previewText').textContent = interpolate(pane.querySelector('[data-content="text"]').value);
const html = interpolate(pane.querySelector('[data-content="html"]').value, true);
document.getElementById('previewHtmlFrame').srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>body{font-family:Arial,sans-serif;padding:20px;color:#1f2937;background:white}img{max-width:100%}</style></head><body>${html}</body></html>`;
}
function updateHtmlPreview(pane) {
const frame = document.getElementById('previewHtmlFrame');
if (!frame) return;
const html = interpolate(pane?.querySelector('[data-content="html"]')?.value ?? '', true);
frame.srcdoc = '<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:; base-uri \'none\'; form-action \'none\'"><style>body{font-family:Arial,sans-serif;padding:20px;color:#1f2937;background:white}img{max-width:100%}</style></head><body>' + html + '</body></html>';
}
tabs.addEventListener('click', event => {
@@ -126,5 +149,25 @@
if (dirty) { event.preventDefault(); event.returnValue = ''; }
});
const navigationDialog = document.createElement('dialog');
navigationDialog.className = 'template-unsaved-dialog';
navigationDialog.setAttribute('aria-labelledby', 'unsaved-title');
navigationDialog.innerHTML = '<h2 id="unsaved-title" class="h5">Unsaved changes</h2><p>Save this channel before leaving, or discard its unsaved changes.</p><div class="d-flex flex-wrap gap-2"><button type="button" class="btn btn-primary" data-keep-editing>Keep editing</button><button type="button" class="btn btn-outline-danger" data-discard-changes>Discard changes</button></div>';
document.body.append(navigationDialog);
let pendingNavigation;
navigationDialog.querySelector('[data-keep-editing]').addEventListener('click', () => navigationDialog.close());
navigationDialog.querySelector('[data-discard-changes]').addEventListener('click', () => {
dirty = false;
navigationDialog.close();
window.location.assign(pendingNavigation);
});
document.addEventListener('click', event => {
const link = event.target.closest('a');
if (!dirty || !link || link.target === '_blank') return;
event.preventDefault();
pendingNavigation = link.href;
navigationDialog.showModal();
});
renderSamples();
})();
@@ -0,0 +1,10 @@
(() => {
const key = document.getElementById('Key');
document.querySelectorAll('[data-template-channel]').forEach(link => {
link.addEventListener('click', () => {
const url = new URL(link.href);
url.searchParams.set('key', key?.value ?? url.searchParams.get('key') ?? '');
link.href = url.href;
});
});
})();