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 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))); 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 }; }