From c6caf335bea6db327f9403e89d6d2ab6afe854b5 Mon Sep 17 00:00:00 2001 From: Daniel Lerch <36048059+daniel-lerch@users.noreply.github.com> Date: Wed, 22 May 2024 12:47:22 +0200 Subject: [PATCH] Start rewrite person filters with duplicate check --- README.md | 3 +- .../Korga.Tests/PersonFilterServiceTests.cs | 266 ++++++++++++++++++ .../Korga/Commands/DistributionListCommand.cs | 167 +++++++++-- .../Controllers/DistributionListController.cs | 15 +- server/Korga/DatabaseContext.cs | 57 +++- .../EmailRelay/DistributionListService.cs | 63 +---- .../EmailRelay/Entities/DistributionList.cs | 5 +- .../Korga/EmailRelay/Entities/PersonFilter.cs | 9 - .../Korga/EmailRelay/Entities/SinglePerson.cs | 9 - .../Korga/EmailRelay/Entities/StatusFilter.cs | 9 - .../Extensions/DbUpdateExceptionExtensions.cs | 12 + .../Entities/GroupFilter.cs | 7 +- .../Korga/Filters/Entities/GroupTypeFilter.cs | 17 ++ server/Korga/Filters/Entities/PersonFilter.cs | 13 + .../Filters/Entities/PersonFilterList.cs | 10 + server/Korga/Filters/Entities/SinglePerson.cs | 14 + server/Korga/Filters/Entities/StatusFilter.cs | 14 + server/Korga/Filters/PersonFilterService.cs | 100 +++++++ .../Models/Json/DistributionListResponse.cs | 7 +- server/Korga/Startup.cs | 3 + 20 files changed, 676 insertions(+), 124 deletions(-) create mode 100644 server/Korga.Tests/PersonFilterServiceTests.cs delete mode 100644 server/Korga/EmailRelay/Entities/PersonFilter.cs delete mode 100644 server/Korga/EmailRelay/Entities/SinglePerson.cs delete mode 100644 server/Korga/EmailRelay/Entities/StatusFilter.cs create mode 100644 server/Korga/Extensions/DbUpdateExceptionExtensions.cs rename server/Korga/{EmailRelay => Filters}/Entities/GroupFilter.cs (54%) create mode 100644 server/Korga/Filters/Entities/GroupTypeFilter.cs create mode 100644 server/Korga/Filters/Entities/PersonFilter.cs create mode 100644 server/Korga/Filters/Entities/PersonFilterList.cs create mode 100644 server/Korga/Filters/Entities/SinglePerson.cs create mode 100644 server/Korga/Filters/Entities/StatusFilter.cs create mode 100644 server/Korga/Filters/PersonFilterService.cs diff --git a/README.md b/README.md index 02b3b71..b6ce22e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ Once configured, Korga automatically synchronizes people and groups from ChurchT There is no Web UI available yet to manage distribution lists so you must stick to the CLI inside the Docker container: ``` -./Korga distribution-list create --group 137 kids +./Korga dist create kids +./Korga dist add-recipient kids -g 137 ``` This command creates a distribution list _kids@example.org_ which forwards emails to every member of group #137. diff --git a/server/Korga.Tests/PersonFilterServiceTests.cs b/server/Korga.Tests/PersonFilterServiceTests.cs new file mode 100644 index 0000000..3454ab1 --- /dev/null +++ b/server/Korga.Tests/PersonFilterServiceTests.cs @@ -0,0 +1,266 @@ +using Korga.ChurchTools.Entities; +using Korga.Filters; +using Korga.Filters.Entities; +using Korga.Tests; +using Microsoft.Extensions.DependencyInjection; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Korga.Server.Tests; + +public class PersonFilterServiceTests : DatabaseTestBase +{ + private readonly PersonFilterService personFilterService; + + public PersonFilterServiceTests() + { + personFilterService = serviceScope.ServiceProvider.GetRequiredService(); + } + + protected override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + } + + [Fact] + public async Task TestGetPeople_Empty() + { + await InitializeSampleDataset(); + + // Create an empty person filter list + PersonFilterList filterList = new(); + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + var people = await personFilterService.GetPeople(filterList.Id); + Assert.Empty(people); + } + + [Fact] + public async Task TestGetPeople_Person() + { + await InitializeSampleDataset(); + + // Create person filter + PersonFilterList filterList = new() + { + Filters = [new SinglePerson { PersonId = 1 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + var people = await personFilterService.GetPeople(filterList.Id); + Person actual = Assert.Single(people); + Assert.Equal(1, actual.Id); + } + + [Fact] + public async Task TestGetPeople_Status() + { + await InitializeSampleDataset(); + + // Create status filter + PersonFilterList filterList = new() + { + Filters = [new StatusFilter { StatusId = 3 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + var people = await personFilterService.GetPeople(filterList.Id); + Assert.Equal(2, people.Count()); + Assert.Contains(people, p => p.Id == 1); + Assert.Contains(people, p => p.Id == 2); + } + + [Fact] + public async Task TestGetPeople_Status_Status() + { + await InitializeSampleDataset(); + + // Create person filter + PersonFilterList filterList = new() + { + Filters = + [ + new StatusFilter { StatusId = 2 }, + new StatusFilter { StatusId = 3 }, + ] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + var people = await personFilterService.GetPeople(filterList.Id); + Assert.Equal(3, people.Count()); + Assert.Contains(people, p => p.Id == 1); + Assert.Contains(people, p => p.Id == 2); + Assert.Contains(people, p => p.Id == 3); + } + + [Fact] + public async Task TestGetPeople_Group() + { + await InitializeSampleDataset(); + + // Create person filter + PersonFilterList filterList = new() + { + Filters = [new GroupFilter { GroupId = 1 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + var people = await personFilterService.GetPeople(filterList.Id); + Person actual = Assert.Single(people); + Assert.Equal(1, actual.Id); + } + + [Fact] + public async Task TestAddFilter_Group_EmptyList() + { + await InitializeSampleDataset(); + PersonFilterList filterList = new(); + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + bool inserted = await personFilterService.AddFilter(filterList.Id, new GroupFilter { GroupId = 1 }); + Assert.True(inserted); + } + + [Fact] + public async Task TestAddFilter_Group_NewFilter() + { + await InitializeSampleDataset(); + + // Create sample filter + PersonFilterList filterList = new() + { + Filters = [new GroupFilter { GroupId = 1 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + bool inserted = await personFilterService.AddFilter(filterList.Id, new GroupFilter { GroupId = 2 }); + Assert.True(inserted); + } + + [Fact] + public async Task TestAddFilter_Group_AlreadyExists() + { + await InitializeSampleDataset(); + + // Create sample filter + PersonFilterList filterList = new() + { + Filters = [new GroupFilter { GroupId = 1 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + bool inserted = await personFilterService.AddFilter(filterList.Id, new GroupFilter { GroupId = 1 }); + Assert.False(inserted); + } + + [Fact] + public async Task TestAddFilter_GroupType_AlreadyExists() + { + await InitializeSampleDataset(); + + // Create sample filter + PersonFilterList filterList = new() + { + Filters = [new GroupTypeFilter { GroupTypeId = 1 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + bool inserted = await personFilterService.AddFilter(filterList.Id, new GroupTypeFilter { GroupTypeId = 1 }); + Assert.False(inserted); + } + + [Fact] + public async Task TestAddFilter_SinglePerson_AlreadyExists() + { + await InitializeSampleDataset(); + + // Create sample filter + PersonFilterList filterList = new() + { + Filters = [new SinglePerson { PersonId = 1 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + bool inserted = await personFilterService.AddFilter(filterList.Id, new SinglePerson { PersonId = 1 }); + Assert.False(inserted); + } + [Fact] + public async Task TestAddFilter_Status_AlreadyExists() + { + await InitializeSampleDataset(); + + // Create sample filter + PersonFilterList filterList = new() + { + Filters = [new StatusFilter { StatusId = 3 }] + }; + databaseContext.PersonFilterLists.Add(filterList); + await databaseContext.SaveChangesAsync(); + + bool inserted = await personFilterService.AddFilter(filterList.Id, new StatusFilter { StatusId = 3 }); + Assert.False(inserted); + } + + + private async ValueTask InitializeSampleDataset() + { + // Initialize database + // TODO: Use migrations again + //await databaseContext.Database.MigrateAsync(); + await databaseContext.Database.EnsureCreatedAsync(); + + databaseContext.Status.AddRange( + [ + new(1, "Gast"), + new(2, "Freund"), + new(3, "Mitglied"), + ]); + databaseContext.GroupTypes.AddRange( + [ + new(1, "Kleingruppe"), + new(2, "Dienst"), + ]); + databaseContext.GroupRoles.AddRange( + [ + new(8, 1, "Teilnehmer"), + new(9, 1, "Leiter"), + new(15, 2, "Mitarbeiter"), + new(16, 2, "Leiter"), + ]); + databaseContext.GroupStatuses.AddRange( + [ + new(1, "active"), + new(2, "archived"), + ]); + databaseContext.People.AddRange( + [ + new(1, 3, "Markus", "Wiebe", "mwiebe@example.org"), + new(2, 3, "Debora", "Wiebe", "debora.wiebe@example.org"), + new(3, 2, "Mohammad", "Khamenei", "m.k@example.org"), + new(4, 1, "Barbara", "Müller", "barbara_m@example.org"), + ]); + databaseContext.Groups.AddRange( + [ + new(1, 1, 1, "Admin"), + new(2, 2, 1, "Kinderdienst"), + ]); + databaseContext.GroupMembers.AddRange( + [ + new() { PersonId = 1, GroupId = 1, GroupRoleId = 9 }, + new() { PersonId = 2, GroupId = 2, GroupRoleId = 16 }, + new() { PersonId = 4, GroupId = 2, GroupRoleId = 15 }, + ]); + await databaseContext.SaveChangesAsync(); + } +} diff --git a/server/Korga/Commands/DistributionListCommand.cs b/server/Korga/Commands/DistributionListCommand.cs index 878a13b..4ced40f 100644 --- a/server/Korga/Commands/DistributionListCommand.cs +++ b/server/Korga/Commands/DistributionListCommand.cs @@ -1,4 +1,6 @@ using Korga.EmailRelay.Entities; +using Korga.Filters; +using Korga.Filters.Entities; using McMaster.Extensions.CommandLineUtils; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; @@ -9,8 +11,8 @@ namespace Korga.Commands; -[Command("distribution-list")] -[Subcommand(typeof(Create), typeof(List))] +[Command("dist")] +[Subcommand(typeof(Create), typeof(AddRecipient), typeof(RemoveRecipient), typeof(List))] public class DistributionListCommand { private int OnExecute(CommandLineApplication app) @@ -21,46 +23,147 @@ private int OnExecute(CommandLineApplication app) [Command("create")] public class Create + { + [Argument(0)] public string? Alias { get; set; } + + private async Task OnExecute(IConsole console, DatabaseContext database) + { + if (!string.IsNullOrWhiteSpace(Alias)) + { + database.DistributionLists.Add(new(Alias)); + await database.SaveChangesAsync(); + + return 0; + } + else + { + console.Out.WriteLine("Invalid alias"); + return 1; + } + } + } + + [Command("add-recipient")] + public class AddRecipient { [Argument(0)] public string? Alias { get; set; } [Option] public int? StatusId { get; set; } [Option] public int? GroupId { get; set; } + [Option("--group-type-id")] public int? GroupTypeId { get; set; } [Option("--group-role-id")] public int? GroupRoleId { get; set; } [Option] public int? PersonId { get; set; } - private async Task OnExecute(IConsole console, DatabaseContext database) + private async Task OnExecute(IConsole console, DatabaseContext database, PersonFilterService filterService) { - if (!string.IsNullOrWhiteSpace(Alias)) + DistributionList? distributionList = await database.DistributionLists.SingleOrDefaultAsync(dl => dl.Alias == Alias); + + if (distributionList == null) { - DistributionList distributionList = new(Alias); - database.DistributionLists.Add(distributionList); - await database.SaveChangesAsync(); + console.Out.WriteLine("Distribution list {0} not found", Alias); + return 1; + } - if (StatusId.HasValue) - { - database.PersonFilters.Add(new StatusFilter() { DistributionListId = distributionList.Id, StatusId = StatusId.Value }); - await database.SaveChangesAsync(); - } + PersonFilter? additionalFilter = null; - if (GroupId.HasValue) - { - database.PersonFilters.Add(new GroupFilter() { DistributionListId = distributionList.Id, GroupId = GroupId.Value, GroupRoleId = GroupRoleId }); - await database.SaveChangesAsync(); - } + if (StatusId == null && GroupId == null && GroupTypeId == null && PersonId == null) + { + console.Out.WriteLine("You must specify one of Status ID, Group ID, Group Type ID and Person ID"); + return 1; + } + else if (StatusId.HasValue && GroupId == null && GroupTypeId == null && PersonId == null) + { + additionalFilter = new StatusFilter() { StatusId = StatusId.Value }; + } + else if (StatusId == null && GroupId.HasValue && GroupTypeId == null && PersonId == null) + { + additionalFilter = new GroupFilter() { GroupId = GroupId.Value, GroupRoleId = GroupRoleId }; + } + else if (StatusId == null && GroupId == null && GroupTypeId.HasValue && PersonId == null) + { + additionalFilter = new GroupTypeFilter() { GroupTypeId = GroupTypeId.Value, GroupRoleId = GroupRoleId }; + } + else if (StatusId == null && GroupId == null && GroupTypeId == null && PersonId.HasValue) + { + additionalFilter = new SinglePerson() { PersonId = PersonId.Value }; + } + else + { + console.Out.WriteLine("Status ID, Group ID, Group Type ID and Person ID are mutually exclusive"); + return 1; + } - if (PersonId.HasValue) + if (distributionList.PermittedRecipientsId.HasValue) + { + if (!await filterService.AddFilter(distributionList.PermittedRecipientsId.Value, additionalFilter)) + console.Out.WriteLine("Filter already exists"); + } + else + { + distributionList.PermittedRecipients = new PersonFilterList { - database.PersonFilters.Add(new SinglePerson() { DistributionListId = distributionList.Id, PersonId = PersonId.Value }); - await database.SaveChangesAsync(); - } + Filters = [ additionalFilter ] + }; + await database.SaveChangesAsync(); + } + return 0; + } + } - return 0; + [Command("remove-recipient")] + public class RemoveRecipient + { + [Argument(0)] public string? Alias { get; set; } + [Option] public int? StatusId { get; set; } + [Option] public int? GroupId { get; set; } + [Option("--group-type-id")] public int? GroupTypeId { get; set; } + [Option("--group-role-id")] public int? GroupRoleId { get; set; } + [Option] public int? PersonId { get; set; } + + private async Task OnExecute(IConsole console, DatabaseContext database, PersonFilterService filterService) + { + DistributionList? distributionList = await database.DistributionLists.SingleOrDefaultAsync(dl => dl.Alias == Alias); + + if (distributionList == null) + { + console.Out.WriteLine("Distribution list {0} not found", Alias); + return 1; + } + + PersonFilter? filterToDelete = null; + + if (StatusId == null && GroupId == null && GroupTypeId == null && PersonId == null) + { + console.Out.WriteLine("You must specify one of Status ID, Group ID, Group Type ID and Person ID"); + return 1; + } + else if (StatusId.HasValue && GroupId == null && GroupTypeId == null && PersonId == null) + { + filterToDelete = new StatusFilter() { StatusId = StatusId.Value }; + } + else if (StatusId == null && GroupId.HasValue && GroupTypeId == null && PersonId == null) + { + filterToDelete = new GroupFilter() { GroupId = GroupId.Value, GroupRoleId = GroupRoleId }; + } + else if (StatusId == null && GroupId == null && GroupTypeId.HasValue && PersonId == null) + { + filterToDelete = new GroupTypeFilter() { GroupTypeId = GroupTypeId.Value, GroupRoleId = GroupRoleId }; + } + else if (StatusId == null && GroupId == null && GroupTypeId == null && PersonId.HasValue) + { + filterToDelete = new SinglePerson() { PersonId = PersonId.Value }; } else { - console.Out.WriteLine("Invalid alias"); + console.Out.WriteLine("Status ID, Group ID, Group Type ID and Person ID are mutually exclusive"); return 1; } + + if (!distributionList.PermittedRecipientsId.HasValue + || !await filterService.RemoveFilter(distributionList.PermittedRecipientsId.Value, filterToDelete)) + { + console.Out.WriteLine("Filter not found"); + } + return 0; } } @@ -69,12 +172,19 @@ public class List { private async Task OnExecute(IConsole console, DatabaseContext database) { - List distributionLists = await database.DistributionLists.Include(dl => dl.Filters).ToListAsync(); + List distributionLists = await database.DistributionLists + .Include(dl => dl.PermittedRecipients) + .ThenInclude(fl => fl!.Filters) + .ToListAsync(); + foreach (DistributionList distributionList in distributionLists) { console.Out.WriteLine("#{0} Alias: {1}", distributionList.Id, distributionList.Alias); - foreach (PersonFilter filter in distributionList.Filters!) + if (distributionList.PermittedRecipients == null || distributionList.PermittedRecipients.Filters == null) + continue; + + foreach (PersonFilter filter in distributionList.PermittedRecipients.Filters) { if (filter is GroupFilter groupFilter) { @@ -83,6 +193,13 @@ private async Task OnExecute(IConsole console, DatabaseContext database) else console.Out.WriteLine("- Group Filter Id: {0}", groupFilter.GroupId); } + else if (filter is GroupTypeFilter groupTypeFilter) + { + if (groupTypeFilter.GroupRoleId.HasValue) + console.Out.WriteLine("- Group Type Filter Id: {0} Role: {1}", groupTypeFilter.GroupTypeId, groupTypeFilter.GroupRoleId); + else + console.Out.WriteLine("- Group Type Filter Id: {0}", groupTypeFilter.GroupTypeId); + } else if (filter is StatusFilter statusFilter) { console.Out.WriteLine("- Status Filter Id: {0}", statusFilter.StatusId); diff --git a/server/Korga/Controllers/DistributionListController.cs b/server/Korga/Controllers/DistributionListController.cs index fa76ff1..ad23e8f 100644 --- a/server/Korga/Controllers/DistributionListController.cs +++ b/server/Korga/Controllers/DistributionListController.cs @@ -1,5 +1,6 @@ using Korga.EmailRelay; using Korga.EmailRelay.Entities; +using Korga.Filters.Entities; using Korga.Models.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -26,7 +27,11 @@ public DistributionListController(DatabaseContext database) [ProducesResponseType(typeof(DistributionListResponse[]), StatusCodes.Status200OK)] public async Task GetDistributionLists() { - List distributionLists = await database.DistributionLists.Include(dl => dl.Filters).OrderBy(dl => dl.Alias).ToListAsync(); + List distributionLists = await database.DistributionLists + .Include(dl => dl.PermittedRecipients) + .ThenInclude(fl => fl!.Filters) + .OrderBy(dl => dl.Alias) + .ToListAsync(); List response = []; @@ -34,7 +39,7 @@ public async Task GetDistributionLists() { List filters = []; - foreach (PersonFilter personFilter in distributionList.Filters!) + foreach (PersonFilter personFilter in distributionList.PermittedRecipients?.Filters ?? []) { DistributionListResponse.PersonFilter filter = new() { Id = personFilter.Id, Discriminator = personFilter.GetType().Name }; @@ -48,6 +53,12 @@ public async Task GetDistributionLists() if (groupFilter.GroupRoleId.HasValue) filter.GroupRoleName = await database.GroupRoles.Where(r => r.Id == groupFilter.GroupRoleId.Value).Select(r => r.Name).SingleAsync(); } + else if (personFilter is GroupTypeFilter groupTypeFilter) + { + filter.GroupTypeName = await database.GroupTypes.Where(t => t.Id == groupTypeFilter.GroupTypeId).Select(t => t.Name).SingleAsync(); + if (groupTypeFilter.GroupRoleId.HasValue) + filter.GroupRoleName = await database.GroupRoles.Where(r => r.Id == groupTypeFilter.GroupRoleId.Value).Select(r => r.Name).SingleAsync(); + } else if (personFilter is SinglePerson singlePerson) { filter.PersonFullName = await database.People.Where(p => p.Id == singlePerson.PersonId).Select(p => $"{p.FirstName} {p.LastName}").SingleAsync(); diff --git a/server/Korga/DatabaseContext.cs b/server/Korga/DatabaseContext.cs index 943b9aa..96b0550 100644 --- a/server/Korga/DatabaseContext.cs +++ b/server/Korga/DatabaseContext.cs @@ -1,5 +1,6 @@ using Korga.ChurchTools.Entities; using Korga.EmailRelay.Entities; +using Korga.Filters.Entities; using Microsoft.EntityFrameworkCore; using Korga.EmailDelivery.Entities; @@ -26,6 +27,7 @@ public sealed class DatabaseContext : DbContext public DbSet InboxEmails => Set(); public DbSet DistributionLists => Set(); + public DbSet PersonFilterLists => Set(); public DbSet PersonFilters => Set(); public DbSet OutboxEmails => Set(); @@ -40,6 +42,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) CreateChurchTools(modelBuilder); + CreateFilters(modelBuilder); + CreateEmailRelay(modelBuilder); CreateEmailDelivery(modelBuilder); @@ -92,6 +96,44 @@ private void CreateChurchTools(ModelBuilder modelBuilder) status.Property(x => x.Id).ValueGeneratedNever(); } + private void CreateFilters(ModelBuilder modelBuilder) + { + var personFilterList = modelBuilder.Entity(); + personFilterList.HasKey(l => l.Id); + + var personFilter = modelBuilder.Entity(); + personFilter.HasKey(f => f.Id); + personFilter.HasOne(f => f.PersonFilterList).WithMany(l => l.Filters).HasForeignKey(f => f.PersonFilterListId); + personFilter.HasIndex(f => f.EqualityKey).IsUnique(); + // Unique composite indices including null values are ignored by SQL databases. + // Therefore we use a computed column which contains values for a unique index to avoid duplicate filters. + personFilter.Property(f => f.EqualityKey).HasComputedColumnSql(@" +CONCAT( + `Discriminator`, + LPAD(HEX(IFNULL(`GroupId`, 0)), 8, '0'), + LPAD(HEX(IFNULL(`GroupRoleId`, 0)), 8, '0'), + LPAD(HEX(IFNULL(`GroupTypeId`, 0)), 8, '0'), + LPAD(HEX(IFNULL(`PersonId`, 0)), 8, '0'), + LPAD(HEX(IFNULL(`StatusId`, 0)), 8, '0')) +"); + + var groupFilter = modelBuilder.Entity(); + groupFilter.HasOne(f => f.Group).WithMany().HasForeignKey(f => f.GroupId); + groupFilter.HasOne(f => f.GroupRole).WithMany().HasForeignKey(f => f.GroupRoleId); + groupFilter.Property(f => f.GroupRoleId).HasColumnName(nameof(GroupFilter.GroupRoleId)); + + var groupTypeFilter = modelBuilder.Entity(); + groupTypeFilter.HasOne(f => f.GroupType).WithMany().HasForeignKey(f => f.GroupTypeId); + groupTypeFilter.HasOne(f => f.GroupRole).WithMany().HasForeignKey(f => f.GroupRoleId); + groupTypeFilter.Property(f => f.GroupRoleId).HasColumnName(nameof(GroupTypeFilter.GroupRoleId)); + + var statusFilter = modelBuilder.Entity(); + statusFilter.HasOne(f => f.Status).WithMany().HasForeignKey(f => f.StatusId); + + var singlePerson = modelBuilder.Entity(); + singlePerson.HasOne(f => f.Person).WithMany().HasForeignKey(f => f.PersonId); + } + private void CreateEmailRelay(ModelBuilder modelBuilder) { var inboxEmail = modelBuilder.Entity(); @@ -105,20 +147,7 @@ private void CreateEmailRelay(ModelBuilder modelBuilder) distributionList.HasKey(dl => dl.Id); distributionList.HasAlternateKey(dl => dl.Alias); distributionList.Property(dl => dl.Flags).HasConversion(); - - var personFilter = modelBuilder.Entity(); - personFilter.HasKey(f => f.Id); - personFilter.HasOne(f => f.DistributionList).WithMany(dl => dl.Filters).HasForeignKey(f => f.DistributionListId); - - var groupFilter = modelBuilder.Entity(); - groupFilter.HasOne(f => f.Group).WithMany().HasForeignKey(f => f.GroupId); - groupFilter.HasOne(f => f.GroupRole).WithMany().HasForeignKey(f => f.GroupRoleId); - - var statusFilter = modelBuilder.Entity(); - statusFilter.HasOne(s => s.Status).WithMany().HasForeignKey(s => s.StatusId); - - var singlePerson = modelBuilder.Entity(); - singlePerson.HasOne(p => p.Person).WithMany().HasForeignKey(p => p.PersonId); + distributionList.HasOne(dl => dl.PermittedRecipients).WithMany().HasForeignKey(dl => dl.PermittedRecipientsId); } private void CreateEmailDelivery(ModelBuilder modelBuilder) diff --git a/server/Korga/EmailRelay/DistributionListService.cs b/server/Korga/EmailRelay/DistributionListService.cs index a1dc02f..29103f2 100644 --- a/server/Korga/EmailRelay/DistributionListService.cs +++ b/server/Korga/EmailRelay/DistributionListService.cs @@ -1,9 +1,6 @@ -using Korga.ChurchTools.Entities; -using Korga.EmailRelay.Entities; -using Korga.Extensions; -using Microsoft.EntityFrameworkCore; +using Korga.EmailRelay.Entities; +using Korga.Filters; using MimeKit; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -12,56 +9,24 @@ namespace Korga.EmailRelay; public class DistributionListService { - private readonly DatabaseContext database; + private readonly PersonFilterService filterService; - public DistributionListService(DatabaseContext database) + public DistributionListService(PersonFilterService filterService) { - this.database = database; + this.filterService = filterService; } public async ValueTask GetRecipients(DistributionList distributionList, CancellationToken cancellationToken) { - return (await GetPeople(distributionList, cancellationToken)) - // Avoid duplicate emails for married couples with a shared email address - .GroupBy(p => p.Email) - .Select(grouping => new MailboxAddress( - name: string.Join(", ", grouping.Select(r => r.FirstName)) + ' ' + grouping.First().LastName, - address: grouping.Key)) - .ToArray(); - } - - public async ValueTask> GetPeople(DistributionList distributionList, CancellationToken cancellationToken) - { - List personFilters = await database.PersonFilters.Where(f => f.DistributionListId == distributionList.Id).ToListAsync(cancellationToken); + if (!distributionList.PermittedRecipientsId.HasValue) return []; - HashSet people = []; - - foreach (PersonFilter personFilter in personFilters) - { - if (personFilter is GroupFilter groupFilter) - { - people.AddRange( - await database.GroupMembers - .Where(m => m.GroupId == groupFilter.GroupId && (groupFilter.GroupRoleId == null || m.GroupRoleId == groupFilter.GroupRoleId)) - .Join(database.People, m => m.PersonId, p => p.Id, (m, p) => p) - .Where(p => !string.IsNullOrEmpty(p.Email)) - .ToListAsync(cancellationToken)); - } - else if (personFilter is StatusFilter statusFilter) - { - people.AddRange( - await database.People - .Where(p => p.StatusId == statusFilter.StatusId && !string.IsNullOrEmpty(p.Email)) - .ToListAsync(cancellationToken)); - } - else if (personFilter is SinglePerson singlePerson) - { - people.AddRange( - await database.People.Where(p => p.Id == singlePerson.PersonId && !string.IsNullOrEmpty(p.Email)) - .ToListAsync(cancellationToken)); - } - } - - return people; + return (await filterService.GetPeople(distributionList.PermittedRecipientsId.Value, cancellationToken)) + .Where(p => !string.IsNullOrWhiteSpace(p.Email)) + // Avoid duplicate emails for married couples with a shared email address + .GroupBy(p => p.Email) + .Select(grouping => new MailboxAddress( + name: string.Join(", ", grouping.Select(r => r.FirstName)) + ' ' + grouping.First().LastName, + address: grouping.Key)) + .ToArray(); } } diff --git a/server/Korga/EmailRelay/Entities/DistributionList.cs b/server/Korga/EmailRelay/Entities/DistributionList.cs index 16f97d0..250691e 100644 --- a/server/Korga/EmailRelay/Entities/DistributionList.cs +++ b/server/Korga/EmailRelay/Entities/DistributionList.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using Korga.Filters.Entities; namespace Korga.EmailRelay.Entities; @@ -13,5 +13,6 @@ public DistributionList(string alias) public string Alias { get; set; } public DistributionListFlags Flags { get; set; } - public IEnumerable? Filters { get; set; } + public long? PermittedRecipientsId { get; set; } + public PersonFilterList? PermittedRecipients { get; set; } } diff --git a/server/Korga/EmailRelay/Entities/PersonFilter.cs b/server/Korga/EmailRelay/Entities/PersonFilter.cs deleted file mode 100644 index a22e9d1..0000000 --- a/server/Korga/EmailRelay/Entities/PersonFilter.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Korga.EmailRelay.Entities; - -public abstract class PersonFilter -{ - public long Id { get; set; } - - public long DistributionListId { get; set; } - public DistributionList? DistributionList { get; set; } -} diff --git a/server/Korga/EmailRelay/Entities/SinglePerson.cs b/server/Korga/EmailRelay/Entities/SinglePerson.cs deleted file mode 100644 index 1df7191..0000000 --- a/server/Korga/EmailRelay/Entities/SinglePerson.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Korga.ChurchTools.Entities; - -namespace Korga.EmailRelay.Entities; - -public class SinglePerson : PersonFilter -{ - public Person? Person { get; set; } - public int PersonId { get; set; } -} diff --git a/server/Korga/EmailRelay/Entities/StatusFilter.cs b/server/Korga/EmailRelay/Entities/StatusFilter.cs deleted file mode 100644 index ea4b1bb..0000000 --- a/server/Korga/EmailRelay/Entities/StatusFilter.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Korga.ChurchTools.Entities; - -namespace Korga.EmailRelay.Entities; - -public class StatusFilter : PersonFilter -{ - public Status? Status { get; set; } - public int StatusId { get; set; } -} diff --git a/server/Korga/Extensions/DbUpdateExceptionExtensions.cs b/server/Korga/Extensions/DbUpdateExceptionExtensions.cs new file mode 100644 index 0000000..4ea05fe --- /dev/null +++ b/server/Korga/Extensions/DbUpdateExceptionExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; +using MySqlConnector; + +namespace Korga.Extensions; + +public static class DbUpdateExceptionExtensions +{ + public static bool IsUniqueConstraintViolation(this DbUpdateException exception) + { + return exception.InnerException is MySqlException sqlException && sqlException.Number == 1062; + } +} diff --git a/server/Korga/EmailRelay/Entities/GroupFilter.cs b/server/Korga/Filters/Entities/GroupFilter.cs similarity index 54% rename from server/Korga/EmailRelay/Entities/GroupFilter.cs rename to server/Korga/Filters/Entities/GroupFilter.cs index 1bddb3e..6914e43 100644 --- a/server/Korga/EmailRelay/Entities/GroupFilter.cs +++ b/server/Korga/Filters/Entities/GroupFilter.cs @@ -1,6 +1,6 @@ using Korga.ChurchTools.Entities; -namespace Korga.EmailRelay.Entities; +namespace Korga.Filters.Entities; public class GroupFilter : PersonFilter { @@ -9,4 +9,9 @@ public class GroupFilter : PersonFilter public GroupRole? GroupRole { get; set; } public int? GroupRoleId { get; set; } + + public override bool FilterConditionEquals(PersonFilter other) + { + return other is GroupFilter o && GroupId == o.GroupId && GroupRoleId == o.GroupRoleId; + } } diff --git a/server/Korga/Filters/Entities/GroupTypeFilter.cs b/server/Korga/Filters/Entities/GroupTypeFilter.cs new file mode 100644 index 0000000..d6fe7ff --- /dev/null +++ b/server/Korga/Filters/Entities/GroupTypeFilter.cs @@ -0,0 +1,17 @@ +using Korga.ChurchTools.Entities; + +namespace Korga.Filters.Entities; + +public class GroupTypeFilter : PersonFilter +{ + public GroupType? GroupType { get; set; } + public int? GroupTypeId { get; set; } + + public GroupRole? GroupRole { get; set; } + public int? GroupRoleId { get; set; } + + public override bool FilterConditionEquals(PersonFilter other) + { + return other is GroupTypeFilter o && GroupTypeId == o.GroupTypeId && GroupRoleId == o.GroupRoleId; + } +} diff --git a/server/Korga/Filters/Entities/PersonFilter.cs b/server/Korga/Filters/Entities/PersonFilter.cs new file mode 100644 index 0000000..8159430 --- /dev/null +++ b/server/Korga/Filters/Entities/PersonFilter.cs @@ -0,0 +1,13 @@ +namespace Korga.Filters.Entities; + +public abstract class PersonFilter +{ + public long Id { get; set; } + + public long PersonFilterListId { get; set; } + public PersonFilterList? PersonFilterList { get; set; } + + public string EqualityKey { get; set; } = null!; // Generated by the database + + public abstract bool FilterConditionEquals(PersonFilter other); +} diff --git a/server/Korga/Filters/Entities/PersonFilterList.cs b/server/Korga/Filters/Entities/PersonFilterList.cs new file mode 100644 index 0000000..85b9295 --- /dev/null +++ b/server/Korga/Filters/Entities/PersonFilterList.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Korga.Filters.Entities; + +public class PersonFilterList +{ + public long Id { get; set; } + + public List? Filters { get; set; } +} diff --git a/server/Korga/Filters/Entities/SinglePerson.cs b/server/Korga/Filters/Entities/SinglePerson.cs new file mode 100644 index 0000000..b0f5c27 --- /dev/null +++ b/server/Korga/Filters/Entities/SinglePerson.cs @@ -0,0 +1,14 @@ +using Korga.ChurchTools.Entities; + +namespace Korga.Filters.Entities; + +public class SinglePerson : PersonFilter +{ + public Person? Person { get; set; } + public int PersonId { get; set; } + + public override bool FilterConditionEquals(PersonFilter other) + { + return other is SinglePerson o && PersonId == o.PersonId; + } +} diff --git a/server/Korga/Filters/Entities/StatusFilter.cs b/server/Korga/Filters/Entities/StatusFilter.cs new file mode 100644 index 0000000..3815b2f --- /dev/null +++ b/server/Korga/Filters/Entities/StatusFilter.cs @@ -0,0 +1,14 @@ +using Korga.ChurchTools.Entities; + +namespace Korga.Filters.Entities; + +public class StatusFilter : PersonFilter +{ + public Status? Status { get; set; } + public int StatusId { get; set; } + + public override bool FilterConditionEquals(PersonFilter other) + { + return other is StatusFilter o && StatusId == o.StatusId; + } +} diff --git a/server/Korga/Filters/PersonFilterService.cs b/server/Korga/Filters/PersonFilterService.cs new file mode 100644 index 0000000..95d8b9a --- /dev/null +++ b/server/Korga/Filters/PersonFilterService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Korga.ChurchTools.Entities; +using Korga.Extensions; +using Korga.Filters.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Korga.Filters; + +public class PersonFilterService +{ + private readonly DatabaseContext database; + + public PersonFilterService(DatabaseContext database) + { + this.database = database; + } + + /// + /// Returns a collection of all people that match at least one of the filters of the given list. + /// + /// + /// + /// An unordered collection of distinct people that are matched by the filter list. + public async ValueTask> GetPeople(long filterListId, CancellationToken cancellationToken = default) + { + List personFilters = await database.PersonFilters + .Where(f => f.PersonFilterListId == filterListId) + .ToListAsync(cancellationToken); + + if (personFilters.Count == 0) return Enumerable.Empty(); + + IQueryable query = FilterToQuery(personFilters[0]); + + for (int i = 1; i < personFilters.Count; i++) + { + query = query.Union(FilterToQuery(personFilters[i])); + } + + return await query.Distinct().ToListAsync(cancellationToken); + } + + private IQueryable FilterToQuery(PersonFilter filter) + { + if (filter is GroupFilter groupFilter) + { + return database.GroupMembers + .Where(m => m.GroupId == groupFilter.GroupId && (groupFilter.GroupRoleId == null || m.GroupRoleId == groupFilter.GroupRoleId)) + .Join(database.People, m => m.PersonId, p => p.Id, (m, p) => p); + } + else if (filter is GroupTypeFilter groupTypeFilter) + { + return database.Groups + .Where(g => g.GroupTypeId == groupTypeFilter.GroupTypeId) + .Join(database.GroupMembers, g => g.Id, m => m.GroupId, (g, m) => m) + .Where(m => groupTypeFilter.GroupRoleId == null || m.GroupRoleId == groupTypeFilter.GroupRoleId) + .Join(database.People, m => m.PersonId, p => p.Id, (m, p) => p); + } + else if (filter is StatusFilter statusFilter) + { + return database.People.Where(p => p.StatusId == statusFilter.StatusId); + } + else if (filter is SinglePerson singlePerson) + { + return database.People.Where(p => p.Id == singlePerson.PersonId); + } + + throw new ArgumentException($"Invalid filter type {filter.GetType().FullName}", nameof(filter)); + } + + public async ValueTask AddFilter(long filterListId, PersonFilter filter) + { + try + { + filter.PersonFilterListId = filterListId; + database.PersonFilters.Add(filter); + await database.SaveChangesAsync(); + return true; + } + catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation()) + { + return false; + } + } + + public async ValueTask RemoveFilter(long filterListId, PersonFilter filter) + { + PersonFilter? toDelete = await database.PersonFilters + .SingleOrDefaultAsync(f => f.PersonFilterListId == filterListId && f.FilterConditionEquals(filter)); + + if (toDelete == null) return false; + + database.PersonFilters.Remove(toDelete); + await database.SaveChangesAsync(); + return true; + } +} diff --git a/server/Korga/Models/Json/DistributionListResponse.cs b/server/Korga/Models/Json/DistributionListResponse.cs index 81235f9..fde4158 100644 --- a/server/Korga/Models/Json/DistributionListResponse.cs +++ b/server/Korga/Models/Json/DistributionListResponse.cs @@ -4,18 +4,18 @@ namespace Korga.Models.Json; public class DistributionListResponse { - public DistributionListResponse(long id, string alias, bool newsletter, IReadOnlyList filters) + public DistributionListResponse(long id, string alias, bool newsletter, IReadOnlyList permittedRecipients) { Id = id; Alias = alias; Newsletter = newsletter; - Filters = filters; + PermittedRecipients = permittedRecipients; } public long Id { get; set; } public string Alias { get; set; } public bool Newsletter { get; set; } - public IReadOnlyList Filters { get; set; } + public IReadOnlyList PermittedRecipients { get; set; } public class PersonFilter { @@ -23,6 +23,7 @@ public class PersonFilter public required string Discriminator { get; set; } public string? StatusName { get; set; } public string? GroupName { get; set; } + public string? GroupTypeName { get; set; } public string? GroupRoleName { get; set; } public string? PersonFullName { get; set; } } diff --git a/server/Korga/Startup.cs b/server/Korga/Startup.cs index 644c6f1..925e28e 100644 --- a/server/Korga/Startup.cs +++ b/server/Korga/Startup.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Korga.EmailDelivery; +using Korga.Filters; namespace Korga; @@ -33,6 +34,8 @@ public void ConfigureServices(IServiceCollection services) services.AddKorgaMySqlDatabase(); + services.AddTransient(); + services.AddSpaStaticFiles(options => options.RootPath = environment.WebRootPath); services.AddControllers();