diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs index 6819287..0c88cb3 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs @@ -4,6 +4,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories; public interface IPushTemplateDefinitionRepository { + Task UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default); Task> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default); Task GetAsync(Guid serviceId, string key, CancellationToken ct = default); Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default); diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateDefinitionPatch.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateDefinitionPatch.cs new file mode 100644 index 0000000..23fafea --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateDefinitionPatch.cs @@ -0,0 +1,11 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class PushTemplateDefinitionPatch +{ + 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/PushTemplateDefinitionRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/PushTemplateDefinitionRepository.cs index 258fbd2..714bca2 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/PushTemplateDefinitionRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/PushTemplateDefinitionRepository.cs @@ -20,7 +20,10 @@ internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, return entity is null ? null : Map(entity); } - public Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () => + public Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default) => + mutations.ExecuteAsync(() => SaveCoreAsync(definition, isNew, ct), ct); + + private async Task SaveCoreAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct) { PushTemplateValidation.Validate(definition); var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == definition.ServiceId, ct) @@ -34,7 +37,49 @@ internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, entity.Updated = DateTimeOffset.UtcNow; definition.Id = entity.Id; definition.Revision = entity.Revision; - }, ct); + } + + public async Task UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default) + { + PushTemplateDefinition? 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 current = await GetAsync(patch.ServiceId, patch.Key, ct); + var supplied = patch.Translations.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal); + var removed = patch.RemoveLanguageCodes.ToHashSet(StringComparer.Ordinal); + saved = new PushTemplateDefinition + { + Id = current?.Id ?? Guid.Empty, ServiceId = patch.ServiceId, Key = patch.Key, + Revision = patch.Revision ?? Guid.Empty, Variables = patch.Variables, + Languages = (current?.Languages ?? []).Where(l => !supplied.Contains(l.LanguageCode) && !removed.Contains(l.LanguageCode)) + .Concat(patch.Translations).OrderBy(l => l.LanguageCode, StringComparer.Ordinal).ToList() + }; + await SaveCoreAsync(saved, patch.Revision is null, ct); + }, ct); + return saved; + } + + private static void ValidatePatch(PushTemplateDefinitionPatch patch, ServiceEntity service) + { + PushTemplateValidation.Validate(new PushTemplateDefinition + { + ServiceId = patch.ServiceId, 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); + } private async Task LoadForSaveAsync(PushTemplateDefinition model, ServiceEntity service, bool isNew, CancellationToken ct) { @@ -70,6 +115,7 @@ internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, Title = translation.Title, Body = translation.Body, Created = DateTimeOffset.UtcNow }; definition.Languages.Add(entity); + db.PushTemplates.Add(entity); } entity.Title = translation.Title; entity.Body = translation.Body; @@ -80,22 +126,25 @@ internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, public async Task DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) { + 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 removed = false; - await mutations.ExecuteAsync(async () => - { - var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct); - if (entity is null) - return; - if (revision == Guid.Empty || entity.Revision != revision) - throw new EmailTemplateWriteException("The push template revision changed. Reload before deleting."); - db.PushTemplateDefinitions.Remove(entity); - if (!await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == serviceId && d.Key == key, ct)) - db.TemplateCatalog.Remove(await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct)); - removed = true; - }, ct); + await mutations.ExecuteAsync(async () => removed = await DeleteCoreAsync(serviceId, key, revision, ct), ct); return removed; } + private async Task DeleteCoreAsync(Guid serviceId, string key, Guid revision, CancellationToken ct) + { + var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct); + if (entity is null) return false; + if (entity.Revision != revision) + throw new EmailTemplateWriteException("The push template revision changed. Reload before deleting."); + db.PushTemplateDefinitions.Remove(entity); + if (!await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == serviceId && d.Key == key, ct)) + db.TemplateCatalog.Remove(await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct)); + return true; + } + private static PushTemplateDefinition Map(PushTemplateDefinitionEntity entity) => new() { Id = entity.Id, ServiceId = entity.ServiceId, Key = entity.Key, Revision = entity.Revision, diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeletePushTemplateDefinitionCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeletePushTemplateDefinitionCommand.cs new file mode 100644 index 0000000..3cce69b --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeletePushTemplateDefinitionCommand.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 DeletePushTemplateDefinitionCommand(Guid ServiceId, string Key, Guid Revision) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeletePushTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeletePushTemplateDefinitionHandler.cs new file mode 100644 index 0000000..ce4ef54 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeletePushTemplateDefinitionHandler.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 DeletePushTemplateDefinitionHandler( + IContextualSerilogLogger logger, IPushTemplateDefinitionRepository repository) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeletePushTemplateDefinitionCommand request, CancellationToken cancellationToken) + { + try + { + var removed = await repository.DeleteAsync(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/GetPushTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetPushTemplateDefinitionHandler.cs new file mode 100644 index 0000000..e92f46c --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetPushTemplateDefinitionHandler.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 GetPushTemplateDefinitionHandler( + IContextualSerilogLogger logger, IPushTemplateDefinitionRepository definitions) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(GetPushTemplateDefinitionQuery request, CancellationToken cancellationToken) + { + var definition = await definitions.GetAsync(request.ServiceId, request.Key, cancellationToken); + return definition is null + ? Failure("Template definition not found.", ServiceErrorCode.NotFound) + : Success(PushTemplateDefinitionMapping.Map(definition)); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetPushTemplateDefinitionQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetPushTemplateDefinitionQuery.cs new file mode 100644 index 0000000..076f387 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetPushTemplateDefinitionQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetPushTemplateDefinitionQuery(Guid ServiceId, string Key) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetServicePushTemplateDefinitionsHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServicePushTemplateDefinitionsHandler.cs new file mode 100644 index 0000000..287da3f --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServicePushTemplateDefinitionsHandler.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 GetServicePushTemplateDefinitionsHandler( + IContextualSerilogLogger logger, + INotificationServiceRepository services, IPushTemplateDefinitionRepository definitions) + : RequestHandler>>(logger) +{ + protected override async Task>> DoOnHandle(GetServicePushTemplateDefinitionsQuery 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(PushTemplateDefinitionMapping.Map).ToList()); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetServicePushTemplateDefinitionsQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServicePushTemplateDefinitionsQuery.cs new file mode 100644 index 0000000..1816a1f --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServicePushTemplateDefinitionsQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetServicePushTemplateDefinitionsQuery(Guid ServiceId) : IRequest>>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateDefinitionDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateDefinitionDto.cs new file mode 100644 index 0000000..3ec3c17 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateDefinitionDto.cs @@ -0,0 +1,4 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record PushTemplateDefinitionDto(Guid Id, Guid ServiceId, string Key, Guid Revision, + IReadOnlyList Variables, IReadOnlyList Translations); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateDefinitionMapping.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateDefinitionMapping.cs new file mode 100644 index 0000000..177c4d6 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateDefinitionMapping.cs @@ -0,0 +1,11 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +internal static class PushTemplateDefinitionMapping +{ + public static PushTemplateDefinitionDto Map(PushTemplateDefinition model) => new(model.Id, model.ServiceId, + model.Key, model.Revision, model.Variables.Select(v => new TemplateVariableDto(v.Name, v.Required)).ToList(), + model.Languages.Select(l => new PushTemplateTranslationDto(l.LanguageCode, l.Title, l.Body, + PushTemplateActionValidation.Parse(l.ActionsJson))).ToList()); +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateTranslationDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateTranslationDto.cs new file mode 100644 index 0000000..4e934dc --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/PushTemplateTranslationDto.cs @@ -0,0 +1,5 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record PushTemplateTranslationDto(string LanguageCode, string Title, string Body, PushTemplateActions Actions); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertPushTemplateDefinitionCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertPushTemplateDefinitionCommand.cs new file mode 100644 index 0000000..aa6272d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertPushTemplateDefinitionCommand.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 UpsertPushTemplateDefinitionCommand(PushTemplateDefinitionPatch Patch) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertPushTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertPushTemplateDefinitionHandler.cs new file mode 100644 index 0000000..8d4da7c --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertPushTemplateDefinitionHandler.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 UpsertPushTemplateDefinitionHandler( + IContextualSerilogLogger logger, IPushTemplateDefinitionRepository definitions) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(UpsertPushTemplateDefinitionCommand request, CancellationToken cancellationToken) + { + try + { + var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken); + return definition is null + ? Failure("Service not found.", ServiceErrorCode.NotFound) + : Success(PushTemplateDefinitionMapping.Map(definition)); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/PushTemplateDefinitionApiTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/PushTemplateDefinitionApiTests.cs new file mode 100644 index 0000000..d1b3ee0 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/PushTemplateDefinitionApiTests.cs @@ -0,0 +1,125 @@ +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 PushTemplateDefinitionApiTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + private static string Url(DefinitionApiTestContext c, string suffix = "", string key = "Welcome") => + c.ServiceRoot + "/push-template-definitions" + suffix + "?key=" + Uri.EscapeDataString(key); + + private static JsonObject Body(Guid? revision, params string[] languages) => new() + { + ["revision"] = revision?.ToString(), + ["variables"] = new JsonArray(new JsonObject { ["name"] = "Name", ["required"] = true }), + ["translations"] = new JsonArray(languages.Select(code => (JsonNode)new JsonObject + { + ["languageCode"] = code, ["title"] = "Title " + code, ["body"] = "Hello {{Name}}", + ["actions"] = new JsonObject { ["defaultAction"] = "contacts", ["buttons"] = new JsonArray( + new JsonObject { ["action"] = "contacts", ["label"] = "Go " + code }) } + }).ToArray()) + }; + + private static async Task Read(DefinitionApiTestContext c) => + (await c.Client.GetFromJsonAsync(Url(c))).GetProperty("data"); + + [Fact] + public async Task CreatePatchReadAndDeletePreserveEmailAndOmittedLanguages() + { + await using var c = await DefinitionApiTestContext.CreateAsync(fixture); + await c.UpsertAsync(DefinitionApiTestContext.Body(null, "en")); + var email = await c.ReadAsync(); + var create = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en", "uk")); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + Assert.NotNull(create.Headers.Location); + var original = await Read(c); + var revision = original.GetProperty("revision").GetGuid(); + var update = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(revision, "de")); + Assert.Equal(HttpStatusCode.OK, update.StatusCode); + var saved = await Read(c); + Assert.Equal(3, saved.GetProperty("translations").GetArrayLength()); + Assert.Equal(original.GetProperty("translations")[1].GetRawText(), saved.GetProperty("translations")[2].GetRawText()); + Assert.Equal("contacts", saved.GetProperty("translations")[0].GetProperty("actions").GetProperty("defaultAction").GetString()); + var all = await c.Client.GetFromJsonAsync(c.ServiceRoot + "/push-template-definitions/all"); + Assert.Single(all.GetProperty("data").EnumerateArray()); + Assert.Equal(HttpStatusCode.Conflict, (await c.Client.DeleteAsync(Url(c) + "&revision=" + revision)).StatusCode); + var remove = Body(saved.GetProperty("revision").GetGuid()); + remove["removeLanguageCodes"] = new JsonArray("de", "en", "uk"); + Assert.Equal(HttpStatusCode.OK, (await c.Client.PostAsJsonAsync(Url(c, "/upsert"), remove)).StatusCode); + saved = await Read(c); + Assert.Empty(saved.GetProperty("translations").EnumerateArray()); + Assert.Equal(HttpStatusCode.OK, (await c.Client.DeleteAsync(Url(c) + "&revision=" + saved.GetProperty("revision").GetGuid())).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await c.Client.GetAsync(Url(c))).StatusCode); + Assert.Equal(email.GetRawText(), (await c.ReadAsync()).GetRawText()); + } + + [Theory] + [InlineData("nullVariables")] + [InlineData("missingTranslations")] + [InlineData("nullTranslation")] + [InlineData("duplicateLanguage")] + [InlineData("unknownLanguage")] + [InlineData("nullActions")] + [InlineData("unknownActionField")] + [InlineData("blankLabel")] + [InlineData("unknownPlaceholder")] + [InlineData("invalidRetainedTranslation")] + [InlineData("overlap")] + [InlineData("unknownRemoval")] + [InlineData("emptyRevision")] + public async Task InvalidPatchPreservesEntireDefinition(string scenario) + { + await using var c = await DefinitionApiTestContext.CreateAsync(fixture); + await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en", "uk")); + var before = await Read(c); + var body = Body(before.GetProperty("revision").GetGuid(), "en"); + ApplyInvalidCase(body, scenario); + var result = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), body); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False((await result.Content.ReadFromJsonAsync()).GetProperty("success").GetBoolean()); + Assert.Equal(before.GetRawText(), (await Read(c)).GetRawText()); + } + + private static void ApplyInvalidCase(JsonObject body, string scenario) + { + var translation = body["translations"]![0]!; + var cases = new Dictionary + { + ["nullVariables"] = () => { body["variables"] = null; }, + ["missingTranslations"] = () => { body.Remove("translations"); }, + ["nullTranslation"] = () => { body["translations"] = new JsonArray((JsonNode?)null); }, + ["duplicateLanguage"] = () => { body["translations"]!.AsArray().Add(translation.DeepClone()); }, + ["unknownLanguage"] = () => { translation["languageCode"] = "fr"; }, + ["nullActions"] = () => { translation["actions"] = null; }, + ["unknownActionField"] = () => { translation["actions"]!["url"] = "ignored"; }, + ["blankLabel"] = () => { translation["actions"]!["buttons"]![0]!["label"] = ""; }, + ["unknownPlaceholder"] = () => { translation["body"] = "{{Unknown}}"; }, + ["invalidRetainedTranslation"] = () => { body["variables"] = new JsonArray(); body["translations"] = new JsonArray(); }, + ["overlap"] = () => { body["removeLanguageCodes"] = new JsonArray("en"); }, + ["unknownRemoval"] = () => { body["removeLanguageCodes"] = new JsonArray("fr"); }, + ["emptyRevision"] = () => { body["revision"] = Guid.Empty.ToString(); }, + }; + cases[scenario](); + } + + [Fact] + public async Task ConcurrentUpdatesAndCreatesRejectStaleRevisions() + { + await using var c = await DefinitionApiTestContext.CreateAsync(fixture); + var creates = await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en")))); + Assert.Single(creates, r => r.StatusCode == HttpStatusCode.Created); + Assert.Single(creates, r => r.StatusCode == HttpStatusCode.Conflict); + var revision = (await Read(c)).GetProperty("revision").GetGuid(); + var updates = await Task.WhenAll(new[] { "de", "uk" }.Select(l => c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(revision, l)))); + Assert.Single(updates, r => r.StatusCode == HttpStatusCode.OK); + Assert.Single(updates, r => r.StatusCode == HttpStatusCode.Conflict); + Assert.Equal(2, (await Read(c)).GetProperty("translations").GetArrayLength()); + Assert.Equal(HttpStatusCode.Conflict, (await c.Client.PostAsJsonAsync(Url(c, "/upsert", "Missing"), Body(Guid.NewGuid(), "en"))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await c.Client.DeleteAsync(Url(c))).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await c.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}/push-template-definitions/all")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await c.Client.PostAsJsonAsync($"/api/v1/services/{Guid.NewGuid()}/push-template-definitions/upsert?key=X", Body(null, "en"))).StatusCode); + } +} + diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/PushTemplateDefinitionsController.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/PushTemplateDefinitionsController.cs new file mode 100644 index 0000000..e2f78fc --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/PushTemplateDefinitionsController.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}/push-template-definitions")] +[TypeFilter(typeof(ApiRequestValidationFilter), Order = -3000)] +public sealed class PushTemplateDefinitionsController(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 GetPushTemplateDefinitionQuery(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] UpsertPushTemplateDefinitionRequest request, CancellationToken cancellationToken) + { + try + { + var patch = request.ToPatch(serviceId, key); + var result = await Mediator.Send(new UpsertPushTemplateDefinitionCommand(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 GetServicePushTemplateDefinitionsQuery(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 DeletePushTemplateDefinitionCommand(serviceId, key, revision), cancellationToken)); + +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/PushTemplateTranslationRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/PushTemplateTranslationRequest.cs new file mode 100644 index 0000000..de21a16 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/PushTemplateTranslationRequest.cs @@ -0,0 +1,11 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Web.Controllers.Api.Services; + +public sealed class PushTemplateTranslationRequest +{ + public required string LanguageCode { get; init; } + public required string Title { get; init; } + public required string Body { get; init; } + public PushTemplateActions Actions { get; init; } = new(); +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/Services/UpsertPushTemplateDefinitionRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/Services/UpsertPushTemplateDefinitionRequest.cs new file mode 100644 index 0000000..43cc3d9 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Api/Services/UpsertPushTemplateDefinitionRequest.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Web.Controllers.Api.Services; + +public sealed class UpsertPushTemplateDefinitionRequest +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + public Guid? Revision { get; init; } + public required IReadOnlyList Variables { get; init; } + public required IReadOnlyList Translations { get; init; } + public IReadOnlyList? RemoveLanguageCodes { get; init; } + + public PushTemplateDefinitionPatch 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 PushTemplateDefinitionPatch + { + ServiceId = serviceId, Key = key, Revision = Revision, + Variables = Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), + Translations = Translations.Select(t => new PushTemplateTranslation + { + LanguageCode = t.LanguageCode, Title = t.Title, Body = t.Body, + ActionsJson = JsonSerializer.Serialize(t.Actions, JsonOptions) + }).ToList(), + RemoveLanguageCodes = RemoveLanguageCodes ?? [] + }; + } +} diff --git a/docs/channel-template-model.md b/docs/channel-template-model.md index d05f17e..aa8f411 100644 --- a/docs/channel-template-model.md +++ b/docs/channel-template-model.md @@ -264,3 +264,81 @@ allowlist. Rider reported no errors in inspected changed production files; warni remain for existing Firebase Token deprecation, defensive JSON null checks and style. Cognitive complexity was reviewed structurally against the limit of 10; numeric scores were not measured. All changes remain uncommitted for owner review/testing. + +## Push template management REST API (IT-1627, 2026-10-31) + +Push definitions now have the same service-scoped management operations as Email: + +| Method | Route | Purpose | +| --- | --- | --- | +| GET | `/api/v1/services/{serviceId}/push-template-definitions?key={key}` | Read one exact key | +| GET | `/api/v1/services/{serviceId}/push-template-definitions/all` | List Push definitions | +| POST | `/api/v1/services/{serviceId}/push-template-definitions/upsert?key={key}` | Create or atomically patch a definition | +| DELETE | `/api/v1/services/{serviceId}/push-template-definitions?key={key}&revision={revision}` | Delete only Push, preserving Email | + +Requests and responses use camelCase and the existing `success/data/error` envelope. +Create with omitted/null `revision`; an existing key returns 409. Update with the +last read nonempty revision; stale/missing definitions return 409. Successful +creation returns 201 with Location; update/read/delete return 200. Unknown services +or absent reads/deletes return 404; invalid requests return 400. + +Example creation body: + +```json +{ + "revision": null, + "variables": [{ "name": "ActorName", "required": true }], + "translations": [{ + "languageCode": "uk", + "title": "Довірені контакти", + "body": "{{ActorName}} додав вас до довірених контактів.", + "actions": { + "defaultAction": "contacts", + "buttons": [{ "action": "contacts", "label": "Перейти в розділ" }] + } + }], + "removeLanguageCodes": [] +} +``` + +The action key above illustrates a producer/client agreement; this endpoint neither +registers actions nor activates any Invemory producer. Object IDs are per-message +metadata, not template content. `actions` is a structured object, not an escaped JSON +string. Omission means no actions for a supplied translation; explicit null is invalid. + +`variables` and `translations` arrays are required (empty is valid). Variables replace +the channel-wide contract. Supplied translations replace their full content, including +actions. Omitted translations remain; only `removeLanguageCodes` removes versions. +Removing every language retains the definition and variables. Codes exactly match the +service catalog; duplicate/unknown codes and simultaneous replace/remove are rejected. +The final merged definition is validated, including retained translations' placeholders, +so a variable change cannot invalidate an omitted translation. Invalid/stale requests +leave content and revision unchanged. The existing transaction/advisory lock covers +catalog checks, merge and revision-guarded persistence. API and administration share +validation and storage, including explicit EF insertion when adding a new language. + +Existing Email endpoints remain unchanged. Both channels can independently use the +same service/key. No migration, new registry, access boundary or Android change is +needed for these management endpoints. Keep them within the existing private +Notification Service administration network boundary. + +Validation of the management API increment: 84 service/unit and 187 PostgreSQL +integration tests passed (271 total). After review cleanup, all 29 focused Push API, +editor and model tests passed again. Coverage includes parallel create/update conflicts, +atomic invalid writes, retained translations, structured localized actions, explicit +language removal and Email preservation. Rider reported no errors in inspected files; +manual structural complexity review was performed, with no numeric score recorded. +Reuse review compared Email definition controllers/handlers, API validation/envelopes, +TemplateMutationRunner and the existing Push editor/storage path. + +A separate local Web launch for additional browser validation was rejected by the +automatic execution policy without further reason. No standalone-browser or installed +Android validation is claimed for this increment; API requests were exercised through +the ASP.NET integration host with real PostgreSQL. Changes await owner testing and +remain uncommitted; the previously delivered service does not yet include these endpoints. + +Owner follow-up: authorized code delivery and another Kubernetes deployment after +verification, and authorized stopping the Rider Web process. The Rider process was +stopped. A second background Web launch was rejected by the execution tool with only +`blocked by policy`; this is an execution-tool restriction, not a request for more +owner permission. The automated API/PostgreSQL checks remain available.