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
+2 -2
View File
@@ -28,8 +28,8 @@
<ApplicationTitle>MijnBesluit</ApplicationTitle>
<ApplicationId>nl.mijnbesluit.app</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>2</ApplicationVersion>
<ApplicationDisplayVersion>1.1</ApplicationDisplayVersion>
<ApplicationVersion>3</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
+5 -16
View File
@@ -140,22 +140,11 @@ public static class Tonen
public static string Kort(DateOnly? dag) =>
dag is null ? "" : $"{dag.Value.Day} {KorteMaanden[dag.Value.Month - 1]} {dag.Value.Year}";
/// <summary>De nummers komen overeen met VaultCategory in het domein.</summary>
public static string Categorie(int categorie) => categorie switch
{
1 => "Uitvaartwens",
2 => "Polis",
3 => "Wachtwoord",
4 => "Apparaat",
5 => "Pincode",
6 => "Document",
7 => "Notitie",
8 => "Sociale media",
9 => "Donorcodicil",
10 => "Rijbewijs",
11 => "Paspoort",
_ => "Overig"
};
/// <summary>
/// De ingebouwde naam bij een soortnummer. Een soort die de beheerder zelf maakte staat hier niet
/// in; die naam reist met het item mee (<see cref="MijnBesluit.Client.KluisItem.SoortNaam"/>).
/// </summary>
public static string Categorie(int categorie) => MijnBesluit.Client.IngebouwdeSoorten.Naam(categorie);
/// <summary>
/// Hoe een relatie op het scherm heet. De lijst staat in MijnBesluit.Client, zodat de app en
+131 -29
View File
@@ -22,18 +22,10 @@
<div class="rij kluiskop">
<select class="groei" aria-label="Filter op soort" disabled="@Bezig" @bind="Filter" @bind:after="LaadAsync">
<option value="">Alles</option>
<option value="3">Wachtwoord</option>
<option value="8">Sociale media</option>
<option value="4">Apparaat</option>
<option value="5">Pincode</option>
<option value="2">Polis</option>
<option value="1">Uitvaartwens</option>
<option value="9">Donorcodicil</option>
<option value="10">Rijbewijs</option>
<option value="11">Paspoort</option>
<option value="6">Document</option>
<option value="7">Notitie</option>
<option value="99">Overig</option>
@foreach (var soort in Soorten)
{
<option value="@soort.Id">@soort.Naam</option>
}
</select>
<button class="knop" disabled="@Bezig" @onclick="() => Nieuw()">Toevoegen</button>
@@ -52,22 +44,19 @@
<div class="veld">
<label for="soort">Wat leg je vast?</label>
@* De nummers komen overeen met VaultCategory in het domein. Overig is 99, niet 9:
dat stond hier eerder verkeerd en leverde een categorie op die nergens bij hoort. *@
@* De lijst komt van de server: de soorten die de beheerder aanbiedt, plus de soorten
waar je al een item van hebt. Kan de server er geen geven, dan staat hier de
ingebouwde lijst (IngebouwdeSoorten), met dezelfde nummers. *@
<select id="soort" @bind="Categorie">
<option value="3">Wachtwoord</option>
<option value="8">Sociale media</option>
<option value="4">Apparaat</option>
<option value="5">Pincode</option>
<option value="2">Polis</option>
<option value="1">Uitvaartwens</option>
<option value="9">Donorcodicil</option>
<option value="10">Rijbewijs</option>
<option value="11">Paspoort</option>
<option value="6">Document</option>
<option value="7">Notitie</option>
<option value="99">Overig</option>
@foreach (var soort in Soorten)
{
<option value="@soort.Id">@soort.Naam</option>
}
</select>
@if (!string.IsNullOrWhiteSpace(HuidigeSoort?.Uitleg))
{
<div class="uitleg">@HuidigeSoort!.Uitleg</div>
}
</div>
<div class="veld">
@@ -153,7 +142,17 @@
break;
}
@if (Categorie is 1 or 9)
@* Een soort die de beheerder zelf maakte beschrijft zijn formulier op de server; hier wordt het
getekend. Een ingebouwde soort heeft zijn velden hierboven in de switch. *@
@if (HuidigeSoort is { Ingebouwd: false } eigen)
{
@foreach (var veld in eigen.Velden.Where(v => v.Actief || HeeftWaarde(v)))
{
@VeldInvoer(veld)
}
}
@if (HuidigeSoort?.BesprekenMet == true)
{
<div class="veld">
<label for="bespreken">Bespreken met</label>
@@ -278,7 +277,7 @@
@onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(item))">
<div class="rij">
<div class="groei">
<span class="groot">@Tonen.Categorie(item.Category)</span>
<span class="groot">@item.SoortNaam(NaamVan)</span>
<div class="zacht" title="@item.Title">@item.Title</div>
@* stopPropagation, want anders opent een druk op de prullenbak ook meteen
@@ -342,7 +341,16 @@
/// <summary>Op welk soort de lijst gefilterd staat; leeg is alles. Zelfde opzet als op de website.</summary>
private string Filter = "";
/// <summary>De soorten in de keuzelijst; zie <see cref="LaadAsync"/>.</summary>
private IReadOnlyList<KluisSoort> Soorten = IngebouwdeSoorten.Lijst;
private int Categorie = 3;
/// <summary>De soort die in het formulier gekozen is. Null als de lijst hem niet kent.</summary>
private KluisSoort? HuidigeSoort => Soorten.FirstOrDefault(s => s.Id == Categorie);
/// <summary>De naam van een soort uit de lijst, of de ingebouwde naam als de lijst hem niet kent.</summary>
private string NaamVan(int soort) => Soorten.FirstOrDefault(s => s.Id == soort)?.Naam ?? IngebouwdeSoorten.Naam(soort);
private IReadOnlyList<Beheerder> Beheerders = Array.Empty<Beheerder>();
private string BesprekenMetTekst = "";
private long? BesprekenMetPersonId => long.TryParse(BesprekenMetTekst, out var p) ? p : null;
@@ -383,6 +391,11 @@
if (lijst.Gelukt) Items = lijst.Waarde!;
else Fout = lijst.Melding;
// De soorten van de server. Geen lijst terug (een oudere server, of even geen verbinding):
// dan blijft het de ingebouwde lijst, en werkt de kluis precies zoals voorheen.
var soorten = await Toestand.Client.KluisSoortenAsync();
Soorten = soorten.Gelukt && soorten.Waarde is { Count: > 0 } ? soorten.Waarde : IngebouwdeSoorten.Lijst;
var beheerders = await Toestand.Client.BeheerdersAsync();
if (beheerders.Gelukt) Beheerders = beheerders.Waarde!;
@@ -405,7 +418,7 @@
Bewerken = new KluisInhoud();
ItemId = 0;
Titel = "";
Categorie = 3;
Categorie = Soorten.Any(s => s.Id == 3) ? 3 : Soorten[0].Id;
Vrijgave = 1;
VrijgaveDatum = DateTime.Today.AddYears(1);
BesprekenMetTekst = "";
@@ -456,6 +469,15 @@
var datum = Vrijgave == 2 ? VrijgaveDatum : null;
if (Vrijgave == 2 && datum is null) { Fout = "Kies een datum waarop dit vrijkomt."; return; }
// Verplichte velden van een soort die de beheerder maakte. De server controleert het ook;
// dit is om het meteen bij het veld te kunnen zeggen.
if (HuidigeSoort is { Ingebouwd: false } eigen)
{
var leeg = eigen.Velden.FirstOrDefault(v => v.Actief && v.Verplicht && v.Soort != KluisVeld.Uitleg
&& string.IsNullOrWhiteSpace(Waarde(v)));
if (leeg is not null) { Fout = $"Vul in: {leeg.Label}."; return; }
}
long nieuwItemId = 0;
if (await ProbeerAsync(async () =>
@@ -592,6 +614,86 @@
}
}
// ------------------------------------------------------------------ eigen velden
private string Waarde(KluisVeld veld) =>
Bewerken is not null && Bewerken.Velden.TryGetValue(veld.Sleutel, out var w) ? w : "";
private bool HeeftWaarde(KluisVeld veld) => !string.IsNullOrWhiteSpace(Waarde(veld));
private void Zet(KluisVeld veld, string? waarde)
{
if (Bewerken is null) return;
if (string.IsNullOrWhiteSpace(waarde)) Bewerken.Velden.Remove(veld.Sleutel);
else Bewerken.Velden[veld.Sleutel] = waarde.Trim();
}
/// <summary>De opgeslagen datum (jjjj-mm-dd) als datum voor de kiezer; null als er niets staat.</summary>
private DateTime? DatumWaarde(KluisVeld veld) =>
DateOnly.TryParseExact(Waarde(veld), "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dag)
? dag.ToDateTime(TimeOnly.MinValue)
: null;
private void ZetDatum(KluisVeld veld, DateTime? datum) =>
Zet(veld, datum?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture));
/// <summary>
/// Eén veld van een door de beheerder gemaakte soort. De invoer gaat via Zet in plaats van @bind:
/// de waarden staan in een woordenboek op de sleutel van het veld, en een veld dat leeg wordt
/// gemaakt hoort er dan uit te verdwijnen in plaats van als lege tekst te blijven staan.
/// </summary>
private RenderFragment VeldInvoer(KluisVeld veld) => @<div class="veld">
@if (veld.Soort == KluisVeld.Uitleg)
{
<div class="uitleg">@veld.Hulptekst</div>
}
else
{
<label for="@VeldId(veld)">@veld.Label@(veld.Verplicht ? " *" : "")</label>
@switch (veld.Soort)
{
case KluisVeld.Lang:
<textarea id="@VeldId(veld)" rows="3" value="@Waarde(veld)"
@onchange="e => Zet(veld, e.Value?.ToString())"></textarea>
break;
case KluisVeld.Datum:
<Datumkiezer Waarde="@DatumWaarde(veld)" WaardeChanged="d => ZetDatum(veld, d)"
Optioneel="true" VanJaar="@(DateTime.Today.Year - 80)" TotJaar="@(DateTime.Today.Year + 5)"
Aflopend="true" />
break;
case KluisVeld.Keuze:
<select id="@VeldId(veld)" value="@Waarde(veld)" @onchange="e => Zet(veld, e.Value?.ToString())">
<option value="">Kies&hellip;</option>
@foreach (var optie in veld.Opties)
{
<option value="@optie" selected="@(optie == Waarde(veld))">@optie</option>
}
@* Stond er een waarde die niet meer in de lijst staat, dan blijft die zichtbaar. *@
@if (HeeftWaarde(veld) && !veld.Opties.Contains(Waarde(veld)))
{
<option value="@Waarde(veld)" selected="selected">@Waarde(veld)</option>
}
</select>
break;
default:
<input id="@VeldId(veld)" value="@Waarde(veld)" @onchange="e => Zet(veld, e.Value?.ToString())" />
break;
}
@if (!string.IsNullOrWhiteSpace(veld.Hulptekst))
{
<div class="uitleg">@veld.Hulptekst</div>
}
}
</div>;
private static string VeldId(KluisVeld veld) => "veld-" + veld.Sleutel;
/// <summary>
/// Muziek en Wensen mogen meerdere regels worden; dan is een vast aantal regels niet
/// genoeg en een schuifbalk niet fijn op een telefoon. Deze twee velden groeien daarom
@@ -156,7 +156,7 @@ else if (Stand is { IsBevestigd: true })
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Tonen.Categorie(item.Category)</div>
<div class="zacht">@item.SoortNaam(IngebouwdeSoorten.Naam)</div>
</div>
<button class="knop rustig klein" disabled="@Bezig" @onclick:stopPropagation="true"
@onclick="() => ItemOpenenAsync(item)">Openen</button>
@@ -187,7 +187,7 @@ else if (Stand is { IsBevestigd: true })
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Tonen.Categorie(item.Category)</div>
<div class="zacht">@item.SoortNaam(IngebouwdeSoorten.Naam)</div>
</div>
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => ItemOpenenAsync(item)">Openen</button>
</div>
@@ -205,7 +205,7 @@ else if (Stand is { IsBevestigd: true })
<Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen">
<div class="stapel">
@foreach (var regel in Regels(Inhoud))
@foreach (var regel in Regels(Inhoud).Concat(Inhoud.EigenRegels(GeopendSoort)))
{
<div class="veld">
<label>@regel.Naam</label>
@@ -482,6 +482,9 @@ else if (Stand is { IsBevestigd: true })
private long? Bekeken, GeopendItem;
private IReadOnlyList<KluisItem> Nagelaten = Array.Empty<KluisItem>();
private KluisInhoud? Inhoud;
/// <summary>De soort van het geopende item, voor de labels van de velden van een eigen soort.</summary>
private KluisSoort? GeopendSoort;
private string? GeopendTitel;
private IReadOnlyList<Bijlage> GeopendBijlagen = Array.Empty<Bijlage>();
@@ -604,6 +607,16 @@ else if (Stand is { IsBevestigd: true })
Inhoud = r.Waarde;
GeopendTitel = item.Title;
// De labels van de velden van een soort die de beheerder zelf maakte staan niet in de
// app. Ze worden apart opgehaald, ook als die soort niet meer wordt aangeboden: de
// namen van de velden zijn geen geheim, de ingevulde waarden blijven versleuteld.
GeopendSoort = null;
if (item.Category >= 100)
{
var soort = await Toestand.Client.KluisSoortAsync(item.Category);
if (soort.Gelukt) GeopendSoort = soort.Waarde;
}
// Bijlagen zijn hier optioneel: als ophalen mislukt tonen we het item gewoon
// zonder bijlagenlijst, in plaats van de hele popup te blokkeren.
var bijlagen = await Toestand.Client.BijlagenAsync(item.Id);
@@ -616,6 +629,7 @@ else if (Stand is { IsBevestigd: true })
{
GeopendItem = null;
Inhoud = null;
GeopendSoort = null;
GeopendTitel = null;
GeopendBijlagen = Array.Empty<Bijlage>();
BijlageDichtdoen();
@@ -1,4 +1,5 @@
using MijnBesluit.Domain.Entities;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Abstractions;
@@ -261,6 +262,31 @@ public interface IAuditLog
string? details = null, CancellationToken ct = default);
}
/// <summary>De soorten kluisitems en hun velden, zoals de beheerder ze instelt.</summary>
public interface IVaultCategoryRepository
{
/// <summary>Alle soorten, met hun velden, op volgorde.</summary>
Task<IReadOnlyList<VaultCategoryDefinition>> ListAsync(CancellationToken ct = default);
Task<VaultCategoryDefinition?> FindAsync(int id, CancellationToken ct = default);
Task<VaultCategoryField?> FindFieldAsync(long fieldId, CancellationToken ct = default);
/// <summary>Per soort hoeveel kluisitems (niet verwijderd) en hoeveel eigenaren. Alleen aantallen.</summary>
Task<IReadOnlyList<SoortGebruik>> GebruikAsync(CancellationToken ct = default);
/// <summary>De soorten waar deze persoon minstens één kluisitem (niet verwijderd) van heeft.</summary>
Task<IReadOnlySet<int>> InGebruikDoorAsync(long personId, CancellationToken ct = default);
/// <summary>Hoeveel kluisitems (niet verwijderd) er van deze soort zijn.</summary>
Task<int> AantalItemsAsync(int categoryId, CancellationToken ct = default);
void Add(VaultCategoryDefinition soort);
void Remove(VaultCategoryDefinition soort);
void Add(VaultCategoryField veld);
void Remove(VaultCategoryField veld);
}
/// <summary>Versturen van uitnodigingen en meldingen.</summary>
/// <summary>
/// Adverteerders, hun campagnes en de tellers per uur. Zie docs/Adverteren.md.
@@ -0,0 +1,28 @@
namespace MijnBesluit.Application.Models;
/// <summary>
/// Een soort kluisitem zoals een gebruiker die te zien krijgt: de naam, de uitleg en bij een
/// nieuwe soort de velden van het formulier. Dit is ook wat er over de lijn naar de app gaat.
/// </summary>
public record KluisSoort(int Id, string Naam, string? Uitleg, bool BesprekenMet, bool Ingebouwd,
IReadOnlyList<KluisVeld> Velden);
/// <summary>Eén veld van het formulier van een nieuwe soort.</summary>
public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyList<string> Opties,
string? Hulptekst, bool Verplicht, bool Actief);
/// <summary>Hoe vaak een soort in gebruik is: aantallen, nooit inhoud of namen.</summary>
public record SoortGebruik(int CategoryId, int Items, int Eigenaren);
/// <summary>Een soort in het beheerscherm, met hoe vaak hij gebruikt wordt.</summary>
public record SoortBeheerbeeld(int Id, string Naam, string? Uitleg, bool Actief, int Volgorde,
bool BesprekenMet, bool Ingebouwd, int Items, int Eigenaren,
IReadOnlyList<VeldBeheerbeeld> Velden)
{
/// <summary>Verwijderen kan alleen als niemand er een item mee heeft gemaakt en hij niet ingebouwd is.</summary>
public bool KanVerwijderen => !Ingebouwd && Items == 0;
}
/// <summary>Een veld in het beheerscherm.</summary>
public record VeldBeheerbeeld(long Id, string Sleutel, string Label, string Soort, string? Opties,
string? Hulptekst, bool Verplicht, bool Actief, int Volgorde);
@@ -35,4 +35,10 @@ public class VaultPayload
public string? Music { get; set; }
public string? Speakers { get; set; }
public string? Wishes { get; set; }
// Soorten die de beheerder zelf heeft gemaakt (nummer 100 en hoger). Hun velden staan niet als
// eigen eigenschap hierboven maar hier, op de vaste sleutel van het veld
// (VaultCategoryField.Sleutel). Zo kan er een veld bij zonder dat de code verandert, en blijft
// het net zo versleuteld als de rest van de inhoud. Bij de ingebouwde soorten is dit leeg.
public Dictionary<string, string>? Velden { get; set; }
}
@@ -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());
}
@@ -26,6 +26,7 @@ public class VaultService
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly IAfbeeldingVerkleiner _verkleiner;
private readonly VaultCategoryService? _soorten;
/// <summary>De langste zijde van de kleine versie die bij een foto wordt bewaard.</summary>
public const int ThumbnailZijde = 160;
@@ -46,12 +47,14 @@ public class VaultService
public VaultService(IVaultRepository vault, IPersonRepository people, IMessageRepository messages,
IUserRepository users, IMasterKeyProtector keys, IFileStore files, IAuditLog audit,
IUnitOfWork uow, IClock clock, IAfbeeldingVerkleiner? verkleiner = null,
IFamilyRepository? families = null, ILegacyRepository? legacy = null)
IFamilyRepository? families = null, ILegacyRepository? legacy = null,
VaultCategoryService? soorten = null)
{
_vault = vault; _people = people; _messages = messages; _users = users; _keys = keys;
_files = files; _audit = audit; _uow = uow; _clock = clock; _families = families;
_verkleiner = verkleiner ?? new GeenAfbeeldingVerkleiner();
_legacy = legacy;
_soorten = soorten;
}
/// <summary>Titels en categorieën van de kluis. De inhoud blijft versleuteld tot hij echt geopend wordt.</summary>
@@ -71,6 +74,10 @@ public class VaultService
if (release == ReleasePolicy.OpDatum && releaseAt is null)
return Result<VaultItem>.Fail("Kies een datum waarop dit item vrijkomt.");
var soortControle = await ControleerSoortAsync(user, category, payload, nieuw: true, aanbodControleren: true, ct);
if (!soortControle.Succeeded) return Result<VaultItem>.Fail(soortControle.Error!);
var kanBespreken = await KanBesprekenMetAsync(category, ct);
var now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey();
@@ -89,7 +96,7 @@ public class VaultService
WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey),
ReleasePolicy = release,
ReleaseAt = releaseAt,
SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null,
SharedManagerPersonId = kanBespreken ? sharedManagerPersonId : null,
CreatedAt = now,
UpdatedAt = now
};
@@ -132,6 +139,12 @@ public class VaultService
if (release == ReleasePolicy.OpDatum && releaseAt is null)
return Result.Fail("Kies een datum waarop dit item vrijkomt.");
// Blijft het item in dezelfde soort, dan is niet-actief geen bezwaar: het is zijn eigen item.
var soortControle = await ControleerSoortAsync(user, category, payload, nieuw: false,
aanbodControleren: item.Category != category, ct);
if (!soortControle.Succeeded) return Result.Fail(soortControle.Error!);
var kanBespreken = await KanBesprekenMetAsync(category, ct);
var now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey();
@@ -147,7 +160,7 @@ public class VaultService
item.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey);
item.ReleasePolicy = release;
item.ReleaseAt = releaseAt;
item.SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null;
item.SharedManagerPersonId = kanBespreken ? sharedManagerPersonId : null;
item.UpdatedAt = now;
// Eerder verleende toegang was op de oude sleutel; die is nu waardeloos. Wie het item
@@ -320,6 +333,66 @@ public class VaultService
}
}
/// <summary>
/// Bij welke soorten kluisitems een beheerder te kiezen is om vooraf mee te bespreken. Dat is
/// een instelling per soort die de beheerder op de website zet; voorheen stond hij vast op
/// uitvaartwens en donorcodicil, en dat zijn ook de startwaarden. Zonder soortenlijst (een
/// oude opzet, of een test) geldt nog die oude regel.
/// </summary>
private async Task<bool> KanBesprekenMetAsync(VaultCategory category, CancellationToken ct)
{
if (_soorten is null)
return category is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil;
return (await _soorten.VindAsync((int)category, ct))?.BesprekenMet == true;
}
/// <summary>
/// Kijkt of dit soort item gemaakt of gewijzigd mag worden en maakt de eigen velden schoon.
///
/// <paramref name="aanbodControleren"/>: wordt de soort nog aangeboden aan deze persoon? Dat
/// geldt bij een nieuw item, en bij een item dat van soort wisselt. Een bestaand item dat in
/// zijn eigen soort blijft, hoeft dat niet: wie een soort al gebruikt, houdt hem.
///
/// Een door de beheerder gemaakte soort bewaart zijn velden in <see cref="VaultPayload.Velden"/>.
/// Komt er bij het wijzigen niets mee, dan is dat een app die de soort niet kent - die zou de
/// ingevulde gegevens overschrijven met niets, dus dat weigeren we.
/// </summary>
private async Task<Result> ControleerSoortAsync(User user, VaultCategory category, VaultPayload payload,
bool nieuw, bool aanbodControleren, CancellationToken ct)
{
if (_soorten is null) return Result.Ok();
VaultCategoryDefinition? soort;
if (aanbodControleren)
{
var mag = await _soorten.MagGebruikenAsync(user.PersonId, (int)category, ct);
if (!mag.Succeeded) return Result.Fail(mag.Error!);
soort = mag.Value;
}
else
{
soort = await _soorten.VindAsync((int)category, ct);
if (soort is null) return Result.Ok(); // een nummer dat nergens meer bij hoort: niet in de weg zitten
}
if (soort!.Ingebouwd)
{
payload.Velden = null;
return Result.Ok();
}
if (!nieuw && payload.Velden is null)
return Result.Fail("Dit item kan alleen met een bijgewerkte versie van de app worden gewijzigd. Werk de app bij en probeer het opnieuw.");
var schoon = VaultCategoryService.ControleerVelden(
soort, payload.Velden?.ToDictionary(v => v.Key, v => (string?)v.Value));
if (!schoon.Succeeded) return Result.Fail(schoon.Error!);
payload.Velden = schoon.Value;
return Result.Ok();
}
/// <summary>
/// Geeft de gekozen nalatenschapsbeheerder van een Uitvaartwens meteen leestoegang tot dit
/// item, zodat het al vóór een overlijden te bespreken is. Alleen als de gekozen persoon
@@ -327,14 +400,6 @@ public class VaultService
/// is er geen datasleutel om de contentsleutel opnieuw mee in te pakken). Ontbreekt een van
/// beide, dan wordt de keuze stil overgeslagen; het item zelf blijft wel bewaard staan.
/// </summary>
/// <summary>
/// Bij welke soorten kluisitems een beheerder te kiezen is om vooraf mee te bespreken. Op dit
/// moment een uitvaartwens en een donorcodicil - allebei dingen waar je iemand graag vooraf
/// bij betrekt, in plaats van dat diegene het pas na een overlijden voor het eerst ziet.
/// </summary>
private static bool KanBesprekenMet(VaultCategory category) =>
category is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil;
private async Task DeelMetBeheerderAsync(VaultItem item, User owner, long beheerderPersonId, CancellationToken ct)
{
if (_legacy is not null)
@@ -333,6 +333,21 @@ public class MijnBesluitClient : IAsyncDisposable
public Task<Uitkomst<IReadOnlyList<KluisItem>>> KluisAsync(int? categorie = null, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<KluisItem>>(SocketActions.VaultList, new { category = categorie }, ct);
/// <summary>
/// De soorten kluisitems die ik te zien krijg. Faalt dit of komt er niets terug (een oudere
/// server), dan hoort het scherm op zijn ingebouwde lijst terug te vallen - zie
/// <see cref="IngebouwdeSoorten"/>.
/// </summary>
public Task<Uitkomst<IReadOnlyList<KluisSoort>>> KluisSoortenAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<KluisSoort>>(SocketActions.VaultCategories, null, ct);
/// <summary>
/// Eén soort op nummer, met zijn velden - ook als die soort niet meer wordt aangeboden. Voor het
/// lezen van het item van een ander, waar je eigen lijst de soort niet hoeft te kennen.
/// </summary>
public Task<Uitkomst<KluisSoort>> KluisSoortAsync(int soortId, CancellationToken ct = default) =>
StuurAsync<KluisSoort>(SocketActions.VaultCategory, new { id = soortId }, ct);
public Task<Uitkomst<KluisInhoud>> KluisOpenenAsync(long itemId, CancellationToken ct = default) =>
StuurAsync<KluisInhoud>(SocketActions.VaultOpen, new { id = itemId }, ct);
+80 -1
View File
@@ -246,7 +246,58 @@ public record KluisItem(
int ReleasePolicy,
DateTime? ReleaseAt,
DateTime UpdatedAt,
long? SharedManagerPersonId = null);
long? SharedManagerPersonId = null,
string? CategoryName = null)
{
/// <summary>
/// Hoe de soort heet. De server stuurt de naam mee, zodat een soort die de beheerder zelf
/// maakte ook in een oudere lijst een naam heeft; ontbreekt hij, dan geldt de ingebouwde naam
/// van het nummer.
/// </summary>
public string SoortNaam(Func<int, string> ingebouwd) =>
string.IsNullOrWhiteSpace(CategoryName) ? ingebouwd(Category) : CategoryName;
}
/// <summary>
/// Een soort kluisitem zoals de server hem aanbiedt. Een ingebouwde soort (nummer 1 tot en met 11
/// en 99) heeft zijn invulscherm in de app en geen <see cref="Velden"/>; een soort die de
/// beheerder maakte heeft een nummer vanaf 100 en beschrijft zijn formulier hier.
/// </summary>
public record KluisSoort(int Id, string Naam, string? Uitleg, bool BesprekenMet, bool Ingebouwd,
IReadOnlyList<KluisVeld> Velden);
/// <summary>Eén veld van het formulier van een soort die de beheerder maakte.</summary>
public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyList<string> Opties,
string? Hulptekst, bool Verplicht, bool Actief)
{
public const string Tekst = "tekst";
public const string Lang = "lang";
public const string Datum = "datum";
public const string Keuze = "keuze";
public const string Uitleg = "uitleg";
}
/// <summary>
/// De ingebouwde soorten kluisitems, voor als de server er geen lijst van geeft: een oudere
/// server, of geen verbinding op het moment dat het scherm opent. Dezelfde nummers, namen en
/// volgorde als de startrijen in db/21_kluissoorten.sql; een controle in de zelftest bewaakt dat
/// ze gelijk blijven.
/// </summary>
public static class IngebouwdeSoorten
{
private static KluisSoort S(int id, string naam, bool bespreken = false) =>
new(id, naam, null, bespreken, true, Array.Empty<KluisVeld>());
public static readonly IReadOnlyList<KluisSoort> Lijst = new[]
{
S(3, "Wachtwoord"), S(8, "Sociale media"), S(4, "Apparaat"), S(5, "Pincode"), S(2, "Polis"),
S(1, "Uitvaartwens", true), S(9, "Donorcodicil", true), S(10, "Rijbewijs"), S(11, "Paspoort"),
S(6, "Document"), S(7, "Notitie"), S(99, "Overig")
};
/// <summary>De naam bij een nummer; "Overig" voor wat we niet kennen.</summary>
public static string Naam(int id) => Lijst.FirstOrDefault(s => s.Id == id)?.Naam ?? "Overig";
}
/// <summary>De ontsleutelde inhoud van een kluisitem. Per soort is maar een deel gevuld.</summary>
public class KluisInhoud
@@ -273,6 +324,34 @@ public class KluisInhoud
public string? Music { get; set; }
public string? Speakers { get; set; }
public string? Wishes { get; set; }
/// <summary>
/// De velden van een soort die de beheerder zelf maakte, op de vaste sleutel van het veld.
/// Bij de ingebouwde soorten blijft dit leeg.
/// </summary>
public Dictionary<string, string> Velden { get; set; } = new();
/// <summary>
/// De ingevulde velden van een soort die de beheerder maakte, met hun label, in de volgorde van
/// het formulier. Alleen wat is ingevuld; datums staan er leesbaar, zoals "12 oktober 2026".
/// Een veld dat niet meer in de soort staat, wordt niet getoond.
/// </summary>
public IEnumerable<(string Naam, string Waarde)> EigenRegels(KluisSoort? soort)
{
if (soort is null) yield break;
foreach (var veld in soort.Velden.Where(v => v.Soort != KluisVeld.Uitleg))
{
if (!Velden.TryGetValue(veld.Sleutel, out var waarde) || string.IsNullOrWhiteSpace(waarde)) continue;
if (veld.Soort == KluisVeld.Datum &&
DateOnly.TryParseExact(waarde, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dag))
waarde = $"{dag.Day} {Datums.Maanden[dag.Month - 1]} {dag.Year}";
yield return (veld.Label, waarde);
}
}
}
/// <summary>Een bijlage bij een kluisitem, zonder de inhoud zelf.</summary>
@@ -0,0 +1,117 @@
namespace MijnBesluit.Domain.Entities;
/// <summary>
/// Eén soort kluisitem zoals de beheerder die instelt: "Wachtwoord", "Polis", of een nieuwe als
/// "Niet reanimeren". Het nummer is hetzelfde nummer als <c>VaultItem.Category</c>; de elf
/// ingebouwde soorten en Overig (99) houden hun oude nummer, nieuwe soorten beginnen bij 100.
///
/// Een ingebouwde soort heeft zijn invulscherm in de app zelf en dus geen <see cref="Velden"/>.
/// Een nieuwe soort beschrijft zijn velden hier, en de app tekent het formulier daarnaar - zodat
/// een nieuwe soort geen nieuwe versie van de app vraagt.
/// </summary>
public class VaultCategoryDefinition
{
/// <summary>Vanaf dit nummer zijn soorten door de beheerder gemaakt.</summary>
public const int EersteEigenNummer = 100;
public int Id { get; set; }
public string Naam { get; set; } = string.Empty;
/// <summary>Korte uitleg die in de app onder de keuzelijst staat. Mag leeg.</summary>
public string? Uitleg { get; set; }
/// <summary>
/// Wordt de soort nog aangeboden? Niet-actief verdwijnt uit de keuzelijst van iedereen die er
/// nog nooit een item van maakte; wie dat wel deed, blijft de soort zien.
/// </summary>
public bool Actief { get; set; } = true;
public int Volgorde { get; set; } = 1000;
/// <summary>Mag de eigenaar bij dit soort een beheerder kiezen om het vooraf mee te bespreken?</summary>
public bool BesprekenMet { get; set; }
/// <summary>Invulscherm zit in de app; velden zijn hier niet te wijzigen en de soort is niet te verwijderen.</summary>
public bool Ingebouwd { get; set; }
public DateTime AangemaaktOp { get; set; } = DateTime.UtcNow;
public DateTime GewijzigdOp { get; set; } = DateTime.UtcNow;
public List<VaultCategoryField> Velden { get; set; } = new();
}
/// <summary>Een veld in het formulier van een door de beheerder gemaakte soort.</summary>
public class VaultCategoryField
{
public long Id { get; set; }
public int CategoryId { get; set; }
/// <summary>
/// De vaste naam waaronder de waarde in de versleutelde inhoud staat. Wordt bij het maken
/// van het veld uit het label afgeleid en wijzigt daarna nooit meer: een label hernoemen mag,
/// maar mag geen ingevulde gegevens losmaken van hun veld.
/// </summary>
public string Sleutel { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
/// <summary>Een van <see cref="VeldSoort"/>. Verandert na het maken niet meer.</summary>
public string Soort { get; set; } = VeldSoort.Tekst;
/// <summary>Bij <see cref="VeldSoort.Keuze"/>: de opties, één per regel.</summary>
public string? Opties { get; set; }
public string? Hulptekst { get; set; }
public bool Verplicht { get; set; }
/// <summary>
/// Een niet-actief veld staat niet meer in nieuwe formulieren, maar wat er al is ingevuld
/// blijft staan en wordt bij het wijzigen van zo'n item nog getoond.
/// </summary>
public bool Actief { get; set; } = true;
public int Volgorde { get; set; } = 1000;
public VaultCategoryDefinition? Category { get; set; }
/// <summary>De opties als lijst, zonder lege regels.</summary>
public IReadOnlyList<string> OptieLijst =>
string.IsNullOrWhiteSpace(Opties)
? Array.Empty<string>()
: Opties.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>De soorten invoerveld die een formulier kent.</summary>
public static class VeldSoort
{
/// <summary>Eén regel tekst.</summary>
public const string Tekst = "tekst";
/// <summary>Meerdere regels tekst.</summary>
public const string Lang = "lang";
/// <summary>Een datum; wordt als jjjj-mm-dd bewaard.</summary>
public const string Datum = "datum";
/// <summary>Kiezen uit een lijst, bijvoorbeeld Ja / Nee / Alleen na overleg.</summary>
public const string Keuze = "keuze";
/// <summary>Geen invoer maar een stukje uitleg tussen de velden.</summary>
public const string Uitleg = "uitleg";
public static readonly string[] Alle = { Tekst, Lang, Datum, Keuze, Uitleg };
public static bool Bekend(string? soort) => soort is not null && Alle.Contains(soort);
/// <summary>Hoe de soort op het scherm heet, voor in het beheerscherm.</summary>
public static string Naam(string soort) => soort switch
{
Tekst => "Korte tekst",
Lang => "Lange tekst",
Datum => "Datum",
Keuze => "Keuzelijst",
Uitleg => "Uitlegtekst (geen invoer)",
_ => soort
};
}
+2 -1
View File
@@ -73,7 +73,8 @@ public enum AuditAction
OverlijdenGemeld = 50, OverlijdenBetwist = 51, OverlijdenBevestigd = 52,
NalatenschapToegangVerleend = 60,
AdvertentieIngediend = 70, AdvertentieBetaald = 71, AdvertentieGoedgekeurd = 72,
AdvertentieAfgewezen = 73, AdvertentieGestopt = 74
AdvertentieAfgewezen = 73, AdvertentieGestopt = 74,
KluisSoortBeheerd = 80
}
/// <summary>
@@ -93,6 +93,7 @@ public static class DependencyInjection
services.AddScoped<IFamilyRepository, FamilyRepository>();
services.AddScoped<IInvitationRepository, InvitationRepository>();
services.AddScoped<IVaultRepository, VaultRepository>();
services.AddScoped<IVaultCategoryRepository, VaultCategoryRepository>();
services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<ILegacyRepository, LegacyRepository>();
services.AddScoped<IToestelRepository, ToestelRepository>();
@@ -103,6 +104,7 @@ public static class DependencyInjection
services.AddScoped<IAuditLog, EfAuditLog>();
services.AddScoped<AccountService>();
services.AddScoped<VaultCategoryService>();
services.AddScoped<VaultService>();
// De link naar de landingspagina voor iemand zonder account moet naar de echte site wijzen.
services.AddScoped(sp =>
@@ -18,6 +18,8 @@ public class MijnBesluitDbContext : DbContext
public DbSet<Invitation> Invitations => Set<Invitation>();
public DbSet<VaultItem> VaultItems => Set<VaultItem>();
public DbSet<VaultFile> VaultFiles => Set<VaultFile>();
public DbSet<VaultCategoryDefinition> VaultCategories => Set<VaultCategoryDefinition>();
public DbSet<VaultCategoryField> VaultCategoryFields => Set<VaultCategoryField>();
public DbSet<Toestel> Toestellen => Set<Toestel>();
public DbSet<Sessie> Sessies => Set<Sessie>();
public DbSet<ContentGrant> ContentGrants => Set<ContentGrant>();
@@ -218,6 +220,37 @@ public class MijnBesluitDbContext : DbContext
e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId);
});
b.Entity<VaultCategoryDefinition>(e =>
{
e.ToTable("vault_categories");
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Naam).HasColumnName("naam").HasMaxLength(80).IsRequired();
e.Property(x => x.Uitleg).HasColumnName("uitleg").HasMaxLength(500);
e.Property(x => x.Actief).HasColumnName("actief");
e.Property(x => x.Volgorde).HasColumnName("volgorde");
e.Property(x => x.BesprekenMet).HasColumnName("bespreken_met");
e.Property(x => x.Ingebouwd).HasColumnName("ingebouwd");
e.Property(x => x.AangemaaktOp).HasColumnName("aangemaakt_op");
e.Property(x => x.GewijzigdOp).HasColumnName("gewijzigd_op");
e.HasMany(x => x.Velden).WithOne(v => v.Category).HasForeignKey(v => v.CategoryId);
});
b.Entity<VaultCategoryField>(e =>
{
e.ToTable("vault_category_fields");
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.CategoryId).HasColumnName("category_id");
e.Property(x => x.Sleutel).HasColumnName("sleutel").HasMaxLength(40).IsRequired();
e.Property(x => x.Label).HasColumnName("label").HasMaxLength(100).IsRequired();
e.Property(x => x.Soort).HasColumnName("soort").HasMaxLength(10).IsRequired();
e.Property(x => x.Opties).HasColumnName("opties").HasMaxLength(1000);
e.Property(x => x.Hulptekst).HasColumnName("hulptekst").HasMaxLength(300);
e.Property(x => x.Verplicht).HasColumnName("verplicht");
e.Property(x => x.Actief).HasColumnName("actief");
e.Property(x => x.Volgorde).HasColumnName("volgorde");
e.Ignore(x => x.OptieLijst);
});
b.Entity<ContentGrant>(e =>
{
e.ToTable("content_grants");
@@ -229,6 +229,44 @@ public class InvitationRepository : IInvitationRepository
public void Remove(Invitation invitation) => _db.Invitations.Remove(invitation);
}
/// <summary>De soorten kluisitems; zie <see cref="IVaultCategoryRepository"/>.</summary>
public class VaultCategoryRepository : IVaultCategoryRepository
{
private readonly MijnBesluitDbContext _db;
public VaultCategoryRepository(MijnBesluitDbContext db) => _db = db;
public async Task<IReadOnlyList<VaultCategoryDefinition>> ListAsync(CancellationToken ct = default) =>
await _db.VaultCategories.Include(c => c.Velden)
.OrderBy(c => c.Volgorde).ThenBy(c => c.Naam).ToListAsync(ct);
public Task<VaultCategoryDefinition?> FindAsync(int id, CancellationToken ct = default) =>
_db.VaultCategories.Include(c => c.Velden).FirstOrDefaultAsync(c => c.Id == id, ct);
public Task<VaultCategoryField?> FindFieldAsync(long fieldId, CancellationToken ct = default) =>
_db.VaultCategoryFields.FirstOrDefaultAsync(f => f.Id == fieldId, ct);
public async Task<IReadOnlyList<SoortGebruik>> 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<IReadOnlySet<int>> 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<int> 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);
}
public class VaultRepository : IVaultRepository
{
private readonly MijnBesluitDbContext _db;
+13
View File
@@ -98,6 +98,19 @@ public static class SocketActions
/// <summary>Kluisitems die iemand rechtstreeks met jou heeft gedeeld, vooraf te bespreken.</summary>
public const string VaultSharedWithMe = "vault.shared-with-me";
/// <summary>
/// De soorten kluisitems die deze gebruiker te zien krijgt, met bij een door de beheerder gemaakte
/// soort de velden van het formulier. Een oudere server kent dit niet; de app valt dan terug op
/// de ingebouwde lijst.
/// </summary>
public const string VaultCategories = "vault.categories";
/// <summary>
/// Eén soort op nummer, met zijn velden, ook als die soort niet meer wordt aangeboden. Voor het
/// lezen van het item van een ander, waar de lijst van jezelf de soort niet hoeft te bevatten.
/// </summary>
public const string VaultCategory = "vault.category";
/// <summary>Nalatenschappen die aan jou zijn toevertrouwd, en wat daarvan is vrijgegeven.</summary>
public const string LegacyEntrusted = "legacy.entrusted";
public const string LegacyEntrustedItems = "legacy.entrusted-items";
+43 -3
View File
@@ -15,6 +15,12 @@ public class RequestScope
public required LegacyService Legacy { get; init; }
public required OverzichtService Overzicht { get; init; }
/// <summary>
/// De soorten kluisitems. Optioneel: zonder deze dienst werkt de kluis met de ingebouwde soorten
/// en krijgen items geen soortnaam mee, en de app valt terug op haar eigen lijst.
/// </summary>
public VaultCategoryService? Soorten { get; init; }
/// <summary>
/// Voor het samenvoegen van dubbele profielen. Optioneel: zonder deze dienst werkt alles,
/// alleen wordt een tweede uitnodiging dan geweigerd in plaats van samengevoegd.
@@ -76,6 +82,17 @@ public class SocketDispatcher
f.Id, f.FileName, f.ContentType, f.SizeBytes, f.CreatedAt, f.IsProfilePhoto
});
/// <summary>
/// De namen van alle soorten op nummer, om onder elk kluisitem te kunnen zetten hoe het heet.
/// Een soort die de beheerder zelf maakte staat niet in de lijst van de app zelf, dus de naam
/// reist met het item mee. Zonder soortendienst is de lijst leeg en valt de app terug.
/// </summary>
private static async Task<IReadOnlyDictionary<int, string>> SoortNamenAsync(RequestScope scope, CancellationToken ct) =>
scope.Soorten is null ? new Dictionary<int, string>() : await scope.Soorten.NamenAsync(ct);
private static string? SoortNaam(IReadOnlyDictionary<int, string> namen, int soort) =>
namen.TryGetValue(soort, out var naam) ? naam : null;
/// <summary>Voor als er geen klok wordt meegegeven; de overdracht heeft er wel een nodig.</summary>
private sealed class SysteemKlok : MijnBesluit.Application.Abstractions.IClock
{
@@ -451,12 +468,31 @@ public class SocketDispatcher
{
var p = request.PayloadAs<VaultListPayload>();
var items = await scope.Vault.ListAsync(session!.PersonId, p?.Category, ct);
var namen = await SoortNamenAsync(scope, ct);
return SocketResponse.Ok(id, items.Select(i => new
{
i.Id, i.Title, i.Category, i.ReleasePolicy, i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId
i.Id, i.Title, i.Category, i.ReleasePolicy, i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId,
CategoryName = SoortNaam(namen, (int)i.Category)
}));
}
case SocketActions.VaultCategory:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen soort opgegeven.");
var soort = scope.Soorten is null ? null : await scope.Soorten.SoortAsync((int)p.Id, ct);
return soort is null ? SocketResponse.Error(id, "Deze soort bestaat niet.") : SocketResponse.Ok(id, soort);
}
case SocketActions.VaultCategories:
{
// Zonder soortendienst een lege lijst: de app valt dan terug op haar ingebouwde soorten.
var lijst = scope.Soorten is null
? Array.Empty<KluisSoort>()
: (await scope.Soorten.ZichtbaarVoorAsync(session!.PersonId, ct)).ToArray();
return SocketResponse.Ok(id, lijst);
}
case SocketActions.VaultOpen:
{
var p = request.PayloadAs<IdPayload>();
@@ -807,10 +843,12 @@ public class SocketDispatcher
var r = await scope.Legacy.NalatenschapItemsAsync(p.PersonId, session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
var nalatenschapNamen = await SoortNamenAsync(scope, ct);
return SocketResponse.Ok(id, r.Value!.Select(i => new
{
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy,
i.ReleaseAt, i.CreatedAt
i.ReleaseAt, i.CreatedAt,
CategoryName = SoortNaam(nalatenschapNamen, (int)i.Category)
}));
}
@@ -828,10 +866,12 @@ public class SocketDispatcher
var r = await scope.Vault.ListSharedWithMeAsync(session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
var gedeeldNamen = await SoortNamenAsync(scope, ct);
return SocketResponse.Ok(id, r.Value!.Select(i => new
{
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy,
i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId
i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId,
CategoryName = SoortNaam(gedeeldNamen, (int)i.Category)
}));
}
@@ -44,6 +44,7 @@ public class SocketServerService : BackgroundService
Accounts = scope.ServiceProvider.GetRequiredService<AccountService>(),
Families = scope.ServiceProvider.GetRequiredService<FamilyService>(),
Vault = scope.ServiceProvider.GetRequiredService<VaultService>(),
Soorten = scope.ServiceProvider.GetRequiredService<VaultCategoryService>(),
Messages = scope.ServiceProvider.GetRequiredService<MessageService>(),
Legacy = scope.ServiceProvider.GetRequiredService<LegacyService>(),
Meldingen = scope.ServiceProvider.GetRequiredService<MeldingService>(),
@@ -1,5 +1,7 @@
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Web.Components.Onderdelen;
@@ -14,8 +16,11 @@ namespace MijnBesluit.Web.Components.Onderdelen;
/// </summary>
public static class Kluisweergave
{
/// <summary>De ingevulde velden van een item, in de volgorde waarin je ze wilt lezen.</summary>
public static IEnumerable<(string Label, string Waarde)> Velden(VaultPayload p)
/// <summary>
/// De ingevulde velden van een item, in de volgorde waarin je ze wilt lezen. Bij een soort die
/// de beheerder zelf maakte staan de eigen velden erbij; geef daarvoor de soort mee.
/// </summary>
public static IEnumerable<(string Label, string Waarde)> Velden(VaultPayload p, KluisSoort? soort = null)
{
if (p.Website is not null) yield return ("Website", p.Website);
if (p.Username is not null) yield return ("Gebruikersnaam", p.Username);
@@ -33,9 +38,35 @@ public static class Kluisweergave
if (p.Music is not null) yield return ("Muziek", p.Music);
if (p.Speakers is not null) yield return ("Sprekers", p.Speakers);
if (p.Wishes is not null) yield return ("Wensen", p.Wishes);
foreach (var eigen in EigenVelden(p, soort)) yield return eigen;
if (p.Notes is not null) yield return ("Toelichting", p.Notes);
}
/// <summary>
/// De velden van een soort die de beheerder zelf maakte, met de naam die hij ze gaf. Alleen wat
/// is ingevuld; ook een veld dat inmiddels niet-actief is blijft zichtbaar zolang er iets in staat.
/// </summary>
public static IEnumerable<(string Label, string Waarde)> EigenVelden(VaultPayload p, KluisSoort? soort)
{
if (soort is null || p.Velden is null) yield break;
foreach (var veld in soort.Velden.Where(v => v.Soort != VeldSoort.Uitleg))
{
if (!p.Velden.TryGetValue(veld.Sleutel, out var waarde) || string.IsNullOrWhiteSpace(waarde)) continue;
if (veld.Soort == VeldSoort.Datum &&
DateOnly.TryParseExact(waarde, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dag))
waarde = $"{dag.Day} {Datums.Maanden[dag.Month - 1]} {dag.Year}";
yield return (veld.Label, waarde);
}
}
/// <summary>De naam van een soort: die van de beheerder als de server hem kent, anders de vaste.</summary>
public static string Naam(VaultCategory c, IReadOnlyDictionary<int, string>? namen) =>
namen is not null && namen.TryGetValue((int)c, out var naam) ? naam : Naam(c);
public static string Naam(VaultCategory c) => c switch
{
VaultCategory.Uitvaartwens => "Uitvaartwens",
@@ -31,6 +31,10 @@ else
hoge uitzondering. *@
<p><a href="/beheer/adverteren">Advertenties beheren &rsaquo;</a></p>
@* Kluissoorten: wat mensen in hun kluis kunnen vastleggen, en wat je daar zelf bij zet.
Ook een eigen scherm: dat is inrichten, geen terugdraaien. *@
<p><a href="/beheer/soorten">Kluissoorten beheren &rsaquo;</a></p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@* ------------------------------------------------------------ statistieken *@
@@ -0,0 +1,486 @@
@page "/beheer/soorten"
@attribute [Authorize]
@inject BeheerService Beheerkant
@inject VaultCategoryService SoortService
@rendermode InteractiveServer
<PageTitle>Kluissoorten - Beheer</PageTitle>
@* Het beheerscherm voor de soorten kluisitems: wat mensen in hun kluis kunnen vastleggen
(Wachtwoord, Polis, Uitvaartwens, en wat je er zelf bij zet, zoals "Niet reanimeren").
Drie regels, in de volgorde waarin ze ertoe doen:
1. Wat in gebruik is, verdwijnt nooit. Een soort met items is alleen op niet-actief te
zetten; wie hem al gebruikt blijft hem zien, wie niet ziet hem niet meer.
2. Je ziet hier aantallen, nooit inhoud of namen. Hoe vaak is een tel, wie en wat niet.
3. Een nieuwe soort vraagt geen nieuwe versie van de app: de app tekent het formulier naar de
velden die je hier opgeeft.
Zie docs/Kluissoorten.md. *@
@if (!Magdit)
{
<h1>Niet gevonden</h1>
<p class="zacht">Deze pagina bestaat niet, of hij is niet voor jou.</p>
}
else
{
<h1>Kluissoorten</h1>
<p class="zacht">
Wat mensen in hun kluis kunnen vastleggen. Een nieuwe soort verschijnt meteen in de app en op
de website, zonder dat er een nieuwe versie van de app nodig is. Bij elke soort zie je hoe
vaak hij gebruikt wordt; wat erin staat zie je nooit.
</p>
<p><a href="/beheer">&lsaquo; Terug naar beheer</a></p>
<div class="melding let-op">
<strong>De naam van een soort is leesbaar voor de server.</strong> Alleen de inhoud van een
kluisitem is versleuteld, de naam van de soort niet. Wie "Dementieverklaring" als soort
aanmaakt, zet dat woord dus in de database. Kies een neutrale naam als de soort zelf iets
over iemands gezondheid zegt, en zet het gevoelige deel in de velden: die waarden blijven
versleuteld.
</div>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@* ------------------------------------------------------------ nieuwe soort *@
<div class="kaart">
<h2 style="margin-top:0">Nieuwe soort</h2>
<div class="raster twee">
<div class="veld">
<label for="nieuwenaam">Naam</label>
<input id="nieuwenaam" type="text" maxlength="80" @bind="NieuweSoort.Naam" @bind:event="oninput"
placeholder="Bijvoorbeeld: Niet reanimeren" />
</div>
<div class="veld">
<label for="nieuweuitleg">Uitleg onder de keuzelijst (mag leeg)</label>
<input id="nieuweuitleg" type="text" maxlength="500" @bind="NieuweSoort.Uitleg" @bind:event="oninput" />
</div>
</div>
<div class="keuze">
<input type="checkbox" id="nieuwbespreken" @bind="NieuweSoort.BesprekenMet" />
<label for="nieuwbespreken">Bij deze soort kan de eigenaar iemand kiezen om het vooraf mee te bespreken</label>
</div>
<div class="rij" style="margin-top:.8rem">
<button class="knop" disabled="@Bezig" @onclick="Aanmaken">Soort toevoegen</button>
</div>
<p class="zacht klein" style="margin-bottom:0">
Na het toevoegen kun je de velden van het formulier invullen en bepalen of de soort
actief is. Een soort zonder velden toont alleen de titel en de toelichting.
</p>
</div>
@* ------------------------------------------------------------ de lijst *@
@if (Lijst is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else
{
<div class="stapel">
@foreach (var s in Lijst)
{
<div class="kaart">
<div class="rij">
<div class="groei">
<div class="groot">@s.Naam</div>
<div class="zacht">@Gebruik(s)</div>
<div style="margin-top:.35rem;display:flex;gap:.4rem;flex-wrap:wrap">
@if (s.Ingebouwd) { <span class="label">Ingebouwd</span> }
else { <span class="label geclaimd">Eigen soort</span> }
@if (!s.Actief) { <span class="label uitgenodigd">Niet actief</span> }
@if (s.BesprekenMet) { <span class="label">Bespreken met</span> }
</div>
</div>
<button class="knop rustig" @onclick="() => Openen(s)">
@(Open == s.Id ? "Sluiten" : "Beheren")
</button>
</div>
@if (Open == s.Id)
{
<hr />
<div class="raster twee">
<div class="veld">
<label>Naam</label>
<input type="text" maxlength="80" @bind="Bewerken.Naam" @bind:event="oninput" />
</div>
<div class="veld">
<label>Plek in de lijst</label>
<input type="number" @bind="Bewerken.Volgorde" />
<div class="uitleg">Kleinste nummer staat bovenaan.</div>
</div>
</div>
<div class="veld">
<label>Uitleg onder de keuzelijst</label>
<input type="text" maxlength="500" @bind="Bewerken.Uitleg" @bind:event="oninput" />
</div>
<div class="keuze">
<input type="checkbox" id="actief@(s.Id)" @bind="Bewerken.Actief" />
<label for="actief@(s.Id)">Actief: nieuwe gebruikers kunnen deze soort kiezen</label>
</div>
@if (!Bewerken.Actief)
{
<p class="zacht klein" style="margin:.2rem 0 .6rem 1.7rem">
Wie al een item van deze soort heeft, blijft hem zien en kan zijn items gewoon
bekijken en wijzigen. Voor iedereen die hem nog nooit gebruikte verdwijnt hij uit de lijst.
</p>
}
<div class="keuze">
<input type="checkbox" id="bespreken@(s.Id)" @bind="Bewerken.BesprekenMet" />
<label for="bespreken@(s.Id)">Bespreken met: de eigenaar kan iemand kiezen om dit vooraf mee te bespreken</label>
</div>
<div class="rij" style="margin-top:.8rem">
<button class="knop" disabled="@Bezig" @onclick="() => Opslaan(s.Id)">Wijzigingen opslaan</button>
@if (s.KanVerwijderen)
{
<button class="knop rustig" @onclick="() => WeghalenSoort = s.Id">Soort verwijderen</button>
}
</div>
@if (!s.KanVerwijderen)
{
<p class="zacht klein" style="margin-bottom:0">
@if (s.Ingebouwd) { <text>Een ingebouwde soort kun je niet verwijderen, alleen niet-actief maken.</text> }
else { <text>Deze soort is in gebruik en kan dus niet worden verwijderd. Zet hem op niet-actief als je hem niet meer wilt aanbieden.</text> }
</p>
}
@if (WeghalenSoort == s.Id)
{
<div class="melding let-op" style="margin-top:.9rem">
<strong>@s.Naam</strong> verwijderen? Dit kan, omdat nog niemand er een item mee heeft gemaakt.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" @onclick="() => Verwijderen(s.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => WeghalenSoort = null">Nee, bewaren</button>
</div>
</div>
}
<hr />
<h3>Velden van het formulier</h3>
@if (s.Ingebouwd)
{
<p class="zacht">
Het invulscherm van een ingebouwde soort zit in de app zelf en is hier niet te
wijzigen. Wil je voor zoiets als "Polis" andere velden, maak dan een eigen soort.
</p>
}
else
{
@if (s.Velden.Count == 0)
{
<p class="zacht">Nog geen velden. Zonder velden vult iemand alleen een titel en een toelichting in.</p>
}
<div class="stapel">
@foreach (var v in s.Velden)
{
<div class="kaart" style="background:var(--vlak-zacht)">
<div class="rij">
<div class="groei">
<div>@v.Label</div>
<div class="zacht klein">
@VeldSoort.Naam(v.Soort)
@if (v.Verplicht) { <text> &middot; verplicht</text> }
@if (!v.Actief) { <text> &middot; niet actief</text> }
</div>
</div>
<button class="knop rustig" @onclick="() => VeldOpenen(v)">
@(VeldBewerkId == v.Id ? "Sluiten" : "Wijzigen")
</button>
</div>
@if (VeldBewerkId == v.Id)
{
<div class="veld" style="margin-top:.8rem">
<label>Naam van het veld</label>
<input type="text" maxlength="100" @bind="VeldBewerken.Label" @bind:event="oninput" />
<div class="uitleg">
De naam hernoemen mag: wat mensen al hebben ingevuld blijft bij het veld horen.
Het soort veld ligt vast.
</div>
</div>
@if (v.Soort == VeldSoort.Keuze)
{
<div class="veld">
<label>Keuzes, één per regel</label>
<textarea rows="4" @bind="VeldBewerken.Opties" @bind:event="oninput"></textarea>
</div>
}
<div class="veld">
<label>@(v.Soort == VeldSoort.Uitleg ? "Tekst" : "Hulptekst onder het veld")</label>
<input type="text" maxlength="300" @bind="VeldBewerken.Hulptekst" @bind:event="oninput" />
</div>
<div class="raster twee">
<div class="veld">
<label>Plek in het formulier</label>
<input type="number" @bind="VeldBewerken.Volgorde" />
</div>
<div>
@if (v.Soort != VeldSoort.Uitleg)
{
<div class="keuze">
<input type="checkbox" id="vv@(v.Id)" @bind="VeldBewerken.Verplicht" />
<label for="vv@(v.Id)">Verplicht</label>
</div>
}
<div class="keuze">
<input type="checkbox" id="va@(v.Id)" @bind="VeldBewerken.Actief" />
<label for="va@(v.Id)">Actief</label>
</div>
</div>
</div>
<div class="rij">
<button class="knop" disabled="@Bezig" @onclick="() => VeldOpslaan(v.Id)">Veld opslaan</button>
<button class="knop rustig" @onclick="() => WeghalenVeld = v.Id">Veld verwijderen</button>
</div>
<p class="zacht klein" style="margin-bottom:0">
@if (s.Items > 0)
{
<text>Er zijn al items van deze soort: een veld verwijderen kan dan niet. Zet het op niet-actief; wat er is ingevuld blijft bewaard.</text>
}
else
{
<text>Er zijn nog geen items van deze soort, dus je kunt het veld nog verwijderen.</text>
}
</p>
@if (WeghalenVeld == v.Id)
{
<div class="melding let-op" style="margin-top:.7rem">
<strong>@v.Label</strong> verwijderen?
<div class="rij" style="margin-top:.6rem">
<button class="knop gevaar" @onclick="() => VeldVerwijderen(v.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => WeghalenVeld = null">Nee, bewaren</button>
</div>
</div>
}
}
</div>
}
</div>
@* ---------------------------------------------------- veld toevoegen *@
<h3 style="margin-top:1.2rem">Veld toevoegen</h3>
<div class="raster twee">
<div class="veld">
<label>Naam van het veld</label>
<input type="text" maxlength="100" @bind="NieuwVeld.Label" @bind:event="oninput"
placeholder="Bijvoorbeeld: Wil je gereanimeerd worden?" />
</div>
<div class="veld">
<label>Soort veld</label>
<select @bind="NieuwVeld.Soort">
@foreach (var soort in VeldSoort.Alle)
{
<option value="@soort">@VeldSoort.Naam(soort)</option>
}
</select>
</div>
</div>
@if (NieuwVeld.Soort == VeldSoort.Keuze)
{
<div class="veld">
<label>Keuzes, één per regel</label>
<textarea rows="4" @bind="NieuwVeld.Opties" @bind:event="oninput"
placeholder="Ja&#10;Nee&#10;Alleen na overleg"></textarea>
</div>
}
<div class="veld">
<label>@(NieuwVeld.Soort == VeldSoort.Uitleg ? "Tekst" : "Hulptekst onder het veld (mag leeg)")</label>
<input type="text" maxlength="300" @bind="NieuwVeld.Hulptekst" @bind:event="oninput" />
</div>
@if (NieuwVeld.Soort != VeldSoort.Uitleg)
{
<div class="keuze">
<input type="checkbox" id="nieuwverplicht@(s.Id)" @bind="NieuwVeld.Verplicht" />
<label for="nieuwverplicht@(s.Id)">Verplicht</label>
</div>
}
<div class="rij" style="margin-top:.8rem">
<button class="knop" disabled="@Bezig" @onclick="() => VeldToevoegen(s.Id)">Veld toevoegen</button>
</div>
}
}
</div>
}
</div>
}
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId;
private bool Magdit, Bezig;
private string? Melding, MeldingSoort;
private IReadOnlyList<SoortBeheerbeeld>? Lijst;
/// <summary>Welke soort openstaat voor beheer.</summary>
private int? Open;
private int? WeghalenSoort;
private long? VeldBewerkId, WeghalenVeld;
private SoortFormulier NieuweSoort = new();
private SoortFormulier Bewerken = new();
private VeldFormulier NieuwVeld = new();
private VeldFormulier VeldBewerken = new();
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
_gebruikerId = (await AuthState).User.UserId() ?? 0;
Magdit = await Beheerkant.IsBeheerderAsync(_gebruikerId);
if (!Magdit) return;
await Laden();
}
private async Task Laden()
{
var resultaat = await SoortService.OverzichtAsync(_gebruikerId);
if (resultaat.Succeeded) Lijst = resultaat.Value;
else Meld(resultaat.Error, false);
}
private void Meld(string? tekst, bool goed)
{
Melding = tekst;
MeldingSoort = goed ? "goed" : "fout";
}
private static string Aantal(int n, string een, string meer) => $"{n} {(n == 1 ? een : meer)}";
private static string Gebruik(SoortBeheerbeeld s) => s.Items == 0
? "Nog nooit gebruikt"
: $"Gebruikt in {Aantal(s.Items, "item", "items")} door {Aantal(s.Eigenaren, "persoon", "mensen")}";
private void Openen(SoortBeheerbeeld s)
{
if (Open == s.Id) { Open = null; return; }
Open = s.Id;
WeghalenSoort = null;
VeldBewerkId = null;
WeghalenVeld = null;
NieuwVeld = new VeldFormulier();
Bewerken = new SoortFormulier
{
Naam = s.Naam, Uitleg = s.Uitleg ?? "", Actief = s.Actief,
Volgorde = s.Volgorde, BesprekenMet = s.BesprekenMet
};
}
private async Task Aanmaken()
{
Bezig = true;
try
{
var resultaat = await SoortService.MaakAsync(_gebruikerId, NieuweSoort.Naam, NieuweSoort.Uitleg,
NieuweSoort.BesprekenMet);
if (resultaat.Succeeded)
{
Meld($"{NieuweSoort.Naam.Trim()} is toegevoegd. Vul nu de velden in.", true);
NieuweSoort = new SoortFormulier();
await Laden();
var gemaakt = Lijst?.FirstOrDefault(x => x.Id == resultaat.Value);
if (gemaakt is not null) { Open = null; Openen(gemaakt); }
}
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task Opslaan(int id)
{
Bezig = true;
try
{
var resultaat = await SoortService.WijzigAsync(_gebruikerId, id, Bewerken.Naam, Bewerken.Uitleg,
Bewerken.Actief, Bewerken.Volgorde, Bewerken.BesprekenMet);
if (resultaat.Succeeded) { Meld("Opgeslagen.", true); await Laden(); }
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task Verwijderen(int id)
{
var resultaat = await SoortService.VerwijderAsync(_gebruikerId, id);
WeghalenSoort = null;
if (resultaat.Succeeded) { Open = null; Meld("De soort is verwijderd.", true); await Laden(); }
else Meld(resultaat.Error, false);
}
private void VeldOpenen(VeldBeheerbeeld v)
{
if (VeldBewerkId == v.Id) { VeldBewerkId = null; return; }
VeldBewerkId = v.Id;
WeghalenVeld = null;
VeldBewerken = new VeldFormulier
{
Label = v.Label, Soort = v.Soort, Opties = v.Opties ?? "", Hulptekst = v.Hulptekst ?? "",
Verplicht = v.Verplicht, Actief = v.Actief, Volgorde = v.Volgorde
};
}
private async Task VeldToevoegen(int soortId)
{
Bezig = true;
try
{
var resultaat = await SoortService.VeldToevoegenAsync(_gebruikerId, soortId, NieuwVeld.Label, NieuwVeld.Soort,
NieuwVeld.Opties, NieuwVeld.Hulptekst, NieuwVeld.Verplicht);
if (resultaat.Succeeded) { Meld($"{NieuwVeld.Label.Trim()} is toegevoegd.", true); NieuwVeld = new VeldFormulier(); await Laden(); }
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task VeldOpslaan(long veldId)
{
Bezig = true;
try
{
var resultaat = await SoortService.VeldWijzigenAsync(_gebruikerId, veldId, VeldBewerken.Label, VeldBewerken.Opties,
VeldBewerken.Hulptekst, VeldBewerken.Verplicht,
VeldBewerken.Actief, VeldBewerken.Volgorde);
if (resultaat.Succeeded) { Meld("Het veld is opgeslagen.", true); VeldBewerkId = null; await Laden(); }
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task VeldVerwijderen(long veldId)
{
var resultaat = await SoortService.VeldVerwijderenAsync(_gebruikerId, veldId);
WeghalenVeld = null;
if (resultaat.Succeeded) { VeldBewerkId = null; Meld("Het veld is verwijderd.", true); await Laden(); }
else Meld(resultaat.Error, false);
}
public class SoortFormulier
{
public string Naam { get; set; } = "";
public string Uitleg { get; set; } = "";
public bool Actief { get; set; } = true;
public int Volgorde { get; set; }
public bool BesprekenMet { get; set; }
}
public class VeldFormulier
{
public string Label { get; set; } = "";
public string Soort { get; set; } = VeldSoort.Tekst;
public string Opties { get; set; } = "";
public string Hulptekst { get; set; } = "";
public bool Verplicht { get; set; }
public bool Actief { get; set; } = true;
public int Volgorde { get; set; }
}
}
+130 -18
View File
@@ -2,6 +2,7 @@
@attribute [Authorize]
@inject VaultService KluisService
@inject LegacyService BeheerService
@inject VaultCategoryService SoortService
@inject IJSRuntime JS
@rendermode InteractiveServer
@@ -21,9 +22,9 @@
<label for="cat">Soort</label>
<select id="cat" @bind="Filter" @bind:after="Verversen">
<option value="">Alles</option>
@foreach (var c in Categorieen)
@foreach (var s in Soorten)
{
<option value="@((int)c)">@Naam(c)</option>
<option value="@s.Id">@s.Naam</option>
}
</select>
</div>
@@ -43,9 +44,13 @@
OpBevestigen="Opslaan" OpSluiten="Annuleren">
<div class="veld">
<label for="soort">Wat wil je vastleggen?</label>
<InputSelect id="soort" @bind-Value="Nieuw.Categorie">
@foreach (var c in Categorieen) { <option value="@c">@Naam(c)</option> }
<InputSelect id="soort" @bind-Value="Nieuw.CategorieNummer">
@foreach (var s in Soorten) { <option value="@s.Id">@s.Naam</option> }
</InputSelect>
@if (!string.IsNullOrWhiteSpace(HuidigeSoort?.Uitleg))
{
<div class="uitleg">@HuidigeSoort!.Uitleg</div>
}
</div>
<div class="veld">
<label for="titel">Titel</label>
@@ -129,9 +134,20 @@
case VaultCategory.Donorcodicil:
break;
default:
@* Een soort die de beheerder zelf heeft gemaakt: het formulier komt uit zijn velden. *@
@if (HuidigeSoort is { Ingebouwd: false } eigen)
{
@foreach (var veld in eigen.Velden.Where(v => v.Actief || HeeftWaarde(v)))
{
@VeldInvoer(veld)
}
}
break;
}
@if (Nieuw.Categorie is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil)
@if (BesprekenMetAan)
{
<div class="veld">
<label>Bespreken met</label>
@@ -256,7 +272,7 @@ else
{
<hr />
<dl style="margin:0">
@foreach (var (label, waarde) in Velden(Inhoud))
@foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud, GeopendSoort))
{
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:150px">@label</span>
@@ -358,13 +374,85 @@ else
/// <summary>Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.</summary>
private const int MaxTegelijk = 10;
private static readonly VaultCategory[] Categorieen =
/// <summary>
/// De soorten die deze persoon te zien krijgt: de actieve, plus de niet-actieve waar hij al een
/// item van heeft. De lijst komt van de server en niet meer uit de code, zodat de beheerder er
/// een soort bij kan zetten zonder dat er iets uitgerold hoeft te worden.
/// </summary>
private IReadOnlyList<KluisSoort> Soorten = Array.Empty<KluisSoort>();
/// <summary>Alle namen op nummer, ook van soorten die niet meer actief zijn.</summary>
private IReadOnlyDictionary<int, string> Namen = new Dictionary<int, string>();
/// <summary>De soort van het item dat openstaat, voor de namen van zijn eigen velden.</summary>
private KluisSoort? GeopendSoort;
private KluisSoort? HuidigeSoort => Soorten.FirstOrDefault(s => s.Id == (int)Nieuw.Categorie);
/// <summary>
/// "Bespreken met": bij een eigen soort bepaalt de beheerder dat per soort. Zonder soortenlijst
/// (die is er altijd, maar voor de zekerheid) geldt de oude regel.
/// </summary>
private bool BesprekenMetAan =>
HuidigeSoort?.BesprekenMet ?? Nieuw.Categorie is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil;
private async Task SoortenLaden()
{
VaultCategory.Uitvaartwens, VaultCategory.Donorcodicil, VaultCategory.Polis, VaultCategory.Wachtwoord,
VaultCategory.SocialMedia, VaultCategory.Apparaat, VaultCategory.Pincode,
VaultCategory.Rijbewijs, VaultCategory.Paspoort,
VaultCategory.Document, VaultCategory.Notitie, VaultCategory.Overig
};
Soorten = await SoortService.ZichtbaarVoorAsync(_persoonId);
Namen = await SoortService.NamenAsync();
}
/// <summary>Een leeg nieuw item, in de eerste soort die bruikbaar is (liefst uitvaartwens).</summary>
private NieuwItem LeegItem()
{
var soort = Soorten.FirstOrDefault(s => s.Id == (int)VaultCategory.Uitvaartwens) ?? Soorten.FirstOrDefault();
return soort is null ? new NieuwItem() : new NieuwItem { CategorieNummer = soort.Id };
}
private string Waarde(string sleutel) => Nieuw.Velden.TryGetValue(sleutel, out var w) ? w : "";
private void Zet(string sleutel, string? waarde) => Nieuw.Velden[sleutel] = waarde ?? "";
private bool HeeftWaarde(KluisVeld veld) => !string.IsNullOrWhiteSpace(Waarde(veld.Sleutel));
/// <summary>Eén invoerveld van een eigen soort, getekend naar de beschrijving van de beheerder.</summary>
private RenderFragment VeldInvoer(KluisVeld veld) => @<div class="veld">
@if (veld.Soort == VeldSoort.Uitleg)
{
<div class="uitleg">@veld.Hulptekst</div>
}
else
{
<label>@veld.Label@(veld.Verplicht ? " *" : "")</label>
@if (veld.Soort == VeldSoort.Lang)
{
<textarea rows="4" value="@Waarde(veld.Sleutel)"
@oninput="e => Zet(veld.Sleutel, e.Value?.ToString())"></textarea>
}
else if (veld.Soort == VeldSoort.Datum)
{
<input type="date" value="@Waarde(veld.Sleutel)"
@onchange="e => Zet(veld.Sleutel, e.Value?.ToString())" />
}
else if (veld.Soort == VeldSoort.Keuze)
{
<select @onchange="e => Zet(veld.Sleutel, e.Value?.ToString())">
<option value="" selected="@(Waarde(veld.Sleutel) == "")">Nog niet gekozen</option>
@foreach (var optie in veld.Opties)
{
<option value="@optie" selected="@(Waarde(veld.Sleutel) == optie)">@optie</option>
}
</select>
}
else
{
<input type="text" value="@Waarde(veld.Sleutel)"
@oninput="e => Zet(veld.Sleutel, e.Value?.ToString())" />
}
@if (!string.IsNullOrWhiteSpace(veld.Hulptekst))
{
<div class="uitleg">@veld.Hulptekst</div>
}
}
</div>;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -382,6 +470,8 @@ else
_gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0;
Beheerders = await BeheerService.ListManagersAsync(_persoonId);
await SoortenLaden();
Nieuw = LeegItem();
await Verversen();
}
@@ -402,6 +492,8 @@ else
{
Geopend = id;
Inhoud = resultaat.Value;
var gekozen = Items?.FirstOrDefault(i => i.Id == id);
GeopendSoort = gekozen is null ? null : await SoortService.SoortAsync((int)gekozen.Category);
Melding = null;
await BijlagenLaden();
}
@@ -514,6 +606,7 @@ else
Muziek = p.Music ?? "",
Sprekers = p.Speakers ?? "",
Wensen = p.Wishes ?? "",
Velden = p.Velden is null ? new Dictionary<string, string>() : new Dictionary<string, string>(p.Velden),
BesprekenMetPersonId = item.SharedManagerPersonId,
Vrijgave = item.ReleasePolicy,
VrijgaveDatum = item.ReleaseAt is { } r ? DateOnly.FromDateTime(r) : null
@@ -533,7 +626,7 @@ else
private void Beginnen()
{
BewerkId = 0;
Nieuw = new NieuwItem();
Nieuw = LeegItem();
Bijlagen = null;
ToonFormulier = true;
}
@@ -541,7 +634,7 @@ else
private void Annuleren()
{
BewerkId = 0;
Nieuw = new NieuwItem();
Nieuw = LeegItem();
Bijlagen = null;
ToonFormulier = false;
}
@@ -575,7 +668,13 @@ else
Location = Leeg(Nieuw.Locatie),
Music = Leeg(Nieuw.Muziek),
Speakers = Leeg(Nieuw.Sprekers),
Wishes = Leeg(Nieuw.Wensen)
Wishes = Leeg(Nieuw.Wensen),
// Alleen voor een soort die de beheerder zelf maakte. Wat hier ook staat, de server
// houdt alleen de velden over die bij de soort horen en controleert ze nog eens.
Velden = HuidigeSoort is { Ingebouwd: false }
? Nieuw.Velden.Where(v => !string.IsNullOrWhiteSpace(v.Value))
.ToDictionary(v => v.Key, v => v.Value)
: null
};
DateTime? datum = Nieuw.Vrijgave == ReleasePolicy.OpDatum && Nieuw.VrijgaveDatum is { } d
@@ -600,9 +699,10 @@ else
Melding = BewerkId == 0 ? "Opgeslagen in je kluis." : "De wijziging is opgeslagen.";
MeldingSoort = "goed";
BewerkId = 0;
Nieuw = new NieuwItem();
Nieuw = LeegItem();
Bijlagen = null;
ToonFormulier = false;
await SoortenLaden();
await Verversen();
}
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
@@ -615,8 +715,7 @@ else
// Velden() en Naam() staan in Onderdelen/Kluisweergave.cs: de nalatenschapspagina toont
// precies hetzelfde, en twee kopieën van die lijst lopen vroeg of laat uit elkaar.
private static IEnumerable<(string, string)> Velden(VaultPayload p) => Kluisweergave.Velden(p);
private static string Naam(VaultCategory c) => Kluisweergave.Naam(c);
private string Naam(VaultCategory c) => Kluisweergave.Naam(c, Namen);
private static string Voorbeeld(VaultCategory c) => c switch
{
@@ -640,6 +739,19 @@ else
public class NieuwItem
{
public VaultCategory Categorie { get; set; } = VaultCategory.Uitvaartwens;
/// <summary>
/// Dezelfde keuze als nummer. De keuzelijst bindt hieraan en niet aan de enum: een soort
/// die de beheerder maakte heeft geen naam in de enum, alleen een nummer.
/// </summary>
public int CategorieNummer
{
get => (int)Categorie;
set => Categorie = (VaultCategory)value;
}
/// <summary>De ingevulde velden van een soort die de beheerder zelf maakte, op sleutel.</summary>
public Dictionary<string, string> Velden { get; set; } = new();
public string Titel { get; set; } = "";
public string? Notitie { get; set; }
public string? Website { get; set; }
@@ -7,6 +7,7 @@
@inject ILegacyRepository LegacyOpslag
@inject IPersonRepository Personen
@inject VaultService Kluis
@inject VaultCategoryService SoortService
@rendermode InteractiveServer
<PageTitle>Nalatenschap - MijnBesluit</PageTitle>
@@ -126,7 +127,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Kluisweergave.Naam(item.Category)</div>
<div class="zacht">@Kluisweergave.Naam(item.Category, Namen)</div>
</div>
<button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button>
</div>
@@ -155,7 +156,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Kluisweergave.Naam(item.Category)</div>
<div class="zacht">@Kluisweergave.Naam(item.Category, Namen)</div>
</div>
<button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button>
</div>
@@ -173,7 +174,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
<Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen">
<div class="stapel">
@foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud))
@foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud, GeopendSoort))
{
<div class="veld">
<label>@label</label>
@@ -388,6 +389,12 @@ else if (Stand.State == DeathProcedureState.Lopend)
private IReadOnlyList<VaultItem>? Nagelaten;
private VaultPayload? Inhoud;
private string? GeopendTitel;
/// <summary>Namen van alle soorten op nummer, ook die de beheerder zelf maakte.</summary>
private IReadOnlyDictionary<int, string> Namen = new Dictionary<int, string>();
/// <summary>De soort van het geopende item, voor de namen van zijn eigen velden.</summary>
private KluisSoort? GeopendSoort;
private IReadOnlyList<VaultFile>? GeopendBijlagen;
protected override async Task OnInitializedAsync()
@@ -397,6 +404,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
_gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0;
Namen = await SoortService.NamenAsync();
_familieId = (await FamilieOpslag.ListOwnedByPersonAsync(_persoonId)).FirstOrDefault()?.Id ?? 0;
if (_familieId != 0) Leden = await Families.ListMembersAsync(_familieId, _gebruikerId);
@@ -465,6 +473,9 @@ else if (Stand.State == DeathProcedureState.Lopend)
Inhoud = resultaat.Value;
GeopendTitel = item.Title;
// De velden van het item van een ander: de namen zijn geen geheim, de waarden blijven versleuteld.
GeopendSoort = await SoortService.SoortAsync((int)item.Category);
// Bijlagen zijn optioneel: als ophalen mislukt (bv. geen toegang) tonen we het
// item gewoon zonder bijlagenlijst, in plaats van de hele popup te blokkeren.
var bijlagen = await Kluis.ListFilesAsync(item.Id, _gebruikerId);
@@ -476,6 +487,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
GeopendItem = null;
Inhoud = null;
GeopendTitel = null;
GeopendSoort = null;
GeopendBijlagen = null;
}