Flexibele kluissoorten: soorten in de database, beheer op de website

- Tabellen vault_categories en vault_category_fields (db/21), startrijen handmatig (db/handmatig/21b)
- VaultCategoryService met regels voor zichtbaarheid, gebruik en eigen velden
- Beheer > Kluissoorten (/beheer/soorten) met gebruikstellers; soort in gebruik niet te verwijderen
- Kluis en Nalatenschap (web en app) tonen soorten en eigen velden uit de database
- Socketprotocol: vault.categories; GRANTs voor mijnbesluit_app in db/02
- db/20 herhaalbaar gemaakt
- App versie 1.1 (versiecode 3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
This commit is contained in:
jf
2026-10-20 08:15:40 +00:00
co-authored by Claude Sonnet 5.5
parent a3a0bcfc05
commit af13d0e533
36 changed files with 2540 additions and 94 deletions
@@ -0,0 +1,438 @@
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;
/// <summary>
/// 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.
/// </summary>
public class VaultCategoryService
{
/// <summary>Meer velden dan dit maakt van een formulier een enquête.</summary>
public const int MaxVelden = 40;
/// <summary>Hoe lang een ingevulde waarde in een eigen veld mag zijn.</summary>
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
/// <summary>
/// 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.
/// </summary>
public async Task<IReadOnlyList<KluisSoort>> 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();
}
/// <summary>Alle namen op nummer, voor onder een kluisitem. Ook van soorten die niet meer actief zijn.</summary>
public async Task<IReadOnlyDictionary<int, string>> NamenAsync(CancellationToken ct = default) =>
(await _soorten.ListAsync(ct)).ToDictionary(s => s.Id, s => s.Naam);
/// <summary>
/// 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.
/// </summary>
public async Task<KluisSoort?> SoortAsync(int id, CancellationToken ct = default)
{
var soort = await _soorten.FindAsync(id, ct);
return soort is null ? null : NaarSoort(soort);
}
/// <summary>De soort met dit nummer, met velden. Null als hij niet bestaat.</summary>
public Task<VaultCategoryDefinition?> VindAsync(int id, CancellationToken ct = default) =>
_soorten.FindAsync(id, ct);
/// <summary>
/// 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.
/// </summary>
public async Task<Result<VaultCategoryDefinition>> MagGebruikenAsync(long personId, int categoryId,
CancellationToken ct = default)
{
var soort = await _soorten.FindAsync(categoryId, ct);
if (soort is null) return Result<VaultCategoryDefinition>.Fail("Deze soort bestaat niet.");
if (!soort.Actief && !(await _soorten.InGebruikDoorAsync(personId, ct)).Contains(soort.Id))
return Result<VaultCategoryDefinition>.Fail($"{soort.Naam} wordt niet meer aangeboden.");
return Result<VaultCategoryDefinition>.Ok(soort);
}
/// <summary>
/// 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.
/// </summary>
public static Result<Dictionary<string, string>> ControleerVelden(
VaultCategoryDefinition soort, IReadOnlyDictionary<string, string?>? ingevuld)
{
var schoon = new Dictionary<string, string>();
foreach (var veld in soort.Velden.Where(v => v.Soort != VeldSoort.Uitleg))
{
ingevuld ??= new Dictionary<string, string?>();
var waarde = ingevuld.TryGetValue(veld.Sleutel, out var w) ? w?.Trim() : null;
if (string.IsNullOrEmpty(waarde))
{
if (veld.Verplicht && veld.Actief)
return Result<Dictionary<string, string>>.Fail($"Vul in: {veld.Label}.");
continue;
}
if (waarde.Length > MaxWaardelengte)
return Result<Dictionary<string, string>>.Fail($"{veld.Label} is te lang.");
if (veld.Soort == VeldSoort.Datum &&
!DateOnly.TryParseExact(waarde, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
return Result<Dictionary<string, string>>.Fail($"{veld.Label}: dat is geen geldige datum.");
schoon[veld.Sleutel] = waarde;
}
return Result<Dictionary<string, string>>.Ok(schoon);
}
// ------------------------------------------------------------------ voor de beheerder
/// <summary>Alle soorten met hoe vaak ze gebruikt worden. Alleen voor beheerders.</summary>
public async Task<Result<IReadOnlyList<SoortBeheerbeeld>>> OverzichtAsync(long currentUserId,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result<IReadOnlyList<SoortBeheerbeeld>>.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<IReadOnlyList<SoortBeheerbeeld>>.Ok(lijst);
}
/// <summary>
/// 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.
/// </summary>
public async Task<Result<int>> MaakAsync(long currentUserId, string naam, string? uitleg, bool besprekenMet,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result<int>.Fail("Je bent geen beheerder.");
var fout = await ControleerNaamAsync(null, naam, uitleg, ct);
if (fout is not null) return Result<int>.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<int>.Ok(soort.Id);
}
/// <summary>
/// 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.
/// </summary>
public async Task<Result> 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<string>();
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();
}
/// <summary>
/// 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.
/// </summary>
public async Task<Result> 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();
}
/// <summary>Voegt een veld toe aan het formulier van een door de beheerder gemaakte soort.</summary>
public async Task<Result> 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();
}
/// <summary>
/// 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.
/// </summary>
public async Task<Result> 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();
}
/// <summary>
/// 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.
/// </summary>
public async Task<Result> 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<bool> IsBeheerderAsync(long userId, CancellationToken ct) =>
(await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true;
private async Task<string?> 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;
}
/// <summary>Opties één per regel, zonder lege regels en dubbelen.</summary>
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<char, string> Accenten = BouwAccenten();
private static Dictionary<char, string> BouwAccenten()
{
var d = new Dictionary<char, string>();
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;
}
/// <summary>
/// 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.
/// </summary>
public static string NieuweSleutel(string label, IEnumerable<string> 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<string>(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());
}