feat(api): manage localized push template definitions
Add revision-protected reads, atomic upsert and deletion with structured actions. Preserve omitted translations and fix insertion of new push languages. Ref: IT-1627
This commit is contained in:
+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,
|
||||
|
||||
Reference in New Issue
Block a user