From 8e8157238602a7807f1e83de86dc3d1d22596d15 Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sat, 17 Oct 2026 22:51:01 +0300 Subject: [PATCH] feat(notification): add admin rest management and deletion Add revision-safe service and template APIs, channel mapping, and validated admin controls. Ref: IT-1601 --- .../IEmailTemplateDefinitionRepository.cs | 4 + .../INotificationServiceRepository.cs | 2 + .../Templates/EmailTemplateDefinitionPatch.cs | 11 + .../Repositories/EmailChannelRepository.cs | 2 +- .../Repositories/EmailTemplateRepository.cs | 92 +++++++ .../NotificationServiceRepository.cs | 27 +++ .../DeleteEmailTemplateDefinitionCommand.cs | 7 + .../DeleteEmailTemplateDefinitionHandler.cs | 25 ++ .../DeleteNotificationServiceCommand.cs | 7 + .../DeleteNotificationServiceHandler.cs | 25 ++ .../DeleteServiceLanguageVersionsCommand.cs | 7 + .../DeleteServiceLanguageVersionsHandler.cs | 30 +++ .../DeleteUnusedServiceLanguageCommand.cs | 7 + .../DeleteUnusedServiceLanguageHandler.cs | 34 +++ .../EmailTemplateDefinitionDto.cs | 5 + .../GetAllNotificationServicesHandler.cs | 17 ++ .../GetAllNotificationServicesQuery.cs | 6 + .../GetEmailTemplateDefinitionHandler.cs | 19 ++ .../GetEmailTemplateDefinitionQuery.cs | 6 + .../GetNotificationServiceHandler.cs | 23 ++ .../GetNotificationServiceQuery.cs | 6 + .../GetServiceTemplateDefinitionsHandler.cs | 20 ++ .../GetServiceTemplateDefinitionsQuery.cs | 6 + .../NotificationServiceDto.cs | 3 + .../NotificationServiceMapping.cs | 10 + .../SaveNotificationServiceCommand.cs | 7 + .../SaveNotificationServiceHandler.cs | 30 +++ .../TemplateDefinitions/ServiceLanguageDto.cs | 3 + .../TemplateDefinitionMapping.cs | 12 + .../TemplateTranslationDto.cs | 3 + .../TemplateVariableDto.cs | 3 + .../UpsertEmailTemplateDefinitionCommand.cs | 7 + .../UpsertEmailTemplateDefinitionHandler.cs | 27 +++ .../AdminDeletionTests.cs | 137 +++++++++++ .../AdminNavigationTests.cs | 60 +++++ .../DefinitionApiTestContext.cs | 47 ++++ .../EmailChannelApiTests.cs | 92 +++++++ .../EmailTemplateDefinitionApiTests.cs | 228 ++++++++++++++++++ ...ailingEmailTemplateDefinitionRepository.cs | 12 + .../InvalidDefinitionApiBody.cs | 47 ++++ .../ServiceCatalogApiTests.cs | 143 +++++++++++ .../ServiceDeletionApiTests.cs | 153 ++++++++++++ .../ServiceTemplateNavigationTests.cs | 2 +- .../TemplateCatalogPresentationTests.cs | 6 +- ...ilableEmailTemplateDefinitionRepository.cs | 15 ++ .../Admin/AdminServicesController.cs | 54 ++++- .../Admin/AdminTemplatesController.cs | 56 ++++- .../EmailTemplateDefinitionDeleteViewModel.cs | 13 + .../NotificationServiceDeleteViewModel.cs | 10 + .../CreateEmailChannelRequest.cs | 5 +- .../Api/EmailChannels/EmailChannelDto.cs | 7 + .../Api/EmailChannels/EmailChannelMapping.cs | 23 ++ .../EmailChannels/EmailChannelsController.cs | 35 ++- .../EmailChannels/SmtpChannelSettingsDto.cs | 19 ++ .../UpdateEmailChannelRequest.cs | 2 +- .../Services/ApiRequestValidationFilter.cs | 23 ++ .../CreateNotificationServiceRequest.cs | 7 + .../DeleteServiceLanguageVersionsRequest.cs | 13 + .../EmailTemplateDefinitionsController.cs | 61 +++++ .../NotificationServiceRequestMapping.cs | 19 ++ .../Api/Services/ServiceLanguageRequest.cs | 7 + .../Api/Services/ServicesController.cs | 94 ++++++++ .../Services/TemplateTranslationRequest.cs | 9 + .../Api/Services/TemplateVariableRequest.cs | 10 + .../UpdateNotificationServiceRequest.cs | 11 + .../UpsertTemplateDefinitionRequest.cs | 30 +++ .../Views/AdminChannels/Index.cshtml | 20 +- .../Views/AdminServices/DeleteService.cshtml | 24 ++ .../Views/AdminServices/Edit.cshtml | 4 +- .../Views/AdminServices/Index.cshtml | 9 +- .../AdminTemplates/DeleteDefinition.cshtml | 17 ++ .../Views/AdminTemplates/Edit.cshtml | 3 +- .../Views/AdminTemplates/Index.cshtml | 23 +- .../Views/Shared/_Layout.cshtml | 4 +- .../wwwroot/css/admin.css | 73 +++++- .../wwwroot/js/admin-tables.js | 19 ++ .../wwwroot/js/email-template-preview.js | 40 +++ README.md | 1 + docs/email-template-definition-api.md | 109 +++++++++ docs/email-template-localizations.md | 18 +- 80 files changed, 2253 insertions(+), 54 deletions(-) create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinitionPatch.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionCommand.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceCommand.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsCommand.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageCommand.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/EmailTemplateDefinitionDto.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesQuery.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionQuery.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceQuery.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsQuery.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceDto.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceMapping.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceCommand.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceHandler.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/ServiceLanguageDto.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/TemplateDefinitionMapping.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/TemplateTranslationDto.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/TemplateVariableDto.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionCommand.cs create mode 100644 HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionHandler.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/DefinitionApiTestContext.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateDefinitionApiTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/InvalidDefinitionApiBody.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/ServiceCatalogApiTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/UnavailableEmailTemplateDefinitionRepository.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateDefinitionDeleteViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/NotificationServiceDeleteViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/SmtpChannelSettingsDto.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/ApiRequestValidationFilter.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/CreateNotificationServiceRequest.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/DeleteServiceLanguageVersionsRequest.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/EmailTemplateDefinitionsController.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/NotificationServiceRequestMapping.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/ServiceLanguageRequest.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/ServicesController.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateTranslationRequest.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateVariableRequest.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/UpdateNotificationServiceRequest.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Api/Services/UpsertTemplateDefinitionRequest.cs create mode 100644 HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml create mode 100644 HrynCo.NotificationService.Web/Views/AdminTemplates/DeleteDefinition.cshtml create mode 100644 HrynCo.NotificationService.Web/wwwroot/js/admin-tables.js create mode 100644 HrynCo.NotificationService.Web/wwwroot/js/email-template-preview.js create mode 100644 docs/email-template-definition-api.md diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs index 6c0a97d..c850bc8 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs @@ -6,5 +6,9 @@ public interface IEmailTemplateDefinitionRepository { Task> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default); Task GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default); + Task GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default); + Task UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default); + Task> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default); + Task DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default); Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default); } diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs index 445fdb8..c6c2465 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs @@ -6,6 +6,8 @@ public interface INotificationServiceRepository { Task> GetAllAsync(CancellationToken ct = default); Task GetAsync(Guid id, CancellationToken ct = default); + Task GetByNameAsync(string name, CancellationToken ct = default); + Task DeleteAsync(Guid id, Guid revision, 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); } diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinitionPatch.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinitionPatch.cs new file mode 100644 index 0000000..29c8522 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinitionPatch.cs @@ -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 Variables { get; init; } + public required IReadOnlyList Translations { get; init; } + public IReadOnlyList RemoveLanguageCodes { get; init; } = []; +} diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs index 8234425..cca409c 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs @@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository 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); } diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index 8ea2dc8..388c271 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -43,6 +43,93 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template return entity is null ? null : MapDefinition(entity); } + public async Task 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 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 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> 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 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 () => { ValidateLegacy(template); @@ -113,6 +200,11 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template { var codes = languages.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal); db.Templates.RemoveRange(definition.Languages.Where(t => !codes.Contains(t.ServiceLanguage.Code))); + UpsertLanguages(definition, languages); + } + + private void UpsertLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList languages) + { foreach (var model in languages) { var code = model.LanguageCode; diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs index 7db34d4..8c77c61 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs @@ -20,6 +20,33 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te return service is null ? null : Map(service, await VersionCountsAsync(ct)); } + public async Task 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 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 () => { Validate(model); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionCommand.cs new file mode 100644 index 0000000..d4468a9 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionCommand.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionHandler.cs new file mode 100644 index 0000000..2801302 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionHandler.cs @@ -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 logger, IEmailTemplateDefinitionRepository repository) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteEmailTemplateDefinitionCommand request, CancellationToken cancellationToken) + { + try + { + var removed = await repository.DeleteDefinitionAsync(request.ServiceId, request.Key, request.Revision, cancellationToken); + return removed ? Success(Unit.Value) : Failure("Template definition not found.", ServiceErrorCode.NotFound); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceCommand.cs new file mode 100644 index 0000000..a3e39db --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceCommand.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceHandler.cs new file mode 100644 index 0000000..b3fd872 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceHandler.cs @@ -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 logger, INotificationServiceRepository repository) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteNotificationServiceCommand request, CancellationToken cancellationToken) + { + try + { + var removed = await repository.DeleteAsync(request.ServiceId, request.Revision, cancellationToken); + return removed ? Success(Unit.Value) : Failure("Service not found.", ServiceErrorCode.NotFound); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsCommand.cs new file mode 100644 index 0000000..d974c90 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsCommand.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsHandler.cs new file mode 100644 index 0000000..a6e59d2 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsHandler.cs @@ -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 logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteServiceLanguageVersionsCommand request, CancellationToken cancellationToken) + { + if (!request.ConfirmDelete || request.ExpectedVersionCount < 0 || request.Revision == Guid.Empty) + return Failure("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("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(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageCommand.cs new file mode 100644 index 0000000..533c98d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageCommand.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageHandler.cs new file mode 100644 index 0000000..35be77d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageHandler.cs @@ -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 logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteUnusedServiceLanguageCommand request, CancellationToken cancellationToken) + { + if (request.Revision == Guid.Empty) + return Failure("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("Service language not found.", ServiceErrorCode.NotFound); + if (language.VersionCount > 0) + return Failure("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(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/EmailTemplateDefinitionDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/EmailTemplateDefinitionDto.cs new file mode 100644 index 0000000..f8c9960 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/EmailTemplateDefinitionDto.cs @@ -0,0 +1,5 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record EmailTemplateDefinitionDto(Guid Id, Guid ServiceId, string ServiceName, string Key, Guid Revision, + IReadOnlyList Variables, IReadOnlyList Translations, + DateTimeOffset Created, DateTimeOffset? Updated); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesHandler.cs new file mode 100644 index 0000000..074ab29 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesHandler.cs @@ -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 logger, INotificationServiceRepository services) + : RequestHandler>>(logger) +{ + protected override async Task>> DoOnHandle(GetAllNotificationServicesQuery request, CancellationToken cancellationToken) + { + var all = await services.GetAllAsync(cancellationToken); + return Success>(all.Select(NotificationServiceMapping.Map).ToList()); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesQuery.cs new file mode 100644 index 0000000..f88d5df --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetAllNotificationServicesQuery() : IRequest>>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionHandler.cs new file mode 100644 index 0000000..890edf7 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionHandler.cs @@ -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 logger, IEmailTemplateDefinitionRepository definitions) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(GetEmailTemplateDefinitionQuery request, CancellationToken cancellationToken) + { + var definition = await definitions.GetDefinitionAsync(request.ServiceId, request.Key, cancellationToken); + return definition is null + ? Failure("Template definition not found.", ServiceErrorCode.NotFound) + : Success(TemplateDefinitionMapping.Map(definition)); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionQuery.cs new file mode 100644 index 0000000..cb19cf3 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionQuery.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceHandler.cs new file mode 100644 index 0000000..a2cc45a --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceHandler.cs @@ -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 logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(GetNotificationServiceQuery request, CancellationToken cancellationToken) + { + if (request.ServiceId is null && string.IsNullOrWhiteSpace(request.Name)) + return Failure("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("Service not found.", ServiceErrorCode.NotFound) + : Success(NotificationServiceMapping.Map(service)); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceQuery.cs new file mode 100644 index 0000000..2bb2e0b --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceQuery.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsHandler.cs new file mode 100644 index 0000000..7407e09 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsHandler.cs @@ -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 logger, + INotificationServiceRepository services, IEmailTemplateDefinitionRepository definitions) + : RequestHandler>>(logger) +{ + protected override async Task>> DoOnHandle(GetServiceTemplateDefinitionsQuery request, CancellationToken cancellationToken) + { + if (await services.GetAsync(request.ServiceId, cancellationToken) is null) + return Failure>("Service not found.", ServiceErrorCode.NotFound); + var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken); + return Success>(all.Select(TemplateDefinitionMapping.Map).ToList()); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsQuery.cs new file mode 100644 index 0000000..856034d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetServiceTemplateDefinitionsQuery(Guid ServiceId) : IRequest>>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceDto.cs new file mode 100644 index 0000000..38f1b81 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceDto.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record NotificationServiceDto(Guid Id, string Name, Guid Revision, IReadOnlyList SupportedLanguageCodes, IReadOnlyList Languages); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceMapping.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceMapping.cs new file mode 100644 index 0000000..01af249 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceMapping.cs @@ -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()); +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceCommand.cs new file mode 100644 index 0000000..1c3b975 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceCommand.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceHandler.cs new file mode 100644 index 0000000..c49d4ff --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceHandler.cs @@ -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 logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(SaveNotificationServiceCommand request, CancellationToken cancellationToken) + { + if (!request.IsNew && request.Service.Revision == Guid.Empty) + return Failure("A nonempty service revision is required for update.", ServiceErrorCode.InvalidRequest); + if (!request.IsNew && await services.GetAsync(request.Service.Id, cancellationToken) is null) + return Failure("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(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/ServiceLanguageDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/ServiceLanguageDto.cs new file mode 100644 index 0000000..346c9e9 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/ServiceLanguageDto.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record ServiceLanguageDto(Guid Id, string Code, int VersionCount); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateDefinitionMapping.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateDefinitionMapping.cs new file mode 100644 index 0000000..99cd669 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateDefinitionMapping.cs @@ -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); +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateTranslationDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateTranslationDto.cs new file mode 100644 index 0000000..8d506c5 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateTranslationDto.cs @@ -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); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateVariableDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateVariableDto.cs new file mode 100644 index 0000000..7b029e2 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateVariableDto.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record TemplateVariableDto(string Name, bool Required); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionCommand.cs new file mode 100644 index 0000000..b8c3a8d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionCommand.cs @@ -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>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionHandler.cs new file mode 100644 index 0000000..32a340d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionHandler.cs @@ -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 logger, IEmailTemplateDefinitionRepository definitions) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(UpsertEmailTemplateDefinitionCommand request, CancellationToken cancellationToken) + { + try + { + var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken); + return definition is null + ? Failure("Service not found.", ServiceErrorCode.NotFound) + : Success(TemplateDefinitionMapping.Map(definition)); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs new file mode 100644 index 0000000..e39ccb6 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs @@ -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 +{ + [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 { ["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(); + 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", filtered); + Assert.DoesNotContain("Create First Template", filtered); + var service = (await context.Client.GetFromJsonAsync(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())); + 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, "]+type=\"checkbox\"[^>]*>").Value); +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs new file mode 100644 index 0000000..8be9409 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs @@ -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 +{ + [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", 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().AddAsync(new EmailChannel + { + ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp, + Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" } + }); + await scope.ServiceProvider.GetRequiredService().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, "]+admin-data-table")); + Assert.Equal(Regex.Matches(html, "]").Count, Regex.Matches(html, "", html); + } + } + +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/DefinitionApiTestContext.cs b/HrynCo.NotificationService.Web.IntegrationTests/DefinitionApiTestContext.cs new file mode 100644 index 0000000..71f3aa3 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/DefinitionApiTestContext.cs @@ -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 CreateAsync(TemplateDatabaseFixture fixture) + { + var app = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync()); + var client = app.CreateClient(); + var response = await client.GetFromJsonAsync("/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 UpsertAsync(JsonNode body, string key = "Welcome") => + Client.PostAsJsonAsync(DefinitionUrl(key, upsert: true), body); + + public async Task ReadAsync(string key = "Welcome") => + (await Client.GetFromJsonAsync(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"] = "

Hello {{Name}}

", ["textBody"] = "Hello {{Name}}" + }).ToArray()) + }; + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.DisposeAsync(); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs new file mode 100644 index 0000000..a57e2b8 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs @@ -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 +{ + [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()).GetProperty("data").GetGuid(); + var original = (await context.Client.GetFromJsonAsync($"/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("/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("/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("/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(); + 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().SaveChangesAsync(); + var all = (await context.Client.GetFromJsonAsync("/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()).GetProperty("error").GetProperty("code").GetString()); + Assert.Empty((await context.Client.GetFromJsonAsync("/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 + }; +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateDefinitionApiTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateDefinitionApiTests.cs new file mode 100644 index 0000000..4c1f211 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateDefinitionApiTests.cs @@ -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 +{ + [Fact] + public async Task ServiceReadsExposeExactCatalogAndStableIdentityThroughRename() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var service = (await context.Client.GetFromJsonAsync(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(); + 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("/api/v1/services?name=Renamed%20Service"); + Assert.Equal(context.ServiceId, renamed.GetProperty("data").GetProperty("id").GetGuid()); + Assert.Equal("Renamed Service", (await context.Client.GetFromJsonAsync(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()).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("/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()).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(); + 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()).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(); + 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(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); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/FailingEmailTemplateDefinitionRepository.cs b/HrynCo.NotificationService.Web.IntegrationTests/FailingEmailTemplateDefinitionRepository.cs index bc9d904..f0b17d5 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/FailingEmailTemplateDefinitionRepository.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/FailingEmailTemplateDefinitionRepository.cs @@ -11,6 +11,18 @@ public sealed class FailingEmailTemplateDefinitionRepository(IEmailTemplateRepos public Task GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) => ((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceName, key, ct); + public Task GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) => + ((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceId, key, ct); + + public Task UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) => + throw new IOException("Simulated save failure."); + + public Task> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) => + ((IEmailTemplateDefinitionRepository)inner).GetDefinitionsAsync(serviceId, ct); + + public Task 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) => throw new IOException("Simulated save failure."); } diff --git a/HrynCo.NotificationService.Web.IntegrationTests/InvalidDefinitionApiBody.cs b/HrynCo.NotificationService.Web.IntegrationTests/InvalidDefinitionApiBody.cs new file mode 100644 index 0000000..17568a0 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/InvalidDefinitionApiBody.cs @@ -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; + } + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/ServiceCatalogApiTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/ServiceCatalogApiTests.cs new file mode 100644 index 0000000..2189023 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/ServiceCatalogApiTests.cs @@ -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 +{ + [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(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()).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() })).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(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 ReadAsync(DefinitionApiTestContext context) => + (await context.Client.GetFromJsonAsync(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()) + }; +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs new file mode 100644 index 0000000..90af440 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs @@ -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 +{ + [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().SaveAsync(other, true); + var services = (await context.Client.GetFromJsonAsync("/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(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($"/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(); + 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("/api/v1/services/all"); + Assert.Empty(all.GetProperty("data").EnumerateArray()); + using var scope = context.App.Services.CreateScope(); + Assert.Single(await scope.ServiceProvider.GetRequiredService().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(); + 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()).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()).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 ReadServiceAsync(DefinitionApiTestContext context) => + (await context.Client.GetFromJsonAsync(context.ServiceRoot)).GetProperty("data"); + + private static async Task SeedChannelAsync(DefinitionApiTestContext context) + { + using var scope = context.App.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().AddAsync(new EmailChannel + { + ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp, + Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" } + }); + await scope.ServiceProvider.GetRequiredService().SaveChangesAsync(); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/ServiceTemplateNavigationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/ServiceTemplateNavigationTests.cs index ac4eb6b..e846e37 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/ServiceTemplateNavigationTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/ServiceTemplateNavigationTests.cs @@ -39,7 +39,7 @@ public sealed class ServiceTemplateNavigationTests(TemplateDatabaseFixture fixtu Assert.DoesNotContain("OtherTemplate", detail); Assert.Contains($"/admin/services/{service.Id}/templates/create", 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.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/admin/services/{Guid.NewGuid()}" )).StatusCode); } diff --git a/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs index 3430c79..253829b 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs @@ -58,7 +58,7 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix var miss = await client.GetStringAsync(baseUrl + "?key=Absent"); Assert.Matches("(?s)]*>\\s*]*method=\"get\"[^>]*class=\"template-key-filter\"", 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", miss); Assert.Contains($"href=\"{baseUrl}\" class=\"btn btn-outline-secondary btn-sm\">Clear", miss); var filtered = await client.GetStringAsync(baseUrl + "?key=Welcome"); @@ -85,13 +85,13 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix var empty = await client.GetStringAsync(baseUrl); Assert.Contains("No languages configured for this service.", 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() .SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true); var withDefinition = await client.GetStringAsync(baseUrl); Assert.Contains("No configured languages", TemplateRow(withDefinition, "Empty")); 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) diff --git a/HrynCo.NotificationService.Web.IntegrationTests/UnavailableEmailTemplateDefinitionRepository.cs b/HrynCo.NotificationService.Web.IntegrationTests/UnavailableEmailTemplateDefinitionRepository.cs new file mode 100644 index 0000000..582ad32 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/UnavailableEmailTemplateDefinitionRepository.cs @@ -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> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository."); + public Task> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository."); + public Task GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository."); + public Task GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository."); + public Task UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository."); + public Task 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."); +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminServicesController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminServicesController.cs index fb01bf4..46ea3ce 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminServicesController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminServicesController.cs @@ -1,3 +1,5 @@ +using HrynCo.NotificationService.Services.TemplateDefinitions; +using MediatR; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; @@ -6,7 +8,8 @@ using Microsoft.AspNetCore.Mvc; namespace HrynCo.NotificationService.Web.Controllers.Admin; [Route("admin/services")] -public sealed class AdminServicesController(INotificationServiceRepository services, ILogger logger) : Controller +public sealed class AdminServicesController(INotificationServiceRepository services, ILogger logger, + IEmailTemplateDefinitionRepository definitions, IMediator mediator) : Controller { [HttpGet("")] public async Task 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 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 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 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 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 ServiceEditorViewAsync(NotificationServiceEditViewModel model, CancellationToken ct) { if (model.Id is not Guid id) diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs index 3aa1b30..a5284d3 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs @@ -1,3 +1,4 @@ +using HrynCo.NotificationService.Services.TemplateDefinitions; using System.Text.Json; using HrynCo.NotificationService.DAL.Abstract.Repositories; 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 }); } + [HttpGet("{definitionId:guid}/delete")] + public async Task 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 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 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 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 SaveDefinitionAsync(EmailTemplateDefinition definition, EmailTemplateEditViewModel model, NotificationServiceInfo service, CancellationToken ct) { @@ -117,7 +171,7 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD { if (service is 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); } diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateDefinitionDeleteViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateDefinitionDeleteViewModel.cs new file mode 100644 index 0000000..b29a923 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateDefinitionDeleteViewModel.cs @@ -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 LanguageCodes { get; init; } = []; + public bool ConfirmDelete { get; set; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/NotificationServiceDeleteViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/NotificationServiceDeleteViewModel.cs new file mode 100644 index 0000000..06e4fcf --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/NotificationServiceDeleteViewModel.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs index b49e70a..748660b 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs @@ -1,12 +1,13 @@ +using System.ComponentModel.DataAnnotations; using HrynCo.NotificationService.DAL.Abstract.Providers; namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels; public sealed record CreateEmailChannelRequest( - string ServiceName, + [Required, StringLength(100)] string ServiceName, int Priority, EmailChannelType ChannelType, - EmailChannelSettings Settings, + SmtpChannelSettingsDto Settings, int? DailyLimit, int? MonthlyLimit, int WarnThresholdPercent, diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs new file mode 100644 index 0000000..d086aec --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs @@ -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); diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs new file mode 100644 index 0000000..4a9355c --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs @@ -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); + } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs index f6d1264..df858c3 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs @@ -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.Services.EmailChannels.Create; using HrynCo.NotificationService.Services.EmailChannels.Delete; using HrynCo.NotificationService.Services.EmailChannels.Get; using HrynCo.NotificationService.Services.EmailChannels.GetByService; +using HrynCo.NotificationService.Services.EmailChannels.GetAll; using HrynCo.NotificationService.Services.EmailChannels.Update; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -10,32 +14,44 @@ using Microsoft.AspNetCore.Mvc; namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels; [Route("api/v1/email-channels")] +[TypeFilter(typeof(ApiRequestValidationFilter), Order = -3000)] public sealed class EmailChannelsController : ApiControllerBase { public EmailChannelsController(IMediator mediator) : base(mediator) { } [HttpGet] + [ProducesResponseType>>(StatusCodes.Status200OK)] public async Task GetAll([FromQuery] string serviceName, CancellationToken cancellationToken) { var result = await Mediator.Send(new GetEmailChannelsQuery(serviceName), cancellationToken); - return FromServiceResult(result); + return FromChannels(result); } + [HttpGet("all")] + [ProducesResponseType>>(StatusCodes.Status200OK)] + public async Task GetAllServices(CancellationToken cancellationToken) => + FromChannels(await Mediator.Send(new GetAllEmailChannelsQuery(), cancellationToken)); + [HttpGet("{id:guid}")] - public async Task Get(Guid id, CancellationToken cancellationToken) + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task Get([FromRoute] Guid id, CancellationToken cancellationToken) { var result = await Mediator.Send(new GetEmailChannelQuery(id), cancellationToken); - return FromServiceResult(result); + return result.IsSuccess + ? Ok(new ApiResponse { Success = true, Data = EmailChannelMapping.Map(result.Result!) }) + : MapServiceError(result.Error!); } [HttpPost] public async Task 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( request.ServiceName, request.Priority, request.ChannelType, - request.Settings, + EmailChannelMapping.ToSettings(request.Settings), request.DailyLimit, request.MonthlyLimit, request.WarnThresholdPercent, @@ -52,12 +68,12 @@ public sealed class EmailChannelsController : ApiControllerBase } [HttpPut("{id:guid}")] - public async Task Update(Guid id, [FromBody] UpdateEmailChannelRequest request, CancellationToken cancellationToken) + public async Task Update([FromRoute] Guid id, [FromBody] UpdateEmailChannelRequest request, CancellationToken cancellationToken) { var command = new UpdateEmailChannelCommand( id, request.Priority, - request.Settings, + EmailChannelMapping.ToSettings(request.Settings), request.DailyLimit, request.MonthlyLimit, request.WarnThresholdPercent, @@ -69,9 +85,14 @@ public sealed class EmailChannelsController : ApiControllerBase } [HttpDelete("{id:guid}")] - public async Task Delete(Guid id, CancellationToken cancellationToken) + public async Task Delete([FromRoute] Guid id, CancellationToken cancellationToken) { var result = await Mediator.Send(new DeleteEmailChannelCommand(id), cancellationToken); return FromServiceResult(result); } + private IActionResult FromChannels(ServiceResult> result) => + result.IsSuccess + ? Ok(new ApiResponse> { Success = true, Data = result.Result!.Select(EmailChannelMapping.Map).ToList() }) + : MapServiceError(result.Error!); + } diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/SmtpChannelSettingsDto.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/SmtpChannelSettingsDto.cs new file mode 100644 index 0000000..0e01faf --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/SmtpChannelSettingsDto.cs @@ -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; } = ""; +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs index 7c36e69..1081b1d 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs @@ -4,7 +4,7 @@ namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels; public sealed record UpdateEmailChannelRequest( int Priority, - EmailChannelSettings Settings, + SmtpChannelSettingsDto Settings, int? DailyLimit, int? MonthlyLimit, int WarnThresholdPercent, diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/ApiRequestValidationFilter.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/ApiRequestValidationFilter.cs new file mode 100644 index 0000000..a523cc0 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/ApiRequestValidationFilter.cs @@ -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 + { + Success = false, Error = new ApiError { Code = "InvalidRequest", Message = string.Join(" ", errors) } + }); + } + + public void OnActionExecuted(ActionExecutedContext context) { } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/CreateNotificationServiceRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/CreateNotificationServiceRequest.cs new file mode 100644 index 0000000..f4b6da3 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/CreateNotificationServiceRequest.cs @@ -0,0 +1,7 @@ +namespace HrynCo.NotificationService.Web.Controllers.Api.Services; + +public sealed class CreateNotificationServiceRequest +{ + public required string Name { get; init; } + public required IReadOnlyList Languages { get; init; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/DeleteServiceLanguageVersionsRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/DeleteServiceLanguageVersionsRequest.cs new file mode 100644 index 0000000..41499d2 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/DeleteServiceLanguageVersionsRequest.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/EmailTemplateDefinitionsController.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/EmailTemplateDefinitionsController.cs new file mode 100644 index 0000000..0ac8e11 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/EmailTemplateDefinitionsController.cs @@ -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>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + public async Task Get([FromRoute] Guid serviceId, [FromQuery] string key, CancellationToken cancellationToken) => + FromServiceResult(await Mediator.Send(new GetEmailTemplateDefinitionQuery(serviceId, key), cancellationToken)); + + [HttpPost("upsert")] + [ProducesResponseType>(StatusCodes.Status201Created)] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + [ProducesResponseType>(StatusCodes.Status409Conflict)] + public async Task 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 { Success = true, Data = result.Result }); + return FromServiceResult(result); + } + catch (EmailTemplateWriteException error) + { + return MapServiceError(new ServiceError(error.Message, ServiceErrorCode.InvalidRequest)); + } + } + [HttpGet("all")] + [ProducesResponseType>>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + public async Task GetAll([FromRoute] Guid serviceId, CancellationToken cancellationToken) => + FromServiceResult(await Mediator.Send(new GetServiceTemplateDefinitionsQuery(serviceId), cancellationToken)); + + [HttpDelete] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + [ProducesResponseType>(StatusCodes.Status409Conflict)] + public async Task Delete([FromRoute] Guid serviceId, [FromQuery] string key, [FromQuery] Guid revision, CancellationToken cancellationToken) => + FromServiceResult(await Mediator.Send(new DeleteEmailTemplateDefinitionCommand(serviceId, key, revision), cancellationToken)); + +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/NotificationServiceRequestMapping.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/NotificationServiceRequestMapping.cs new file mode 100644 index 0000000..d9cbf9a --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/NotificationServiceRequestMapping.cs @@ -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? 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() + }; + } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/ServiceLanguageRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/ServiceLanguageRequest.cs new file mode 100644 index 0000000..072bdbb --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/ServiceLanguageRequest.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/ServicesController.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/ServicesController.cs new file mode 100644 index 0000000..beeb43d --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/ServicesController.cs @@ -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>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + public async Task Get([FromRoute] Guid serviceId, CancellationToken cancellationToken) => + FromServiceResult(await Mediator.Send(new GetNotificationServiceQuery(serviceId, null), cancellationToken)); + + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + public async Task GetByName([FromQuery] string? name, CancellationToken cancellationToken) => + FromServiceResult(await Mediator.Send(new GetNotificationServiceQuery(null, name), cancellationToken)); + [HttpGet("all")] + [ProducesResponseType>>(StatusCodes.Status200OK)] + public async Task GetAll(CancellationToken cancellationToken) => + FromServiceResult(await Mediator.Send(new GetAllNotificationServicesQuery(), cancellationToken)); + + [HttpDelete("{serviceId:guid}")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + [ProducesResponseType>(StatusCodes.Status409Conflict)] + public async Task Delete([FromRoute] Guid serviceId, [FromQuery] Guid revision, CancellationToken cancellationToken) => + FromServiceResult(await Mediator.Send(new DeleteNotificationServiceCommand(serviceId, revision), cancellationToken)); + + [HttpPost] + [ProducesResponseType>(StatusCodes.Status201Created)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status409Conflict)] + public Task Create([FromBody] CreateNotificationServiceRequest request, CancellationToken cancellationToken) => + SaveServiceAsync(null, request.Name, request.Languages, Guid.Empty, cancellationToken); + + [HttpPut("{serviceId:guid}")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + [ProducesResponseType>(StatusCodes.Status409Conflict)] + public Task 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>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + [ProducesResponseType>(StatusCodes.Status409Conflict)] + public async Task 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>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + [ProducesResponseType>(StatusCodes.Status404NotFound)] + [ProducesResponseType>(StatusCodes.Status409Conflict)] + public async Task 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 SaveServiceAsync(Guid? id, string name, IReadOnlyList 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 { Success = true, Data = result.Result }); + return FromServiceResult(result); + } + catch (EmailTemplateWriteException error) + { + return MapServiceError(new ServiceError(error.Message, ServiceErrorCode.InvalidRequest)); + } + } + +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateTranslationRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateTranslationRequest.cs new file mode 100644 index 0000000..66283c7 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateTranslationRequest.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateVariableRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateVariableRequest.cs new file mode 100644 index 0000000..39c1206 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/TemplateVariableRequest.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/UpdateNotificationServiceRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/UpdateNotificationServiceRequest.cs new file mode 100644 index 0000000..a1e1dc8 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/UpdateNotificationServiceRequest.cs @@ -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 Languages { get; init; } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/UpsertTemplateDefinitionRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/UpsertTemplateDefinitionRequest.cs new file mode 100644 index 0000000..9e16e4e --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/UpsertTemplateDefinitionRequest.cs @@ -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 Variables { get; init; } + public required IReadOnlyList Translations { get; init; } + public IReadOnlyList? 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 ?? [] + }; + } +} diff --git a/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml index 27ac40d..6ff46a7 100644 --- a/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml @@ -37,16 +37,16 @@ else {
- - +
+ - - - - - - - + + + + + + + @@ -106,7 +106,7 @@ else @monthlyLabel } -
ServiceTypePriorityStatusTodayThis MonthActionsServiceTypePriorityStatusTodayThis MonthActions
+ Edit diff --git a/HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml b/HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml new file mode 100644 index 0000000..d648426 --- /dev/null +++ b/HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml @@ -0,0 +1,24 @@ +@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels +@model NotificationServiceDeleteViewModel +@{ ViewData["Title"] = "Delete service"; } +
+ +

