Compare commits

...

2 Commits

Author SHA1 Message Date
agrynco 343d67802c feat(api): manage localized push template definitions
Ref: IT-1627
2026-10-31 10:04:18 +02:00
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 public interface IPushTemplateDefinitionRepository
{ {
Task<PushTemplateDefinition?> UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default);
Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default); Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default);
Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default); Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default);
Task SaveAsync(PushTemplateDefinition definition, bool isNew, 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); 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); PushTemplateValidation.Validate(definition);
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == definition.ServiceId, ct) 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; entity.Updated = DateTimeOffset.UtcNow;
definition.Id = entity.Id; definition.Id = entity.Id;
definition.Revision = entity.Revision; 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) 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 Title = translation.Title, Body = translation.Body, Created = DateTimeOffset.UtcNow
}; };
definition.Languages.Add(entity); definition.Languages.Add(entity);
db.PushTemplates.Add(entity);
} }
entity.Title = translation.Title; entity.Title = translation.Title;
entity.Body = translation.Body; 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) 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; var removed = false;
await mutations.ExecuteAsync(async () => await mutations.ExecuteAsync(async () => removed = await DeleteCoreAsync(serviceId, key, revision, ct), ct);
{
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);
return removed; 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() private static PushTemplateDefinition Map(PushTemplateDefinitionEntity entity) => new()
{ {
Id = entity.Id, ServiceId = entity.ServiceId, Key = entity.Key, Revision = entity.Revision, 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. remain for existing Firebase Token deprecation, defensive JSON null checks and style.
Cognitive complexity was reviewed structurally against the limit of 10; numeric Cognitive complexity was reviewed structurally against the limit of 10; numeric
scores were not measured. All changes remain uncommitted for owner review/testing. 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.