using Microsoft.EntityFrameworkCore; using MijnBesluit.Application.Abstractions; using MijnBesluit.Domain.Entities; using MijnBesluit.Domain.Enums; using MijnBesluit.Infrastructure.Persistence; using MijnBesluit.Application.Models; namespace MijnBesluit.Infrastructure.Repositories; /// Implementaties op EF Core. Eén context per bewerking, samen opgeslagen in één transactie. public class EfUnitOfWork : IUnitOfWork { private readonly MijnBesluitDbContext _db; public EfUnitOfWork(MijnBesluitDbContext db) => _db = db; public Task SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); } public class UserRepository : IUserRepository { private readonly MijnBesluitDbContext _db; public UserRepository(MijnBesluitDbContext db) => _db = db; // De persoon hoort er altijd bij: een account zonder persoon bestaat niet, en zonder // Include is user.Person leeg. Dat viel op doordat er bovenaan het scherm een e-mailadres // stond in plaats van een naam - de terugval als de naam niet bekend is. public Task FindByIdAsync(long id, CancellationToken ct = default) => _db.Users.Include(u => u.Person).FirstOrDefaultAsync(u => u.Id == id, ct); public Task FindByEmailAsync(string email, CancellationToken ct = default) => _db.Users.Include(u => u.Person).FirstOrDefaultAsync(u => u.Email == email, ct); public Task FindByPersonIdAsync(long personId, CancellationToken ct = default) => _db.Users.Include(u => u.Person).FirstOrDefaultAsync(u => u.PersonId == personId, ct); public Task EmailExistsAsync(string email, CancellationToken ct = default) => _db.Users.AnyAsync(u => u.Email == email, ct); public async Task> ListBeheerdersAsync(CancellationToken ct = default) => await _db.Users.Where(u => u.IsBeheerder).OrderBy(u => u.Id).ToListAsync(ct); public void Add(User user) => _db.Users.Add(user); public void Remove(User user) => _db.Users.Remove(user); } /// Opslag van de eenmalige codes die per e-mail worden verstuurd. public class LoginCodeRepository : ILoginCodeRepository { private readonly MijnBesluitDbContext _db; public LoginCodeRepository(MijnBesluitDbContext db) => _db = db; public Task FindOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, CancellationToken ct = default) => _db.LoginCodes .Where(c => c.Email == email && c.Purpose == purpose && c.ConsumedAt == null && c.ExpiresAt > nowUtc) .OrderByDescending(c => c.CreatedAt) .FirstOrDefaultAsync(ct); public async Task> ListOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, CancellationToken ct = default) => await _db.LoginCodes .Where(c => c.Email == email && c.Purpose == purpose && c.ConsumedAt == null && c.ExpiresAt > nowUtc) .ToListAsync(ct); public Task CountSinceAsync(string email, DateTime sinceUtc, CancellationToken ct = default) => _db.LoginCodes.CountAsync( c => c.Email == email && c.CreatedAt >= sinceUtc && c.SignedInAt == null, ct); public async Task RemoveExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) { var oud = await _db.LoginCodes.Where(c => c.ExpiresAt < olderThanUtc).ToListAsync(ct); _db.LoginCodes.RemoveRange(oud); return oud.Count; } public async Task RemoveForEmailAsync(string email, CancellationToken ct = default) { var codes = await _db.LoginCodes.Where(c => c.Email == email).ToListAsync(ct); _db.LoginCodes.RemoveRange(codes); return codes.Count; } public void Add(LoginCode code) => _db.LoginCodes.Add(code); } public class PersonRepository : IPersonRepository { private readonly MijnBesluitDbContext _db; public PersonRepository(MijnBesluitDbContext db) => _db = db; public Task FindByIdAsync(long id, CancellationToken ct = default) => _db.Persons.FirstOrDefaultAsync(p => p.Id == id, ct); // Geclaimde profielen eerst, en daarna op nummer. Er kunnen er meerdere zijn met hetzelfde // adres (zie docs/Identiteit.md); wie er één vraagt hoort in elk geval altijd dezelfde terug // te krijgen, en het profiel van de persoon zelf weegt zwaarder dan andermans aantekening. public Task FindByEmailAsync(string email, CancellationToken ct = default) => _db.Persons.Where(p => p.Email == email) .OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id) .FirstOrDefaultAsync(ct); public async Task> ListByEmailAsync(string email, CancellationToken ct = default) => await _db.Persons.Where(p => p.Email == email) .OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id) .ToListAsync(ct); public async Task> ListAantekeningenOverAdresAsync(string email, long gebruikerId, CancellationToken ct = default) => await _db.Persons .Where(p => p.Email == email && p.ClaimedByUserId == null // nog van niemand zelf && p.MergedIntoPersonId == null // niet al opgegaan in een ander && p.ManagedByUserId != null // iemand beheert hem && p.ManagedByUserId != gebruikerId // en dat ben ik niet zelf && (p.LinkDeclinedByUserId == null || p.LinkDeclinedByUserId != gebruikerId)) .OrderBy(p => p.CreatedAt) .ToListAsync(ct); public async Task> ListByIdsAsync(IEnumerable ids, CancellationToken ct = default) { var list = ids.Distinct().ToList(); return await _db.Persons.Where(p => list.Contains(p.Id)).ToListAsync(ct); } public async Task FindByObjectionTokenAsync(byte[] tokenHash, CancellationToken ct = default) => await _db.Persons.FirstOrDefaultAsync(p => p.ObjectionTokenHash == tokenHash, ct); public void Add(Person person) => _db.Persons.Add(person); public void Remove(Person person) => _db.Persons.Remove(person); } public class FamilyRepository : IFamilyRepository { private readonly MijnBesluitDbContext _db; public FamilyRepository(MijnBesluitDbContext db) => _db = db; public Task FindByIdAsync(long id, CancellationToken ct = default) => _db.Families.FirstOrDefaultAsync(f => f.Id == id, ct); public async Task> ListOwnedByPersonAsync(long personId, CancellationToken ct = default) => await _db.Families.Where(f => f.OwnerPersonId == personId && f.IsActive).ToListAsync(ct); public async Task> ListMembersAsync(long familyId, CancellationToken ct = default) => await _db.FamilyMembers.Where(m => m.FamilyId == familyId).OrderBy(m => m.Relation).ToListAsync(ct); public Task FindMemberAsync(long familyId, long personId, CancellationToken ct = default) => _db.FamilyMembers.FirstOrDefaultAsync(m => m.FamilyId == familyId && m.PersonId == personId, ct); public async Task> ListFamiliesContainingAsync(long personId, CancellationToken ct = default) => await _db.FamilyMembers.Where(m => m.PersonId == personId) .Join(_db.Families, m => m.FamilyId, f => f.Id, (m, f) => f) .Where(f => f.IsActive) .ToListAsync(ct); public async Task> ListCircleAsync(long personId, CancellationToken ct = default) { // De families waar hij lid van is óf eigenaar van. Dat laatste erbij omdat een eigenaar // in theorie zijn eigen lidmaatschap kan hebben weggehaald. var families = _db.FamilyMembers.Where(m => m.PersonId == personId).Select(m => m.FamilyId) .Union(_db.Families.Where(f => f.OwnerPersonId == personId).Select(f => f.Id)); var kring = await _db.FamilyMembers .Where(m => families.Contains(m.FamilyId)) .Select(m => m.PersonId) .Distinct() .ToListAsync(ct); if (!kring.Contains(personId)) kring.Add(personId); return kring; } public async Task> ListMembershipsOfPersonAsync(long personId, CancellationToken ct = default) => await _db.FamilyMembers.Where(m => m.PersonId == personId).ToListAsync(ct); public async Task> ListOwnedAsync(long personId, CancellationToken ct = default) => await _db.Families.Where(f => f.OwnerPersonId == personId).ToListAsync(ct); public void Add(Family family) => _db.Families.Add(family); public void Remove(Family family) => _db.Families.Remove(family); public void Add(FamilyMember member) => _db.FamilyMembers.Add(member); public void Remove(FamilyMember member) => _db.FamilyMembers.Remove(member); } public class InvitationRepository : IInvitationRepository { private readonly MijnBesluitDbContext _db; public InvitationRepository(MijnBesluitDbContext db) => _db = db; public Task FindByIdAsync(long id, CancellationToken ct = default) => _db.Invitations.FirstOrDefaultAsync(i => i.Id == id, ct); public Task FindByTokenHashAsync(byte[] tokenHash, CancellationToken ct = default) => _db.Invitations.FirstOrDefaultAsync(i => i.TokenHash == tokenHash, ct); public async Task> ListOpenForPersonAsync(long personId, CancellationToken ct = default) => await _db.Invitations.Where(i => i.PersonId == personId && (i.Status == InvitationStatus.Verzonden || i.Status == InvitationStatus.Concept)) .ToListAsync(ct); public Task HasOpenInvitationAsync(long personId, DateTime nowUtc, CancellationToken ct = default) => _db.Invitations.AnyAsync(i => i.PersonId == personId && i.ExpiresAt > nowUtc && (i.Status == InvitationStatus.Verzonden || i.Status == InvitationStatus.Concept), ct); public async Task> ListLatestForPeopleAsync(IEnumerable personIds, CancellationToken ct = default) { // Bewust een List en geen array. Bij een array kiest een nieuwere C#-compiler // MemoryExtensions.Contains (de span-versie) in plaats van Enumerable.Contains, en die // kan Entity Framework niet vertalen: dat geeft pas op de server een ArgumentException // over ReadOnlySpan. Met een List is het de instantiemethode en is er niets te kiezen. var ids = personIds.Distinct().ToList(); if (ids.Count == 0) return Array.Empty(); var alle = await _db.Invitations.Where(i => ids.Contains(i.PersonId)).ToListAsync(ct); // Groeperen doen we hier en niet in SQL: MariaDB kan geen "laatste rij per groep" zonder // vensterfuncties, en het gaat om een familie, niet om een miljoen rijen. return alle.GroupBy(i => i.PersonId) .Select(g => g.OrderByDescending(i => i.SentAt ?? i.CreatedAt).First()) .ToList(); } public async Task> ListAllForPersonAsync(long personId, CancellationToken ct = default) => await _db.Invitations.Where(i => i.PersonId == personId).ToListAsync(ct); public async Task> ListSentByUserAsync(long userId, CancellationToken ct = default) => await _db.Invitations.Where(i => i.CreatedByUserId == userId).ToListAsync(ct); public void Add(Invitation invitation) => _db.Invitations.Add(invitation); public void Remove(Invitation invitation) => _db.Invitations.Remove(invitation); } /// De soorten kluisitems; zie . public class VaultCategoryRepository : IVaultCategoryRepository { private readonly MijnBesluitDbContext _db; public VaultCategoryRepository(MijnBesluitDbContext db) => _db = db; public async Task> ListAsync(CancellationToken ct = default) => await _db.VaultCategories.Include(c => c.Velden) .OrderBy(c => c.Volgorde).ThenBy(c => c.Naam).ToListAsync(ct); public Task FindAsync(int id, CancellationToken ct = default) => _db.VaultCategories.Include(c => c.Velden).FirstOrDefaultAsync(c => c.Id == id, ct); public Task FindFieldAsync(long fieldId, CancellationToken ct = default) => _db.VaultCategoryFields.FirstOrDefaultAsync(f => f.Id == fieldId, ct); public async Task> GebruikAsync(CancellationToken ct = default) => await _db.VaultItems.Where(v => v.DeletedAt == null) .GroupBy(v => v.Category) .Select(g => new SoortGebruik((int)g.Key, g.Count(), g.Select(v => v.OwnerPersonId).Distinct().Count())) .ToListAsync(ct); public async Task> InGebruikDoorAsync(long personId, CancellationToken ct = default) { var lijst = await _db.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null) .Select(v => v.Category).Distinct().ToListAsync(ct); return lijst.Select(c => (int)c).ToHashSet(); } public Task AantalItemsAsync(int categoryId, CancellationToken ct = default) => _db.VaultItems.CountAsync(v => v.Category == (VaultCategory)categoryId && v.DeletedAt == null, ct); public void Add(VaultCategoryDefinition soort) => _db.VaultCategories.Add(soort); public void Remove(VaultCategoryDefinition soort) => _db.VaultCategories.Remove(soort); public void Add(VaultCategoryField veld) => _db.VaultCategoryFields.Add(veld); public void Remove(VaultCategoryField veld) => _db.VaultCategoryFields.Remove(veld); } /// De versiehistorie; zie . public class VersiehistorieRepository : IVersiehistorieRepository { private readonly MijnBesluitDbContext _db; public VersiehistorieRepository(MijnBesluitDbContext db) => _db = db; public async Task> ListAsync(bool alleenZichtbaar, CancellationToken ct = default) => await _db.VersieRegels.Where(r => !alleenZichtbaar || r.Zichtbaar) .OrderByDescending(r => r.Datum).ThenBy(r => r.Id).ToListAsync(ct); public Task FindAsync(int id, CancellationToken ct = default) => _db.VersieRegels.FirstOrDefaultAsync(r => r.Id == id, ct); public Task BestaatAsync(DateOnly datum, string kop, int? behalveId, CancellationToken ct = default) => _db.VersieRegels.AnyAsync(r => r.Datum == datum && r.Kop == kop && (behalveId == null || r.Id != behalveId), ct); public void Add(VersieRegel regel) => _db.VersieRegels.Add(regel); public void Remove(VersieRegel regel) => _db.VersieRegels.Remove(regel); } public class VaultRepository : IVaultRepository { private readonly MijnBesluitDbContext _db; public VaultRepository(MijnBesluitDbContext db) => _db = db; public Task FindByIdAsync(long id, CancellationToken ct = default) => _db.VaultItems.FirstOrDefaultAsync(v => v.Id == id, ct); public async Task> ListForPersonAsync(long personId, VaultCategory? category = null, CancellationToken ct = default) { var q = _db.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null); if (category is { } c) q = q.Where(v => v.Category == c); return await q.OrderBy(v => v.Category).ThenBy(v => v.Title).ToListAsync(ct); } public Task FindFileAsync(long id, CancellationToken ct = default) => _db.VaultFiles.FirstOrDefaultAsync(f => f.Id == id, ct); public async Task> ListFilesForItemAsync(long vaultItemId, CancellationToken ct = default) => await _db.VaultFiles .Where(f => f.VaultItemId == vaultItemId && f.DeletedAt == null) .OrderBy(f => f.CreatedAt) .ToListAsync(ct); public async Task> ListFilesForPersonAsync(long subjectPersonId, CancellationToken ct = default) => await _db.VaultFiles .Where(f => f.SubjectPersonId == subjectPersonId && f.DeletedAt == null) .OrderBy(f => f.CreatedAt) .ToListAsync(ct); public async Task> ListFilesForMessageAsync(long messageId, CancellationToken ct = default) => await _db.VaultFiles .Where(f => f.MessageId == messageId && f.DeletedAt == null) .OrderBy(f => f.CreatedAt) .ToListAsync(ct); public async Task> ListProfilePhotosAsync(IEnumerable personIds, CancellationToken ct = default) { // Een List en niet een array: op een array kiest de compiler MemoryExtensions.Contains, // en dat kan EF niet naar SQL vertalen. Zie de opmerking bij ListByIdsAsync. var ids = personIds.Distinct().ToList(); if (ids.Count == 0) return Array.Empty(); return await _db.VaultFiles .Where(f => f.SubjectPersonId != null && ids.Contains(f.SubjectPersonId!.Value) && f.IsProfilePhoto && f.DeletedAt == null) .ToListAsync(ct); } public Task FindGrantAsync(string resourceType, long resourceId, long userId, CancellationToken ct = default) => _db.ContentGrants.FirstOrDefaultAsync(g => g.ResourceType == resourceType && g.ResourceId == resourceId && g.UserId == userId, ct); public async Task> ListSharedWithAsync(long userId, CancellationToken ct = default) { var itemIds = _db.ContentGrants .Where(g => g.ResourceType == nameof(VaultItem) && g.UserId == userId && g.RevokedAt == null) .Select(g => g.ResourceId); return await _db.VaultItems .Where(v => itemIds.Contains(v.Id) && v.DeletedAt == null) .OrderBy(v => v.Category).ThenBy(v => v.Title) .ToListAsync(ct); } public async Task> ListFilesOwnedByAsync(long ownerPersonId, CancellationToken ct = default) => await _db.VaultFiles.Where(f => f.OwnerPersonId == ownerPersonId).ToListAsync(ct); public void Add(VaultItem item) => _db.VaultItems.Add(item); public void Add(VaultFile file) => _db.VaultFiles.Add(file); public void Add(ContentGrant grant) => _db.ContentGrants.Add(grant); public void Remove(VaultItem item) => _db.VaultItems.Remove(item); public void Remove(VaultFile file) => _db.VaultFiles.Remove(file); public async Task RemoveGrantsForUserAsync(long userId, CancellationToken ct = default) { var verleend = await _db.ContentGrants.Where(g => g.UserId == userId).ToListAsync(ct); _db.ContentGrants.RemoveRange(verleend); } public async Task RevokeGrantsForOwnerAsync(long ownerPersonId, string reason, DateTime nowUtc, CancellationToken ct = default) { var items = _db.VaultItems.Where(i => i.OwnerPersonId == ownerPersonId).Select(i => i.Id); var verleend = await _db.ContentGrants .Where(g => g.ResourceType == nameof(VaultItem) && items.Contains(g.ResourceId) && g.Reason == reason && g.RevokedAt == null) .ToListAsync(ct); foreach (var toegang in verleend) toegang.RevokedAt = nowUtc; return verleend.Count; } public async Task RemoveGrantsAsync(long itemId, CancellationToken ct = default) { var verleend = await _db.ContentGrants .Where(g => g.ResourceType == nameof(VaultItem) && g.ResourceId == itemId) .ToListAsync(ct); _db.ContentGrants.RemoveRange(verleend); } } public class MessageRepository : IMessageRepository { private readonly MijnBesluitDbContext _db; public MessageRepository(MijnBesluitDbContext db) => _db = db; public Task FindByIdAsync(long id, CancellationToken ct = default) => _db.Messages.FirstOrDefaultAsync(m => m.Id == id, ct); public async Task> ListOutboxAsync(long fromPersonId, CancellationToken ct = default) => await _db.Messages.Where(m => m.FromPersonId == fromPersonId && !m.HiddenBySender) .OrderByDescending(m => m.CreatedAt).ToListAsync(ct); public async Task> ListInboxAsync(long toPersonId, CancellationToken ct = default) => await _db.Messages.Where(m => m.ToPersonId == toPersonId && !m.HiddenByRecipient && (m.Status == MessageStatus.Bezorgd || m.Status == MessageStatus.Gelezen)) .OrderByDescending(m => m.DeliveredAt).ToListAsync(ct); /// /// Kandidaten voor bezorging: gepland, en óf er geen datum is (meteen), óf de datum is /// bereikt, óf het bericht wacht op een overlijden. De definitieve beoordeling doet /// MessageRelease.IsDue, met de afzender erbij. /// /// Een bericht "meteen" heeft geen DeliverAt (null betekent direct, zie Message.cs) en geen /// DeliverAfterDeath - die viel hier eerder buiten de boot en bleef voor altijd "Gepland" /// staan, ook al was IsDue() er allang klaar voor. /// public async Task> ListDueAsync(DateTime nowUtc, int max, CancellationToken ct = default) => await _db.Messages .Where(m => m.Status == MessageStatus.Gepland && (m.DeliverAfterDeath || m.DeliverAt == null || m.DeliverAt <= nowUtc)) .OrderBy(m => m.DeliverAt) .Take(max) .ToListAsync(ct); public async Task> ListAwaitingDeathOfAsync(long fromPersonId, CancellationToken ct = default) => await _db.Messages.Where(m => m.FromPersonId == fromPersonId && m.DeliverAfterDeath && m.Status == MessageStatus.Gepland).ToListAsync(ct); public async Task> ListPlannedForAsync(long toPersonId, CancellationToken ct = default) => await _db.Messages .Where(m => m.ToPersonId == toPersonId && m.Status == MessageStatus.Gepland) .OrderBy(m => m.DeliverAt) .ToListAsync(ct); public async Task> ListAllTouchingAsync(long personId, CancellationToken ct = default) => await _db.Messages.Where(m => m.FromPersonId == personId || m.ToPersonId == personId).ToListAsync(ct); public Task FindByViewTokenHashAsync(byte[] tokenHash, CancellationToken ct = default) => _db.Messages.FirstOrDefaultAsync(m => m.ViewTokenHash != null && m.ViewTokenHash == tokenHash, ct); public void Add(Message message) => _db.Messages.Add(message); public void Remove(Message message) => _db.Messages.Remove(message); } public class LegacyRepository : ILegacyRepository { private readonly MijnBesluitDbContext _db; public LegacyRepository(MijnBesluitDbContext db) => _db = db; public async Task> ListDeclarationsAsync(long personId, CancellationToken ct = default) => await _db.DeathDeclarations.Where(d => d.PersonId == personId).ToListAsync(ct); public Task FindDeclarationByUserAsync(long personId, long userId, CancellationToken ct = default) => _db.DeathDeclarations.FirstOrDefaultAsync(d => d.PersonId == personId && d.DeclaredByUserId == userId, ct); public async Task> ListManagersForAsync(long personId, CancellationToken ct = default) => await _db.LegacyManagers.Where(m => m.PersonId == personId).OrderBy(m => m.Priority).ToListAsync(ct); public async Task> ListAssignmentsOfAsync(long managerPersonId, CancellationToken ct = default) => await _db.LegacyManagers.Where(m => m.ManagerPersonId == managerPersonId).ToListAsync(ct); public async Task> ListAllDeclarationsAsync(long personId, CancellationToken ct = default) => await _db.DeathDeclarations.Where(d => d.PersonId == personId).ToListAsync(ct); public async Task> ListDeclarationsByUserAsync(long userId, CancellationToken ct = default) => await _db.DeathDeclarations.Where(d => d.DeclaredByUserId == userId).ToListAsync(ct); public void Remove(DeathDeclaration declaration) => _db.DeathDeclarations.Remove(declaration); public void Add(DeathDeclaration declaration) => _db.DeathDeclarations.Add(declaration); public void Add(LegacyManager manager) => _db.LegacyManagers.Add(manager); public void Remove(LegacyManager manager) => _db.LegacyManagers.Remove(manager); } public class SamenvoegRepository : ISamenvoegRepository { private readonly MijnBesluitDbContext _db; public SamenvoegRepository(MijnBesluitDbContext db) => _db = db; public Task FindByIdAsync(long id, CancellationToken ct = default) => _db.Samenvoegingen.FirstOrDefaultAsync(s => s.Id == id, ct); public async Task> ListVoorDuplicaatAsync(long duplicaatPersonId, CancellationToken ct = default) => await _db.Samenvoegingen.Where(s => s.DuplicaatPersonId == duplicaatPersonId) .OrderByDescending(s => s.SamengevoegdOp).ToListAsync(ct); public async Task> ListVoorDoelAsync(long doelPersonId, CancellationToken ct = default) => await _db.Samenvoegingen.Where(s => s.DoelPersonId == doelPersonId) .OrderByDescending(s => s.SamengevoegdOp).ToListAsync(ct); public void Add(Samenvoeging samenvoeging) => _db.Samenvoegingen.Add(samenvoeging); } /// Schrijft het logboek. Regels worden alleen toegevoegd, nooit gewijzigd. public class EfAuditLog : IAuditLog { private readonly MijnBesluitDbContext _db; private readonly IClock _clock; public EfAuditLog(MijnBesluitDbContext db, IClock clock) { _db = db; _clock = clock; } /// Wordt per verzoek gevuld met het IP-adres en het toestel van de aanroeper. public string? IpAddress { get; set; } public string? UserAgent { get; set; } public Task WriteAsync(AuditAction action, long? userId, string? entityType = null, long? entityId = null, string? details = null, CancellationToken ct = default) { _db.AuditLog.Add(new AuditLogEntry { Action = action, UserId = userId, EntityType = entityType, EntityId = entityId, Details = details, IpAddress = IpAddress, UserAgent = UserAgent, CreatedAt = _clock.UtcNow }); return Task.CompletedTask; } } /// /// Aanmeldingen van de app. Zie voor waarom ze in de database staan. /// /// Er komt hier nooit een token binnen, alleen de hash ervan. /// public class SessieRepository : ISessieRepository { private readonly MijnBesluitDbContext _db; public SessieRepository(MijnBesluitDbContext db) => _db = db; public Task ZoekAsync(byte[] tokenHash, CancellationToken ct = default) => _db.Sessies.FirstOrDefaultAsync(s => s.TokenHash == tokenHash, ct); public void Add(Sessie sessie) => _db.Sessies.Add(sessie); public async Task VerwijderAsync(byte[] tokenHash, CancellationToken ct = default) { var sessie = await _db.Sessies.FirstOrDefaultAsync(s => s.TokenHash == tokenHash, ct); if (sessie is not null) _db.Sessies.Remove(sessie); } public async Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default) { var sessies = await _db.Sessies.Where(s => s.UserId == userId).ToListAsync(ct); _db.Sessies.RemoveRange(sessies); } public async Task RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default) { var verlopen = await _db.Sessies.Where(s => s.VerlooptOp <= nu).ToListAsync(ct); _db.Sessies.RemoveRange(verlopen); return verlopen.Count; } } public class ToestelRepository : IToestelRepository { private readonly MijnBesluitDbContext _db; public ToestelRepository(MijnBesluitDbContext db) => _db = db; public Task FindByKenmerkAsync(string kenmerk, CancellationToken ct = default) => _db.Toestellen.FirstOrDefaultAsync(t => t.Kenmerk == kenmerk, ct); public async Task> ListForUserAsync(long userId, CancellationToken ct = default) => await _db.Toestellen .Where(t => t.UserId == userId && t.AfgemeldOp == null) .OrderByDescending(t => t.LaatstGezien) .ToListAsync(ct); public void Add(Toestel toestel) => _db.Toestellen.Add(toestel); public void Remove(Toestel toestel) => _db.Toestellen.Remove(toestel); } /// /// Adverteerders, campagnes en de tellers per uur. Zie docs/Adverteren.md. /// public class AdvertentieRepository : IAdvertentieRepository { private readonly MijnBesluitDbContext _db; public AdvertentieRepository(MijnBesluitDbContext db) => _db = db; public Task FindAdverteerderAsync(long id, CancellationToken ct = default) => _db.Adverteerders.FirstOrDefaultAsync(a => a.Id == id, ct); public Task FindAdverteerderByEmailAsync(string email, CancellationToken ct = default) => _db.Adverteerders.FirstOrDefaultAsync(a => a.Email == email, ct); public async Task> ListAdverteerdersAsync(CancellationToken ct = default) => await _db.Adverteerders.OrderBy(a => a.Bedrijfsnaam).ToListAsync(ct); public void Add(Adverteerder adverteerder) => _db.Adverteerders.Add(adverteerder); // De adverteerder hoort erbij: het beheerscherm laat campagnes zien met de bedrijfsnaam // ernaast, en zonder Include is die leeg. public Task FindCampagneAsync(long id, CancellationToken ct = default) => _db.Campagnes.Include(c => c.Adverteerder).FirstOrDefaultAsync(c => c.Id == id, ct); public async Task> ListCampagnesAsync(long adverteerderId, CancellationToken ct = default) => await _db.Campagnes.Where(c => c.AdverteerderId == adverteerderId) .OrderByDescending(c => c.AangemaaktOp).ToListAsync(ct); public async Task> ListLopendAsync(DateOnly dag, CancellationToken ct = default) => await _db.Campagnes .Where(c => c.Stand == CampagneStand.Loopt && c.VanafDatum <= dag && c.TotEnMetDatum >= dag) .ToListAsync(ct); public async Task> ListWachtendAsync(CancellationToken ct = default) => await _db.Campagnes.Include(c => c.Adverteerder) .Where(c => c.Stand == CampagneStand.WachtOpBetaling || c.Stand == CampagneStand.WachtOpGoedkeuring) .OrderBy(c => c.AangemaaktOp).ToListAsync(ct); public void Add(Campagne campagne) => _db.Campagnes.Add(campagne); public Task FindUurAsync(long campagneId, DateTime uurUtc, CancellationToken ct = default) => _db.CampagneUren.FirstOrDefaultAsync(u => u.CampagneId == campagneId && u.UurUtc == uurUtc, ct); public async Task> ListUrenAsync(long campagneId, CancellationToken ct = default) => await _db.CampagneUren.Where(u => u.CampagneId == campagneId) .OrderBy(u => u.UurUtc).ToListAsync(ct); public async Task> VertoningenInUurAsync( DateTime uurUtc, IEnumerable campagneIds, CancellationToken ct = default) { var lijst = campagneIds.ToList(); if (lijst.Count == 0) return new Dictionary(); return await _db.CampagneUren .Where(u => u.UurUtc == uurUtc && lijst.Contains(u.CampagneId)) .ToDictionaryAsync(u => u.CampagneId, u => u.Vertoningen, ct); } /// /// Eén opdracht naar de database, want twee bezoekers tegelijk zouden anders allebei een /// nieuwe regel aanmaken en op de unieke sleutel stuklopen. ON DUPLICATE KEY UPDATE is van /// MariaDB/MySQL; dit project draait nergens anders op. /// public Task TelAsync(long campagneId, DateTime uurUtc, int vertoningen, int kliks, CancellationToken ct = default) => _db.Database.ExecuteSqlInterpolatedAsync( $@"INSERT INTO campagne_uren (campaign_id, hour_utc, shown, clicks) VALUES ({campagneId}, {uurUtc}, {vertoningen}, {kliks}) ON DUPLICATE KEY UPDATE shown = shown + {vertoningen}, clicks = clicks + {kliks}", ct); public async Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default) { var uren = await _db.CampagneUren.Where(u => u.CampagneId == campagneId) .Select(u => new { u.Vertoningen, u.Kliks }).ToListAsync(ct); return (uren.Sum(u => u.Vertoningen), uren.Sum(u => u.Kliks)); } public void Add(CampagneUur uur) => _db.CampagneUren.Add(uur); } /// /// Tellingen voor de statistiektegel op de beheerpagina. Rechtstreeks op de context: dit zijn /// aantallen over de hele tabel heen, niet iets dat bij één van de andere repositories hoort. /// public class StatistiekRepository : IStatistiekRepository { private readonly MijnBesluitDbContext _db; public StatistiekRepository(MijnBesluitDbContext db) => _db = db; public async Task OphalenAsync(int groeidagen, DateTime nowUtc, CancellationToken ct = default) { // Samengevoegde profielen tellen niet mee: dat zijn duplicaten die zijn opgegaan in een // ander profiel, geen extra belasting. var profielen = await _db.Persons.CountAsync(p => p.MergedIntoPersonId == null, ct); var geclaimd = await _db.Persons.CountAsync(p => p.MergedIntoPersonId == null && p.ClaimedByUserId != null, ct); var families = await _db.Families.CountAsync(ct); var kluisItems = await _db.VaultItems.CountAsync(i => i.DeletedAt == null, ct); var kluisBestanden = await _db.VaultFiles.CountAsync(ct); var momenten = await _db.Messages.CountAsync(ct); var bezorgd = await _db.Messages.CountAsync(m => m.Status == MessageStatus.Bezorgd || m.Status == MessageStatus.Gelezen, ct); var beheerders = await _db.LegacyManagers.CountAsync(ct); var perCategorie = await _db.VaultItems.Where(i => i.DeletedAt == null) .GroupBy(i => i.Category) .Select(g => new { Categorie = g.Key, Aantal = g.Count() }) .ToListAsync(ct); // De groeigrafiek: nieuwe profielen en nieuwe kluisitems per dag, de laatste // `groeidagen` dagen. Twee losse groeperingen en dan samenvoegen, want het zijn twee // verschillende tabellen. var vanaf = nowUtc.Date.AddDays(-(groeidagen - 1)); var nieuweProfielenPerDag = await _db.Persons .Where(p => p.CreatedAt >= vanaf && p.MergedIntoPersonId == null) .GroupBy(p => p.CreatedAt.Date) .Select(g => new { Datum = g.Key, Aantal = g.Count() }) .ToListAsync(ct); var nieuweItemsPerDag = await _db.VaultItems .Where(i => i.CreatedAt >= vanaf) .GroupBy(i => i.CreatedAt.Date) .Select(g => new { Datum = g.Key, Aantal = g.Count() }) .ToListAsync(ct); var profielenPerDatum = nieuweProfielenPerDag.ToDictionary(x => DateOnly.FromDateTime(x.Datum), x => x.Aantal); var itemsPerDatum = nieuweItemsPerDag.ToDictionary(x => DateOnly.FromDateTime(x.Datum), x => x.Aantal); var groei = new List(); for (var d = DateOnly.FromDateTime(vanaf); d <= DateOnly.FromDateTime(nowUtc.Date); d = d.AddDays(1)) { groei.Add(new GroeiPunt(d, profielenPerDatum.TryGetValue(d, out var np) ? np : 0, itemsPerDatum.TryGetValue(d, out var ni) ? ni : 0)); } return new Statistieken(profielen, geclaimd, families, kluisItems, kluisBestanden, momenten, bezorgd, beheerders, perCategorie.ToDictionary(x => x.Categorie, x => x.Aantal), groei); } }