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 EmailTemplateRepository(NotificationDbContext db, TemplateMutationRunner mutations) : IEmailTemplateRepository, IEmailTemplateDefinitionRepository { private IQueryable Languages => db.Templates .Include(t => t.Definition).ThenInclude(d => d.Service).Include(t => t.ServiceLanguage); private IQueryable Definitions => db.TemplateDefinitions .Include(d => d.Service).ThenInclude(s => s.Languages) .Include(d => d.Languages).ThenInclude(t => t.ServiceLanguage); public async Task> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) { var query = Languages.AsNoTracking(); if (!string.IsNullOrWhiteSpace(serviceName)) query = query.Where(t => t.Definition.Service.Name == serviceName); if (!string.IsNullOrWhiteSpace(key)) query = query.Where(t => t.Definition.Key == key); var entities = await query.OrderBy(t => t.Definition.Service.Name).ThenBy(t => t.Definition.Key) .ThenBy(t => t.ServiceLanguage.Code).ToListAsync(ct); return entities.Select(MapLanguage).ToList(); } public Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) => GetAllAsync(serviceName, ct: ct); public async Task GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default) { var code = languageCode; var entity = await Languages.AsNoTracking().SingleOrDefaultAsync(t => t.Definition.Service.Name == serviceName && t.Definition.Key == key && t.ServiceLanguage.Code == code, ct); return entity is null ? null : MapLanguage(entity); } public async Task GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) { var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.Service.Name == serviceName && d.Key == key, ct); return entity is null ? null : MapDefinition(entity); } public async Task 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 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 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> 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 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); var definition = await FindDefinitionAsync(template.ServiceName, template.Key, ct); if (definition is null) { await RequireServiceLanguageAsync(template.ServiceName, template.LanguageCode, ct); definition = await CreateDefinitionAsync(template.ServiceName, template.Key, template.Variables, ct); } EnsureMatchingContract(definition, template.Variables); await RequireServiceLanguageAsync(template.ServiceName, template.LanguageCode, ct); var code = template.LanguageCode; if (definition.Languages.Any(t => t.ServiceLanguage.Code == code)) throw new EmailTemplateWriteException("This language already exists for the template."); var language = NewLanguage(template, definition); definition.Languages.Add(language); db.Templates.Add(language); Touch(definition); }, ct); public Task UpdateAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () => { ValidateLegacy(template); var definition = await RequireDefinitionAsync(template.ServiceName, template.Key, ct); EnsureMatchingContract(definition, template.Variables); var language = definition.Languages.SingleOrDefault(t => t.Id == template.Id) ?? throw new EmailTemplateWriteException("The language changed or was removed. Reload the template."); SetContent(language, template); Touch(definition); }, ct); public Task DeleteAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () => { var definition = await RequireDefinitionAsync(template.ServiceName, template.Key, ct); var language = definition.Languages.SingleOrDefault(t => t.Id == template.Id) ?? throw new EmailTemplateWriteException("The language changed or was removed. Reload the template."); db.Templates.Remove(language); Touch(definition); }, ct); public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () => { EmailTemplateValidation.ValidateDefinition(definition); await ValidateServiceLanguagesAsync(definition, ct); var entity = await LoadForSaveAsync(definition, isNew, ct); entity.Variables = MapVariables(definition.Variables); SynchronizeLanguages(entity, definition.Languages); Touch(entity); definition.Id = entity.Id; definition.Revision = entity.Revision; }, ct); private async Task LoadForSaveAsync(EmailTemplateDefinition model, bool isNew, CancellationToken ct) { var entity = await FindDefinitionAsync(model.ServiceName, model.Key, ct); if (isNew) { if (entity is not null) throw new EmailTemplateWriteException("A template with this service and key already exists."); return await CreateDefinitionAsync(model.ServiceName, model.Key, model.Variables, ct); } if (entity is null || entity.Id != model.Id || entity.Revision != model.Revision) throw new EmailTemplateWriteException("This template changed after you opened it. Reload before saving; your entered values are retained."); return entity; } private void SynchronizeLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList languages) { 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 languages) { foreach (var model in languages) { var code = model.LanguageCode; var entity = definition.Languages.SingleOrDefault(t => t.ServiceLanguage.Code == code); if (entity is null) { var language = NewLanguage(model, definition); definition.Languages.Add(language); db.Templates.Add(language); } else SetContent(entity, model); } } public async Task> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) { var query = Definitions.AsNoTracking(); if (!string.IsNullOrWhiteSpace(serviceName)) query = query.Where(d => d.Service.Name == serviceName); if (!string.IsNullOrWhiteSpace(key)) query = query.Where(d => d.Key == key); var entities = await query.OrderBy(d => d.Service.Name).ThenBy(d => d.Key).ToListAsync(ct); return entities.Select(MapDefinition).ToList(); } private async Task RequireServiceLanguageAsync(string serviceName, string code, CancellationToken ct) { if (!await db.ServiceLanguages.AnyAsync(l => l.Service.Name == serviceName && l.Code == code, ct)) throw new EmailTemplateWriteException("Language is not in this service's catalog. Add it in the service editor first.", false); } private async Task ValidateServiceLanguagesAsync(EmailTemplateDefinition definition, CancellationToken ct) { var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Name == definition.ServiceName && (definition.ServiceId == Guid.Empty || s.Id == definition.ServiceId), ct) ?? throw new EmailTemplateWriteException("Service does not exist. Create the service first.", false); var codes = service.Languages.Select(l => l.Code).ToHashSet(StringComparer.Ordinal); if (definition.Languages.Any(l => !codes.Contains(l.LanguageCode))) throw new EmailTemplateWriteException("Language is not in this service's catalog. Add it in the service editor first.", false); } private Task FindDefinitionAsync(string serviceName, string key, CancellationToken ct) => Definitions.SingleOrDefaultAsync(d => d.Service.Name == serviceName && d.Key == key, ct); private async Task RequireDefinitionAsync(string serviceName, string key, CancellationToken ct) => await FindDefinitionAsync(serviceName, key, ct) ?? throw new EmailTemplateWriteException("The template was removed. Reload the template list."); private async Task CreateDefinitionAsync(string serviceName, string key, IReadOnlyList variables, CancellationToken ct) { var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Name == serviceName, ct) ?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false); var entity = new EmailTemplateDefinitionEntity { Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow }; db.TemplateDefinitions.Add(entity); return entity; } private static void ValidateLegacy(EmailTemplate template) { EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key); EmailTemplateValidation.ValidateLanguage(template); EmailTemplateValidation.ValidateVariables(template.Variables); } private static void EnsureMatchingContract(EmailTemplateDefinitionEntity definition, IReadOnlyList variables) { var shared = definition.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(); if (!EmailTemplateValidation.ContractsEqual(shared, variables)) throw new EmailTemplateWriteException("Variables conflict with the shared template contract. Edit shared variables in the template editor."); } private static void Touch(EmailTemplateDefinitionEntity definition) { definition.Revision = Guid.NewGuid(); definition.Updated = DateTimeOffset.UtcNow; } private static List MapVariables(IReadOnlyList variables) => variables.Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required }).ToList(); private static EmailTemplateEntity NewLanguage(EmailTemplate model, EmailTemplateDefinitionEntity definition) => new() { Id = model.Id, Definition = definition, ServiceId = definition.Service.Id, ServiceLanguage = definition.Service.Languages.SingleOrDefault(l => l.Code == model.LanguageCode) ?? throw new EmailTemplateWriteException("Language is not in this service's catalog. Add it in the service editor first.", false), Subject = model.Subject, HtmlBody = model.HtmlBody, TextBody = model.TextBody, Created = model.Created }; private static void SetContent(EmailTemplateEntity entity, EmailTemplate model) { entity.Subject = model.Subject; entity.HtmlBody = model.HtmlBody; entity.TextBody = model.TextBody; entity.Updated = DateTimeOffset.UtcNow; } private static EmailTemplate MapLanguage(EmailTemplateEntity entity) => new() { Id = entity.Id, ServiceName = entity.Definition.Service.Name, Key = entity.Definition.Key, LanguageCode = entity.ServiceLanguage.Code, Subject = entity.Subject, HtmlBody = entity.HtmlBody, TextBody = entity.TextBody, Variables = entity.Definition.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), Created = entity.Created, Updated = entity.Updated }; private static EmailTemplateDefinition MapDefinition(EmailTemplateDefinitionEntity entity) => new() { Id = entity.Id, ServiceId = entity.ServiceId, ServiceName = entity.Service.Name, Key = entity.Key, Revision = entity.Revision, Variables = entity.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), Languages = entity.Languages.OrderBy(t => t.ServiceLanguage.Code).Select(MapLanguage).ToList(), Created = entity.Created, Updated = entity.Updated }; }