Delete service @Model.Name?

+ +@if (Model.DefinitionCount > 0) +{ +
This service still has @Model.DefinitionCount template definition(s), including definitions without translations. Remove every definition before deleting the service.
+ View templates +} +else +{ +
This permanently deletes the service and its language catalog.
+} +

Email channels are retained. Their service names are independent and are not renamed or deleted.

+
+ @Html.AntiForgeryToken() + +
+
Cancel
+
+
diff --git a/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml index 927c301..6b49c40 100644 --- a/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml @@ -6,7 +6,7 @@ ViewData["EditorTitle"] = ViewData["Title"]; } @section Breadcrumbs { - + }
@Html.AntiForgeryToken() @@ -17,7 +17,7 @@

Language codes

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.

- +
CodeVersionsActions
@for (var i = 0; i < Model.Languages.Count; i++) { var countUnavailable = ViewData["LanguageCountsUnavailable"] is true && Model.Languages[i].Id != Guid.Empty; diff --git a/HrynCo.NotificationService.Web/Views/AdminServices/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminServices/Index.cshtml index 8842240..3eb38c5 100644 --- a/HrynCo.NotificationService.Web/Views/AdminServices/Index.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminServices/Index.cshtml @@ -2,13 +2,16 @@ @model IReadOnlyList @{ ViewData["Title"] = "Services"; } -@if (Model.Count == 0) {

No services yet. Create a service and its language codes before adding templates.

} +@if (Model.Count == 0) +{ +

No services yet.

Create First Service
+} else { -
CodeVersionsActions
+
NameLanguage codesAction
@foreach (var service in Model) { - + }
NameLanguage codesAction
@service.Name@string.Join(", ", service.Languages.Select(l => l.Code))Edit
@service.Name@string.Join(", ", service.Languages.Select(l => l.Code))
} diff --git a/HrynCo.NotificationService.Web/Views/AdminTemplates/DeleteDefinition.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/DeleteDefinition.cshtml new file mode 100644 index 0000000..0c1509d --- /dev/null +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/DeleteDefinition.cshtml @@ -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 }); } +
+ +

