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:
@@ -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()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user