Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 343d67802c | |||
| 517b38791b | |||
| 1351279b36 |
+1
@@ -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; } = [];
|
||||
}
|
||||
+63
-14
@@ -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,
|
||||
|
||||
+7
@@ -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>>;
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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));
|
||||
}
|
||||
}
|
||||
+6
@@ -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>>;
|
||||
+20
@@ -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());
|
||||
}
|
||||
}
|
||||
+6
@@ -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);
|
||||
+11
@@ -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);
|
||||
+7
@@ -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>>;
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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));
|
||||
|
||||
}
|
||||
+11
@@ -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();
|
||||
}
|
||||
+32
@@ -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 ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user