Delete template @Model.Key?

+ +
This permanently deletes the definition, shared variables and @Model.LanguageCodes.Count translation(s) from service @Model.ServiceName.
+

Languages: @(Model.LanguageCodes.Count == 0 ? "No translations" : string.Join(", ", Model.LanguageCodes))

+

The service, its language catalog and email channels are retained.

+ + @Html.AntiForgeryToken() + +
+
Cancel
+ +
diff --git a/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml index 691d0e5..ee81b43 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml @@ -86,7 +86,7 @@
Rendered subject
Rendered HTML
- +
Rendered text

             
@@ -98,5 +98,6 @@
     Cancel
 }
 @section Scripts {
+    
     
 }
diff --git a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml
index 838dee3..200c7b3 100644
--- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml
+++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml
@@ -5,9 +5,9 @@
     var serviceId = Model.Service.Id;
     var keyFilter = Model.KeyFilter;
 }
-
+
 
 
@@ -29,7 +29,7 @@ Create template
-
+
@if (Model.Definitions.Count == 0) { - + } @foreach (var template in Model.Definitions) { @@ -71,7 +84,7 @@ } - + }
@@ -45,7 +45,20 @@
No email templates found for this service.
+ @if (!ViewData.ModelState.IsValid) + { +

Templates are unavailable. Review the error and try again.

+ } + else if (!string.IsNullOrWhiteSpace(keyFilter)) + { +

No templates match this key.

Clear filter + } + else + { +

No email templates for this service yet.

Create First Template + } +
Edit
diff --git a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml index c79a7fb..381ca34 100644 --- a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml +++ b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml @@ -16,10 +16,11 @@ -