feat(notification): add admin rest management and deletion

Add revision-safe service and template APIs, channel mapping, and validated admin controls.

Ref: IT-1601
This commit is contained in:
2026-10-17 22:51:01 +03:00
parent 81654c0d33
commit 8e81572386
80 changed files with 2253 additions and 54 deletions
@@ -6,5 +6,9 @@ public interface IEmailTemplateDefinitionRepository
{ {
Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default); Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default);
Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default); Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default);
Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default);
Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default);
Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default);
Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default);
Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default); Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default);
} }
@@ -6,6 +6,8 @@ public interface INotificationServiceRepository
{ {
Task<IReadOnlyList<NotificationServiceInfo>> GetAllAsync(CancellationToken ct = default); Task<IReadOnlyList<NotificationServiceInfo>> GetAllAsync(CancellationToken ct = default);
Task<NotificationServiceInfo?> GetAsync(Guid id, CancellationToken ct = default); Task<NotificationServiceInfo?> GetAsync(Guid id, CancellationToken ct = default);
Task<NotificationServiceInfo?> GetByNameAsync(string name, CancellationToken ct = default);
Task<bool> DeleteAsync(Guid id, Guid revision, CancellationToken ct = default);
Task SaveAsync(NotificationServiceInfo service, bool isNew, CancellationToken ct = default); Task SaveAsync(NotificationServiceInfo service, bool isNew, CancellationToken ct = default);
Task RemoveLanguageAsync(Guid serviceId, Guid languageId, int expectedVersionCount, Guid expectedRevision, CancellationToken ct = default); Task RemoveLanguageAsync(Guid serviceId, Guid languageId, int expectedVersionCount, Guid expectedRevision, CancellationToken ct = default);
} }
@@ -0,0 +1,11 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class EmailTemplateDefinitionPatch
{
public Guid ServiceId { get; init; }
public required string Key { get; init; }
public Guid? Revision { get; init; }
public required IReadOnlyList<EmailTemplateVariable> Variables { get; init; }
public required IReadOnlyList<EmailTemplate> Translations { get; init; }
public IReadOnlyList<string> RemoveLanguageCodes { get; init; } = [];
}
@@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default) public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default)
{ {
EmailChannelEntity? entity = await EfRepository.GetByIdAsync(id); EmailChannelEntity? entity = await EfRepository.Get().AsNoTracking().SingleOrDefaultAsync(c => c.Id == id, ct);
return entity is null ? null : MapToDomain(entity); return entity is null ? null : MapToDomain(entity);
} }
@@ -43,6 +43,93 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
return entity is null ? null : MapDefinition(entity); return entity is null ? null : MapDefinition(entity);
} }
public async Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default)
{
var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
return entity is null ? null : MapDefinition(entity);
}
public async Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default)
{
EmailTemplateDefinitionEntity? saved = null;
await mutations.ExecuteAsync(async () =>
{
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == patch.ServiceId, ct);
if (service is null)
return;
ValidatePatch(patch, service);
var entity = await LoadForUpsertAsync(patch, service, ct);
entity.Variables = MapVariables(patch.Variables);
var removed = patch.RemoveLanguageCodes.ToHashSet(StringComparer.Ordinal);
db.Templates.RemoveRange(entity.Languages.Where(t => removed.Contains(t.ServiceLanguage.Code)));
// Omitted translations are retained. Only explicitly supplied content is changed.
UpsertLanguages(entity, patch.Translations);
Touch(entity);
saved = entity;
}, ct);
return saved is null ? null : MapDefinition(saved);
}
private async Task<EmailTemplateDefinitionEntity> LoadForUpsertAsync(EmailTemplateDefinitionPatch patch, ServiceEntity service, CancellationToken ct)
{
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == patch.ServiceId && d.Key == patch.Key, ct);
if (patch.Revision is null)
{
if (entity is not null)
throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating.");
entity = new EmailTemplateDefinitionEntity { Service = service, ServiceId = service.Id, Key = patch.Key, Created = DateTimeOffset.UtcNow };
db.TemplateDefinitions.Add(entity);
return entity;
}
if (entity is null || entity.Revision != patch.Revision)
throw new EmailTemplateWriteException("The definition is missing or its revision changed. Read it again before updating.");
return entity;
}
private static void ValidatePatch(EmailTemplateDefinitionPatch patch, ServiceEntity service)
{
EmailTemplateValidation.ValidateDefinition(new EmailTemplateDefinition
{
ServiceId = service.Id, ServiceName = service.Name, Key = patch.Key,
Variables = patch.Variables, Languages = patch.Translations
});
if (patch.Revision == Guid.Empty)
throw new EmailTemplateWriteException("Revision must be null for creation or a nonempty revision from the last read for update.", false);
var removed = patch.RemoveLanguageCodes;
if (removed.Any(string.IsNullOrWhiteSpace) || removed.Distinct(StringComparer.Ordinal).Count() != removed.Count)
throw new EmailTemplateWriteException("removeLanguageCodes must contain unique nonblank language codes.", false);
var supplied = patch.Translations.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
if (removed.Any(supplied.Contains))
throw new EmailTemplateWriteException("A language cannot be supplied and removed in the same request.", false);
var supported = service.Languages.Select(l => l.Code).ToHashSet(StringComparer.Ordinal);
if (supplied.Concat(removed).Any(code => !supported.Contains(code)))
throw new EmailTemplateWriteException("Every supplied or removed language must exactly match a code in this service's catalog.", false);
}
public async Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default)
{
var entities = await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct);
return entities.Select(MapDefinition).ToList();
}
public async Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default)
{
var removed = false;
await mutations.ExecuteAsync(async () =>
{
if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || revision == Guid.Empty)
throw new EmailTemplateWriteException("An exact key (at most 100 characters) and a nonempty revision are required.", false);
var definition = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
if (definition is null)
return;
if (definition.Revision != revision)
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
db.TemplateDefinitions.Remove(definition);
removed = true;
}, ct);
return removed;
}
public Task AddAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () => public Task AddAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{ {
ValidateLegacy(template); ValidateLegacy(template);
@@ -113,6 +200,11 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
{ {
var codes = languages.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal); var codes = languages.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
db.Templates.RemoveRange(definition.Languages.Where(t => !codes.Contains(t.ServiceLanguage.Code))); db.Templates.RemoveRange(definition.Languages.Where(t => !codes.Contains(t.ServiceLanguage.Code)));
UpsertLanguages(definition, languages);
}
private void UpsertLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList<EmailTemplate> languages)
{
foreach (var model in languages) foreach (var model in languages)
{ {
var code = model.LanguageCode; var code = model.LanguageCode;
@@ -20,6 +20,33 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return service is null ? null : Map(service, await VersionCountsAsync(ct)); return service is null ? null : Map(service, await VersionCountsAsync(ct));
} }
public async Task<NotificationServiceInfo?> GetByNameAsync(string name, CancellationToken ct = default)
{
var service = await db.Services.AsNoTracking().Include(s => s.Languages).SingleOrDefaultAsync(s => s.Name == name, ct);
return service is null ? null : Map(service, await VersionCountsAsync(ct));
}
public async Task<bool> DeleteAsync(Guid id, Guid revision, CancellationToken ct = default)
{
var removed = false;
await mutations.ExecuteAsync(async () =>
{
if (revision == Guid.Empty)
throw new EmailTemplateWriteException("A nonempty service revision is required.", false);
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == id, ct);
if (service is null)
return;
if (service.Revision != revision)
throw new EmailTemplateWriteException("The service revision changed. Read it again before deleting.");
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct))
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
db.ServiceLanguages.RemoveRange(service.Languages);
db.Services.Remove(service);
removed = true;
}, ct);
return removed;
}
public Task SaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () => public Task SaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{ {
Validate(model); Validate(model);
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteEmailTemplateDefinitionCommand(Guid ServiceId, string Key, Guid Revision) : IRequest<ServiceResult<Unit>>;
@@ -0,0 +1,25 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeleteEmailTemplateDefinitionHandler(
IContextualSerilogLogger<DeleteEmailTemplateDefinitionCommand> logger, IEmailTemplateDefinitionRepository repository)
: RequestHandler<DeleteEmailTemplateDefinitionCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteEmailTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteDefinitionAsync(request.ServiceId, request.Key, request.Revision, cancellationToken);
return removed ? Success(Unit.Value) : Failure<Unit>("Template definition not found.", ServiceErrorCode.NotFound);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteNotificationServiceCommand(Guid ServiceId, Guid Revision) : IRequest<ServiceResult<Unit>>;
@@ -0,0 +1,25 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeleteNotificationServiceHandler(
IContextualSerilogLogger<DeleteNotificationServiceCommand> logger, INotificationServiceRepository repository)
: RequestHandler<DeleteNotificationServiceCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteNotificationServiceCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteAsync(request.ServiceId, request.Revision, cancellationToken);
return removed ? Success(Unit.Value) : Failure<Unit>("Service not found.", ServiceErrorCode.NotFound);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteServiceLanguageVersionsCommand(Guid ServiceId, Guid LanguageId, Guid Revision, int ExpectedVersionCount, bool ConfirmDelete) : IRequest<ServiceResult<Unit>>;
@@ -0,0 +1,30 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeleteServiceLanguageVersionsHandler(
IContextualSerilogLogger<DeleteServiceLanguageVersionsCommand> logger, INotificationServiceRepository services)
: RequestHandler<DeleteServiceLanguageVersionsCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteServiceLanguageVersionsCommand request, CancellationToken cancellationToken)
{
if (!request.ConfirmDelete || request.ExpectedVersionCount < 0 || request.Revision == Guid.Empty)
return Failure<Unit>("ConfirmDelete must be true; a nonnegative expectedVersionCount and nonempty service revision are required.", ServiceErrorCode.InvalidRequest);
var service = await services.GetAsync(request.ServiceId, cancellationToken);
if (service?.Languages.Any(l => l.Id == request.LanguageId) != true)
return Failure<Unit>("Service language not found.", ServiceErrorCode.NotFound);
try
{
await services.RemoveLanguageAsync(request.ServiceId, request.LanguageId, request.ExpectedVersionCount, request.Revision, cancellationToken);
return Success(Unit.Value);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteUnusedServiceLanguageCommand(Guid ServiceId, Guid LanguageId, Guid Revision) : IRequest<ServiceResult<Unit>>;
@@ -0,0 +1,34 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeleteUnusedServiceLanguageHandler(
IContextualSerilogLogger<DeleteUnusedServiceLanguageCommand> logger, INotificationServiceRepository services)
: RequestHandler<DeleteUnusedServiceLanguageCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteUnusedServiceLanguageCommand request, CancellationToken cancellationToken)
{
if (request.Revision == Guid.Empty)
return Failure<Unit>("A nonempty service revision is required.", ServiceErrorCode.InvalidRequest);
var service = await services.GetAsync(request.ServiceId, cancellationToken);
var language = service?.Languages.SingleOrDefault(l => l.Id == request.LanguageId);
if (language is null)
return Failure<Unit>("Service language not found.", ServiceErrorCode.NotFound);
if (language.VersionCount > 0)
return Failure<Unit>("This language has template versions. Use delete-with-versions with explicit confirmation and the current count.", ServiceErrorCode.Conflict);
try
{
// The repository repeats the zero-count and revision checks under its mutation lock.
await services.RemoveLanguageAsync(request.ServiceId, request.LanguageId, 0, request.Revision, cancellationToken);
return Success(Unit.Value);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,5 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record EmailTemplateDefinitionDto(Guid Id, Guid ServiceId, string ServiceName, string Key, Guid Revision,
IReadOnlyList<TemplateVariableDto> Variables, IReadOnlyList<TemplateTranslationDto> Translations,
DateTimeOffset Created, DateTimeOffset? Updated);
@@ -0,0 +1,17 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetAllNotificationServicesHandler(
IContextualSerilogLogger<GetAllNotificationServicesQuery> logger, INotificationServiceRepository services)
: RequestHandler<GetAllNotificationServicesQuery, ServiceResult<IReadOnlyList<NotificationServiceDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<NotificationServiceDto>>> DoOnHandle(GetAllNotificationServicesQuery request, CancellationToken cancellationToken)
{
var all = await services.GetAllAsync(cancellationToken);
return Success<IReadOnlyList<NotificationServiceDto>>(all.Select(NotificationServiceMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetAllNotificationServicesQuery() : IRequest<ServiceResult<IReadOnlyList<NotificationServiceDto>>>;
@@ -0,0 +1,19 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetEmailTemplateDefinitionHandler(
IContextualSerilogLogger<GetEmailTemplateDefinitionQuery> logger, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<GetEmailTemplateDefinitionQuery, ServiceResult<EmailTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<EmailTemplateDefinitionDto>> DoOnHandle(GetEmailTemplateDefinitionQuery request, CancellationToken cancellationToken)
{
var definition = await definitions.GetDefinitionAsync(request.ServiceId, request.Key, cancellationToken);
return definition is null
? Failure<EmailTemplateDefinitionDto>("Template definition not found.", ServiceErrorCode.NotFound)
: Success(TemplateDefinitionMapping.Map(definition));
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetEmailTemplateDefinitionQuery(Guid ServiceId, string Key) : IRequest<ServiceResult<EmailTemplateDefinitionDto>>;
@@ -0,0 +1,23 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetNotificationServiceHandler(
IContextualSerilogLogger<GetNotificationServiceQuery> logger, INotificationServiceRepository services)
: RequestHandler<GetNotificationServiceQuery, ServiceResult<NotificationServiceDto>>(logger)
{
protected override async Task<ServiceResult<NotificationServiceDto>> DoOnHandle(GetNotificationServiceQuery request, CancellationToken cancellationToken)
{
if (request.ServiceId is null && string.IsNullOrWhiteSpace(request.Name))
return Failure<NotificationServiceDto>("An exact service name is required.", ServiceErrorCode.InvalidRequest);
var service = request.ServiceId is Guid id
? await services.GetAsync(id, cancellationToken)
: await services.GetByNameAsync(request.Name!, cancellationToken);
return service is null
? Failure<NotificationServiceDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(NotificationServiceMapping.Map(service));
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetNotificationServiceQuery(Guid? ServiceId, string? Name) : IRequest<ServiceResult<NotificationServiceDto>>;
@@ -0,0 +1,20 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetServiceTemplateDefinitionsHandler(
IContextualSerilogLogger<GetServiceTemplateDefinitionsQuery> logger,
INotificationServiceRepository services, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<GetServiceTemplateDefinitionsQuery, ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>> DoOnHandle(GetServiceTemplateDefinitionsQuery request, CancellationToken cancellationToken)
{
if (await services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<IReadOnlyList<EmailTemplateDefinitionDto>>("Service not found.", ServiceErrorCode.NotFound);
var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken);
return Success<IReadOnlyList<EmailTemplateDefinitionDto>>(all.Select(TemplateDefinitionMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetServiceTemplateDefinitionsQuery(Guid ServiceId) : IRequest<ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>>;
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record NotificationServiceDto(Guid Id, string Name, Guid Revision, IReadOnlyList<string> SupportedLanguageCodes, IReadOnlyList<ServiceLanguageDto> Languages);
@@ -0,0 +1,10 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal static class NotificationServiceMapping
{
public static NotificationServiceDto Map(NotificationServiceInfo service) =>
new(service.Id, service.Name, service.Revision, service.Languages.Select(l => l.Code).ToList(),
service.Languages.Select(l => new ServiceLanguageDto(l.Id, l.Code, l.VersionCount)).ToList());
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record SaveNotificationServiceCommand(NotificationServiceInfo Service, bool IsNew) : IRequest<ServiceResult<NotificationServiceDto>>;
@@ -0,0 +1,30 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class SaveNotificationServiceHandler(
IContextualSerilogLogger<SaveNotificationServiceCommand> logger, INotificationServiceRepository services)
: RequestHandler<SaveNotificationServiceCommand, ServiceResult<NotificationServiceDto>>(logger)
{
protected override async Task<ServiceResult<NotificationServiceDto>> DoOnHandle(SaveNotificationServiceCommand request, CancellationToken cancellationToken)
{
if (!request.IsNew && request.Service.Revision == Guid.Empty)
return Failure<NotificationServiceDto>("A nonempty service revision is required for update.", ServiceErrorCode.InvalidRequest);
if (!request.IsNew && await services.GetAsync(request.Service.Id, cancellationToken) is null)
return Failure<NotificationServiceDto>("Service not found.", ServiceErrorCode.NotFound);
try
{
await services.SaveAsync(request.Service, request.IsNew, cancellationToken);
var saved = await services.GetAsync(request.Service.Id, cancellationToken);
return Success(NotificationServiceMapping.Map(saved!));
}
catch (EmailTemplateWriteException error)
{
return Failure<NotificationServiceDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record ServiceLanguageDto(Guid Id, string Code, int VersionCount);
@@ -0,0 +1,12 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal static class TemplateDefinitionMapping
{
public static EmailTemplateDefinitionDto Map(EmailTemplateDefinition definition) => new(
definition.Id, definition.ServiceId, definition.ServiceName, definition.Key, definition.Revision,
definition.Variables.Select(v => new TemplateVariableDto(v.Name, v.Required)).ToList(),
definition.Languages.Select(t => new TemplateTranslationDto(t.Id, t.LanguageCode, t.Subject, t.HtmlBody, t.TextBody, t.Created, t.Updated)).ToList(),
definition.Created, definition.Updated);
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record TemplateTranslationDto(Guid Id, string LanguageCode, string Subject, string HtmlBody, string TextBody, DateTimeOffset Created, DateTimeOffset? Updated);
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record TemplateVariableDto(string Name, bool Required);
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record UpsertEmailTemplateDefinitionCommand(EmailTemplateDefinitionPatch Patch) : IRequest<ServiceResult<EmailTemplateDefinitionDto>>;
@@ -0,0 +1,27 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class UpsertEmailTemplateDefinitionHandler(
IContextualSerilogLogger<UpsertEmailTemplateDefinitionCommand> logger, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<UpsertEmailTemplateDefinitionCommand, ServiceResult<EmailTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<EmailTemplateDefinitionDto>> DoOnHandle(UpsertEmailTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken);
return definition is null
? Failure<EmailTemplateDefinitionDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(TemplateDefinitionMapping.Map(definition));
}
catch (EmailTemplateWriteException error)
{
return Failure<EmailTemplateDefinitionDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,137 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task DefinitionDeleteRequiresAntiforgeryAndConfirmation_AndPreservesFilter()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var definition = await context.ReadAsync();
var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete";
var html = await client.GetStringAsync(url + "?keyFilter=Welcome");
Assert.Contains("2 translation(s)", html);
Assert.Contains("Languages: en, uk", html);
var fields = TemplateEditorTestFields.Read(html);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(url, new FormUrlEncodedContent(new Dictionary<string, string> { ["ConfirmDelete"] = "true" }))).StatusCode);
var unconfirmed = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("Confirm deletion", unconfirmed);
AssertConfirmationReset(unconfirmed);
fields = TemplateEditorTestFields.Read(unconfirmed);
fields["ConfirmDelete"] = "true";
fields["Key"] = "Different";
var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode);
Assert.Equal($"/admin/services/{context.ServiceId}?key=Welcome", deleted.Headers.Location!.OriginalString);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(url)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/admin/services/{context.ServiceId}")).StatusCode);
}
[Fact]
public async Task StaleDefinitionConfirmationRefreshesCountRevisionAndUncheckedConfirmation()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var definition = await context.ReadAsync();
var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete";
var fields = TemplateEditorTestFields.Read(await context.Client.GetStringAsync(url));
var oldRevision = fields["Revision"];
await context.UpsertAsync(DefinitionApiTestContext.Body(definition.GetProperty("revision").GetGuid(), "en"));
fields["ConfirmDelete"] = "true";
var html = await (await context.Client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("revision changed", html);
Assert.Contains("1 translation(s)", html);
Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(html)["Revision"]);
AssertConfirmationReset(html);
Assert.Single((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
var wrongScope = $"/admin/services/{Guid.NewGuid()}/templates/{definition.GetProperty("id").GetGuid()}/delete";
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(wrongScope)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsync(wrongScope, new FormUrlEncodedContent(fields))).StatusCode);
}
[Fact]
public async Task ServiceDeleteIsBlockedByEmptyDefinition_AndRequiresFreshConfirmationAfterRename()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var url = $"/admin/services/{context.ServiceId}/delete";
var blocked = await client.GetStringAsync(url);
Assert.Contains("1 template definition(s)", blocked);
Assert.Contains("Email channels are retained", blocked);
var fields = TemplateEditorTestFields.Read(blocked);
fields["ConfirmDelete"] = "true";
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
var definition = await context.ReadAsync();
await client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid());
fields = TemplateEditorTestFields.Read(await client.GetStringAsync(url));
var oldRevision = fields["Revision"];
using (var scope = context.App.Services.CreateScope())
{
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Renamed Before Delete";
await services.SaveAsync(service, false);
}
fields["ConfirmDelete"] = "true";
var stale = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("revision changed", stale);
Assert.Contains("Renamed Before Delete", stale);
Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(stale)["Revision"]);
AssertConfirmationReset(stale);
fields = TemplateEditorTestFields.Read(stale);
Assert.Contains("Confirm deletion", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
fields["ConfirmDelete"] = "true";
var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode);
Assert.Equal("/admin/services", deleted.Headers.Location!.OriginalString);
}
[Fact]
public async Task EmptyStatesOfferScopedFirstCreateOrClearFilterActions()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var root = $"/admin/services/{context.ServiceId}";
var templates = await context.Client.GetStringAsync(root);
Assert.Contains("No email templates for this service yet.", templates);
Assert.Contains("Create First Template", templates);
Assert.Contains(root + "/templates/create", templates);
Assert.Contains("template-key-filter", templates);
var filtered = await context.Client.GetStringAsync(root + "?key=Missing");
Assert.Contains("No templates match this key.", filtered);
Assert.Contains(">Clear filter</a>", filtered);
Assert.DoesNotContain("Create First Template", filtered);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid());
var services = await context.Client.GetStringAsync("/admin/services");
Assert.Contains("No services yet.", services);
Assert.Contains("Create First Service", services);
Assert.Contains("aria-hidden=\"true\"", services);
}
[Fact]
public async Task TemplateLoadFailureDoesNotPretendTheServiceHasNoTemplates()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var failing = context.App.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
services.AddScoped<IEmailTemplateDefinitionRepository, UnavailableEmailTemplateDefinitionRepository>()));
using var client = failing.CreateClient();
var html = await client.GetStringAsync($"/admin/services/{context.ServiceId}");
Assert.Contains("Could not load templates", html);
Assert.Contains("Templates are unavailable", html);
Assert.DoesNotContain("Create First Template", html);
Assert.DoesNotContain("No email templates for this service yet.", html);
}
private static void AssertConfirmationReset(string html) =>
Assert.DoesNotContain("checked", Regex.Match(html, "<input[^>]+type=\"checkbox\"[^>]*>").Value);
}
@@ -0,0 +1,60 @@
using System.Net;
using System.Text.RegularExpressions;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task LayoutLinksToApiDocumentationInNewTab_AndScalarIsAvailable()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var pages = new[] { "/admin/services", "/admin/channels", "/admin/services/create", "/admin/channels/create" };
foreach (var page in pages)
{
var html = await context.Client.GetStringAsync(page);
Assert.Single(Regex.Matches(html, "class=\"admin-api-documentation\""));
Assert.Contains("href=\"/scalar/v1\" target=\"_blank\" rel=\"noopener noreferrer\"", html);
Assert.Contains("aria-label=\"API documentation (opens in a new tab)\">API documentation</a>", html);
}
Assert.Equal(HttpStatusCode.OK, (await context.Client.GetAsync("/scalar/v1")).StatusCode);
}
[Fact]
public async Task BothListTablesHaveScopedGridBorders_AndRetainTemplateHeaderOnFilterMiss()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var services = await context.Client.GetStringAsync("/admin/services");
Assert.Contains("table table-hover admin-data-table", services);
var templates = await context.Client.GetStringAsync($"/admin/services/{context.ServiceId}?key=Missing");
Assert.Contains("table table-hover admin-data-table service-template-grid", templates);
Assert.Contains("class=\"template-actions\"", templates);
Assert.Contains("No templates match this key.", templates);
Assert.Contains("class=\"template-key-filter\"", templates);
}
[Fact]
public async Task AllFourDataTablesShareTheGridClassAndScopedColumnHeaders()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var pages = new[] { "/admin/services", $"/admin/services/{context.ServiceId}", "/admin/channels", $"/admin/services/{context.ServiceId}/edit" };
foreach (var page in pages)
{
var html = await context.Client.GetStringAsync(page);
Assert.Single(Regex.Matches(html, "<table[^>]+admin-data-table"));
Assert.Equal(Regex.Matches(html, "<th[ >]").Count, Regex.Matches(html, "<th scope=\"col\"").Count);
Assert.DoesNotContain("<thead class=\"table-dark\">", html);
}
}
}
@@ -0,0 +1,47 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class DefinitionApiTestContext : IAsyncDisposable
{
public required TemplateWebApplicationFactory App { get; init; }
public required HttpClient Client { get; init; }
public required Guid ServiceId { get; init; }
public string ServiceRoot => $"/api/v1/services/{ServiceId}";
public static async Task<DefinitionApiTestContext> CreateAsync(TemplateDatabaseFixture fixture)
{
var app = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync());
var client = app.CreateClient();
var response = await client.GetFromJsonAsync<JsonElement>("/api/v1/services?name=TestService");
return new DefinitionApiTestContext { App = app, Client = client, ServiceId = response.GetProperty("data").GetProperty("id").GetGuid() };
}
public string DefinitionUrl(string key = "Welcome", bool upsert = false) =>
ServiceRoot + "/email-template-definitions" + (upsert ? "/upsert" : "") + "?key=" + Uri.EscapeDataString(key);
public Task<HttpResponseMessage> UpsertAsync(JsonNode body, string key = "Welcome") =>
Client.PostAsJsonAsync(DefinitionUrl(key, upsert: true), body);
public async Task<JsonElement> ReadAsync(string key = "Welcome") =>
(await Client.GetFromJsonAsync<JsonElement>(DefinitionUrl(key))).GetProperty("data");
public static JsonObject Body(Guid? revision, params string[] codes) => new()
{
["revision"] = revision?.ToString(),
["variables"] = new JsonArray(new JsonObject { ["name"] = "Name", ["required"] = true }),
["translations"] = new JsonArray(codes.Select(code => (JsonNode)new JsonObject
{
["languageCode"] = code, ["subject"] = "Subject " + code,
["htmlBody"] = "<p>Hello {{Name}}</p>", ["textBody"] = "Hello {{Name}}"
}).ToArray())
};
public async ValueTask DisposeAsync()
{
Client.Dispose();
await App.DisposeAsync();
}
}
@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var request = Content("smtp.example.invalid", 2525, "test-only-user");
var created = await context.Client.PostAsJsonAsync("/api/v1/email-channels", request);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var id = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid();
var original = (await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/email-channels/{id}")).GetProperty("data");
Assert.Equal("smtp.example.invalid", original.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-user", original.GetProperty("settings").GetProperty("username").GetString());
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content("updated.example.invalid", 2526, "updated-user"))).StatusCode);
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
var updated = Assert.Single(all.EnumerateArray());
Assert.Equal("updated.example.invalid", updated.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal("updated-user", updated.GetProperty("settings").GetProperty("username").GetString());
Assert.Equal(2526, updated.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-value", updated.GetProperty("settings").GetProperty("password").GetString());
Assert.False(updated.GetProperty("settings").GetProperty("useSsl").GetBoolean());
Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString());
Assert.Equal("Test sender", updated.GetProperty("settings").GetProperty("fromName").GetString());
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=IndependentChannelService");
Assert.Single(filtered.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync($"/api/v1/email-channels/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/email-channels/{id}")).StatusCode);
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
}
[Fact]
public async Task GetAllIncludesChannelsWhoseServiceNameHasNoServiceRecord()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
foreach (var serviceName in new[] { "TestService", "OrphanChannelService" })
await channels.AddAsync(new EmailChannel
{
ServiceName = serviceName, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
Assert.Equal(2, all.GetArrayLength());
Assert.Contains(all.EnumerateArray(), c => c.GetProperty("serviceName").GetString() == "OrphanChannelService");
Assert.All(all.EnumerateArray(), c => Assert.NotEqual(Guid.Empty, c.GetProperty("id").GetGuid()));
}
[Theory]
[InlineData("nullSettings")]
[InlineData("invalidPort")]
[InlineData("blankHost")]
[InlineData("unsupportedType")]
[InlineData("blankService")]
public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = JsonSerializer.SerializeToNode(Content("smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
switch (scenario)
{
case "nullSettings": body["settings"] = null; break;
case "invalidPort": body["settings"]!["port"] = 0; break;
case "blankHost": body["settings"]!["host"] = " "; break;
case "unsupportedType": body["channelType"] = 99; break;
case "blankService": body["serviceName"] = " "; break;
}
var response = await context.Client.PostAsJsonAsync("/api/v1/email-channels", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
}
private static object Content(string host, int port, string username) => new
{
serviceName = "IndependentChannelService", priority = 1, channelType = 1,
settings = new { host, port, username, password = "test-only-value", useSsl = false, fromEmail = "sender@example.invalid", fromName = "Test sender" },
dailyLimit = 100, monthlyLimit = 1000, warnThresholdPercent = 90, isActive = true
};
}
@@ -0,0 +1,228 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailTemplateDefinitionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ServiceReadsExposeExactCatalogAndStableIdentityThroughRename()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
Assert.Equal(context.ServiceId, service.GetProperty("id").GetGuid());
Assert.Equal("TestService", service.GetProperty("name").GetString());
Assert.Equal(new[] { "de", "en", "ru", "ru-RU", "uk" }, service.GetProperty("supportedLanguageCodes").EnumerateArray().Select(c => c.GetString()));
Assert.Equal(5, service.EnumerateObject().Count());
Assert.NotEqual(Guid.Empty, service.GetProperty("revision").GetGuid());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=testservice")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync("/api/v1/services")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}")).StatusCode);
using var scope = context.App.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var model = (await repository.GetAsync(context.ServiceId))!;
model.Name = "Renamed Service";
await repository.SaveAsync(model, false);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=TestService")).StatusCode);
var renamed = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services?name=Renamed%20Service");
Assert.Equal(context.ServiceId, renamed.GetProperty("data").GetProperty("id").GetGuid());
Assert.Equal("Renamed Service", (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data").GetProperty("name").GetString());
}
[Fact]
public async Task AtomicUpsertReplacesContractAndSentContent_AndRetainsOmittedTranslations()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"))).StatusCode);
var original = await context.ReadAsync();
var uk = original.GetProperty("translations")[1];
Assert.True(uk.GetProperty("created").GetDateTimeOffset().Year >= 2020);
var revision = original.GetProperty("revision").GetGuid();
var update = DefinitionApiTestContext.Body(revision, "en", "de");
update["variables"] = new JsonArray(new JsonObject { ["name"] = "Changed", ["required"] = false });
update["translations"]![0]!["subject"] = "Edited English";
var response = await context.UpsertAsync(update);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var returned = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
var saved = await context.ReadAsync();
Assert.Equal(saved.GetProperty("revision").GetGuid(), returned.GetProperty("revision").GetGuid());
Assert.NotEqual(revision, saved.GetProperty("revision").GetGuid());
Assert.Equal(original.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid());
Assert.Equal(3, saved.GetProperty("translations").GetArrayLength());
Assert.Equal(uk.GetRawText(), saved.GetProperty("translations")[2].GetRawText());
Assert.Equal("Edited English", saved.GetProperty("translations")[1].GetProperty("subject").GetString());
Assert.Equal(original.GetProperty("translations")[0].GetProperty("id").GetGuid(), saved.GetProperty("translations")[1].GetProperty("id").GetGuid());
var legacy = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-templates/TestService/Welcome/uk");
Assert.Equal("Changed", legacy.GetProperty("data").GetProperty("variables")[0].GetProperty("name").GetString());
Assert.False(saved.GetProperty("variables")[0].GetProperty("required").GetBoolean());
}
[Fact]
public async Task ExplicitRemovalCanRemoveLastVersion_AndKeepDefinitionAndContract()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var before = await context.ReadAsync();
var request = DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid());
request["removeLanguageCodes"] = new JsonArray("en", "uk", "de");
var response = await context.UpsertAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var returned = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Assert.Empty(returned.GetProperty("translations").EnumerateArray());
var saved = await context.ReadAsync();
Assert.Equal(before.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid());
Assert.Empty(saved.GetProperty("translations").EnumerateArray());
Assert.Single(saved.GetProperty("variables").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/email-templates/TestService/Welcome/en")).StatusCode);
}
[Fact]
public async Task RevisionsEnforceCreateOnlyAndUpdateOnly_WithoutPartialChanges()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var before = await context.ReadAsync();
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"), "Missing")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl("Missing"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.Empty))).StatusCode);
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
var revision = before.GetProperty("revision").GetGuid();
Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk"))).StatusCode);
var otherRoot = $"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/upsert?key=Welcome";
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsJsonAsync(otherRoot, DefinitionApiTestContext.Body(null))).StatusCode);
}
[Theory]
[InlineData("missingVariables")]
[InlineData("nullVariables")]
[InlineData("missingTranslations")]
[InlineData("nullTranslations")]
[InlineData("nullVariable")]
[InlineData("nullTranslation")]
[InlineData("missingRequired")]
[InlineData("nonBooleanRequired")]
[InlineData("duplicateVariable")]
[InlineData("blankVariable")]
[InlineData("duplicateLanguage")]
[InlineData("unknownLanguage")]
[InlineData("blankContent")]
[InlineData("missingContent")]
[InlineData("duplicateRemoval")]
[InlineData("unknownRemoval")]
[InlineData("nullRemoval")]
[InlineData("overlap")]
public async Task InvalidUpdateDoesNotChangeContractTranslationsOrRevision(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var before = await context.ReadAsync();
var request = InvalidDefinitionApiBody.Create(scenario, before.GetProperty("revision").GetGuid());
var response = await context.UpsertAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.False(error.GetProperty("success").GetBoolean());
Assert.Equal("InvalidRequest", error.GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task ConcurrentCreatesAndUpdatesHaveOneWinnerAndNoLostMerge()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var created = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(null, "en")), context.UpsertAsync(DefinitionApiTestContext.Body(null, "uk")));
Assert.Equal(new[] { HttpStatusCode.Created, HttpStatusCode.Conflict }, created.Select(r => r.StatusCode).Order().ToArray());
var before = await context.ReadAsync();
var revision = before.GetProperty("revision").GetGuid();
var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "de")), context.UpsertAsync(DefinitionApiTestContext.Body(revision, "ru")));
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray());
var after = await context.ReadAsync();
Assert.Equal(2, after.GetProperty("translations").GetArrayLength());
var retained = before.GetProperty("translations")[0];
Assert.Contains(after.GetProperty("translations").EnumerateArray(), item => item.GetProperty("id").GetGuid() == retained.GetProperty("id").GetGuid());
}
[Fact]
public async Task RejectedCreateAndMalformedRequestsLeaveNoDefinition()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var definitions = context.ServiceRoot + "/email-template-definitions";
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(definitions + "/upsert", DefinitionApiTestContext.Body(null))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions + "?key=%20")).StatusCode);
var invalid = DefinitionApiTestContext.Body(null, "en", "unknown");
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalid)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode);
using var nullBody = new StringContent("null", System.Text.Encoding.UTF8, "application/json");
var response = await context.Client.PostAsync(context.DefinitionUrl(upsert: true), nullBody);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
var invalidRevision = DefinitionApiTestContext.Body(null);
invalidRevision["revision"] = "not-a-guid";
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalidRevision)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode);
}
[Fact]
public async Task ExactCaseCatalogCodesCanCoexistAndBeRemovedIndependently()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Languages = service.Languages.Append(new() { Code = "RU" }).ToList();
await services.SaveAsync(service, false);
Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "RU", "ru", "ru-RU"))).StatusCode);
var saved = await context.ReadAsync();
var removal = DefinitionApiTestContext.Body(saved.GetProperty("revision").GetGuid());
removal["variables"] = new JsonArray();
removal["removeLanguageCodes"] = new JsonArray("RU");
Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(removal)).StatusCode);
var after = await context.ReadAsync();
Assert.Empty(after.GetProperty("variables").EnumerateArray());
Assert.Equal(new[] { "ru", "ru-RU" }, after.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString()));
}
[Fact]
public async Task LegacyContentWriteInvalidatesLastReadRevision()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await context.ReadAsync();
var body = DefinitionApiTestContext.Body(null, "en");
var legacy = (JsonObject)body["translations"]![0]!.DeepClone();
legacy["subject"] = "Legacy edit";
legacy["variables"] = body["variables"]!.DeepClone();
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", legacy)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid(), "uk"))).StatusCode);
var after = await context.ReadAsync();
Assert.Single(after.GetProperty("translations").EnumerateArray());
Assert.Equal("Legacy edit", after.GetProperty("translations")[0].GetProperty("subject").GetString());
}
[Theory]
[InlineData("Welcome + % # ? Привіт")]
[InlineData("Welcome%2Fexact")]
[InlineData("Welcome/child")]
public async Task EncodedKeysRoundTripExactly(string key)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var created = await context.UpsertAsync(DefinitionApiTestContext.Body(null, "ru", "ru-RU"), key);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.NotNull(created.Headers.Location);
var located = await context.Client.GetFromJsonAsync<JsonElement>(created.Headers.Location);
Assert.Equal(key, located.GetProperty("data").GetProperty("key").GetString());
Assert.Equal(context.ServiceId, located.GetProperty("data").GetProperty("serviceId").GetGuid());
var read = await context.ReadAsync(key);
Assert.Equal(read.GetProperty("id").GetGuid(), located.GetProperty("data").GetProperty("id").GetGuid());
Assert.Equal(key, read.GetProperty("key").GetString());
Assert.Equal(new[] { "ru", "ru-RU" }, read.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString()));
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key.ToLowerInvariant()))).StatusCode);
}
}
@@ -11,6 +11,18 @@ public sealed class FailingEmailTemplateDefinitionRepository(IEmailTemplateRepos
public Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) => public Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceName, key, ct); ((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceName, key, ct);
public Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceId, key, ct);
public Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) =>
throw new IOException("Simulated save failure.");
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).GetDefinitionsAsync(serviceId, ct);
public Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).DeleteDefinitionAsync(serviceId, key, revision, ct);
public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) => public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) =>
throw new IOException("Simulated save failure."); throw new IOException("Simulated save failure.");
} }
@@ -0,0 +1,47 @@
using System.Text.Json.Nodes;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public static class InvalidDefinitionApiBody
{
public static JsonObject Create(string scenario, Guid revision)
{
var body = DefinitionApiTestContext.Body(revision, "en");
body["removeLanguageCodes"] = new JsonArray("uk");
ApplyVariableScenario(scenario, body, (JsonArray)body["variables"]!);
ApplyTranslationScenario(scenario, body, (JsonArray)body["translations"]!);
return body;
}
private static void ApplyVariableScenario(string scenario, JsonObject body, JsonArray variables)
{
switch (scenario)
{
case "missingVariables": body.Remove("variables"); break;
case "nullVariables": body["variables"] = null; break;
case "nullVariable": variables.Add((JsonNode?)null); break;
case "missingRequired": ((JsonObject)variables[0]!).Remove("required"); break;
case "nonBooleanRequired": variables[0]!["required"] = "true"; break;
case "duplicateVariable": variables.Add(variables[0]!.DeepClone()); break;
case "blankVariable": variables[0]!["name"] = " "; break;
}
}
private static void ApplyTranslationScenario(string scenario, JsonObject body, JsonArray translations)
{
switch (scenario)
{
case "missingTranslations": body.Remove("translations"); break;
case "nullTranslations": body["translations"] = null; break;
case "nullTranslation": translations.Add((JsonNode?)null); break;
case "duplicateLanguage": translations.Add(translations[0]!.DeepClone()); break;
case "unknownLanguage": translations[0]!["languageCode"] = "RU"; break;
case "blankContent": translations[0]!["textBody"] = " "; break;
case "missingContent": ((JsonObject)translations[0]!).Remove("htmlBody"); break;
case "duplicateRemoval": body["removeLanguageCodes"] = new JsonArray("uk", "uk"); break;
case "unknownRemoval": body["removeLanguageCodes"] = new JsonArray("UK"); break;
case "nullRemoval": body["removeLanguageCodes"] = new JsonArray((JsonNode?)null); break;
case "overlap": body["removeLanguageCodes"] = new JsonArray("en"); break;
}
}
}
@@ -0,0 +1,143 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ServiceCatalogApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task CreateUpdateAndExactReadRoundTripLanguageIdentitiesAndRevision()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = new { name = "Api Created", languages = new[] { new { code = "en" }, new { code = "uk" } } };
var response = await context.Client.PostAsJsonAsync("/api/v1/services", body);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(response.Headers.Location)).GetProperty("data");
Assert.Equal(2, service.GetProperty("languages").GetArrayLength());
Assert.All(service.GetProperty("languages").EnumerateArray(), l => Assert.NotEqual(Guid.Empty, l.GetProperty("id").GetGuid()));
var url = "/api/v1/services/" + service.GetProperty("id").GetGuid();
var update = UpdateBody(service);
update["name"] = "Renamed API";
update["languages"]![0]!["code"] = "en-GB";
((JsonArray)update["languages"]!).Add(new JsonObject { ["code"] = "de" });
var updated = await context.Client.PutAsJsonAsync(url, update);
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
var saved = (await updated.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Assert.NotEqual(service.GetProperty("revision").GetGuid(), saved.GetProperty("revision").GetGuid());
Assert.Contains(saved.GetProperty("languages").EnumerateArray(), l => l.GetProperty("id").GetGuid() == service.GetProperty("languages")[0].GetProperty("id").GetGuid() && l.GetProperty("code").GetString() == "en-GB");
Assert.Equal(3, saved.GetProperty("languages").GetArrayLength());
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PutAsJsonAsync(url, update)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync("/api/v1/services", new { name = "Renamed API", languages = Array.Empty<object>() })).StatusCode);
}
[Fact]
public async Task SnapshotOmissionOfUsedLanguageFailsWithoutChangingServiceOrDefinition()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await ReadAsync(context);
var definition = await context.ReadAsync();
var update = UpdateBody(before);
update["name"] = "Unsaved API rename";
update["languages"] = new JsonArray(new JsonObject { ["code"] = "fr" });
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PutAsJsonAsync(context.ServiceRoot, update)).StatusCode);
Assert.Equal(before.GetRawText(), (await ReadAsync(context)).GetRawText());
Assert.Equal(definition.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task OrdinaryRemovalOnlyDeletesUnusedCatalogRows_AndReturnsNewRevisionOnRead()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await ReadAsync(context);
var used = before.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "en");
var unused = before.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "uk");
var root = context.ServiceRoot + "/languages/";
Assert.Equal(1, used.GetProperty("versionCount").GetInt32());
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(root + used.GetProperty("id").GetGuid() + "?revision=" + before.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(root + unused.GetProperty("id").GetGuid() + "?revision=" + before.GetProperty("revision").GetGuid())).StatusCode);
var after = await ReadAsync(context);
Assert.NotEqual(before.GetProperty("revision").GetGuid(), after.GetProperty("revision").GetGuid());
Assert.DoesNotContain(after.GetProperty("languages").EnumerateArray(), l => l.GetProperty("code").GetString() == "uk");
Assert.Single((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(root + Guid.NewGuid() + "?revision=" + after.GetProperty("revision").GetGuid())).StatusCode);
}
[Fact]
public async Task ExplicitDestructiveRemovalRequiresConfirmationRevisionAndCurrentCount_AndRetainsDefinitions()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "First");
var service = await ReadAsync(context);
var language = service.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "en");
var url = context.ServiceRoot + "/languages/" + language.GetProperty("id").GetGuid() + "/delete-with-versions";
var body = new JsonObject { ["revision"] = service.GetProperty("revision").GetString(), ["expectedVersionCount"] = 1, ["confirmDelete"] = false };
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body.Remove("confirmDelete");
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body["confirmDelete"] = true;
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "Second");
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body["expectedVersionCount"] = 2;
body["revision"] = Guid.NewGuid().ToString();
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body["revision"] = service.GetProperty("revision").GetString();
Assert.Equal(HttpStatusCode.OK, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
var definitions = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot + "/email-template-definitions/all")).GetProperty("data");
Assert.Equal(2, definitions.GetArrayLength());
Assert.All(definitions.EnumerateArray(), d => Assert.Empty(d.GetProperty("translations").EnumerateArray()));
Assert.DoesNotContain((await ReadAsync(context)).GetProperty("languages").EnumerateArray(), l => l.GetProperty("code").GetString() == "en");
}
[Theory]
[InlineData("nullLanguages")]
[InlineData("duplicateCodes")]
[InlineData("foreignId")]
public async Task InvalidCreatePersistsNoService(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = new JsonObject { ["name"] = "InvalidCreate", ["languages"] = new JsonArray(new JsonObject { ["code"] = "en" }) };
switch (scenario)
{
case "nullLanguages": body["languages"] = null; break;
case "duplicateCodes": ((JsonArray)body["languages"]!).Add(new JsonObject { ["code"] = "en" }); break;
case "foreignId": body["languages"]![0]!["id"] = Guid.NewGuid().ToString(); break;
}
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync("/api/v1/services", body)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=InvalidCreate")).StatusCode);
}
[Theory]
[InlineData("emptyRevision", HttpStatusCode.BadRequest)]
[InlineData("missingRevision", HttpStatusCode.BadRequest)]
[InlineData("foreignLanguageId", HttpStatusCode.Conflict)]
public async Task InvalidUpdatePreservesCurrentServiceSnapshot(string scenario, HttpStatusCode expectedStatus)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var before = await ReadAsync(context);
var body = UpdateBody(before);
body["name"] = "Unsaved name";
switch (scenario)
{
case "emptyRevision": body["revision"] = Guid.Empty.ToString(); break;
case "missingRevision": body.Remove("revision"); break;
case "foreignLanguageId": body["languages"]![0]!["id"] = Guid.NewGuid().ToString(); break;
}
Assert.Equal(expectedStatus, (await context.Client.PutAsJsonAsync(context.ServiceRoot, body)).StatusCode);
Assert.Equal(before.GetRawText(), (await ReadAsync(context)).GetRawText());
}
private static async Task<JsonElement> ReadAsync(DefinitionApiTestContext context) =>
(await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
private static JsonObject UpdateBody(JsonElement service) => new()
{
["name"] = service.GetProperty("name").GetString(), ["revision"] = service.GetProperty("revision").GetString(),
["languages"] = new JsonArray(service.GetProperty("languages").EnumerateArray().Select(l => (JsonNode)new JsonObject
{ ["id"] = l.GetProperty("id").GetString(), ["code"] = l.GetProperty("code").GetString() }).ToArray())
};
}
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ListsExposeRevisions_AndDefinitionsAreServiceScopedIncludingEmptyDefinitions()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null), "Empty");
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "Populated");
using var scope = context.App.Services.CreateScope();
var other = new NotificationServiceInfo { Name = "OtherService" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var services = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all")).GetProperty("data");
Assert.Equal(2, services.GetArrayLength());
Assert.All(services.EnumerateArray(), s => Assert.NotEqual(Guid.Empty, s.GetProperty("revision").GetGuid()));
var definitions = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot + "/email-template-definitions/all")).GetProperty("data");
Assert.Equal(new[] { "Empty", "Populated" }, definitions.EnumerateArray().Select(d => d.GetProperty("key").GetString()));
Assert.Empty(definitions[0].GetProperty("translations").EnumerateArray());
Assert.Single(definitions[1].GetProperty("translations").EnumerateArray());
Assert.All(definitions.EnumerateArray(), d => Assert.Equal(context.ServiceId, d.GetProperty("serviceId").GetGuid()));
var otherDefinitions = await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/services/{other.Id}/email-template-definitions/all");
Assert.Empty(otherDefinitions.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/all")).StatusCode);
}
[Fact]
public async Task ExplicitCleanupDeletesDefinitionsThenServiceAndLanguages_ButRetainsChannels()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await SeedChannelAsync(context);
const string key = "Delete / %2F Привіт";
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"), key);
var definition = await context.ReadAsync(key);
var service = await ReadServiceAsync(context);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
var deleted = await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid());
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
var result = await deleted.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(result.GetProperty("success").GetBoolean());
Assert.Equal(JsonValueKind.Object, result.GetProperty("data").ValueKind);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key))).StatusCode);
var afterDefinitionDeletion = await ReadServiceAsync(context);
Assert.Equal(service.GetProperty("revision").GetGuid(), afterDefinitionDeletion.GetProperty("revision").GetGuid());
Assert.Equal(service.GetProperty("supportedLanguageCodes").GetRawText(), afterDefinitionDeletion.GetProperty("supportedLanguageCodes").GetRawText());
Assert.All(afterDefinitionDeletion.GetProperty("languages").EnumerateArray(), l => Assert.Equal(0, l.GetProperty("versionCount").GetInt32()));
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.ServiceRoot)).StatusCode);
var all = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all");
Assert.Empty(all.GetProperty("data").EnumerateArray());
using var scope = context.App.Services.CreateScope();
Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
}
[Fact]
public async Task DeleteIsRevisionGuarded_AndEvenEmptyDefinitionsBlockServiceRemoval()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var definition = await context.ReadAsync();
var service = await ReadServiceAsync(context);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + Guid.NewGuid())).StatusCode);
using var scope = context.App.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var edit = (await repository.GetAsync(context.ServiceId))!;
edit.Name = "RenamedService";
await repository.SaveAsync(edit, false);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal("RenamedService", (await ReadServiceAsync(context)).GetProperty("name").GetString());
Assert.Empty((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
}
[Theory]
[InlineData("")]
[InlineData("?revision=not-a-guid")]
[InlineData("?revision=00000000-0000-0000-0000-000000000000")]
public async Task InvalidServiceDeleteDoesNotRemoveAnything(string query)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var before = await ReadServiceAsync(context);
var response = await context.Client.DeleteAsync(context.ServiceRoot + query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await ReadServiceAsync(context)).GetRawText());
}
[Theory]
[InlineData("?key=Welcome")]
[InlineData("?key=Welcome&revision=not-a-guid")]
[InlineData("?key=Welcome&revision=00000000-0000-0000-0000-000000000000")]
[InlineData("?revision=11111111-1111-1111-1111-111111111111")]
public async Task InvalidDefinitionDeleteDoesNotRemoveAnything(string query)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await context.ReadAsync();
var response = await context.Client.DeleteAsync(context.ServiceRoot + "/email-template-definitions" + query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task ConcurrentDefinitionUpdateAndDeleteHaveOneWinner()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var revision = (await context.ReadAsync()).GetProperty("revision").GetGuid();
var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk")), context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + revision));
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray());
}
[Fact]
public async Task ConcurrentServiceDeleteAndDefinitionCreationCannotLeaveOrphans()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var service = await ReadServiceAsync(context);
var deletion = context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid());
var creation = context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var responses = await Task.WhenAll(deletion, creation);
var deleted = responses[0].StatusCode;
var created = responses[1].StatusCode;
Assert.True(deleted == HttpStatusCode.OK && created == HttpStatusCode.NotFound
|| deleted == HttpStatusCode.Conflict && created == HttpStatusCode.Created);
}
private static async Task<JsonElement> ReadServiceAsync(DefinitionApiTestContext context) =>
(await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
private static async Task SeedChannelAsync(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
}
}
@@ -39,7 +39,7 @@ public sealed class ServiceTemplateNavigationTests(TemplateDatabaseFixture fixtu
Assert.DoesNotContain("OtherTemplate", detail); Assert.DoesNotContain("OtherTemplate", detail);
Assert.Contains($"/admin/services/{service.Id}/templates/create", detail); Assert.Contains($"/admin/services/{service.Id}/templates/create", detail);
Assert.Contains("data-state=\"missing\"", detail); Assert.Contains("data-state=\"missing\"", detail);
Assert.Contains("No email templates found", await client.GetStringAsync($"/admin/services/{service.Id}?key=Missing")); Assert.Contains("No templates match this key.", await client.GetStringAsync($"/admin/services/{service.Id}?key=Missing"));
Assert.Contains("Language codes", await client.GetStringAsync($"/admin/services/{service.Id}/edit")); Assert.Contains("Language codes", await client.GetStringAsync($"/admin/services/{service.Id}/edit"));
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/admin/services/{Guid.NewGuid()}" )).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/admin/services/{Guid.NewGuid()}" )).StatusCode);
} }
@@ -58,7 +58,7 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix
var miss = await client.GetStringAsync(baseUrl + "?key=Absent"); var miss = await client.GetStringAsync(baseUrl + "?key=Absent");
Assert.Matches("(?s)<th[^>]*>\\s*<form[^>]*method=\"get\"[^>]*class=\"template-key-filter\"", miss); Assert.Matches("(?s)<th[^>]*>\\s*<form[^>]*method=\"get\"[^>]*class=\"template-key-filter\"", miss);
Assert.Contains("value=\"Absent\"", miss); Assert.Contains("value=\"Absent\"", miss);
Assert.Contains("No email templates found", miss); Assert.Contains("No templates match this key.", miss);
Assert.Contains("class=\"template-actions\">Action</th>", miss); Assert.Contains("class=\"template-actions\">Action</th>", miss);
Assert.Contains($"href=\"{baseUrl}\" class=\"btn btn-outline-secondary btn-sm\">Clear</a>", miss); Assert.Contains($"href=\"{baseUrl}\" class=\"btn btn-outline-secondary btn-sm\">Clear</a>", miss);
var filtered = await client.GetStringAsync(baseUrl + "?key=Welcome"); var filtered = await client.GetStringAsync(baseUrl + "?key=Welcome");
@@ -85,13 +85,13 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix
var empty = await client.GetStringAsync(baseUrl); var empty = await client.GetStringAsync(baseUrl);
Assert.Contains("No languages configured for this service.", empty); Assert.Contains("No languages configured for this service.", empty);
Assert.Contains("template-key-filter", empty); Assert.Contains("template-key-filter", empty);
Assert.Contains("No email templates found", empty); Assert.Contains("No email templates for this service yet.", empty);
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>() await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true); .SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
var withDefinition = await client.GetStringAsync(baseUrl); var withDefinition = await client.GetStringAsync(baseUrl);
Assert.Contains("No configured languages", TemplateRow(withDefinition, "Empty")); Assert.Contains("No configured languages", TemplateRow(withDefinition, "Empty"));
Assert.DoesNotContain("data-state=", withDefinition); Assert.DoesNotContain("data-state=", withDefinition);
Assert.DoesNotContain("No email templates found", withDefinition); Assert.DoesNotContain("No templates match this key.", withDefinition);
} }
private static string TemplateRow(string html, string key) private static string TemplateRow(string html, string key)
@@ -0,0 +1,15 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class UnavailableEmailTemplateDefinitionRepository : IEmailTemplateDefinitionRepository
{
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
}
@@ -1,3 +1,5 @@
using HrynCo.NotificationService.Services.TemplateDefinitions;
using MediatR;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
@@ -6,7 +8,8 @@ using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin; namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/services")] [Route("admin/services")]
public sealed class AdminServicesController(INotificationServiceRepository services, ILogger<AdminServicesController> logger) : Controller public sealed class AdminServicesController(INotificationServiceRepository services, ILogger<AdminServicesController> logger,
IEmailTemplateDefinitionRepository definitions, IMediator mediator) : Controller
{ {
[HttpGet("")] [HttpGet("")]
public async Task<IActionResult> Index(CancellationToken ct) => View(await services.GetAllAsync(ct)); public async Task<IActionResult> Index(CancellationToken ct) => View(await services.GetAllAsync(ct));
@@ -77,6 +80,55 @@ public sealed class AdminServicesController(INotificationServiceRepository servi
} }
} }
[HttpGet("{serviceId:guid}/delete")]
public async Task<IActionResult> ConfirmServiceDelete([FromRoute] Guid serviceId, CancellationToken ct)
{
var model = await ServiceDeleteModelAsync(serviceId, ct);
return model is null ? NotFound() : View("DeleteService", model);
}
[HttpPost("{serviceId:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteService([FromRoute] Guid serviceId, NotificationServiceDeleteViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid || !model.ConfirmDelete)
return await ServiceDeleteErrorAsync(serviceId, "Confirm deletion of this service to continue.", ct);
try
{
var result = await mediator.Send(new DeleteNotificationServiceCommand(serviceId, model.Revision), ct);
if (result.IsSuccess)
return RedirectToAction(nameof(Index));
return await ServiceDeleteErrorAsync(serviceId, result.Error!.Message, ct);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to delete notification service.");
return await ServiceDeleteErrorAsync(serviceId, "Could not complete deletion. Review the current service before trying again.", ct);
}
}
private async Task<NotificationServiceDeleteViewModel?> ServiceDeleteModelAsync(Guid serviceId, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null)
return null;
return new NotificationServiceDeleteViewModel
{
ServiceId = service.Id, Name = service.Name, Revision = service.Revision,
DefinitionCount = (await definitions.GetDefinitionsAsync(serviceId, ct)).Count
};
}
private async Task<IActionResult> ServiceDeleteErrorAsync(Guid serviceId, string error, CancellationToken ct)
{
var model = await ServiceDeleteModelAsync(serviceId, ct);
if (model is null)
return NotFound();
ModelState.Clear();
ModelState.AddModelError("", error);
return View("DeleteService", model);
}
private async Task<IActionResult> ServiceEditorViewAsync(NotificationServiceEditViewModel model, CancellationToken ct) private async Task<IActionResult> ServiceEditorViewAsync(NotificationServiceEditViewModel model, CancellationToken ct)
{ {
if (model.Id is not Guid id) if (model.Id is not Guid id)
@@ -1,3 +1,4 @@
using HrynCo.NotificationService.Services.TemplateDefinitions;
using System.Text.Json; using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
@@ -95,6 +96,59 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
return RedirectToAction(nameof(Index), new { serviceId, key = keyFilter }); return RedirectToAction(nameof(Index), new { serviceId, key = keyFilter });
} }
[HttpGet("{definitionId:guid}/delete")]
public async Task<IActionResult> ConfirmDefinitionDelete([FromRoute] Guid serviceId, [FromRoute] Guid definitionId,
[FromQuery] string? keyFilter, CancellationToken ct)
{
var model = await DefinitionDeleteModelAsync(serviceId, definitionId, keyFilter, ct);
return model is null ? NotFound() : View("DeleteDefinition", model);
}
[HttpPost("{definitionId:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteDefinition([FromRoute] Guid serviceId, [FromRoute] Guid definitionId,
EmailTemplateDefinitionDeleteViewModel model, CancellationToken ct)
{
var current = await DefinitionDeleteModelAsync(serviceId, definitionId, model.KeyFilter, ct);
if (current is null)
return NotFound();
if (!ModelState.IsValid || !model.ConfirmDelete)
return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, "Confirm deletion of this template and its translations to continue.", ct);
try
{
var result = await mediator.Send(new DeleteEmailTemplateDefinitionCommand(serviceId, current.Key, model.Revision), ct);
if (result.IsSuccess)
return RedirectToAction(nameof(Index), new { serviceId, key = model.KeyFilter });
return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, result.Error!.Message, ct);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to delete email template definition.");
return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, "Could not complete deletion. Review the current template before trying again.", ct);
}
}
private async Task<EmailTemplateDefinitionDeleteViewModel?> DefinitionDeleteModelAsync(Guid serviceId, Guid definitionId, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await OwnedDefinitionAsync(service, definitionId, ct);
return definition is null ? null : new EmailTemplateDefinitionDeleteViewModel
{
ServiceId = serviceId, DefinitionId = definition.Id, ServiceName = definition.ServiceName, Key = definition.Key,
Revision = definition.Revision, KeyFilter = keyFilter, LanguageCodes = definition.Languages.Select(l => l.LanguageCode).ToList()
};
}
private async Task<IActionResult> DefinitionDeleteErrorAsync(Guid serviceId, Guid definitionId, string? keyFilter, string error, CancellationToken ct)
{
var model = await DefinitionDeleteModelAsync(serviceId, definitionId, keyFilter, ct);
if (model is null)
return NotFound();
ModelState.Clear();
ModelState.AddModelError("", error);
return View("DeleteDefinition", model);
}
private async Task<IActionResult> SaveDefinitionAsync(EmailTemplateDefinition definition, EmailTemplateEditViewModel model, private async Task<IActionResult> SaveDefinitionAsync(EmailTemplateDefinition definition, EmailTemplateEditViewModel model,
NotificationServiceInfo service, CancellationToken ct) NotificationServiceInfo service, CancellationToken ct)
{ {
@@ -117,7 +171,7 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
{ {
if (service is null) if (service is null)
return null; return null;
return (await definitions.GetDefinitionsAsync(service.Name, ct: ct)) return (await definitions.GetDefinitionsAsync(service.Id, ct))
.SingleOrDefault(d => d.Id == definitionId && d.ServiceId == service.Id); .SingleOrDefault(d => d.Id == definitionId && d.ServiceId == service.Id);
} }
@@ -0,0 +1,13 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class EmailTemplateDefinitionDeleteViewModel
{
public Guid ServiceId { get; init; }
public Guid DefinitionId { get; init; }
public string ServiceName { get; init; } = "";
public string Key { get; init; } = "";
public Guid Revision { get; set; }
public string? KeyFilter { get; set; }
public IReadOnlyList<string> LanguageCodes { get; init; } = [];
public bool ConfirmDelete { get; set; }
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class NotificationServiceDeleteViewModel
{
public Guid ServiceId { get; init; }
public string Name { get; init; } = "";
public Guid Revision { get; set; }
public int DefinitionCount { get; init; }
public bool ConfirmDelete { get; set; }
}
@@ -1,12 +1,13 @@
using System.ComponentModel.DataAnnotations;
using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels; namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record CreateEmailChannelRequest( public sealed record CreateEmailChannelRequest(
string ServiceName, [Required, StringLength(100)] string ServiceName,
int Priority, int Priority,
EmailChannelType ChannelType, EmailChannelType ChannelType,
EmailChannelSettings Settings, SmtpChannelSettingsDto Settings,
int? DailyLimit, int? DailyLimit,
int? MonthlyLimit, int? MonthlyLimit,
int WarnThresholdPercent, int WarnThresholdPercent,
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record EmailChannelDto(Guid Id, string ServiceName, int Priority, EmailChannelType EmailChannelType,
SmtpChannelSettingsDto Settings, int? DailyLimit, int? MonthlyLimit, int WarnThresholdPercent,
bool IsActive, DateTimeOffset Created, DateTimeOffset? Updated);
@@ -0,0 +1,23 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
internal static class EmailChannelMapping
{
public static SmtpChannelSettings ToSettings(SmtpChannelSettingsDto settings) => new()
{
Host = settings.Host, Port = settings.Port, Username = settings.Username, Password = settings.Password,
UseSsl = settings.UseSsl, FromEmail = settings.FromEmail, FromName = settings.FromName
};
public static EmailChannelDto Map(EmailChannel channel)
{
var smtp = (SmtpChannelSettings)channel.Settings;
return new EmailChannelDto(channel.Id, channel.ServiceName, channel.Priority, channel.EmailChannelType,
new SmtpChannelSettingsDto
{
Host = smtp.Host, Port = smtp.Port, Username = smtp.Username, Password = smtp.Password,
UseSsl = smtp.UseSsl, FromEmail = smtp.FromEmail, FromName = smtp.FromName
}, channel.DailyLimit, channel.MonthlyLimit, channel.WarnThresholdPercent, channel.IsActive, channel.Created, channel.Updated);
}
}
@@ -1,8 +1,12 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Web.Controllers.Api.Services;
using HrynCo.NotificationService.Web.Infrastructure; using HrynCo.NotificationService.Web.Infrastructure;
using HrynCo.NotificationService.Services.EmailChannels.Create; using HrynCo.NotificationService.Services.EmailChannels.Create;
using HrynCo.NotificationService.Services.EmailChannels.Delete; using HrynCo.NotificationService.Services.EmailChannels.Delete;
using HrynCo.NotificationService.Services.EmailChannels.Get; using HrynCo.NotificationService.Services.EmailChannels.Get;
using HrynCo.NotificationService.Services.EmailChannels.GetByService; using HrynCo.NotificationService.Services.EmailChannels.GetByService;
using HrynCo.NotificationService.Services.EmailChannels.GetAll;
using HrynCo.NotificationService.Services.EmailChannels.Update; using HrynCo.NotificationService.Services.EmailChannels.Update;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -10,32 +14,44 @@ using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels; namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
[Route("api/v1/email-channels")] [Route("api/v1/email-channels")]
[TypeFilter(typeof(ApiRequestValidationFilter), Order = -3000)]
public sealed class EmailChannelsController : ApiControllerBase public sealed class EmailChannelsController : ApiControllerBase
{ {
public EmailChannelsController(IMediator mediator) : base(mediator) { } public EmailChannelsController(IMediator mediator) : base(mediator) { }
[HttpGet] [HttpGet]
[ProducesResponseType<ApiResponse<IReadOnlyList<EmailChannelDto>>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetAll([FromQuery] string serviceName, CancellationToken cancellationToken) public async Task<IActionResult> GetAll([FromQuery] string serviceName, CancellationToken cancellationToken)
{ {
var result = await Mediator.Send(new GetEmailChannelsQuery(serviceName), cancellationToken); var result = await Mediator.Send(new GetEmailChannelsQuery(serviceName), cancellationToken);
return FromServiceResult(result); return FromChannels(result);
} }
[HttpGet("all")]
[ProducesResponseType<ApiResponse<IReadOnlyList<EmailChannelDto>>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetAllServices(CancellationToken cancellationToken) =>
FromChannels(await Mediator.Send(new GetAllEmailChannelsQuery(), cancellationToken));
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken) [ProducesResponseType<ApiResponse<EmailChannelDto>>(StatusCodes.Status200OK)]
public async Task<IActionResult> Get([FromRoute] Guid id, CancellationToken cancellationToken)
{ {
var result = await Mediator.Send(new GetEmailChannelQuery(id), cancellationToken); var result = await Mediator.Send(new GetEmailChannelQuery(id), cancellationToken);
return FromServiceResult(result); return result.IsSuccess
? Ok(new ApiResponse<EmailChannelDto> { Success = true, Data = EmailChannelMapping.Map(result.Result!) })
: MapServiceError(result.Error!);
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Create([FromBody] CreateEmailChannelRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Create([FromBody] CreateEmailChannelRequest request, CancellationToken cancellationToken)
{ {
if (request.ChannelType != EmailChannelType.Smtp)
return MapServiceError(new ServiceError("Only SMTP channels are supported.", ServiceErrorCode.InvalidRequest));
var command = new CreateEmailChannelCommand( var command = new CreateEmailChannelCommand(
request.ServiceName, request.ServiceName,
request.Priority, request.Priority,
request.ChannelType, request.ChannelType,
request.Settings, EmailChannelMapping.ToSettings(request.Settings),
request.DailyLimit, request.DailyLimit,
request.MonthlyLimit, request.MonthlyLimit,
request.WarnThresholdPercent, request.WarnThresholdPercent,
@@ -52,12 +68,12 @@ public sealed class EmailChannelsController : ApiControllerBase
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateEmailChannelRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateEmailChannelRequest request, CancellationToken cancellationToken)
{ {
var command = new UpdateEmailChannelCommand( var command = new UpdateEmailChannelCommand(
id, id,
request.Priority, request.Priority,
request.Settings, EmailChannelMapping.ToSettings(request.Settings),
request.DailyLimit, request.DailyLimit,
request.MonthlyLimit, request.MonthlyLimit,
request.WarnThresholdPercent, request.WarnThresholdPercent,
@@ -69,9 +85,14 @@ public sealed class EmailChannelsController : ApiControllerBase
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken) public async Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken)
{ {
var result = await Mediator.Send(new DeleteEmailChannelCommand(id), cancellationToken); var result = await Mediator.Send(new DeleteEmailChannelCommand(id), cancellationToken);
return FromServiceResult(result); return FromServiceResult(result);
} }
private IActionResult FromChannels(ServiceResult<IReadOnlyList<EmailChannel>> result) =>
result.IsSuccess
? Ok(new ApiResponse<IReadOnlyList<EmailChannelDto>> { Success = true, Data = result.Result!.Select(EmailChannelMapping.Map).ToList() })
: MapServiceError(result.Error!);
} }
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed class SmtpChannelSettingsDto
{
public EmailChannelType EmailChannelType => EmailChannelType.Smtp;
[Required]
public string Host { get; init; } = "";
[Range(1, 65535)]
public int Port { get; init; } = 587;
public string Username { get; init; } = "";
public string Password { get; init; } = "";
public bool UseSsl { get; init; } = true;
[Required, EmailAddress]
public string FromEmail { get; init; } = "";
public string FromName { get; init; } = "";
}
@@ -4,7 +4,7 @@ namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record UpdateEmailChannelRequest( public sealed record UpdateEmailChannelRequest(
int Priority, int Priority,
EmailChannelSettings Settings, SmtpChannelSettingsDto Settings,
int? DailyLimit, int? DailyLimit,
int? MonthlyLimit, int? MonthlyLimit,
int WarnThresholdPercent, int WarnThresholdPercent,
@@ -0,0 +1,23 @@
using HrynCo.NotificationService.Web.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
// Runs before ApiController's default model-state filter, only for these endpoints.
public sealed class ApiRequestValidationFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
if (context.ModelState.IsValid)
return;
var errors = context.ModelState.SelectMany(field => field.Value!.Errors.Select(error =>
string.IsNullOrWhiteSpace(error.ErrorMessage) ? "Invalid JSON value at " + field.Key + "." : error.ErrorMessage));
context.Result = new BadRequestObjectResult(new ApiResponse<object>
{
Success = false, Error = new ApiError { Code = "InvalidRequest", Message = string.Join(" ", errors) }
});
}
public void OnActionExecuted(ActionExecutedContext context) { }
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
public sealed class CreateNotificationServiceRequest
{
public required string Name { get; init; }
public required IReadOnlyList<ServiceLanguageRequest> Languages { get; init; }
}
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
public sealed class DeleteServiceLanguageVersionsRequest
{
[JsonRequired]
public Guid Revision { get; init; }
[JsonRequired]
public int ExpectedVersionCount { get; init; }
[JsonRequired]
public bool ConfirmDelete { get; init; }
}
@@ -0,0 +1,61 @@
using Unit = HrynCo.NotificationService.Services.Core.Unit;
using HrynCo.NotificationService.Web.Infrastructure;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.TemplateDefinitions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
[Route("api/v1/services/{serviceId:guid}/email-template-definitions")]
[TypeFilter(typeof(ApiRequestValidationFilter), Order = -3000)]
public sealed class EmailTemplateDefinitionsController(IMediator mediator) : ApiControllerBase(mediator)
{
[HttpGet]
[ProducesResponseType<ApiResponse<EmailTemplateDefinitionDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] Guid serviceId, [FromQuery] string key, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new GetEmailTemplateDefinitionQuery(serviceId, key), cancellationToken));
[HttpPost("upsert")]
[ProducesResponseType<ApiResponse<EmailTemplateDefinitionDto>>(StatusCodes.Status201Created)]
[ProducesResponseType<ApiResponse<EmailTemplateDefinitionDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Upsert([FromRoute] Guid serviceId, [FromQuery] string key,
[FromBody] UpsertTemplateDefinitionRequest request, CancellationToken cancellationToken)
{
try
{
var patch = request.ToPatch(serviceId, key);
var result = await Mediator.Send(new UpsertEmailTemplateDefinitionCommand(patch), cancellationToken);
if (!result.IsSuccess)
return MapServiceError(result.Error!);
if (patch.Revision is null)
return CreatedAtAction(nameof(Get), new { serviceId, key },
new ApiResponse<EmailTemplateDefinitionDto> { Success = true, Data = result.Result });
return FromServiceResult(result);
}
catch (EmailTemplateWriteException error)
{
return MapServiceError(new ServiceError(error.Message, ServiceErrorCode.InvalidRequest));
}
}
[HttpGet("all")]
[ProducesResponseType<ApiResponse<IReadOnlyList<EmailTemplateDefinitionDto>>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetAll([FromRoute] Guid serviceId, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new GetServiceTemplateDefinitionsQuery(serviceId), cancellationToken));
[HttpDelete]
[ProducesResponseType<ApiResponse<Unit>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, [FromQuery] string key, [FromQuery] Guid revision, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new DeleteEmailTemplateDefinitionCommand(serviceId, key, revision), cancellationToken));
}
@@ -0,0 +1,19 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
internal static class NotificationServiceRequestMapping
{
public static NotificationServiceInfo Map(Guid? id, string name, IReadOnlyList<ServiceLanguageRequest>? languages, Guid revision)
{
if (languages is null || languages.Any(l => l is null))
throw new EmailTemplateWriteException("languages must be a JSON array without null elements.", false);
if (id is null && languages.Any(l => l.Id != Guid.Empty))
throw new EmailTemplateWriteException("New services cannot reference persisted language IDs.", false);
return new NotificationServiceInfo
{
Id = id ?? Guid.Empty, Name = name, Revision = revision,
Languages = languages.Select(l => new ServiceLanguageInfo { Id = l.Id, Code = l.Code }).ToList()
};
}
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
public sealed class ServiceLanguageRequest
{
public Guid Id { get; init; }
public required string Code { get; init; }
}
@@ -0,0 +1,94 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
using HrynCo.NotificationService.Web.Infrastructure;
using HrynCo.NotificationService.Services.TemplateDefinitions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
[Route("api/v1/services")]
[TypeFilter(typeof(ApiRequestValidationFilter), Order = -3000)]
public sealed class ServicesController(IMediator mediator) : ApiControllerBase(mediator)
{
[HttpGet("{serviceId:guid}")]
[ProducesResponseType<ApiResponse<NotificationServiceDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] Guid serviceId, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new GetNotificationServiceQuery(serviceId, null), cancellationToken));
[HttpGet]
[ProducesResponseType<ApiResponse<NotificationServiceDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetByName([FromQuery] string? name, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new GetNotificationServiceQuery(null, name), cancellationToken));
[HttpGet("all")]
[ProducesResponseType<ApiResponse<IReadOnlyList<NotificationServiceDto>>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetAll(CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new GetAllNotificationServicesQuery(), cancellationToken));
[HttpDelete("{serviceId:guid}")]
[ProducesResponseType<ApiResponse<Unit>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, [FromQuery] Guid revision, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new DeleteNotificationServiceCommand(serviceId, revision), cancellationToken));
[HttpPost]
[ProducesResponseType<ApiResponse<NotificationServiceDto>>(StatusCodes.Status201Created)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public Task<IActionResult> Create([FromBody] CreateNotificationServiceRequest request, CancellationToken cancellationToken) =>
SaveServiceAsync(null, request.Name, request.Languages, Guid.Empty, cancellationToken);
[HttpPut("{serviceId:guid}")]
[ProducesResponseType<ApiResponse<NotificationServiceDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public Task<IActionResult> Update([FromRoute] Guid serviceId, [FromBody] UpdateNotificationServiceRequest request, CancellationToken cancellationToken) =>
SaveServiceAsync(serviceId, request.Name, request.Languages, request.Revision, cancellationToken);
[HttpDelete("{serviceId:guid}/languages/{languageId:guid}")]
[ProducesResponseType<ApiResponse<Unit>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeleteUnusedLanguage([FromRoute] Guid serviceId, [FromRoute] Guid languageId,
[FromQuery] Guid revision, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new DeleteUnusedServiceLanguageCommand(serviceId, languageId, revision), cancellationToken));
[HttpPost("{serviceId:guid}/languages/{languageId:guid}/delete-with-versions")]
[ProducesResponseType<ApiResponse<Unit>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeleteLanguageWithVersions([FromRoute] Guid serviceId, [FromRoute] Guid languageId,
[FromBody] DeleteServiceLanguageVersionsRequest request, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new DeleteServiceLanguageVersionsCommand(serviceId, languageId, request.Revision,
request.ExpectedVersionCount, request.ConfirmDelete), cancellationToken));
private async Task<IActionResult> SaveServiceAsync(Guid? id, string name, IReadOnlyList<ServiceLanguageRequest> languages, Guid revision, CancellationToken cancellationToken)
{
try
{
var model = NotificationServiceRequestMapping.Map(id, name, languages, revision);
var result = await Mediator.Send(new SaveNotificationServiceCommand(model, id is null), cancellationToken);
if (!result.IsSuccess)
return MapServiceError(result.Error!);
if (id is null)
return CreatedAtAction(nameof(Get), new { serviceId = result.Result!.Id },
new ApiResponse<NotificationServiceDto> { Success = true, Data = result.Result });
return FromServiceResult(result);
}
catch (EmailTemplateWriteException error)
{
return MapServiceError(new ServiceError(error.Message, ServiceErrorCode.InvalidRequest));
}
}
}
@@ -0,0 +1,9 @@
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
public sealed class TemplateTranslationRequest
{
public required string LanguageCode { get; init; }
public required string Subject { get; init; }
public required string HtmlBody { get; init; }
public required string TextBody { get; init; }
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
public sealed class TemplateVariableRequest
{
public required string Name { get; init; }
[JsonRequired]
public bool Required { get; init; }
}
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
public sealed class UpdateNotificationServiceRequest
{
[JsonRequired]
public Guid Revision { get; init; }
public required string Name { get; init; }
public required IReadOnlyList<ServiceLanguageRequest> Languages { get; init; }
}
@@ -0,0 +1,30 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
public sealed class UpsertTemplateDefinitionRequest
{
public Guid? Revision { get; init; }
public required IReadOnlyList<TemplateVariableRequest> Variables { get; init; }
public required IReadOnlyList<TemplateTranslationRequest> Translations { get; init; }
public IReadOnlyList<string>? RemoveLanguageCodes { get; init; }
public EmailTemplateDefinitionPatch ToPatch(Guid serviceId, string key)
{
if (Variables is null || Translations is null)
throw new EmailTemplateWriteException("variables and translations must be JSON arrays, including when empty.", false);
if (Variables.Any(v => v is null) || Translations.Any(t => t is null))
throw new EmailTemplateWriteException("variables and translations cannot contain null elements.", false);
return new EmailTemplateDefinitionPatch
{
ServiceId = serviceId, Key = key, Revision = Revision,
Variables = Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(),
Translations = Translations.Select(t => new EmailTemplate
{
ServiceName = "", Key = key, LanguageCode = t.LanguageCode, Created = DateTimeOffset.UtcNow,
Subject = t.Subject, HtmlBody = t.HtmlBody, TextBody = t.TextBody
}).ToList(),
RemoveLanguageCodes = RemoveLanguageCodes ?? []
};
}
}
@@ -37,16 +37,16 @@ else
{ {
<div class="card shadow-sm table-card"> <div class="card shadow-sm table-card">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover table-sm mb-0"> <table class="table table-hover admin-data-table table-sm mb-0">
<thead class="table-dark"> <thead>
<tr> <tr>
<th>Service</th> <th scope="col">Service</th>
<th>Type</th> <th scope="col">Type</th>
<th>Priority</th> <th scope="col">Priority</th>
<th>Status</th> <th scope="col">Status</th>
<th>Today</th> <th scope="col">Today</th>
<th>This Month</th> <th scope="col">This Month</th>
<th class="text-end">Actions</th> <th scope="col" class="channel-actions">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -106,7 +106,7 @@ else
<small class="text-muted">@monthlyLabel</small> <small class="text-muted">@monthlyLabel</small>
} }
</td> </td>
<td class="text-end"> <td class="channel-actions">
<a href="/admin/channels/@c.ChannelId" <a href="/admin/channels/@c.ChannelId"
class="btn btn-sm btn-outline-primary me-1"> class="btn btn-sm btn-outline-primary me-1">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
@@ -0,0 +1,24 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model NotificationServiceDeleteViewModel
@{ ViewData["Title"] = "Delete service"; }
<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 active" aria-current="page">Delete service</li></ol></nav>
<h2>Delete service @Model.Name?</h2>
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
@if (Model.DefinitionCount > 0)
{
<div class="alert alert-warning">This service still has <strong>@Model.DefinitionCount template definition(s)</strong>, including definitions without translations. Remove every definition before deleting the service.</div>
<a href="/admin/services/@Model.ServiceId" class="btn btn-primary mb-3">View templates</a>
}
else
{
<div class="alert alert-danger">This permanently deletes the service and its language catalog.</div>
}
<p>Email channels are retained. Their service names are independent and are not renamed or deleted.</p>
<form asp-action="DeleteService" asp-route-serviceId="@Model.ServiceId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Revision" type="hidden" />
<div class="form-check mb-3"><input asp-for="ConfirmDelete" class="form-check-input" disabled="@(Model.DefinitionCount > 0)" /><label asp-for="ConfirmDelete" class="form-check-label">I confirm deletion of service @Model.Name and its language catalog.</label></div>
<div class="d-flex flex-wrap gap-2"><button type="submit" class="btn btn-danger" disabled="@(Model.DefinitionCount > 0)">Delete service</button><a class="btn btn-secondary" href="/admin/services">Cancel</a></div>
</form>
</div>
@@ -6,7 +6,7 @@
ViewData["EditorTitle"] = ViewData["Title"]; ViewData["EditorTitle"] = ViewData["Title"];
} }
@section Breadcrumbs { @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> <nav aria-label="Breadcrumb" class="service-editor-breadcrumbs"><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"> <form asp-action="Save" id="serviceForm" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
@@ -17,7 +17,7 @@
<h3 class="h5">Language codes</h3> <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> <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"> <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"> <table class="table admin-data-table service-language-table"><thead><tr><th scope="col">Code</th><th scope="col">Versions</th><th scope="col" class="service-language-actions">Actions</th></tr></thead><tbody id="serviceLanguages">
@for (var i = 0; i < Model.Languages.Count; i++) @for (var i = 0; i < Model.Languages.Count; i++)
{ {
var countUnavailable = ViewData["LanguageCountsUnavailable"] is true && Model.Languages[i].Id != Guid.Empty; var countUnavailable = ViewData["LanguageCountsUnavailable"] is true && Model.Languages[i].Id != Guid.Empty;
@@ -2,13 +2,16 @@
@model IReadOnlyList<NotificationServiceInfo> @model IReadOnlyList<NotificationServiceInfo>
@{ ViewData["Title"] = "Services"; } @{ ViewData["Title"] = "Services"; }
<div class="page-header"><h2>Services</h2><a href="/admin/services/create" class="btn btn-primary">Create service</a></div> <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> } @if (Model.Count == 0)
{
<div class="card shadow-sm table-card"><div class="empty-state"><i class="bi bi-collection" aria-hidden="true"></i><p class="mt-2 mb-0">No services yet.</p><a href="/admin/services/create" class="btn btn-primary btn-sm mt-3">Create First Service</a></div></div>
}
else 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> <div class="table-responsive"><table class="table table-hover admin-data-table"><thead><tr><th scope="col">Name</th><th scope="col">Language codes</th><th scope="col" class="service-actions">Action</th></tr></thead><tbody>
@foreach (var service in Model) @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> <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"><div class="list-action-links"><a href="/admin/services/@service.Id/edit" class="btn btn-outline-primary btn-sm" aria-label="Edit service @service.Name">Edit</a><a href="/admin/services/@service.Id/delete" class="btn btn-outline-danger btn-sm" aria-label="Delete service @service.Name">Delete</a></div></td></tr>
} }
</tbody></table></div> </tbody></table></div>
} }
@@ -0,0 +1,17 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@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>
<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>
<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>
<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="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>
@@ -86,7 +86,7 @@
<div class="template-preview-section-title">Rendered subject</div> <div class="template-preview-section-title">Rendered subject</div>
<div id="previewSubject" class="template-preview-subject"></div> <div id="previewSubject" class="template-preview-subject"></div>
<div class="template-preview-section-title mt-3">Rendered HTML</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> <iframe id="previewHtmlFrame" class="template-preview-frame" sandbox="allow-same-origin" referrerpolicy="no-referrer" title="Email HTML preview"></iframe>
<div class="template-preview-section-title mt-3">Rendered text</div> <div class="template-preview-section-title mt-3">Rendered text</div>
<pre id="previewText" class="template-preview-text mb-0"></pre> <pre id="previewText" class="template-preview-text mb-0"></pre>
</div> </div>
@@ -98,5 +98,6 @@
<a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a> <a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a>
} }
@section Scripts { @section Scripts {
<script src="~/js/email-template-preview.js" asp-append-version="true"></script>
<script src="~/js/email-template-editor.js" asp-append-version="true"></script> <script src="~/js/email-template-editor.js" asp-append-version="true"></script>
} }
@@ -5,9 +5,9 @@
var serviceId = Model.Service.Id; var serviceId = Model.Service.Id;
var keyFilter = Model.KeyFilter; 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> <nav aria-label="Breadcrumb" class="service-template-breadcrumbs"><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"> <div class="page-header">
<h2>@Model.Service.Name</h2> <h2 class="service-template-service-name">@Model.Service.Name</h2>
<a href="/admin/services/@serviceId/edit" class="btn btn-outline-primary">Edit Service</a> <a href="/admin/services/@serviceId/edit" class="btn btn-outline-primary">Edit Service</a>
</div> </div>
<div class="service-template-catalog mb-3" aria-label="Configured service languages"> <div class="service-template-catalog mb-3" aria-label="Configured service languages">
@@ -29,7 +29,7 @@
<a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a> <a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a>
</div> </div>
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></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> <div class="table-responsive"><table class="table table-hover admin-data-table service-template-grid"><thead><tr>
<th scope="col"> <th scope="col">
<form method="get" action="/admin/services/@serviceId" class="template-key-filter"> <form method="get" action="/admin/services/@serviceId" class="template-key-filter">
<label for="key" class="form-label fw-semibold">Template Key</label> <label for="key" class="form-label fw-semibold">Template Key</label>
@@ -45,7 +45,20 @@
</tr></thead><tbody> </tr></thead><tbody>
@if (Model.Definitions.Count == 0) @if (Model.Definitions.Count == 0)
{ {
<tr><td colspan="3">No email templates found for this service.</td></tr> <tr><td colspan="3"><div class="empty-state">
@if (!ViewData.ModelState.IsValid)
{
<p class="mb-0">Templates are unavailable. Review the error and try again.</p>
}
else if (!string.IsNullOrWhiteSpace(keyFilter))
{
<i class="bi bi-search" aria-hidden="true"></i><p class="mt-2 mb-0">No templates match this key.</p><a href="/admin/services/@serviceId" class="btn btn-primary btn-sm mt-3">Clear filter</a>
}
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>
}
</div></td></tr>
} }
@foreach (var template in Model.Definitions) @foreach (var template in Model.Definitions)
{ {
@@ -71,7 +84,7 @@
</ul> </ul>
} }
</td> </td>
<td class="template-actions"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @template.Key">Edit</a></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>
</tr> </tr>
} }
</tbody></table></div> </tbody></table></div>
@@ -16,10 +16,11 @@
<link href="/css/admin.css" rel="stylesheet" /> <link href="/css/admin.css" rel="stylesheet" />
</head> </head>
<body> <body>
<nav class="navbar navbar-dark bg-dark px-3 admin-navbar"> <nav class="navbar navbar-dark bg-dark admin-navbar">
<a class="navbar-brand fw-bold" href="/"> <a class="navbar-brand fw-bold" href="/">
<i class="bi bi-bell-fill me-1"></i> Notification Service <i class="bi bi-bell-fill me-1"></i> Notification Service
</a> </a>
<a class="admin-api-documentation" href="/scalar/v1" target="_blank" rel="noopener noreferrer" aria-label="API documentation (opens in a new tab)">API documentation</a>
</nav> </nav>
<div class="container-fluid"> <div class="container-fluid">
@@ -43,6 +44,7 @@
</div> </div>
<script src="/lib/bootstrap/bootstrap.bundle.min.js"></script> <script src="/lib/bootstrap/bootstrap.bundle.min.js"></script>
<script src="~/js/admin-tables.js" asp-append-version="true"></script>
@RenderSection("Scripts", required: false) @RenderSection("Scripts", required: false)
</body> </body>
</html> </html>
@@ -20,7 +20,10 @@ body {
.admin-navbar { .admin-navbar {
border-bottom: 1px solid rgba(255,255,255,.07); border-bottom: 1px solid rgba(255,255,255,.07);
box-shadow: 0 2px 6px rgba(0,0,0,.4); box-shadow: 0 2px 6px rgba(0,0,0,.4);
height: 52px; min-height: 52px;
align-items: center;
gap: .5rem;
padding-inline: 1rem;
} }
.admin-navbar .navbar-brand { .admin-navbar .navbar-brand {
@@ -29,6 +32,27 @@ body {
letter-spacing: .02em; letter-spacing: .02em;
} }
.admin-api-documentation {
margin-left: auto;
padding-block: .25rem;
color: #f8f9fa;
font-size: .85rem;
white-space: nowrap;
}
.admin-api-documentation:hover { color: #fff; }
.admin-api-documentation:focus-visible {
outline: 2px solid #f8f9fa;
outline-offset: 3px;
}
@media (max-width: 575.98px) {
.admin-navbar { padding-inline: .5rem; }
.admin-navbar .navbar-brand { margin-right: 0; font-size: .85rem; }
.admin-api-documentation { font-size: .8rem; }
}
/* ── Sidebar ──────────────────────────────────────────── */ /* ── Sidebar ──────────────────────────────────────────── */
.sidebar { .sidebar {
min-height: calc(100vh - 52px); min-height: calc(100vh - 52px);
@@ -109,21 +133,44 @@ body {
.table tbody tr td { vertical-align: middle; } .table tbody tr td { vertical-align: middle; }
.table thead th { font-size: .75rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; } .table thead th { font-size: .75rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
/* ── Administration list grids ────────────────────────── */
.admin-data-table { border: 1px solid #b8c4d3; }
.admin-data-table :is(a, button, input, select, textarea) { scroll-margin-inline: .5rem; }
.admin-data-table > thead > tr > th {
--bs-table-bg: #dce7f6;
--bs-table-color: #243a55;
border-bottom: 2px solid #aec0db;
vertical-align: middle;
}
.admin-data-table > tbody > tr > td:first-child { overflow-wrap: anywhere; }
.admin-data-table > :not(caption) > * > * + * {
border-left: 1px solid #b8c4d3;
}
.admin-data-table .list-action-links { display: inline-flex; align-items: center; gap: .375rem; }
.admin-delete-confirmation { overflow-wrap: anywhere; }
/* ── List actions ─────────────────────────────────────── */ /* ── List actions ─────────────────────────────────────── */
.table .template-actions, .admin-data-table .template-actions,
.table .service-actions { .admin-data-table .service-actions,
.admin-data-table .channel-actions {
width: 1%; width: 1%;
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: right;
} }
/* ── Service template catalog and filters ─────────────── */ .page-header .service-template-service-name {
.service-template-grid > thead > tr > th { min-width: 0;
--bs-table-bg: #dce7f6; overflow-wrap: anywhere;
--bs-table-color: #243a55;
border-bottom: 2px solid #aec0db;
} }
.service-template-service-name + .btn { flex-shrink: 0; }
/* ── Service template catalog and filters ─────────────── */
.service-template-grid .template-key-filter .btn-outline-secondary { .service-template-grid .template-key-filter .btn-outline-secondary {
--bs-btn-bg: #fff; --bs-btn-bg: #fff;
} }
@@ -154,6 +201,7 @@ body {
.template-key-filter-controls { .template-key-filter-controls {
display: flex; display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: .375rem; gap: .375rem;
} }
@@ -302,7 +350,9 @@ body {
} }
/* ── Template service context ─────────────────────────── */ /* ── Template service context ─────────────────────────── */
.template-editor-breadcrumbs .breadcrumb-item { .template-editor-breadcrumbs .breadcrumb-item,
.service-template-breadcrumbs .breadcrumb-item,
.service-editor-breadcrumbs .breadcrumb-item {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -473,7 +523,7 @@ body {
.template-preview-frame { .template-preview-frame {
width: 100%; width: 100%;
height: 420px; height: 80px;
border: 1px solid #cfd8e3; border: 1px solid #cfd8e3;
border-radius: .5rem; border-radius: .5rem;
background: #eef2f7; background: #eef2f7;
@@ -495,9 +545,6 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.template-preview-frame {
height: 360px;
}
} }
/* ── Form-section divider ─────────────────────────────── */ /* ── Form-section divider ─────────────────────────────── */
@@ -0,0 +1,19 @@
// Reveal keyboard-focused controls only within their nearest horizontal table scroller.
(() => {
let keyboardFocus = false;
document.addEventListener('keydown', () => { keyboardFocus = true; }, true);
document.addEventListener('pointerdown', () => { keyboardFocus = false; }, true);
document.addEventListener('focusin', event => {
const target = event.target;
if (!keyboardFocus || !(target instanceof HTMLElement) || !target.closest('.admin-data-table')) return;
const wrapper = target.closest('.table-responsive');
if (!wrapper) return;
const control = target.getBoundingClientRect();
const viewport = wrapper.getBoundingClientRect();
// Leave space for the focus ring without moving the document or vertical ancestors.
const left = viewport.left + wrapper.clientLeft + 8;
const right = left + wrapper.clientWidth - 16;
if (control.left < left) wrapper.scrollLeft += control.left - left;
else if (control.right > right) wrapper.scrollLeft += control.right - right;
});
})();
@@ -0,0 +1,40 @@
// Trusted parent-side measurement; template scripts stay disabled by sandbox and CSP.
(() => {
const frame = document.getElementById('previewHtmlFrame');
let observer;
let scheduled = 0;
function resizePreview() {
scheduled = 0;
const document = frame.contentDocument;
if (!document?.body) return;
frame.style.height = '0px';
const height = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
frame.style.height = `${Math.max(80, Math.ceil(height) + 2)}px`;
}
function scheduleResize() {
cancelAnimationFrame(scheduled);
scheduled = requestAnimationFrame(resizePreview);
}
function preventLinkNavigation(event) {
if (event.target?.closest?.('a, area')) event.preventDefault();
}
frame.addEventListener('load', () => {
observer?.disconnect();
const document = frame.contentDocument;
if (!document?.body) return;
document.addEventListener('click', preventLinkNavigation, true);
document.addEventListener('submit', event => event.preventDefault(), true);
observer = new ResizeObserver(scheduleResize);
observer.observe(document.body);
scheduleResize();
});
window.addEventListener('resize', scheduleResize);
window.addEventListener('pagehide', () => {
observer?.disconnect();
cancelAnimationFrame(scheduled);
});
})();
+1
View File
@@ -5,6 +5,7 @@ Production image references now use `registry.hrynco.com`, preserving the existi
## Documentation ## Documentation
- [Email template definitions and service languages](docs/email-template-localizations.md) - [Email template definitions and service languages](docs/email-template-localizations.md)
- [Service and whole-template API for AI clients](docs/email-template-definition-api.md)
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md) - [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
- [Shared Notification Service Kubernetes package](docker/kubernetes/shared-notification/README.md) - [Shared Notification Service Kubernetes package](docker/kubernetes/shared-notification/README.md)
+109
View File
@@ -0,0 +1,109 @@
# Service and template-definition API — IT-1601
The owner approved these AI-client endpoints on 2026-10-17. They use the existing application API response convention and camelCase JSON. No application authentication was added, following the owner's managed-tunnel access decision. Restrict access at the deployment boundary. Tunnel and Kubernetes configuration have **not** been verified by this implementation. Existing administration, language-specific API and worker behavior remain intact.
## Reads
| Method and route | Result |
| --- | --- |
| `GET /api/v1/services/all` | All services, including each revision and exact supported codes. |
| `GET /api/v1/services/{serviceId}` | Service ID, current name, service `revision`, exact `supportedLanguageCodes` array, and `languages` objects with stable `id`, exact `code`, and current `versionCount`. |
| `GET /api/v1/services?name={encodedExactName}` | The same single-service object, using an exact, case-sensitive name lookup. This is not a service-list/search endpoint. A missing/blank name returns 400. |
| `GET /api/v1/services/{serviceId}/email-template-definitions?key={encodedKey}` | Whole definition: `id`, `serviceId`, `serviceName`, `key`, `revision`, shared `variables`, every existing `translations` version, and definition `created`/`updated` timestamps. |
Missing services or definitions return 404. Service IDs remain stable through renames. Name, key and language codes are never trimmed or case-folded. The exact `key` is a required query parameter on both definition endpoints; a missing/blank key returns 400. Encode it as a query value (for example `?key=Welcome%2Fchild` for `Welcome/child`, or `?key=Welcome%252Fchild` for the literal key `Welcome%2Fchild`). Normal query decoding preserves that distinction without custom raw-path parsing. Encode spaces, Unicode, percent signs, plus signs, `#` and `?` using the HTTP client's query builder. Proxy/tunnel configuration is outside the completed validation.
A variable is `{ "name": "Name", "required": true }`. A translation contains `id`, `languageCode`, `subject`, `htmlBody`, `textBody`, `created`, and `updated`. Reads include only persisted versions; an empty definition returns `translations: []`. Missing service languages do not create placeholder versions.
`GET /api/v1/services/{serviceId}/email-template-definitions/all` returns complete definition DTOs for only that service, including definitions with zero versions; an existing empty service returns `[]` and a missing service returns 404.
## Service and catalog management
`POST /api/v1/services` accepts `{ "name": "ExampleService", "languages": [{ "code": "en" }, { "code": "uk" }] }`. It returns 201 with a Location to the service read and the complete service DTO. Names are exact and unique. The required languages array may be empty; new services cannot reference persisted language IDs.
`PUT /api/v1/services/{serviceId}` accepts the last-read nonempty `revision`, `name`, and a required full `languages` array. Retain each existing language's ID from the read DTO; omit/empty the ID for a new code. The returned service DTO includes the new revision and generated language IDs. Omitted unused catalog rows are removed; omitting a used language returns 409 with no changes. A stale revision returns 409; a missing service returns 404. Existing migrated nonstandard codes remain valid unchanged, and the existing stricter syntax validation applies only to added/renamed codes. Renames do not rewrite independently configured channel service names.
`DELETE /api/v1/services/{serviceId}/languages/{languageId}?revision={serviceRevision}` removes an unused catalog row only. Used languages return 409; use the explicit destructive endpoint when the versions should be deleted:
```text
POST /api/v1/services/{serviceId}/languages/{languageId}/delete-with-versions
```
```json
{ "revision": "22222222-2222-2222-2222-222222222222", "expectedVersionCount": 2, "confirmDelete": true }
```
All three body properties are required. The nonnegative count and service revision must still match under the existing mutation lock. Missing/false confirmation or invalid values return 400; count/revision changes return 409 without deletion. Success returns 200 with `ApiResponse<Unit>` and deletes that language plus exactly its current versions, retaining definitions/shared contracts. Reread the service and explicitly reconfirm after a conflict. These API operations reuse the same safeguards as the existing separate admin confirmation page; no modal UX was introduced.
## Revision-guarded deletion
- `DELETE /api/v1/services/{serviceId}/email-template-definitions?key={encodedKey}&revision={lastReadDefinitionRevision}` removes the selected definition and all of its versions atomically.
- `DELETE /api/v1/services/{serviceId}?revision={lastReadServiceRevision}` requires **every definition** to have been removed first, including zero-version definitions; otherwise it returns 409. It removes the remaining catalog languages and service atomically.
Both endpoints return HTTP 200 with the existing `ApiResponse<Unit>` success envelope (`data: {}`). A missing resource returns 404, missing/empty/malformed revision or invalid key returns 400, and stale revision returns 409. Every check and mutation runs under the common transaction advisory lock. Concurrent definition creation cannot race past the service's nonempty check. These operations do not remove email channels, whose service name is a separate identifier without a service foreign key.
## Atomic upsert
`POST /api/v1/services/{serviceId}/email-template-definitions/upsert?key={encodedKey}`
```json
{
"revision": null,
"variables": [{ "name": "Name", "required": true }],
"translations": [
{
"languageCode": "en",
"subject": "Hello {{Name}}",
"htmlBody": "<p>Hello {{Name}}</p>",
"textBody": "Hello {{Name}}"
}
],
"removeLanguageCodes": []
}
```
- A null or omitted `revision` means **create only**. An existing service/key returns 409. A nonempty GUID revision means **update only** and must equal the last-read revision. A missing definition or stale token returns 409; an empty GUID or malformed revision returns 400. The route supplies the stable service ID and the required query parameter supplies the exact key.
- `variables` and `translations` are required JSON arrays, including when empty; missing/null arrays and null elements return 400. Each variable needs a unique nonblank `name` and an explicitly supplied boolean `required`. Variables replace the entire shared contract, including for translations omitted from the request. `variables: []` clears it.
- Sent translations add or replace only their exact codes. Every sent version requires nonblank subject, HTML body and text body. Duplicate translation codes return 400. Existing IDs/created timestamps are retained for updates; new versions receive new identities. Omitted versions retain all their content and timestamps.
- Optional `removeLanguageCodes` names the exact versions to delete. It defaults to an empty list. It must contain unique nonblank codes, cannot overlap sent translations, and cannot name codes absent from the service catalog. Removing an already absent version for a supported code is a no-op. Removing the final version keeps the definition and contract. Supplying empty content is an error; use the explicit removal list.
- Both sent and removed codes must exactly match the current service catalog. `RU`, `ru` and `ru-RU` are distinct. No language is added to the catalog by this API.
- A successful create returns HTTP **201 Created** with a `Location` pointing to the definition GET route (including the correctly encoded `key` query value); a successful update returns HTTP **200 OK**. Both return the **complete saved definition and its new revision**. Any successful upsert advances the revision, including a valid request that leaves content unchanged. Use that revision on the next update.
The service/catalog lookup, validation, revision check, explicit removals, translation merge and shared-contract replacement run under the existing PostgreSQL transaction/advisory lock. There is no read/merge outside the lock and no nested mutation runner. Invalid or conflicting requests save nothing. Concurrent create requests have one winner; updates sharing a revision have one winner. Administration, service catalog changes and legacy language writes advance definition revisions and therefore invalidate stale AI-client revisions.
## Email channels
- `GET /api/v1/email-channels/all` enumerates **all** channels, including channels whose exact `serviceName` has no service row.
- Existing `GET /api/v1/email-channels?serviceName={exactName}`, GET by ID, POST, PUT by ID and DELETE by ID remain available. Create returns 201/Location and its existing GUID response; update/delete return 200/Unit.
- Create supports SMTP (`channelType: 1`). Request/response `settings` explicitly maps `host`, `port`, `username`, `password`, `useSsl`, `fromEmail`, and `fromName`; reads no longer lose derived SMTP fields through an abstract JSON type. Host/fromEmail and a valid port are required. The optional empty username/password support unauthenticated development SMTP.
- Existing PUT behavior is retained: it changes settings, priority, limits, warning threshold and active state, **not** serviceName or channel type. Service names have no service FK and no rename cascade. Read all channels separately when performing an explicitly authorized cleanup; deleting a service does not delete its channels.
- SMTP HTTP lifecycle tests cover create/read/update/filter/all/delete. The repository ID lookup is a no-tracking domain projection so an update can attach its mapped entity without an EF tracking conflict. No SMTP send or owner-channel cleanup is implied by these tests.
## Responses
```json
{
"success": true,
"data": {
"id": "11111111-1111-1111-1111-111111111111",
"name": "ExampleService",
"revision": "22222222-2222-2222-2222-222222222222",
"supportedLanguageCodes": ["en", "uk"],
"languages": [
{ "id": "33333333-3333-3333-3333-333333333333", "code": "en", "versionCount": 1 },
{ "id": "44444444-4444-4444-4444-444444444444", "code": "uk", "versionCount": 0 }
]
},
"error": null
}
```
Errors use `success: false`, null `data`, and `error: { "code": "InvalidRequest|NotFound|Conflict", "message": "..." }`. New-endpoint JSON/model-binding errors use that envelope too. Invalid content produces 400, absent read resources produce 404, and duplicate/stale/missing-definition writes produce 409. Read the current definition after a 409, reconcile the intended changes, and send its current revision; do not retry by discarding the revision.
The revision is an optimistic-concurrency token, not a historical snapshot or restore handle. No history storage, restore API/UI, or migration was introduced for this extension.
## Verification scope
Disposable PostgreSQL integration tests cover service lookup/rename, whole-definition reads, create/update, required arrays/boolean presence, exact codes, explicit removals, empty definitions, retention of omitted versions and their IDs/content/timestamps, shared contract replacement, invalid-save atomicity, duplicate/stale conflicts, concurrent requests, encoded keys and creation-Location round trips, and legacy-write revision invalidation. The full existing suite remains the regression gate for administration, migrations and the worker. Separate local HTTP checks exercise the host Web against task-owned infrastructure; no owner database or running Rider environment is used. Deployment/tunnel/Kubernetes validation is not implied by these tests.
On 2026-10-17 the solution build and all 148 tests passed after the REST CRUD, 201/Location and administration follow-ups (32 service/worker tests and 116 PostgreSQL integration tests, including 29 new definition-API cases and 3 administration-navigation checks). A separate host Web at `127.0.0.1:52656`, backed by the task-owned PostgreSQL container on port `58519`, passed service reads, exact encoded-key round trips (slash/literal `%2F`/Unicode), 201-created Location round trips and 200-updates, omitted-version retention, duplicate/stale conflicts, required-boolean rejection with no changes, final-version removal retaining the definition, required-key errors, and exact-name case checks. Its OpenAPI document exposed the typed response schemas and required request fields. The task Web and disposable database were stopped/removed afterward. No owner database, containers or Rider process were modified. Existing Microsoft.OpenApi NU1903 dependency warnings remain.
+16 -2
View File
@@ -9,12 +9,14 @@ This is internal administration for the independently released HrynCo Notificati
- `email_template_definitions`: ID, service ID, key, one shared variables JSON array, revision and timestamps; unique `(service_id, key)`. - `email_template_definitions`: ID, service ID, key, one shared variables JSON array, revision and timestamps; unique `(service_id, key)`.
- `email_templates`: existing language-version ID, definition ID, service ID, service-language ID, subject, HTML body, text body and timestamps; unique `(definition_id, service_language_id)`. - `email_templates`: existing language-version ID, definition ID, service ID, service-language ID, subject, HTML body, text body and timestamps; unique `(definition_id, service_language_id)`.
Composite foreign keys from a version's `(definition_id, service_id)` and `(service_language_id, service_id)` enforce that its language and definition belong to the same service. Service and service-language deletion are restricted by foreign keys; deleting a definition cascades to its versions. The administration does not expose service/definition deletion. Removing a version never removes its definition. Zero-version definitions retain their key, ID and shared variables. Composite foreign keys from a version's `(definition_id, service_id)` and `(service_language_id, service_id)` enforce that its language and definition belong to the same service. Service and service-language deletion are restricted by foreign keys; deleting a definition cascades to its versions. Service and template lists expose Delete links to separate confirmation pages. The owner-authorized REST API has explicit revision-guarded definition/service DELETE operations; service deletion requires all definitions to have been removed first. Removing a version never removes its definition. Zero-version definitions retain their key, ID and shared variables.
Codes are case-sensitive exact identifiers. `ru`, `ru-RU`, `en` and `EN` remain distinct. Repository and API reads use exact codes without trimming, case folding or locale substitution. The worker first requests the exact code; only after a miss does it retry `LanguageCode.Trim().ToLowerInvariant()` when that string differs ordinally. It never scans case-insensitively, substitutes another region/language or modifies stored codes. New/edited catalog codes allow letters, numbers and hyphens, up to ten characters, with no blank code or empty hyphen segment. This is syntax validation, not a supported-language allowlist. Migration retains existing nonblank codes exactly. Unchanged migrated identifiers such as `en_US` remain valid during service edits; stricter syntax validation applies only to newly added or actually renamed code rows. Codes are case-sensitive exact identifiers. `ru`, `ru-RU`, `en` and `EN` remain distinct. Repository and API reads use exact codes without trimming, case folding or locale substitution. The worker first requests the exact code; only after a miss does it retry `LanguageCode.Trim().ToLowerInvariant()` when that string differs ordinally. It never scans case-insensitively, substitutes another region/language or modifies stored codes. New/edited catalog codes allow letters, numbers and hyphens, up to ten characters, with no blank code or empty hyphen segment. This is syntax validation, not a supported-language allowlist. Migration retains existing nonblank codes exactly. Unchanged migrated identifiers such as `en_US` remain valid during service edits; stricter syntax validation applies only to newly added or actually renamed code rows.
## Administration ## Administration
The dark navbar includes **API documentation** at the right, opening `/scalar/v1` in a new tab with an explicit accessible new-tab label and safe opener attributes. All four administration data tables (Services, service templates, Email Channels and embedded Service Languages) explicitly use the shared `admin-data-table` style: contrasting blue headers, outer borders, vertical column separators and vertically centered column headings. Vendor tables are not globally overridden. The template list retains its white filter controls and compact Action column; channel actions and the existing responsive table wrappers remain available. Navbar items and filter controls are vertically centered. When a data-table control receives keyboard focus, only its nearest responsive wrapper's horizontal scroll position is adjusted to expose the full control and focus ring, including newly added language rows. This helper never scrolls the document or vertical ancestors and does not run for pointer-originated focus. Long service names wrap inside list cells and the selected-service heading/breadcrumb rather than widening the page.
`/admin/services` lists services. Its create/edit form manages Name and an inline table of language codes. Successful creation or editing, including a rename, returns to `/admin/services/{serviceId}` and its template list; validation/save errors retain the service form and entered values. The service page's **Edit Service** action uses the primary blue outline style. There is no separate language editor. Existing code rows retain their IDs when renamed. A transaction-local staging step frees old codes before final assignment so valid code swaps and replacements respect uniqueness without changing version identities. Intermediate codes are never committed or visible to other transactions. Renaming a service or code changes the identifiers used by template API clients and senders; publishers and independently configured email-channel service names must be aligned by the administrator. `/admin/services` lists services. Its create/edit form manages Name and an inline table of language codes. Successful creation or editing, including a rename, returns to `/admin/services/{serviceId}` and its template list; validation/save errors retain the service form and entered values. The service page's **Edit Service** action uses the primary blue outline style. There is no separate language editor. Existing code rows retain their IDs when renamed. A transaction-local staging step frees old codes before final assignment so valid code swaps and replacements respect uniqueness without changing version identities. Intermediate codes are never committed or visible to other transactions. Renaming a service or code changes the identifiers used by template API clients and senders; publishers and independently configured email-channel service names must be aligned by the administrator.
Ordinary removal of an unused language row is saved with the service. For a used language, the form displays its affected version count and blocks ordinary removal. The server repeats this check using the current database count. After a validation or save error, usage counts are reloaded by persisted language ID while preserving entered names/codes and the original revision. If counts cannot be loaded, their display is unavailable and ordinary removal stays disabled. Ordinary removal of an unused language row is saved with the service. For a used language, the form displays its affected version count and blocks ordinary removal. The server repeats this check using the current database count. After a validation or save error, usage counts are reloaded by persisted language ID while preserving entered names/codes and the original revision. If counts cannot be loaded, their display is unavailable and ordinary removal stays disabled.
@@ -33,7 +35,11 @@ The language tabs and their Subject/HTML/Text fields form one bordered sheet, wi
Service and template editor breadcrumbs appear above the editor card, followed by a prominent semantic page heading in its blue header. The shared editor layout keeps breadcrumbs optional so the channel editor continues to work without a breadcrumb section. In the template editor, the selected Service is plain semantic context with a prominent wrapping name beside Template Key; its smaller Edit Service and language codes link remains a separate keyboard-focusable action. Long service names wrap in both the context and template breadcrumb. The hidden service identity and routes are unchanged. Service and template editor breadcrumbs appear above the editor card, followed by a prominent semantic page heading in its blue header. The shared editor layout keeps breadcrumbs optional so the channel editor continues to work without a breadcrumb section. In the template editor, the selected Service is plain semantic context with a prominent wrapping name beside Template Key; its smaller Edit Service and language codes link remains a separate keyboard-focusable action. Long service names wrap in both the context and template breadcrumb. The hidden service identity and routes are unchanged.
Preview uses the selected version and sample values from the shared contract. HTML variables are encoded as in the worker, while subject and text stay plain. The iframe is sandboxed without permissions and has a restrictive CSP; scripts, forms, top navigation and external resources are blocked. Save errors preserve entered content and active language; navigation warns about unsaved edits. Preview uses the selected version and sample values from the shared contract. HTML variables are encoded as in the worker, while subject and text stay plain. The owner approved `sandbox="allow-same-origin"` without `allow-scripts` for automatic HTML-preview height on 2026-10-17. This trades the prior opaque origin for trusted parent-side DOM measurement; it does not permit template scripts. The existing restrictive CSP remains unchanged. Scripts, forms, top/pop-up navigation and external resources stay blocked; the trusted parent also prevents preview link navigation and submits. No JavaScript is inserted into the template HTML. On load, content/data-image/width changes and viewport resize, the parent measures content height; resetting height before measurement lets short/empty content shrink. Old ResizeObservers are disconnected when the document changes or page hides. Save errors preserve entered content and active language; navigation warns about unsaved edits.
The Services and scoped Templates lists offer compact Edit/Delete links. Their separate confirmation pages require antiforgery-protected POST and explicit ConfirmDelete. Definition deletion shows its selected service, key, current translation count and exact codes; it deletes the definition/shared contract/versions while retaining the service/catalog/channels. Service deletion is blocked while any definition exists, including empty definitions, and explicitly retains channels. Revision conflicts reload the current count/revision, clear the checkbox, and require fresh confirmation; definition cancel/success retain the key filter. These routes reuse the revision-protected REST commands and validate selected-service ownership.
Empty Services uses the existing administration card/empty-state pattern with Create First Service. An empty selected service keeps its table/filter header and offers Create First Template; a filter miss instead offers Clear filter. Template load failures remain errors and never claim an empty catalog. The Email Channels empty-state view is unchanged.
Every template/service mutation takes the same PostgreSQL transaction advisory lock (`1601`). This makes catalog membership, affected counts and uniqueness checks consistent across API and administration writers. Group and service editors also compare revision tokens, rejecting stale edits rather than overwriting changes. The lock serializes infrequent administration writes; reads and email sends do not take it. Every template/service mutation takes the same PostgreSQL transaction advisory lock (`1601`). This makes catalog membership, affected counts and uniqueness checks consistent across API and administration writers. Group and service editors also compare revision tokens, rejecting stale edits rather than overwriting changes. The lock serializes infrequent administration writes; reads and email sends do not take it.
@@ -56,6 +62,10 @@ Legacy PUT requests have no revision field and retain last-successful-write beha
The worker still uses the selected language's subject/HTML/text and validates required variables from the shared parent contract. The message envelope, SMTP/channel selection, quotas, retries and ACK/NACK behavior are unchanged. Exact stored versions take priority, so a request for `RU` selects `RU` when both `RU` and `ru` exist. If the exact version is absent, one differing trimmed/lowercase code is tried for compatibility with existing senders (`RU` or ` ru ` can resolve `ru`). A missing `RU-ru` may retry `ru-ru`; it does not search for `ru-RU` or fall back to `ru`. Blank codes are rejected before querying. API reads and writes remain exact, and storage/migration never normalize codes. The worker still uses the selected language's subject/HTML/text and validates required variables from the shared parent contract. The message envelope, SMTP/channel selection, quotas, retries and ACK/NACK behavior are unchanged. Exact stored versions take priority, so a request for `RU` selects `RU` when both `RU` and `ru` exist. If the exact version is absent, one differing trimmed/lowercase code is tried for compatibility with existing senders (`RU` or ` ru ` can resolve `ru`). A missing `RU-ru` may retry `ru-ru`; it does not search for `ru-RU` or fall back to `ru`. Blank codes are rejected before querying. API reads and writes remain exact, and storage/migration never normalize codes.
## Whole-definition API for AI clients
The owner-approved extension provides service read/exact-name lookup and exact supported codes, whole-definition read by stable service ID and key, and one atomic revision-guarded upsert. Shared variables are replaced; supplied translations add/update, omitted translations remain, and an explicit removal list deletes versions without deleting the definition. Validation and merging execute under the same mutation lock as administration/legacy writers. See the [API contract and examples](email-template-definition-api.md) for routes, DTOs, response/error rules, encoding and validation scope. No application authentication or history/restore mechanism is added; the owner's managed-tunnel access decision is recorded without claiming tunnel/Kubernetes verification.
## Migration and rollout gate ## Migration and rollout gate
The current `20261017072410_EmailTemplateLocalizations` EF migration accepts two explicitly recognized source layouts: the flat legacy schema, or the known interim three-entity schema recorded as `20261017070315_EmailTemplateLocalizations`. It checks column names/types/nullability and the expected index/constraint definitions before modifying the schema. An unknown or hybrid layout/history is rejected; the migrator never guesses, drops the database, or rewrites migration history to adopt it. The operation locks the source tables and executes transactionally. The current `20261017072410_EmailTemplateLocalizations` EF migration accepts two explicitly recognized source layouts: the flat legacy schema, or the known interim three-entity schema recorded as `20261017070315_EmailTemplateLocalizations`. It checks column names/types/nullability and the expected index/constraint definitions before modifying the schema. An unknown or hybrid layout/history is rejected; the migrator never guesses, drops the database, or rewrites migration history to adopt it. The operation locks the source tables and executes transactionally.
@@ -94,3 +104,7 @@ Chromium administration checks on 2026-10-17 included a 1440px desktop viewport
The service-scoped navigation and all-language tab revision was validated separately on 2026-10-17 with temporary host Web at `127.0.0.1:52655` and a fresh, task-owned PostgreSQL database (no restored owner content). Chromium desktop and a 390px touch viewport covered Services name navigation, separate Edit Service, selected-service template list, blank-definition creation, both configured tabs, partial-content errors with no persisted versions, retained input/active tab, keyboard switching, unchanged selected-language preview and HTML isolation/escaping, successful save, and clearing each version through to No versions while keeping the template. The narrow page had no horizontal overflow. The temporary Web and browser tab were stopped afterward. This is browser validation of the Notification Service administration, not validation of an installed Invemory Android application. The service-scoped navigation and all-language tab revision was validated separately on 2026-10-17 with temporary host Web at `127.0.0.1:52655` and a fresh, task-owned PostgreSQL database (no restored owner content). Chromium desktop and a 390px touch viewport covered Services name navigation, separate Edit Service, selected-service template list, blank-definition creation, both configured tabs, partial-content errors with no persisted versions, retained input/active tab, keyboard switching, unchanged selected-language preview and HTML isolation/escaping, successful save, and clearing each version through to No versions while keeping the template. The narrow page had no horizontal overflow. The temporary Web and browser tab were stopped afterward. This is browser validation of the Notification Service administration, not validation of an installed Invemory Android application.
The later administration styling pass checked all four `admin-data-table` views at 1440, 390 and 320 pixels using temporary Web `127.0.0.1:52656` and task-owned PostgreSQL `58519`. Shared header colors/borders/vertical alignment, long service-name wrapping, filter-miss headers, compact actions, and table-local scrolling passed without page overflow. Keyboard focus exposed the complete channel/template/language actions and newly added language rows. The navbar remained 52px tall with matching brand/link centers, and API documentation opened a rendered Scalar page in a separate tab with a null opener; link contrast was 14.63:1. The full solution build and 148 tests passed (32 service/worker and 116 integration tests). Temporary resources were removed afterward. Remove behavior and the separate DeleteLanguage confirmation page were retained; no modal UX was introduced.
The final preview/deletion browser pass used the same task-owned host and database. At 1440px, long content grew to 1047px and an empty language tab shrank to 80px; at 390px the corresponding measurements were 997px and 80px. At 320px responsive text/data-image content grew from 1206px to 1653px when the image height changed, with document width remaining 320px. Preview script execution stayed blocked, and parent-side event checks cancelled top/self links and form submission while retaining about:srcdoc. At 320px the definition confirmation rejected an unchecked submission, then confirmed deletion returned to the scoped empty-template state; nonempty service deletion was blocked, and confirmed empty-service deletion reached the empty Services state. The table header/filter and first-create links remained visible with no page overflow. A separate read-only owner-Web browser check confirmed Invemory-Prod and its eight imported definitions; no owner data was changed.