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
@@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
EmailChannelEntity? entity = await EfRepository.GetByIdAsync(id);
EmailChannelEntity? entity = await EfRepository.Get().AsNoTracking().SingleOrDefaultAsync(c => c.Id == id, ct);
return entity is null ? null : MapToDomain(entity);
}
@@ -43,6 +43,93 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
return entity is null ? null : MapDefinition(entity);
}
public async Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default)
{
var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
return entity is null ? null : MapDefinition(entity);
}
public async Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default)
{
EmailTemplateDefinitionEntity? 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 entity = await LoadForUpsertAsync(patch, service, ct);
entity.Variables = MapVariables(patch.Variables);
var removed = patch.RemoveLanguageCodes.ToHashSet(StringComparer.Ordinal);
db.Templates.RemoveRange(entity.Languages.Where(t => removed.Contains(t.ServiceLanguage.Code)));
// Omitted translations are retained. Only explicitly supplied content is changed.
UpsertLanguages(entity, patch.Translations);
Touch(entity);
saved = entity;
}, ct);
return saved is null ? null : MapDefinition(saved);
}
private async Task<EmailTemplateDefinitionEntity> LoadForUpsertAsync(EmailTemplateDefinitionPatch patch, ServiceEntity service, CancellationToken ct)
{
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == patch.ServiceId && d.Key == patch.Key, ct);
if (patch.Revision is null)
{
if (entity is not null)
throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating.");
entity = new EmailTemplateDefinitionEntity { Service = service, ServiceId = service.Id, Key = patch.Key, Created = DateTimeOffset.UtcNow };
db.TemplateDefinitions.Add(entity);
return entity;
}
if (entity is null || entity.Revision != patch.Revision)
throw new EmailTemplateWriteException("The definition is missing or its revision changed. Read it again before updating.");
return entity;
}
private static void ValidatePatch(EmailTemplateDefinitionPatch patch, ServiceEntity service)
{
EmailTemplateValidation.ValidateDefinition(new EmailTemplateDefinition
{
ServiceId = service.Id, ServiceName = service.Name, 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);
}
public async Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default)
{
var entities = await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct);
return entities.Select(MapDefinition).ToList();
}
public async Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default)
{
var removed = false;
await mutations.ExecuteAsync(async () =>
{
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 definition = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
if (definition is null)
return;
if (definition.Revision != revision)
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
db.TemplateDefinitions.Remove(definition);
removed = true;
}, ct);
return removed;
}
public Task AddAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{
ValidateLegacy(template);
@@ -113,6 +200,11 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
{
var codes = languages.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
db.Templates.RemoveRange(definition.Languages.Where(t => !codes.Contains(t.ServiceLanguage.Code)));
UpsertLanguages(definition, languages);
}
private void UpsertLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList<EmailTemplate> languages)
{
foreach (var model in languages)
{
var code = model.LanguageCode;
@@ -20,6 +20,33 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return service is null ? null : Map(service, await VersionCountsAsync(ct));
}
public async Task<NotificationServiceInfo?> GetByNameAsync(string name, CancellationToken ct = default)
{
var service = await db.Services.AsNoTracking().Include(s => s.Languages).SingleOrDefaultAsync(s => s.Name == name, ct);
return service is null ? null : Map(service, await VersionCountsAsync(ct));
}
public async Task<bool> DeleteAsync(Guid id, Guid revision, CancellationToken ct = default)
{
var removed = false;
await mutations.ExecuteAsync(async () =>
{
if (revision == Guid.Empty)
throw new EmailTemplateWriteException("A nonempty service revision is required.", false);
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == id, ct);
if (service is null)
return;
if (service.Revision != revision)
throw new EmailTemplateWriteException("The service revision changed. Read it again before deleting.");
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct))
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
db.ServiceLanguages.RemoveRange(service.Languages);
db.Services.Remove(service);
removed = true;
}, ct);
return removed;
}
public Task SaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{
Validate(model);