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