feat(templates): add localized push templates and actions

Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery.

Ref: IT-1627
This commit is contained in:
2026-10-31 09:38:05 +02:00
parent d4ee8fc0c8
commit aba4173827
79 changed files with 3906 additions and 104 deletions
@@ -78,6 +78,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
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 };
entity.Template = await GetOrCreateTemplateAsync(service, patch.Key, ct);
db.TemplateDefinitions.Add(entity);
return entity;
}
@@ -125,6 +126,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
if (definition.Revision != revision)
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
db.TemplateDefinitions.Remove(definition);
await RemoveEmptyTemplateAsync(serviceId, key, ct);
removed = true;
}, ct);
return removed;
@@ -261,12 +263,31 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false);
var entity = new EmailTemplateDefinitionEntity
{
Template = await GetOrCreateTemplateAsync(service, key, ct),
Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow
};
db.TemplateDefinitions.Add(entity);
return entity;
}
private async Task<TemplateEntity> GetOrCreateTemplateAsync(ServiceEntity service, string key, CancellationToken ct)
{
var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == key, ct);
if (template is not null)
return template;
template = new TemplateEntity { Service = service, ServiceId = service.Id, Key = key, Created = DateTimeOffset.UtcNow };
db.TemplateCatalog.Add(template);
return template;
}
private async Task RemoveEmptyTemplateAsync(Guid serviceId, string key, CancellationToken ct)
{
if (await db.PushTemplateDefinitions.AnyAsync(t => t.ServiceId == serviceId && t.Key == key, ct))
return;
var template = await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct);
db.TemplateCatalog.Remove(template);
}
private static void ValidateLegacy(EmailTemplate template)
{
EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key);
@@ -48,7 +48,7 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
private async Task EnsureServiceIsEmptyAsync(Guid id, CancellationToken ct)
{
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct))
if (await db.TemplateCatalog.AnyAsync(d => d.ServiceId == id, ct))
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
if (await db.Providers.AnyAsync(c => c.ServiceId == id, ct) || await db.PushChannels.AnyAsync(c => c.ServiceId == id, ct))
throw new EmailTemplateWriteException("Reassign or remove every notification channel before deleting this service.");
@@ -76,9 +76,11 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
var language = service.Languages.SingleOrDefault(l => l.Id == languageId)
?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service.");
var versions = await db.Templates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
if (service.Revision != expectedRevision || versions.Count != expectedVersionCount)
var pushVersions = await db.PushTemplates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
if (service.Revision != expectedRevision || versions.Count + pushVersions.Count != expectedVersionCount)
throw new EmailTemplateWriteException("The service or affected version count changed. Review the current count and confirm again.");
db.Templates.RemoveRange(versions);
db.PushTemplates.RemoveRange(pushVersions);
// Save dependent removals first because language deletion is restricted by its FK.
await db.SaveChangesAsync(ct);
db.ServiceLanguages.Remove(language);
@@ -112,7 +114,8 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
var removed = service.Languages.Where(l => !retained.Contains(l.Id)).ToList();
foreach (var language in removed)
{
var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct);
var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct)
+ await db.PushTemplates.CountAsync(t => t.ServiceLanguageId == language.Id, ct);
if (count > 0)
throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation.");
}
@@ -168,10 +171,17 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
definition.Revision = Guid.NewGuid();
definition.Updated = DateTimeOffset.UtcNow;
}
var pushDefinitions = await db.PushTemplateDefinitions.Where(d => d.ServiceId == serviceId).ToListAsync(ct);
foreach (var definition in pushDefinitions)
{
definition.Revision = Guid.NewGuid();
definition.Updated = DateTimeOffset.UtcNow;
}
}
private Task<Dictionary<Guid, int>> VersionCountsAsync(CancellationToken ct) => db.Templates.AsNoTracking()
.GroupBy(t => t.ServiceLanguageId).Select(g => new { Id = g.Key, Count = g.Count() })
.Select(t => t.ServiceLanguageId).Concat(db.PushTemplates.Select(t => t.ServiceLanguageId))
.GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.Id, g => g.Count, ct);
private static void Validate(NotificationServiceInfo service)
@@ -0,0 +1,108 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories;
internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, TemplateMutationRunner mutations)
: IPushTemplateDefinitionRepository
{
private IQueryable<PushTemplateDefinitionEntity> Definitions => db.PushTemplateDefinitions
.Include(d => d.Languages).ThenInclude(l => l.ServiceLanguage);
public async Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) =>
(await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct)).Select(Map).ToList();
public async Task<PushTemplateDefinition?> GetAsync(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 : Map(entity);
}
public Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{
PushTemplateValidation.Validate(definition);
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == definition.ServiceId, ct)
?? throw new EmailTemplateWriteException("Service no longer exists. Reload services.");
if (definition.Languages.Any(l => !service.Languages.Any(s => s.Code == l.LanguageCode)))
throw new EmailTemplateWriteException("Every language must exactly match this service's catalog.", false);
var entity = await LoadForSaveAsync(definition, service, isNew, ct);
entity.Variables = definition.Variables.Select(v => new PushTemplateVariableData { Name = v.Name, Required = v.Required }).ToList();
SynchronizeLanguages(entity, service, definition.Languages);
entity.Revision = Guid.NewGuid();
entity.Updated = DateTimeOffset.UtcNow;
definition.Id = entity.Id;
definition.Revision = entity.Revision;
}, ct);
private async Task<PushTemplateDefinitionEntity> LoadForSaveAsync(PushTemplateDefinition model, ServiceEntity service, bool isNew, CancellationToken ct)
{
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == service.Id && d.Key == model.Key, ct);
if (!isNew)
{
if (entity is null || entity.Id != model.Id || entity.Revision != model.Revision)
throw new EmailTemplateWriteException("This push template changed after you opened it. Reload before saving; your entered values are retained.");
return entity;
}
if (entity is not null)
throw new EmailTemplateWriteException("A push template with this key already exists.");
var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == model.Key, ct)
?? new TemplateEntity { Service = service, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow };
entity = new PushTemplateDefinitionEntity { Template = template, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow };
db.PushTemplateDefinitions.Add(entity);
return entity;
}
private void SynchronizeLanguages(PushTemplateDefinitionEntity definition, ServiceEntity service, IReadOnlyList<PushTemplateTranslation> translations)
{
var codes = translations.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
db.PushTemplates.RemoveRange(definition.Languages.Where(l => !codes.Contains(l.ServiceLanguage.Code)));
foreach (var translation in translations)
{
var entity = definition.Languages.SingleOrDefault(l => l.ServiceLanguage.Code == translation.LanguageCode);
if (entity is null)
{
entity = new PushTemplateEntity
{
Definition = definition, ServiceId = service.Id,
ServiceLanguage = service.Languages.Single(l => l.Code == translation.LanguageCode),
Title = translation.Title, Body = translation.Body, Created = DateTimeOffset.UtcNow
};
definition.Languages.Add(entity);
}
entity.Title = translation.Title;
entity.Body = translation.Body;
entity.ActionsJson = translation.ActionsJson;
entity.Updated = DateTimeOffset.UtcNow;
}
}
public async Task<bool> DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default)
{
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);
return removed;
}
private static PushTemplateDefinition Map(PushTemplateDefinitionEntity entity) => new()
{
Id = entity.Id, ServiceId = entity.ServiceId, Key = entity.Key, Revision = entity.Revision,
Variables = entity.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(),
Languages = entity.Languages.OrderBy(l => l.ServiceLanguage.Code).Select(l => new PushTemplateTranslation
{
LanguageCode = l.ServiceLanguage.Code, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson
}).ToList()
};
}