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; } = [];
}
+3 -2
View File
@@ -29,7 +29,8 @@ app.MapScalarApiReference(options =>
app.UseStaticFiles();
app.UseHttpsRedirection();
app.MapGet("/", () => Results.Redirect("/admin/templates"));
app.MapGet("/", () => Results.Redirect("/admin/services"));
app.MapControllers();
app.MapDefaultControllerRoute();
app.Run();
app.Run();
public partial class Program { }
@@ -0,0 +1,13 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model ServiceLanguageDeleteViewModel
@{ ViewData["Title"] = "Delete service language and versions"; }
<h2>Delete language @Model.Code from @Model.ServiceName?</h2>
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
<div class="alert alert-danger">This permanently deletes <strong>@Model.VersionCount template version(s)</strong> using this language in this service and removes the language code. Template definitions, their keys and shared variables are retained.</div>
<form method="post" action="/admin/services/@Model.ServiceId/languages/@Model.LanguageId/delete">
@Html.AntiForgeryToken()
<input asp-for="VersionCount" type="hidden" /><input asp-for="Revision" 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.VersionCount version(s) and language @Model.Code.</label></div>
<button type="submit" class="btn btn-danger">Delete @Model.VersionCount version(s) and language</button>
<a class="btn btn-secondary" href="/admin/services/@Model.ServiceId/edit">Cancel</a>
</form>
@@ -0,0 +1,48 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model NotificationServiceEditViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = Model.IsNew ? "Create Service" : "Edit Service";
ViewData["EditorTitle"] = ViewData["Title"];
}
@section Breadcrumbs {
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li>@if (!Model.IsNew) { <li class="breadcrumb-item"><a href="/admin/services/@Model.Id">@Model.Name</a></li> }<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li></ol></nav>
}
<form asp-action="Save" id="serviceForm" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" /><input asp-for="Revision" type="hidden" />
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
<label asp-for="Name" class="form-label fw-semibold">Service name</label>
<input asp-for="Name" class="form-control mb-3" maxlength="100" />
<h3 class="h5">Language codes</h3>
<p class="text-muted">Codes are case-sensitive and belong to this service. Existing template versions use these codes when sending. Renaming a service or code changes sender lookup; align publishers and email channel service names.</p>
<div class="table-responsive">
<table class="table service-language-table"><thead><tr><th>Code</th><th>Versions</th><th class="service-language-actions">Actions</th></tr></thead><tbody id="serviceLanguages">
@for (var i = 0; i < Model.Languages.Count; i++)
{
var countUnavailable = ViewData["LanguageCountsUnavailable"] is true && Model.Languages[i].Id != Guid.Empty;
<tr>
<td><input asp-for="Languages[i].Id" type="hidden" /><label for="service-code-@i" class="visually-hidden">Language code</label><input asp-for="Languages[i].Code" id="service-code-@i" class="form-control" maxlength="10" /></td>
<td>@(countUnavailable ? "Unavailable" : Model.Languages[i].VersionCount.ToString())</td>
<td class="service-language-actions">
<div class="service-language-action-buttons">
<button type="button" class="btn btn-outline-secondary remove-service-language" data-count="@Model.Languages[i].VersionCount" disabled="@countUnavailable">Remove</button>
@if (!Model.IsNew && Model.Languages[i].Id != Guid.Empty)
{
<a class="btn btn-outline-danger" href="/admin/services/@Model.Id/languages/@Model.Languages[i].Id/delete">Delete language and versions…</a>
}
</div>
</td>
</tr>
}
</tbody></table>
</div>
<button type="button" id="addServiceLanguage" class="btn btn-outline-primary">Add language code</button>
<div id="serviceLanguageAlert" role="alert" class="text-danger mt-3"></div>
</form>
<template id="serviceLanguageRow"><tr><td><input type="hidden" name="Languages[__index__].Id" value="00000000-0000-0000-0000-000000000000" /><label for="service-code-__index__" class="visually-hidden">Language code</label><input id="service-code-__index__" name="Languages[__index__].Code" class="form-control" maxlength="10" /></td><td>0</td><td class="service-language-actions"><div class="service-language-action-buttons"><button type="button" class="btn btn-outline-secondary remove-service-language" data-count="0">Remove</button></div></td></tr></template>
@section FormActions {
<button type="submit" form="serviceForm" class="btn btn-primary">Save service</button>
<a href="@(Model.IsNew ? "/admin/services" : $"/admin/services/{Model.Id}")" class="btn btn-secondary">Cancel</a>
}
@section Scripts { <script src="~/js/service-editor.js" asp-append-version="true"></script> }
@@ -0,0 +1,14 @@
@using HrynCo.NotificationService.DAL.Abstract.Templates
@model IReadOnlyList<NotificationServiceInfo>
@{ ViewData["Title"] = "Services"; }
<div class="page-header"><h2>Services</h2><a href="/admin/services/create" class="btn btn-primary">Create service</a></div>
@if (Model.Count == 0) { <p>No services yet. Create a service and its language codes before adding templates.</p> }
else
{
<div class="table-responsive"><table class="table table-hover"><thead><tr><th>Name</th><th>Language codes</th><th class="service-actions">Action</th></tr></thead><tbody>
@foreach (var service in Model)
{
<tr><td><a href="/admin/services/@service.Id">@service.Name</a></td><td>@string.Join(", ", service.Languages.Select(l => l.Code))</td><td class="service-actions"><a href="/admin/services/@service.Id/edit" class="btn btn-outline-primary btn-sm" aria-label="Edit service @service.Name">Edit</a></td></tr>
}
</tbody></table></div>
}
@@ -1,300 +1,102 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model EmailTemplateEditViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = Model.PageTitle;
ViewData["EditorTitle"] = Model.PageTitle;
}
<form id="templateForm" asp-action="Save" asp-controller="AdminTemplates" method="post">
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="AdminTemplates" asp-route-serviceId="@Model.ServiceId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
<input type="hidden" name="IsNew" value="@Model.IsNew" />
<input asp-for="ServiceNameFilter" 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" />
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger mb-3">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
<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.IsNew)" />
</div>
</div>
<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="4"></textarea>
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
</div>
<p class="text-muted">Every configured service language has a tab. 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> }
<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++)
{
<div>@error.ErrorMessage</div>
<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">@Model.Languages[i].LanguageCode</button>
}
</div>
}
<ul class="nav nav-tabs template-editor-tabs mb-3" id="templateEditorTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="edit-tab" data-bs-toggle="tab" data-bs-target="#edit-pane" type="button" role="tab" aria-controls="edit-pane" aria-selected="true">
Edit
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="preview-tab" data-bs-toggle="tab" data-bs-target="#preview-pane" type="button" role="tab" aria-controls="preview-pane" aria-selected="false">
Preview
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="edit-pane" role="tabpanel" aria-labelledby="edit-tab" tabindex="0">
<div class="row g-3 mb-3">
<div class="col-md-5">
<label asp-for="ServiceName" class="form-label fw-semibold">Service Name</label>
<input asp-for="ServiceName" class="form-control" readonly="@(!Model.IsNew)" />
<span asp-validation-for="ServiceName" class="text-danger small"></span>
</div>
<div class="col-md-5">
<label asp-for="Key" class="form-label fw-semibold">Key</label>
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew)" />
<span asp-validation-for="Key" class="text-danger small"></span>
</div>
<div class="col-md-2">
<label asp-for="LanguageCode" class="form-label fw-semibold">Language</label>
<input asp-for="LanguageCode" class="form-control" readonly="@(!Model.IsNew)" />
<span asp-validation-for="LanguageCode" class="text-danger small"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
<input asp-for="Subject" class="form-control" />
<span asp-validation-for="Subject" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="HtmlBody" class="form-label fw-semibold">HTML Body</label>
<textarea asp-for="HtmlBody" class="form-control font-monospace" rows="10"></textarea>
<span asp-validation-for="HtmlBody" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="TextBody" class="form-label fw-semibold">Text Body</label>
<textarea asp-for="TextBody" class="form-control font-monospace" rows="5"></textarea>
</div>
<div class="mb-3">
<label asp-for="VariablesJson" class="form-label fw-semibold">Variables (JSON)</label>
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="4"
placeholder='[{"name":"UserName","required":true}]'></textarea>
<span asp-validation-for="VariablesJson" class="text-danger small"></span>
<div class="form-text">JSON array of <code>{"name":"...", "required":true|false}</code></div>
</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].Subject" class="form-label fw-semibold">Subject</label>
<input asp-for="Languages[i].Subject" class="form-control" data-content="subject" />
</div>
<div class="mb-3">
<label asp-for="Languages[i].HtmlBody" class="form-label fw-semibold">HTML Body</label>
<textarea asp-for="Languages[i].HtmlBody" class="form-control font-monospace" rows="10" data-content="html"></textarea>
</div>
<div class="mb-3">
<label asp-for="Languages[i].TextBody" class="form-label fw-semibold">Text Body</label>
<textarea asp-for="Languages[i].TextBody" class="form-control font-monospace" rows="5" data-content="text"></textarea>
</div>
</section>
}
</div>
<div class="tab-pane fade" id="preview-pane" role="tabpanel" aria-labelledby="preview-tab" tabindex="0">
<div class="template-preview-panel mb-3">
<div class="template-preview-panel-header">
<div>
<div class="template-preview-title">Preview</div>
<div class="template-preview-subtitle">Rendered with sample values from the variable list.</div>
</div>
<span id="previewStatus" class="badge text-bg-secondary">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 class="form-text mt-2">Change these values to see the rendered output update immediately.</div>
</div>
<div class="template-preview-output">
<div class="template-preview-section-title">Rendered subject</div>
<div id="previewSubject" class="template-preview-subject"></div>
<div class="template-preview-section-title mt-3">Rendered HTML</div>
<iframe id="previewHtmlFrame" class="template-preview-frame" title="Email HTML preview"></iframe>
<div class="template-preview-section-title mt-3">Rendered text</div>
<pre id="previewText" class="template-preview-text mb-0"></pre>
</div>
</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">Sample values use the shared contract. External content is blocked in this preview.</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 subject</div>
<div id="previewSubject" class="template-preview-subject"></div>
<div class="template-preview-section-title mt-3">Rendered HTML</div>
<iframe id="previewHtmlFrame" class="template-preview-frame" sandbox="" referrerpolicy="no-referrer" title="Email HTML preview"></iframe>
<div class="template-preview-section-title mt-3">Rendered text</div>
<pre id="previewText" class="template-preview-text mb-0"></pre>
</div>
</div>
</div>
@section FormActions {
<button type="submit" form="templateForm" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i> Save
</button>
<a href="/admin/templates@(string.IsNullOrWhiteSpace(Model.ServiceNameFilter) && string.IsNullOrWhiteSpace(Model.KeyFilter) ? string.Empty : $"?serviceName={Uri.EscapeDataString(Model.ServiceNameFilter ?? string.Empty)}&key={Uri.EscapeDataString(Model.KeyFilter ?? string.Empty)}")" class="btn btn-secondary">
<i class="bi bi-x-lg me-1"></i> Cancel
</a>
}
@section Scripts {
<script>
(function () {
const subjectField = document.getElementById('Subject');
const htmlField = document.getElementById('HtmlBody');
const textField = document.getElementById('TextBody');
const variablesField = document.getElementById('VariablesJson');
const previewVariablesHost = document.getElementById('previewVariables');
const previewSubject = document.getElementById('previewSubject');
const previewText = document.getElementById('previewText');
const previewFrame = document.getElementById('previewHtmlFrame');
const previewStatus = document.getElementById('previewStatus');
const previewTab = document.getElementById('preview-tab');
if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) {
return;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function parseVariables() {
const raw = variablesField.value?.trim() || '[]';
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('Variables JSON must be an array.');
}
return parsed
.filter(item => item && typeof item.Name === 'string' && item.Name.trim().length > 0)
.map(item => ({
name: item.Name.trim(),
required: !!item.Required
}));
}
function buildSampleValue(name) {
return `Sample ${name}`;
}
function renderVariableInputs() {
let variables = [];
try {
variables = parseVariables();
previewStatus.className = 'badge text-bg-secondary';
previewStatus.textContent = 'Ready';
} catch (error) {
previewVariablesHost.innerHTML = `<div class="alert alert-warning mb-0">${escapeHtml(error.message || 'Invalid variables JSON')}</div>`;
previewStatus.className = 'badge text-bg-danger';
previewStatus.textContent = 'Invalid JSON';
updatePreview();
return;
}
if (variables.length === 0) {
previewVariablesHost.innerHTML = '<div class="text-muted small">No variables defined.</div>';
updatePreview();
return;
}
const currentValues = readVariableValues();
previewVariablesHost.innerHTML = variables.map(variable => {
const value = Object.prototype.hasOwnProperty.call(currentValues, variable.name)
? currentValues[variable.name]
: buildSampleValue(variable.name);
return `
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">${escapeHtml(variable.name)}${variable.required ? ' *' : ''}</label>
<input type="text" class="form-control form-control-sm preview-variable-input" data-variable-name="${escapeHtml(variable.name)}" value="${escapeHtml(value)}" />
</div>
`;
}).join('');
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
input.addEventListener('input', updatePreview);
});
updatePreview();
}
function readVariableValues() {
const values = {};
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
values[input.dataset.variableName] = input.value;
});
return values;
}
function interpolate(text, values) {
let result = text || '';
Object.keys(values).forEach(key => {
const token = new RegExp(`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, 'g');
result = result.replace(token, values[key] ?? '');
});
return result;
}
function updatePreview() {
let values = {};
try {
values = readVariableValues();
} catch (error) {
values = {};
}
const renderedSubject = interpolate(subjectField.value, values);
const renderedHtml = interpolate(htmlField.value, values);
const renderedText = interpolate(textField.value, values);
previewSubject.textContent = renderedSubject || '(empty subject)';
previewText.textContent = renderedText || '(empty text body)';
previewFrame.srcdoc = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
margin: 0;
padding: 24px;
background: #eef2f7;
color: #1f2937;
font-family: Arial, Helvetica, sans-serif;
}
.email-shell {
max-width: 640px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #dbe3ee;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 12px 30px rgba(15, 23, 42, .08);
}
.email-body {
padding: 28px 36px;
}
img { max-width: 100%; height: auto; }
a { color: #2563eb; }
</style>
</head>
<body>
<div class="email-shell">
<div class="email-body">
${renderedHtml || '<div style="color:#6b7280">No HTML body provided.</div>'}
</div>
</div>
</body>
</html>`;
}
variablesField.addEventListener('input', renderVariableInputs);
subjectField.addEventListener('input', updatePreview);
htmlField.addEventListener('input', updatePreview);
textField.addEventListener('input', updatePreview);
if (previewTab) {
previewTab.addEventListener('shown.bs.tab', updatePreview);
}
renderVariableInputs();
})();
</script>
}
</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/email-template-editor.js" asp-append-version="true"></script>
}
@@ -1,172 +1,77 @@
@using HrynCo.NotificationService.DAL.Abstract.Templates
@model IReadOnlyList<EmailTemplate>
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model ServiceTemplatesViewModel
@{
ViewData["Title"] = "Email Templates";
var serviceNameFilter = ViewData["ServiceNameFilter"] as string ?? string.Empty;
var keyFilter = ViewData["KeyFilter"] as string ?? string.Empty;
var filterQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
? string.Empty
: $"?serviceNameFilter={Uri.EscapeDataString(serviceNameFilter)}&keyFilter={Uri.EscapeDataString(keyFilter)}";
var listQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
? string.Empty
: $"?serviceName={Uri.EscapeDataString(serviceNameFilter)}&key={Uri.EscapeDataString(keyFilter)}";
ViewData["Title"] = Model.Service.Name;
var serviceId = Model.Service.Id;
var keyFilter = Model.KeyFilter;
}
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item active" aria-current="page">@Model.Service.Name</li></ol></nav>
<div class="page-header">
<h2><i class="bi bi-envelope-paper"></i> Email Templates</h2>
<a href="/admin/templates/create@(filterQuery)" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i> Create New Template
</a>
<h2>@Model.Service.Name</h2>
<a href="/admin/services/@serviceId/edit" class="btn btn-outline-primary">Edit Service</a>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body">
<form id="templateFiltersForm" method="get" action="/admin/templates" class="row g-2 align-items-end">
<div class="col-12 col-md-5">
<label class="form-label fw-semibold" for="serviceName">Service Name</label>
<input id="serviceName"
name="serviceName"
value="@serviceNameFilter"
class="form-control"
placeholder="Filter by service name" />
</div>
<div class="col-12 col-md-5">
<label class="form-label fw-semibold" for="key">Key</label>
<input id="key"
name="key"
value="@keyFilter"
class="form-control"
placeholder="Filter by key" />
</div>
<div class="col-12 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100">Filter</button>
<a id="clearTemplateFilters" href="/admin/templates" class="btn btn-outline-secondary w-100">Clear</a>
<div class="service-template-catalog mb-3" aria-label="Configured service languages">
<span class="fw-semibold">Configured languages:</span>
@if (Model.Service.Languages.Count == 0)
{
<span>No languages configured for this service.</span>
}
else
{
@foreach (var language in Model.Service.Languages)
{
<span class="service-template-language-code">@language.Code</span>
}
}
</div>
<div class="page-header">
<h3 class="h4">Email 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>
<div class="table-responsive"><table class="table table-hover service-template-grid"><thead><tr>
<th scope="col">
<form method="get" action="/admin/services/@serviceId" class="template-key-filter">
<label for="key" class="form-label fw-semibold">Template Key</label>
<div class="template-key-filter-controls">
<input id="key" name="key" value="@keyFilter" class="form-control form-control-sm" placeholder="Filter by key" />
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/admin/services/@serviceId" class="btn btn-outline-secondary btn-sm">Clear</a>
</div>
</form>
</div>
</div>
@if (!ViewData.ModelState.IsValid)
</th>
<th scope="col">Languages</th>
<th scope="col" class="template-actions">Action</th>
</tr></thead><tbody>
@if (Model.Definitions.Count == 0)
{
<div class="alert alert-danger">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<div>@error.ErrorMessage</div>
}
</div>
<tr><td colspan="3">No email templates found for this service.</td></tr>
}
<script>
(() => {
const storageKey = 'hrynco.notificationService.adminTemplates.filters';
const form = document.getElementById('templateFiltersForm');
const serviceNameInput = document.getElementById('serviceName');
const keyInput = document.getElementById('key');
const clearLink = document.getElementById('clearTemplateFilters');
if (!form || !serviceNameInput || !keyInput || !clearLink) {
return;
}
const saveState = () => {
const state = {
serviceName: serviceNameInput.value ?? '',
key: keyInput.value ?? ''
};
localStorage.setItem(storageKey, JSON.stringify(state));
};
const restoreState = () => {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return false;
@foreach (var template in Model.Definitions)
{
var editUrl = Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = template.Id, keyFilter });
var presentCodes = template.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
<tr>
<td>@template.Key</td>
<td>
@if (Model.Service.Languages.Count == 0)
{
<span>No configured languages</span>
}
try {
const state = JSON.parse(raw);
const serviceName = typeof state.serviceName === 'string' ? state.serviceName : '';
const key = typeof state.key === 'string' ? state.key : '';
serviceNameInput.value = serviceName;
keyInput.value = key;
return serviceName.length > 0 || key.length > 0;
} catch {
localStorage.removeItem(storageKey);
return false;
else
{
<ul class="template-language-statuses">
@foreach (var language in Model.Service.Languages)
{
var present = presentCodes.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>
}
};
form.addEventListener('submit', saveState);
clearLink.addEventListener('click', () => localStorage.removeItem(storageKey));
const hasQueryParams = new URLSearchParams(window.location.search).toString().length > 0;
if (!hasQueryParams && restoreState()) {
form.requestSubmit();
return;
}
saveState();
})();
</script>
@if (Model is null || Model.Count == 0)
{
<div class="card shadow-sm table-card">
<div class="empty-state">
<i class="bi bi-envelope-paper"></i>
<p class="mt-2 mb-0">No email templates found.</p>
<a href="/admin/templates/create" class="btn btn-primary btn-sm mt-3">
<i class="bi bi-plus-lg me-1"></i> Create First Template
</a>
</div>
</div>
}
else
{
<div class="card shadow-sm table-card">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-dark">
<tr>
<th>Service Name</th>
<th>Key</th>
<th>Language</th>
<th>Subject</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var t in Model)
{
<tr>
<td>@t.ServiceName</td>
<td>@t.Key</td>
<td>@t.LanguageCode</td>
<td>@t.Subject</td>
<td class="text-end">
<a href="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode@(filterQuery)"
class="btn btn-sm btn-outline-primary me-1">
<i class="bi bi-pencil"></i> Edit
</a>
<form method="post"
action="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode/delete"
class="d-inline">
<input type="hidden" name="serviceNameFilter" value="@serviceNameFilter" />
<input type="hidden" name="keyFilter" value="@keyFilter" />
@Html.AntiForgeryToken()
<button type="submit"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Delete this template?')">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</td>
<td class="template-actions"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @template.Key">Edit</a></td>
</tr>
}
</tbody></table></div>
@@ -2,9 +2,10 @@
Layout = "~/Views/Shared/_Layout.cshtml";
}
<div class="editor-wrapper">
@RenderSection("Breadcrumbs", required: false)
<div class="card editor-card">
<div class="card-header editor-card-header-bar">
<i class="bi bi-pencil-square me-2"></i>@ViewData["EditorTitle"]
<h1 class="editor-card-title"><i class="bi bi-pencil-square" aria-hidden="true"></i><span>@ViewData["EditorTitle"]</span></h1>
</div>
<div class="card-body editor-card-body">
@RenderBody()
@@ -1,6 +1,7 @@
@{
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? "";
bool isTemplates = currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
bool isServices = currentController.Equals("AdminServices", StringComparison.OrdinalIgnoreCase)
|| currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
bool isChannels = currentController.Equals("AdminChannels", StringComparison.OrdinalIgnoreCase);
}
<!DOCTYPE html>
@@ -26,11 +27,7 @@
<nav class="col-md-2 d-none d-md-block sidebar py-3">
<div class="sidebar-section-label">Management</div>
<ul class="nav flex-column px-2">
<li class="nav-item">
<a class="nav-link @(isTemplates ? "active" : "")" href="/admin/templates">
<i class="bi bi-envelope-paper"></i> Email Templates
</a>
</li>
<li class="nav-item"><a class="nav-link @(isServices ? "active" : "")" href="/admin/services"><i class="bi bi-collection"></i> Services</a></li>
<li class="nav-item">
<a class="nav-link @(isChannels ? "active" : "")" href="/admin/channels">
<i class="bi bi-broadcast"></i> Email Channels
@@ -109,6 +109,120 @@ body {
.table tbody tr td { vertical-align: middle; }
.table thead th { font-size: .75rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
/* ── List actions ─────────────────────────────────────── */
.table .template-actions,
.table .service-actions {
width: 1%;
white-space: nowrap;
text-align: right;
}
/* ── Service template catalog and filters ─────────────── */
.service-template-grid > thead > tr > th {
--bs-table-bg: #dce7f6;
--bs-table-color: #243a55;
border-bottom: 2px solid #aec0db;
}
.service-template-grid .template-key-filter .btn-outline-secondary {
--bs-btn-bg: #fff;
}
.service-template-catalog,
.template-language-statuses {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .375rem;
}
.service-template-language-code {
padding: .15rem .4rem;
border: 1px solid #dce3eb;
border-radius: .25rem;
background: #fff;
overflow-wrap: anywhere;
}
.template-key-filter {
min-width: 9rem;
max-width: 32rem;
font-weight: 400;
letter-spacing: normal;
text-transform: none;
}
.template-key-filter-controls {
display: flex;
flex-wrap: wrap;
gap: .375rem;
}
.template-key-filter-controls .form-control {
flex: 1 1 9rem;
min-width: 0;
}
.template-language-statuses {
margin: 0;
padding: 0;
list-style: none;
}
.template-language-status {
display: inline-flex;
flex-wrap: wrap;
gap: .25rem;
padding: .2rem .4rem;
border: 1px solid;
border-radius: .25rem;
font-size: .75rem;
overflow-wrap: anywhere;
}
.template-language-present {
color: #146c43;
background: #e8f5ec;
border-color: #b7dcc0;
}
.template-language-missing {
color: #825800;
background: #fff4df;
border-color: #f3d19e;
}
/* ── Service language actions ─────────────────────────── */
.service-language-table .service-language-actions {
width: 1%;
text-align: right;
}
.service-language-table .service-language-action-buttons {
display: flex;
justify-content: flex-end;
gap: .5rem;
}
.service-language-table .service-language-action-buttons > .btn {
white-space: nowrap;
}
.service-language-table .form-control {
min-width: 4rem;
}
@media (max-width: 575.98px) {
.service-language-table .service-language-action-buttons {
flex-direction: column;
align-items: flex-end;
}
.service-language-table .service-language-action-buttons > .btn {
white-space: normal;
}
}
/* ── Empty state ──────────────────────────────────────── */
.empty-state {
text-align: center;
@@ -134,9 +248,7 @@ body {
.editor-card-header-bar {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
color: #fff;
font-size: .9rem;
font-weight: 600;
padding: .75rem 1.25rem;
padding: 1rem 1.25rem;
border-bottom: none;
display: flex;
align-items: center;
@@ -145,6 +257,20 @@ body {
.editor-card-body { padding: 1.5rem 1.5rem 1rem; }
.editor-card-header-bar .editor-card-title {
display: flex;
align-items: center;
gap: .625rem;
min-width: 0;
margin: 0;
font-size: 1.375rem;
font-weight: 600;
line-height: 1.3;
}
.editor-card-title > .bi { flex-shrink: 0; }
.editor-card-title > span { min-width: 0; overflow-wrap: anywhere; }
.editor-card-footer {
background-color: #f8f9fa;
border-top: 1px solid #e9ecef;
@@ -175,6 +301,82 @@ body {
min-width: 0;
}
/* ── Template service context ─────────────────────────── */
.template-editor-breadcrumbs .breadcrumb-item {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.template-service-context {
margin: 0 0 .5rem;
}
.template-service-context > dt {
margin-bottom: .25rem;
color: #6b7280;
font-size: .75rem;
font-weight: 600;
}
.template-service-context > dd {
margin: 0;
color: #1f2937;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
}
.template-service-edit-link:focus-visible {
outline: 2px solid #0d6efd;
outline-offset: 2px;
}
/* ── Template language sheet ──────────────────────────── */
.template-language-sheet {
min-width: 0;
border: 1px solid #dce3eb;
border-radius: .5rem;
background: #fff;
}
.template-language-sheet > .nav-tabs {
flex-wrap: nowrap;
gap: .25rem;
margin: 0;
padding: .5rem .5rem 0;
border-bottom: 0;
border-radius: .5rem .5rem 0 0;
background: #f3f6fb;
overflow-x: auto;
scrollbar-width: thin;
}
.template-language-sheet > .nav-tabs .nav-link {
flex: 0 0 auto;
margin-bottom: 0;
white-space: nowrap;
}
.template-language-sheet > .nav-tabs .nav-link.active {
border-color: #dce3eb #dce3eb #fff;
background: #fff;
}
.template-language-sheet > .nav-tabs .nav-link:focus-visible {
outline: 2px solid #0d6efd;
outline-offset: -3px;
}
.template-language-sheet > #languagePanes {
padding: 1rem;
}
.template-language-sheet .language-pane > :last-child {
margin-bottom: 0 !important;
}
/* ── Email template preview ───────────────────────────── */
.template-preview-panel {
border: 1px solid #dce3eb;
@@ -0,0 +1,130 @@
(() => {
const form = document.getElementById('templateForm');
const tabs = document.getElementById('languageTabs');
const panes = document.getElementById('languagePanes');
const activeField = document.getElementById('ActiveLanguageIndex');
const variablesField = document.getElementById('VariablesJson');
const samples = document.getElementById('previewVariables');
let dirty = false;
const sampleValues = new Map();
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;')
.replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}
function activePane() {
return panes.querySelector('.language-pane:not([hidden])');
}
function selectPane(pane, focus = false) {
Array.from(panes.children).forEach(item => { item.hidden = item !== pane; });
Array.from(tabs.children).forEach(tab => {
const selected = tab.dataset.pane === pane.id;
tab.classList.toggle('active', selected);
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
if (selected && focus) tab.focus();
});
activeField.value = Array.from(panes.children).indexOf(pane);
updatePreview();
}
function readVariables() {
const variables = JSON.parse(variablesField.value);
if (!Array.isArray(variables)) throw new Error('Variables must be an array.');
const names = new Set();
variables.forEach(variable => {
if (!variable || typeof variable.name !== 'string' || !variable.name.trim() || typeof variable.required !== 'boolean')
throw new Error('Every variable needs a name and a boolean required property.');
if (Object.keys(variable).some(key => !['name', 'required'].includes(key)))
throw new Error('Use only name and required properties for each variable.');
if (names.has(variable.name)) throw new Error('Variable names must be unique.');
names.add(variable.name);
});
return variables;
}
function renderSamples() {
const status = document.getElementById('previewStatus');
samples.replaceChildren();
try {
const variables = readVariables();
variables.forEach((variable, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'mb-2';
const label = document.createElement('label');
label.htmlFor = `sample-${index}`;
label.className = 'form-label';
label.textContent = variable.name + (variable.required ? ' *' : '');
const input = document.createElement('input');
input.id = label.htmlFor;
input.dataset.variableName = variable.name;
input.className = 'form-control form-control-sm';
input.value = sampleValues.get(variable.name) ?? `Sample ${variable.name}`;
sampleValues.set(variable.name, input.value);
input.addEventListener('input', () => { sampleValues.set(variable.name, input.value); updatePreview(); });
wrapper.append(label, input);
samples.append(wrapper);
});
status.textContent = 'Ready';
status.className = 'badge text-bg-secondary';
} catch (error) {
status.textContent = error.message;
status.className = 'badge text-bg-danger';
}
updatePreview();
}
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 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;
}
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>`;
}
tabs.addEventListener('click', event => {
const tab = event.target.closest('[role="tab"]');
if (tab) selectPane(document.getElementById(tab.dataset.pane));
});
tabs.addEventListener('keydown', event => {
const offsets = { ArrowLeft: -1, ArrowRight: 1, Home: -tabs.children.length, End: tabs.children.length };
if (!(event.key in offsets)) return;
event.preventDefault();
const buttons = Array.from(tabs.children);
if (!buttons.length) return;
let index = buttons.indexOf(event.target) + offsets[event.key];
if (event.key === 'Home') index = 0;
if (event.key === 'End') index = buttons.length - 1;
const tab = buttons[(index + buttons.length) % buttons.length];
selectPane(document.getElementById(tab.dataset.pane), true);
});
panes.addEventListener('input', updatePreview);
variablesField.addEventListener('input', renderSamples);
form.addEventListener('input', () => { dirty = true; });
form.addEventListener('submit', () => { dirty = false; });
window.addEventListener('beforeunload', event => {
if (dirty) { event.preventDefault(); event.returnValue = ''; }
});
renderSamples();
})();
@@ -0,0 +1,36 @@
(() => {
const form = document.getElementById('serviceForm');
const rows = document.getElementById('serviceLanguages');
const alert = document.getElementById('serviceLanguageAlert');
let sequence = rows.children.length;
let dirty = false;
function reindex() {
Array.from(rows.children).forEach((row, index) => row.querySelectorAll('[name]').forEach(input => {
input.name = input.name.replace(/Languages\[\d+\]/, `Languages[${index}]`);
}));
}
document.getElementById('addServiceLanguage').addEventListener('click', () => {
const fragment = document.createElement('template');
fragment.innerHTML = document.getElementById('serviceLanguageRow').innerHTML.replaceAll('__index__', String(sequence++));
rows.append(fragment.content);
reindex();
rows.lastElementChild.querySelector('input:not([type="hidden"])').focus();
dirty = true;
});
rows.addEventListener('click', event => {
const button = event.target.closest('.remove-service-language');
if (!button) return;
if (Number(button.dataset.count) > 0) {
alert.textContent = `Cannot remove this language: ${button.dataset.count} version(s) use it. Save other changes, then use the separate delete language and versions confirmation.`;
return;
}
button.closest('tr').remove();
reindex();
dirty = true;
});
form.addEventListener('input', () => { dirty = true; });
form.addEventListener('submit', () => { dirty = false; });
window.addEventListener('beforeunload', event => {
if (dirty) { event.preventDefault(); event.returnValue = ''; }
});
})();