feat(notification): add admin rest management and deletion

Add revision-safe service and template APIs, channel mapping, and validated admin controls.

Ref: IT-1601
This commit is contained in:
2026-10-17 22:51:01 +03:00
parent 81654c0d33
commit 8e81572386
80 changed files with 2253 additions and 54 deletions
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteEmailTemplateDefinitionCommand(Guid ServiceId, string Key, Guid Revision) : IRequest<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 DeleteEmailTemplateDefinitionHandler(
IContextualSerilogLogger<DeleteEmailTemplateDefinitionCommand> logger, IEmailTemplateDefinitionRepository repository)
: RequestHandler<DeleteEmailTemplateDefinitionCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteEmailTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteDefinitionAsync(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,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteNotificationServiceCommand(Guid ServiceId, Guid Revision) : IRequest<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 DeleteNotificationServiceHandler(
IContextualSerilogLogger<DeleteNotificationServiceCommand> logger, INotificationServiceRepository repository)
: RequestHandler<DeleteNotificationServiceCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteNotificationServiceCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteAsync(request.ServiceId, request.Revision, cancellationToken);
return removed ? Success(Unit.Value) : Failure<Unit>("Service not found.", ServiceErrorCode.NotFound);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteServiceLanguageVersionsCommand(Guid ServiceId, Guid LanguageId, Guid Revision, int ExpectedVersionCount, bool ConfirmDelete) : IRequest<ServiceResult<Unit>>;
@@ -0,0 +1,30 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeleteServiceLanguageVersionsHandler(
IContextualSerilogLogger<DeleteServiceLanguageVersionsCommand> logger, INotificationServiceRepository services)
: RequestHandler<DeleteServiceLanguageVersionsCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteServiceLanguageVersionsCommand request, CancellationToken cancellationToken)
{
if (!request.ConfirmDelete || request.ExpectedVersionCount < 0 || request.Revision == Guid.Empty)
return Failure<Unit>("ConfirmDelete must be true; a nonnegative expectedVersionCount and nonempty service revision are required.", ServiceErrorCode.InvalidRequest);
var service = await services.GetAsync(request.ServiceId, cancellationToken);
if (service?.Languages.Any(l => l.Id == request.LanguageId) != true)
return Failure<Unit>("Service language not found.", ServiceErrorCode.NotFound);
try
{
await services.RemoveLanguageAsync(request.ServiceId, request.LanguageId, request.ExpectedVersionCount, request.Revision, cancellationToken);
return Success(Unit.Value);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeleteUnusedServiceLanguageCommand(Guid ServiceId, Guid LanguageId, Guid Revision) : IRequest<ServiceResult<Unit>>;
@@ -0,0 +1,34 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeleteUnusedServiceLanguageHandler(
IContextualSerilogLogger<DeleteUnusedServiceLanguageCommand> logger, INotificationServiceRepository services)
: RequestHandler<DeleteUnusedServiceLanguageCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteUnusedServiceLanguageCommand request, CancellationToken cancellationToken)
{
if (request.Revision == Guid.Empty)
return Failure<Unit>("A nonempty service revision is required.", ServiceErrorCode.InvalidRequest);
var service = await services.GetAsync(request.ServiceId, cancellationToken);
var language = service?.Languages.SingleOrDefault(l => l.Id == request.LanguageId);
if (language is null)
return Failure<Unit>("Service language not found.", ServiceErrorCode.NotFound);
if (language.VersionCount > 0)
return Failure<Unit>("This language has template versions. Use delete-with-versions with explicit confirmation and the current count.", ServiceErrorCode.Conflict);
try
{
// The repository repeats the zero-count and revision checks under its mutation lock.
await services.RemoveLanguageAsync(request.ServiceId, request.LanguageId, 0, request.Revision, cancellationToken);
return Success(Unit.Value);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,5 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record EmailTemplateDefinitionDto(Guid Id, Guid ServiceId, string ServiceName, string Key, Guid Revision,
IReadOnlyList<TemplateVariableDto> Variables, IReadOnlyList<TemplateTranslationDto> Translations,
DateTimeOffset Created, DateTimeOffset? Updated);
@@ -0,0 +1,17 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetAllNotificationServicesHandler(
IContextualSerilogLogger<GetAllNotificationServicesQuery> logger, INotificationServiceRepository services)
: RequestHandler<GetAllNotificationServicesQuery, ServiceResult<IReadOnlyList<NotificationServiceDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<NotificationServiceDto>>> DoOnHandle(GetAllNotificationServicesQuery request, CancellationToken cancellationToken)
{
var all = await services.GetAllAsync(cancellationToken);
return Success<IReadOnlyList<NotificationServiceDto>>(all.Select(NotificationServiceMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetAllNotificationServicesQuery() : IRequest<ServiceResult<IReadOnlyList<NotificationServiceDto>>>;
@@ -0,0 +1,19 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetEmailTemplateDefinitionHandler(
IContextualSerilogLogger<GetEmailTemplateDefinitionQuery> logger, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<GetEmailTemplateDefinitionQuery, ServiceResult<EmailTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<EmailTemplateDefinitionDto>> DoOnHandle(GetEmailTemplateDefinitionQuery request, CancellationToken cancellationToken)
{
var definition = await definitions.GetDefinitionAsync(request.ServiceId, request.Key, cancellationToken);
return definition is null
? Failure<EmailTemplateDefinitionDto>("Template definition not found.", ServiceErrorCode.NotFound)
: Success(TemplateDefinitionMapping.Map(definition));
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetEmailTemplateDefinitionQuery(Guid ServiceId, string Key) : IRequest<ServiceResult<EmailTemplateDefinitionDto>>;
@@ -0,0 +1,23 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetNotificationServiceHandler(
IContextualSerilogLogger<GetNotificationServiceQuery> logger, INotificationServiceRepository services)
: RequestHandler<GetNotificationServiceQuery, ServiceResult<NotificationServiceDto>>(logger)
{
protected override async Task<ServiceResult<NotificationServiceDto>> DoOnHandle(GetNotificationServiceQuery request, CancellationToken cancellationToken)
{
if (request.ServiceId is null && string.IsNullOrWhiteSpace(request.Name))
return Failure<NotificationServiceDto>("An exact service name is required.", ServiceErrorCode.InvalidRequest);
var service = request.ServiceId is Guid id
? await services.GetAsync(id, cancellationToken)
: await services.GetByNameAsync(request.Name!, cancellationToken);
return service is null
? Failure<NotificationServiceDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(NotificationServiceMapping.Map(service));
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetNotificationServiceQuery(Guid? ServiceId, string? Name) : IRequest<ServiceResult<NotificationServiceDto>>;
@@ -0,0 +1,20 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetServiceTemplateDefinitionsHandler(
IContextualSerilogLogger<GetServiceTemplateDefinitionsQuery> logger,
INotificationServiceRepository services, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<GetServiceTemplateDefinitionsQuery, ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>> DoOnHandle(GetServiceTemplateDefinitionsQuery request, CancellationToken cancellationToken)
{
if (await services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<IReadOnlyList<EmailTemplateDefinitionDto>>("Service not found.", ServiceErrorCode.NotFound);
var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken);
return Success<IReadOnlyList<EmailTemplateDefinitionDto>>(all.Select(TemplateDefinitionMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetServiceTemplateDefinitionsQuery(Guid ServiceId) : IRequest<ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>>;
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record NotificationServiceDto(Guid Id, string Name, Guid Revision, IReadOnlyList<string> SupportedLanguageCodes, IReadOnlyList<ServiceLanguageDto> Languages);
@@ -0,0 +1,10 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal static class NotificationServiceMapping
{
public static NotificationServiceDto Map(NotificationServiceInfo service) =>
new(service.Id, service.Name, service.Revision, service.Languages.Select(l => l.Code).ToList(),
service.Languages.Select(l => new ServiceLanguageDto(l.Id, l.Code, l.VersionCount)).ToList());
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record SaveNotificationServiceCommand(NotificationServiceInfo Service, bool IsNew) : IRequest<ServiceResult<NotificationServiceDto>>;
@@ -0,0 +1,30 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class SaveNotificationServiceHandler(
IContextualSerilogLogger<SaveNotificationServiceCommand> logger, INotificationServiceRepository services)
: RequestHandler<SaveNotificationServiceCommand, ServiceResult<NotificationServiceDto>>(logger)
{
protected override async Task<ServiceResult<NotificationServiceDto>> DoOnHandle(SaveNotificationServiceCommand request, CancellationToken cancellationToken)
{
if (!request.IsNew && request.Service.Revision == Guid.Empty)
return Failure<NotificationServiceDto>("A nonempty service revision is required for update.", ServiceErrorCode.InvalidRequest);
if (!request.IsNew && await services.GetAsync(request.Service.Id, cancellationToken) is null)
return Failure<NotificationServiceDto>("Service not found.", ServiceErrorCode.NotFound);
try
{
await services.SaveAsync(request.Service, request.IsNew, cancellationToken);
var saved = await services.GetAsync(request.Service.Id, cancellationToken);
return Success(NotificationServiceMapping.Map(saved!));
}
catch (EmailTemplateWriteException error)
{
return Failure<NotificationServiceDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record ServiceLanguageDto(Guid Id, string Code, int VersionCount);
@@ -0,0 +1,12 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal static class TemplateDefinitionMapping
{
public static EmailTemplateDefinitionDto Map(EmailTemplateDefinition definition) => new(
definition.Id, definition.ServiceId, definition.ServiceName, definition.Key, definition.Revision,
definition.Variables.Select(v => new TemplateVariableDto(v.Name, v.Required)).ToList(),
definition.Languages.Select(t => new TemplateTranslationDto(t.Id, t.LanguageCode, t.Subject, t.HtmlBody, t.TextBody, t.Created, t.Updated)).ToList(),
definition.Created, definition.Updated);
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record TemplateTranslationDto(Guid Id, string LanguageCode, string Subject, string HtmlBody, string TextBody, DateTimeOffset Created, DateTimeOffset? Updated);
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record TemplateVariableDto(string Name, bool Required);
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record UpsertEmailTemplateDefinitionCommand(EmailTemplateDefinitionPatch Patch) : IRequest<ServiceResult<EmailTemplateDefinitionDto>>;
@@ -0,0 +1,27 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class UpsertEmailTemplateDefinitionHandler(
IContextualSerilogLogger<UpsertEmailTemplateDefinitionCommand> logger, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<UpsertEmailTemplateDefinitionCommand, ServiceResult<EmailTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<EmailTemplateDefinitionDto>> DoOnHandle(UpsertEmailTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken);
return definition is null
? Failure<EmailTemplateDefinitionDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(TemplateDefinitionMapping.Map(definition));
}
catch (EmailTemplateWriteException error)
{
return Failure<EmailTemplateDefinitionDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}