using System.Globalization;
using System.Text;
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Services;
///
/// De soorten kluisitems. Wat je in de keuzelijst ziet, en bij een door de beheerder gemaakte soort
/// ook welke velden het formulier heeft, komt hier vandaan en niet meer uit de code. Zo vraagt een
/// nieuwe soort ("Dementie", "Niet reanimeren") geen nieuwe versie van de app.
///
/// Twee regels bepalen alles wat hier gebeurt:
///
/// 1. Wat in gebruik is, verdwijnt nooit. Een soort waar al een kluisitem mee is gemaakt kan niet
/// worden verwijderd, alleen op niet-actief gezet. Wie er nog nooit een item mee maakte ziet hem
/// dan niet meer in zijn keuzelijst; wie dat wel deed, blijft hem zien en kan zijn items
/// gewoon bekijken en wijzigen.
/// 2. De beheerder ziet aantallen, nooit inhoud. Hoe vaak een soort gebruikt wordt is een tel;
/// wie het deed en wat er in staat, blijft versleuteld en onbereikbaar.
///
public class VaultCategoryService
{
/// Meer velden dan dit maakt van een formulier een enquête.
public const int MaxVelden = 40;
/// Hoe lang een ingevulde waarde in een eigen veld mag zijn.
public const int MaxWaardelengte = 4000;
private readonly IVaultCategoryRepository _soorten;
private readonly IUserRepository _users;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
public VaultCategoryService(IVaultCategoryRepository soorten, IUserRepository users, IAuditLog audit,
IUnitOfWork uow, IClock clock)
{
_soorten = soorten; _users = users; _audit = audit; _uow = uow; _clock = clock;
}
// ------------------------------------------------------------------ voor gebruikers
///
/// De soorten die deze persoon te zien krijgt: alle actieve, plus de niet-actieve waar hij al
/// een item van heeft. Op volgorde van de beheerder.
///
public async Task> ZichtbaarVoorAsync(long personId, CancellationToken ct = default)
{
var alle = await _soorten.ListAsync(ct);
var gebruikt = await _soorten.InGebruikDoorAsync(personId, ct);
return alle.Where(s => s.Actief || gebruikt.Contains(s.Id))
.Select(NaarSoort)
.ToList();
}
/// Alle namen op nummer, voor onder een kluisitem. Ook van soorten die niet meer actief zijn.
public async Task> NamenAsync(CancellationToken ct = default) =>
(await _soorten.ListAsync(ct)).ToDictionary(s => s.Id, s => s.Naam);
///
/// Eén soort op nummer, ook als hij niet actief is of de vrager hem nooit gebruikte. Nodig om
/// het item van een ander te kunnen lezen (een nalatenschap, iets dat met je is gedeeld): de
/// namen van de velden zijn geen geheim, de ingevulde waarden wel, en die blijven versleuteld.
///
public async Task SoortAsync(int id, CancellationToken ct = default)
{
var soort = await _soorten.FindAsync(id, ct);
return soort is null ? null : NaarSoort(soort);
}
/// De soort met dit nummer, met velden. Null als hij niet bestaat.
public Task VindAsync(int id, CancellationToken ct = default) =>
_soorten.FindAsync(id, ct);
///
/// Mag deze persoon een nieuw kluisitem in deze soort maken? Alleen als de soort bestaat en
/// actief is, of als hij er al een item van heeft - dan is het voor hem nog gewoon een soort
/// die hij kent.
///
public async Task> MagGebruikenAsync(long personId, int categoryId,
CancellationToken ct = default)
{
var soort = await _soorten.FindAsync(categoryId, ct);
if (soort is null) return Result.Fail("Deze soort bestaat niet.");
if (!soort.Actief && !(await _soorten.InGebruikDoorAsync(personId, ct)).Contains(soort.Id))
return Result.Fail($"{soort.Naam} wordt niet meer aangeboden.");
return Result.Ok(soort);
}
///
/// Controleert wat er in de eigen velden van een door de beheerder gemaakte soort is ingevuld
/// en geeft de schone versie terug: alleen bekende velden, bijgesneden, met de verplichte
/// velden ingevuld en datums die kloppen. Op de server en niet alleen in het scherm, want
/// een oudere of andere app stuurt wat hij wil.
///
public static Result> ControleerVelden(
VaultCategoryDefinition soort, IReadOnlyDictionary? ingevuld)
{
var schoon = new Dictionary();
foreach (var veld in soort.Velden.Where(v => v.Soort != VeldSoort.Uitleg))
{
ingevuld ??= new Dictionary();
var waarde = ingevuld.TryGetValue(veld.Sleutel, out var w) ? w?.Trim() : null;
if (string.IsNullOrEmpty(waarde))
{
if (veld.Verplicht && veld.Actief)
return Result>.Fail($"Vul in: {veld.Label}.");
continue;
}
if (waarde.Length > MaxWaardelengte)
return Result>.Fail($"{veld.Label} is te lang.");
if (veld.Soort == VeldSoort.Datum &&
!DateOnly.TryParseExact(waarde, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
return Result>.Fail($"{veld.Label}: dat is geen geldige datum.");
schoon[veld.Sleutel] = waarde;
}
return Result>.Ok(schoon);
}
// ------------------------------------------------------------------ voor de beheerder
/// Alle soorten met hoe vaak ze gebruikt worden. Alleen voor beheerders.
public async Task>> OverzichtAsync(long currentUserId,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result>.Fail("Je bent geen beheerder.");
var alle = await _soorten.ListAsync(ct);
var gebruik = (await _soorten.GebruikAsync(ct)).ToDictionary(g => g.CategoryId);
var lijst = alle.Select(s =>
{
gebruik.TryGetValue(s.Id, out var g);
return new SoortBeheerbeeld(
s.Id, s.Naam, s.Uitleg, s.Actief, s.Volgorde, s.BesprekenMet, s.Ingebouwd,
g?.Items ?? 0, g?.Eigenaren ?? 0,
s.Velden.OrderBy(v => v.Volgorde).ThenBy(v => v.Id)
.Select(v => new VeldBeheerbeeld(v.Id, v.Sleutel, v.Label, v.Soort, v.Opties,
v.Hulptekst, v.Verplicht, v.Actief, v.Volgorde))
.ToList());
}).ToList();
return Result>.Ok(lijst);
}
///
/// Maakt een nieuwe soort. Hij begint actief, onderaan de lijst. De naam is leesbaar voor de
/// server (het item zelf blijft versleuteld), dus een naam die iets over iemands gezondheid
/// zegt, zegt dat ook tegen de server.
///
public async Task> MaakAsync(long currentUserId, string naam, string? uitleg, bool besprekenMet,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var fout = await ControleerNaamAsync(null, naam, uitleg, ct);
if (fout is not null) return Result.Fail(fout);
var alle = await _soorten.ListAsync(ct);
var nu = _clock.UtcNow;
var soort = new VaultCategoryDefinition
{
// Nummers van eigen soorten beginnen bij 100; de database kiest het volgende vrije.
Naam = naam.Trim(),
Uitleg = Schoon(uitleg),
Actief = true,
Volgorde = (alle.Count == 0 ? 0 : alle.Max(s => s.Volgorde)) + 10,
BesprekenMet = besprekenMet,
Ingebouwd = false,
AangemaaktOp = nu,
GewijzigdOp = nu
};
_soorten.Add(soort);
await _uow.SaveChangesAsync(ct);
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
soort.Id, $"gemaakt: {soort.Naam}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok(soort.Id);
}
///
/// Wijzigt een soort: de naam, de uitleg, of hij nog wordt aangeboden, waar hij in de lijst
/// staat en of er "Bespreken met" bij zit. Dit kan ook bij de ingebouwde soorten.
///
public async Task WijzigAsync(long currentUserId, int id, string naam, string? uitleg, bool actief,
int volgorde, bool besprekenMet, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var soort = await _soorten.FindAsync(id, ct);
if (soort is null) return Result.Fail("Deze soort bestaat niet.");
var fout = await ControleerNaamAsync(id, naam, uitleg, ct);
if (fout is not null) return Result.Fail(fout);
var wijzigingen = new List();
if (soort.Naam != naam.Trim()) wijzigingen.Add($"naam {soort.Naam} -> {naam.Trim()}");
if (soort.Actief != actief) wijzigingen.Add(actief ? "actief" : "niet-actief");
if (soort.BesprekenMet != besprekenMet) wijzigingen.Add(besprekenMet ? "bespreken met aan" : "bespreken met uit");
soort.Naam = naam.Trim();
soort.Uitleg = Schoon(uitleg);
soort.Actief = actief;
soort.Volgorde = volgorde;
soort.BesprekenMet = besprekenMet;
soort.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), id,
wijzigingen.Count == 0 ? "gewijzigd" : string.Join(", ", wijzigingen), ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
///
/// Verwijdert een soort - maar alleen een door de beheerder gemaakte waar nog nooit een
/// kluisitem mee is gemaakt. Is hij in gebruik, dan blijft alleen "niet-actief" over, zodat
/// wie hem gebruikt hem houdt.
///
public async Task VerwijderAsync(long currentUserId, int id, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var soort = await _soorten.FindAsync(id, ct);
if (soort is null) return Result.Fail("Deze soort bestaat niet.");
if (soort.Ingebouwd) return Result.Fail("Een ingebouwde soort kun je niet verwijderen, alleen niet-actief maken.");
var aantal = await _soorten.AantalItemsAsync(id, ct);
if (aantal > 0)
return Result.Fail($"{soort.Naam} is in gebruik ({aantal} " +
(aantal == 1 ? "item" : "items") + "). Zet hem op niet-actief; dan zien alleen de mensen die hem gebruiken hem nog.");
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), id,
$"verwijderd: {soort.Naam}", ct);
_soorten.Remove(soort);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// Voegt een veld toe aan het formulier van een door de beheerder gemaakte soort.
public async Task VeldToevoegenAsync(long currentUserId, int categoryId, string label, string soortVeld,
string? opties, string? hulptekst, bool verplicht,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var soort = await _soorten.FindAsync(categoryId, ct);
if (soort is null) return Result.Fail("Deze soort bestaat niet.");
if (soort.Ingebouwd) return Result.Fail("De velden van een ingebouwde soort zitten in de app en zijn hier niet te wijzigen.");
if (soort.Velden.Count >= MaxVelden) return Result.Fail($"Meer dan {MaxVelden} velden is te veel voor één formulier.");
var fout = ControleerVeld(label, soortVeld, opties, hulptekst, verplicht);
if (fout is not null) return Result.Fail(fout);
var veld = new VaultCategoryField
{
CategoryId = categoryId,
Sleutel = NieuweSleutel(label, soort.Velden.Select(v => v.Sleutel)),
Label = label.Trim(),
Soort = soortVeld,
Opties = soortVeld == VeldSoort.Keuze ? SchoneOpties(opties) : null,
Hulptekst = Schoon(hulptekst),
Verplicht = verplicht && soortVeld != VeldSoort.Uitleg,
Actief = true,
Volgorde = (soort.Velden.Count == 0 ? 0 : soort.Velden.Max(v => v.Volgorde)) + 10
};
_soorten.Add(veld);
soort.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), categoryId,
$"veld toegevoegd: {veld.Label}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
///
/// Wijzigt een veld. De sleutel en de soort veld staan vast, want daaraan hangt wat mensen al
/// hebben ingevuld; het label, de opties, de hulptekst, verplicht, actief en de plek mogen wel.
///
public async Task VeldWijzigenAsync(long currentUserId, long veldId, string label, string? opties,
string? hulptekst, bool verplicht, bool actief, int volgorde,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var veld = await _soorten.FindFieldAsync(veldId, ct);
if (veld is null) return Result.Fail("Dit veld bestaat niet.");
var fout = ControleerVeld(label, veld.Soort, opties, hulptekst, verplicht);
if (fout is not null) return Result.Fail(fout);
veld.Label = label.Trim();
veld.Opties = veld.Soort == VeldSoort.Keuze ? SchoneOpties(opties) : null;
veld.Hulptekst = Schoon(hulptekst);
veld.Verplicht = verplicht && veld.Soort != VeldSoort.Uitleg;
veld.Actief = actief;
veld.Volgorde = volgorde;
var soort = await _soorten.FindAsync(veld.CategoryId, ct);
if (soort is not null) soort.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
veld.CategoryId, $"veld gewijzigd: {veld.Label}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
///
/// Haalt een veld weg. Alleen zolang er geen kluisitem van deze soort bestaat: anders staan
/// er ingevulde gegevens onder en is "niet-actief" de weg - die waarden blijven dan gewoon
/// bewaard en zichtbaar bij de items die ze hebben.
///
public async Task VeldVerwijderenAsync(long currentUserId, long veldId, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var veld = await _soorten.FindFieldAsync(veldId, ct);
if (veld is null) return Result.Fail("Dit veld bestaat niet.");
if (await _soorten.AantalItemsAsync(veld.CategoryId, ct) > 0)
return Result.Fail("Er zijn al items van deze soort; zet het veld op niet-actief in plaats van het te verwijderen. Wat er is ingevuld blijft dan bewaard.");
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
veld.CategoryId, $"veld verwijderd: {veld.Label}", ct);
_soorten.Remove(veld);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
// ------------------------------------------------------------------ hulp
private async Task IsBeheerderAsync(long userId, CancellationToken ct) =>
(await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true;
private async Task ControleerNaamAsync(int? eigenId, string? naam, string? uitleg, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(naam)) return "Geef de soort een naam.";
if (naam.Trim().Length > 80) return "Een naam van meer dan 80 tekens is te lang.";
if (uitleg is { Length: > 500 }) return "De uitleg mag niet langer zijn dan 500 tekens.";
var dubbel = (await _soorten.ListAsync(ct))
.Any(s => s.Id != eigenId && string.Equals(s.Naam, naam.Trim(), StringComparison.OrdinalIgnoreCase));
return dubbel ? "Er bestaat al een soort met deze naam." : null;
}
private static string? ControleerVeld(string? label, string? soortVeld, string? opties, string? hulptekst, bool verplicht)
{
if (string.IsNullOrWhiteSpace(label)) return "Geef het veld een naam.";
if (label.Trim().Length > 100) return "De naam van een veld mag niet langer zijn dan 100 tekens.";
if (!VeldSoort.Bekend(soortVeld)) return "Dit soort veld kennen we niet.";
if (hulptekst is { Length: > 300 }) return "De hulptekst mag niet langer zijn dan 300 tekens.";
if (soortVeld == VeldSoort.Keuze)
{
var lijst = (SchoneOpties(opties) ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lijst.Length < 2) return "Een keuzelijst heeft minstens twee opties nodig, één per regel.";
if ((SchoneOpties(opties) ?? "").Length > 1000) return "De opties zijn samen te lang.";
}
if (soortVeld == VeldSoort.Uitleg && string.IsNullOrWhiteSpace(hulptekst))
return "Een uitlegtekst heeft een tekst nodig; vul die in bij de hulptekst.";
return null;
}
/// Opties één per regel, zonder lege regels en dubbelen.
private static string? SchoneOpties(string? opties) =>
opties is null
? null
: string.Join('\n', opties.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase));
private static string? Schoon(string? tekst) => string.IsNullOrWhiteSpace(tekst) ? null : tekst.Trim();
private static readonly Dictionary Accenten = BouwAccenten();
private static Dictionary BouwAccenten()
{
var d = new Dictionary();
void Voeg(string letters, string door) { foreach (var l in letters) d[l] = door; }
Voeg("àáâãäåāăą", "a"); Voeg("çćĉċč", "c"); Voeg("ďđ", "d"); Voeg("èéêëēĕėęě", "e");
Voeg("ĝğġģ", "g"); Voeg("ĥħ", "h"); Voeg("ìíîïĩīĭįı", "i"); Voeg("ĵ", "j"); Voeg("ķ", "k");
Voeg("ĺļľŀł", "l"); Voeg("ñńņňʼn", "n"); Voeg("òóôõöøōŏő", "o"); Voeg("ŕŗř", "r");
Voeg("śŝşš", "s"); Voeg("ţťŧ", "t"); Voeg("ùúûüũūŭůűų", "u"); Voeg("ŵ", "w");
Voeg("ýÿŷ", "y"); Voeg("źżž", "z"); d['ß'] = "ss"; d['æ'] = "ae"; d['œ'] = "oe"; d['þ'] = "th";
return d;
}
///
/// Een vaste, korte naam voor een veld, afgeleid van het label: "Wil je gereanimeerd worden?"
/// wordt "wil_je_gereanimeerd_worden". Is die al in gebruik in deze soort, dan komt er een
/// nummer achter.
///
public static string NieuweSleutel(string label, IEnumerable bestaand)
{
var bouw = new StringBuilder();
foreach (var teken in label.ToLowerInvariant())
{
// Accenten zelf wegwerken in plaats van via string.Normalize: dat werkt niet overal
// hetzelfde zodra InvariantGlobalization aan staat (zoals hier), en de sleutel mag niet
// per machine verschillen.
var ascii = Accenten.TryGetValue(teken, out var zonder) ? zonder : teken.ToString();
foreach (var t in ascii)
{
if (char.IsAsciiLetterOrDigit(t)) bouw.Append(t);
else if (bouw.Length > 0 && bouw[^1] != '_') bouw.Append('_');
}
}
var basis = bouw.ToString().Trim('_');
if (basis.Length == 0) basis = "veld";
if (basis.Length > 30) basis = basis[..30].TrimEnd('_');
var gebruikt = new HashSet(bestaand, StringComparer.OrdinalIgnoreCase);
var sleutel = basis;
for (var n = 2; gebruikt.Contains(sleutel); n++) sleutel = $"{basis}_{n}";
return sleutel;
}
private static KluisSoort NaarSoort(VaultCategoryDefinition s) =>
new(s.Id, s.Naam, s.Uitleg, s.BesprekenMet, s.Ingebouwd,
s.Velden.OrderBy(v => v.Volgorde).ThenBy(v => v.Id)
.Select(v => new KluisVeld(v.Sleutel, v.Label, v.Soort, v.OptieLijst, v.Hulptekst, v.Verplicht, v.Actief))
.ToList());
}