Compare commits

...

1 Commits

Author SHA1 Message Date
agrynco 517b38791b feat(api): manage localized push template definitions
Add revision-protected reads, atomic upsert and deletion with structured actions. Preserve omitted translations and fix insertion of new push languages.

Ref: IT-1627
2026-10-31 10:03:47 +02:00
19 changed files with 519 additions and 14 deletions
@@ -4,6 +4,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IPushTemplateDefinitionRepository
{
Task<PushTemplateDefinition?> UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default);
Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default);
Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default);
Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default);
@@ -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<EmailTemplateVariable> Variables { get; init; }
public required IReadOnlyList<PushTemplateTranslation> Translations { get; init; }
public IReadOnlyList<string> RemoveLanguageCodes { get; init; } = [];
}
@@ -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<PushTemplateDefinition?> 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<PushTemplateDefinitionEntity> 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<bool> 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<bool> 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,
@@ -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<ServiceResult<Unit>>;
@@ -0,0 +1,25 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeletePushTemplateDefinitionHandler(
IContextualSerilogLogger<DeletePushTemplateDefinitionCommand> logger, IPushTemplateDefinitionRepository repository)
: RequestHandler<DeletePushTemplateDefinitionCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeletePushTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteAsync(request.ServiceId, request.Key, request.Revision, cancellationToken);
return removed ? Success(Unit.Value) : Failure<Unit>("Template definition not found.", ServiceErrorCode.NotFound);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,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<GetPushTemplateDefinitionQuery> logger, IPushTemplateDefinitionRepository definitions)
: RequestHandler<GetPushTemplateDefinitionQuery, ServiceResult<PushTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<PushTemplateDefinitionDto>> DoOnHandle(GetPushTemplateDefinitionQuery request, CancellationToken cancellationToken)
{
var definition = await definitions.GetAsync(request.ServiceId, request.Key, cancellationToken);
return definition is null
? Failure<PushTemplateDefinitionDto>("Template definition not found.", ServiceErrorCode.NotFound)
: Success(PushTemplateDefinitionMapping.Map(definition));
}
}
@@ -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<ServiceResult<PushTemplateDefinitionDto>>;
@@ -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<GetServicePushTemplateDefinitionsQuery> logger,
INotificationServiceRepository services, IPushTemplateDefinitionRepository definitions)
: RequestHandler<GetServicePushTemplateDefinitionsQuery, ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>> DoOnHandle(GetServicePushTemplateDefinitionsQuery request, CancellationToken cancellationToken)
{
if (await services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<IReadOnlyList<PushTemplateDefinitionDto>>("Service not found.", ServiceErrorCode.NotFound);
var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken);
return Success<IReadOnlyList<PushTemplateDefinitionDto>>(all.Select(PushTemplateDefinitionMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetServicePushTemplateDefinitionsQuery(Guid ServiceId) : IRequest<ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>>;
@@ -0,0 +1,4 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record PushTemplateDefinitionDto(Guid Id, Guid ServiceId, string Key, Guid Revision,
IReadOnlyList<TemplateVariableDto> Variables, IReadOnlyList<PushTemplateTranslationDto> Translations);
@@ -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());
}
@@ -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);
@@ -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<ServiceResult<PushTemplateDefinitionDto>>;
@@ -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<UpsertPushTemplateDefinitionCommand> logger, IPushTemplateDefinitionRepository definitions)
: RequestHandler<UpsertPushTemplateDefinitionCommand, ServiceResult<PushTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<PushTemplateDefinitionDto>> DoOnHandle(UpsertPushTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken);
return definition is null
? Failure<PushTemplateDefinitionDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(PushTemplateDefinitionMapping.Map(definition));
}
catch (EmailTemplateWriteException error)
{
return Failure<PushTemplateDefinitionDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -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<TemplateDatabaseFixture>
{
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<JsonElement> Read(DefinitionApiTestContext c) =>
(await c.Client.GetFromJsonAsync<JsonElement>(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<JsonElement>(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<JsonElement>()).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<string, Action>
{
["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);
}
}
@@ -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<ApiResponse<PushTemplateDefinitionDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] Guid serviceId, [FromQuery] string key, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new GetPushTemplateDefinitionQuery(serviceId, key), cancellationToken));
[HttpPost("upsert")]
[ProducesResponseType<ApiResponse<PushTemplateDefinitionDto>>(StatusCodes.Status201Created)]
[ProducesResponseType<ApiResponse<PushTemplateDefinitionDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Upsert([FromRoute] Guid serviceId, [FromQuery] string key,
[FromBody] 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<PushTemplateDefinitionDto> { Success = true, Data = result.Result });
return FromServiceResult(result);
}
catch (EmailTemplateWriteException error)
{
return MapServiceError(new ServiceError(error.Message, ServiceErrorCode.InvalidRequest));
}
}
[HttpGet("all")]
[ProducesResponseType<ApiResponse<IReadOnlyList<PushTemplateDefinitionDto>>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetAll([FromRoute] Guid serviceId, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new GetServicePushTemplateDefinitionsQuery(serviceId), cancellationToken));
[HttpDelete]
[ProducesResponseType<ApiResponse<Unit>>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, [FromQuery] string key, [FromQuery] Guid revision, CancellationToken cancellationToken) =>
FromServiceResult(await Mediator.Send(new DeletePushTemplateDefinitionCommand(serviceId, key, revision), cancellationToken));
}
@@ -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();
}
@@ -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<TemplateVariableRequest> Variables { get; init; }
public required IReadOnlyList<PushTemplateTranslationRequest> Translations { get; init; }
public IReadOnlyList<string>? 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 ?? []
};
}
}
+78
View File
@@ -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.