diff --git a/db/02_applicatiegebruiker.sql b/db/02_applicatiegebruiker.sql
index fd004b3..8cc48bd 100644
--- a/db/02_applicatiegebruiker.sql
+++ b/db/02_applicatiegebruiker.sql
@@ -53,6 +53,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`adverteerders` TO 'm
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`campagnes` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`campagne_uren` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`sessies` TO 'mijnbesluit_app'@'localhost';
+GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`vault_categories` TO 'mijnbesluit_app'@'localhost';
+GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`vault_category_fields` TO 'mijnbesluit_app'@'localhost';
-- Het logboek mag er alleen bij komen en uit gelezen worden, niet uit verdwijnen.
GRANT SELECT, INSERT ON `mijnbesluit`.`audit_log` TO 'mijnbesluit_app'@'localhost';
diff --git a/db/20_bericht_verbergen.sql b/db/20_bericht_verbergen.sql
index 88cb872..9e5987d 100644
--- a/db/20_bericht_verbergen.sql
+++ b/db/20_bericht_verbergen.sql
@@ -3,6 +3,33 @@
-- Wie een bezorgd bericht "verwijdert" haalt het alleen bij zichzelf weg; de ander heeft er nog
-- steeds recht op. Vandaar twee vlaggen in plaats van één verwijderdatum: de afzender en de
-- ontvanger ruimen onafhankelijk van elkaar op. De rij zelf blijft altijd bestaan.
-ALTER TABLE `messages`
- ADD COLUMN `hidden_by_sender` TINYINT(1) NOT NULL DEFAULT 0 AFTER `read_at`,
- ADD COLUMN `hidden_by_recipient` TINYINT(1) NOT NULL DEFAULT 0 AFTER `hidden_by_sender`;
+--
+-- Draaien met:
+-- mysql -u root -p mijnbesluit < db\20_bericht_verbergen.sql
+--
+-- Opnieuw draaien mag: er gebeurt dan niets. (De uitroller draait alle scripts elke keer; een
+-- script dat alleen "ADD COLUMN" zegt, loopt de tweede keer vast en houdt de rest tegen.)
+
+USE `mijnbesluit`;
+
+-- ---------------------------------------------------------------- hidden_by_sender
+SET @bestaat := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'hidden_by_sender');
+
+SET @sql := IF(@bestaat = 0,
+ 'ALTER TABLE `messages` ADD COLUMN `hidden_by_sender` TINYINT(1) NOT NULL DEFAULT 0 AFTER `read_at`',
+ 'SELECT "hidden_by_sender bestaat al" AS melding');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ---------------------------------------------------------------- hidden_by_recipient
+SET @bestaat := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'hidden_by_recipient');
+
+SET @sql := IF(@bestaat = 0,
+ 'ALTER TABLE `messages` ADD COLUMN `hidden_by_recipient` TINYINT(1) NOT NULL DEFAULT 0 AFTER `hidden_by_sender`',
+ 'SELECT "hidden_by_recipient bestaat al" AS melding');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SELECT 'Klaar. Een bezorgd bericht is vanaf nu uit je eigen lijst te verwijderen.' AS melding;
diff --git a/db/21_kluissoorten.sql b/db/21_kluissoorten.sql
new file mode 100644
index 0000000..c20d3dd
--- /dev/null
+++ b/db/21_kluissoorten.sql
@@ -0,0 +1,76 @@
+-- MijnBesluit - kluissoorten uit de database in plaats van uit de code
+--
+-- Tot nu toe stond de lijst "Wachtwoord, Polis, Uitvaartwens, ..." vast in de code, met per soort
+-- een eigen invulscherm. Elke nieuwe soort (denk aan "Dementie" of "Niet reanimeren") vroeg dus
+-- een nieuwe versie van de app in de Play Store en de App Store. Nu staan de soorten hier, en de
+-- velden van een nieuwe soort ook: de beheerder onderhoudt ze op de website (/beheer/soorten) en
+-- de app tekent het formulier naar wat de server beschrijft.
+--
+-- vault_categories de soorten. De elf ingebouwde en "Overig" (nummer 99) krijgen hier
+-- hun startrij; nieuwe soorten beginnen bij nummer 100.
+-- vault_category_fields de velden van een nieuwe soort. Ingebouwde soorten hebben hier geen
+-- rijen: hun invulscherm zit nog in de app zelf.
+--
+-- Regels die de server afdwingt (niet de database):
+-- * Een soort waar kluisitems mee zijn gemaakt kan niet worden verwijderd, alleen op niet-actief
+-- gezet. Niet-actief verdwijnt uit de keuzelijst van wie er nog nooit een item mee maakte;
+-- wie dat wel deed, blijft de soort zien.
+-- * Nieuwe items kunnen alleen in een actieve soort, of in een soort waar de eigenaar al een
+-- item van heeft.
+--
+-- vault_items.category blijft een gewoon getal; er komt bewust geen foreign key bij, zodat
+-- bestaande gegevens en oudere apps niet in de weg zitten.
+--
+-- Draaien met:
+-- mysql -u root -p mijnbesluit < db\21_kluissoorten.sql
+--
+-- Opnieuw draaien mag: er gebeurt dan niets.
+--
+-- Na dit script, eenmalig als root:
+-- mysql -u root -p < db\02_applicatiegebruiker.sql (rechten op de nieuwe tabellen)
+-- mysql -u root -p mijnbesluit < db\handmatig\21b_kluissoorten_startrijen.sql (de startrijen)
+
+USE `mijnbesluit`;
+
+-- ---------------------------------------------------------------- vault_categories
+CREATE TABLE IF NOT EXISTS `vault_categories` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `naam` VARCHAR(80) NOT NULL,
+ `uitleg` VARCHAR(500) NULL, -- verschijnt onder de keuzelijst in de app
+ `actief` TINYINT(1) NOT NULL DEFAULT 1,
+ `volgorde` INT NOT NULL DEFAULT 1000,
+ `bespreken_met` TINYINT(1) NOT NULL DEFAULT 0, -- mag de eigenaar een beheerder kiezen om vooraf mee te bespreken?
+ `ingebouwd` TINYINT(1) NOT NULL DEFAULT 0, -- 1 = invulscherm zit in de app; velden hier niet te wijzigen
+ `aangemaakt_op` DATETIME(3) NOT NULL,
+ `gewijzigd_op` DATETIME(3) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `ux_vault_categories_naam` (`naam`)
+) ENGINE=InnoDB AUTO_INCREMENT=100;
+
+-- ---------------------------------------------------------------- vault_category_fields
+CREATE TABLE IF NOT EXISTS `vault_category_fields` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `category_id` INT NOT NULL,
+ `sleutel` VARCHAR(40) NOT NULL, -- vaste naam in de versleutelde inhoud; wijzigt nooit
+ `label` VARCHAR(100) NOT NULL,
+ `soort` VARCHAR(10) NOT NULL DEFAULT 'tekst', -- tekst | lang | datum | keuze | uitleg
+ `opties` VARCHAR(1000) NULL, -- bij 'keuze': één optie per regel
+ `hulptekst` VARCHAR(300) NULL,
+ `verplicht` TINYINT(1) NOT NULL DEFAULT 0,
+ `actief` TINYINT(1) NOT NULL DEFAULT 1,
+ `volgorde` INT NOT NULL DEFAULT 1000,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `ux_vault_category_fields_sleutel` (`category_id`, `sleutel`),
+ CONSTRAINT `fk_vcf_category` FOREIGN KEY (`category_id`) REFERENCES `vault_categories` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB;
+
+-- ---------------------------------------------------------------- startrijen
+-- De startrijen (de elf ingebouwde soorten en Overig) staan NIET hier maar in
+-- handmatig\21b_kluissoorten_startrijen.sql.
+--
+-- Waarom: dit script wordt ook door de uitrolagent gedraaid, met een beperkt databaseaccount dat
+-- tabellen mag maken maar er geen gegevens in mag zetten. Een INSERT hier laat de hele uitrol
+-- struikelen (ERROR 1142: INSERT command denied). Gegevens neerzetten is inrichten, en dat doet een
+-- mens één keer als root, net als de rechten in 02_applicatiegebruiker.sql.
+
+SELECT 'Klaar. De tabellen voor kluissoorten staan klaar. Vergeet handmatig\\21b_kluissoorten_startrijen.sql en 02_applicatiegebruiker.sql niet (als root).' AS melding;
diff --git a/db/handmatig/21b_kluissoorten_startrijen.sql b/db/handmatig/21b_kluissoorten_startrijen.sql
new file mode 100644
index 0000000..ed996e4
--- /dev/null
+++ b/db/handmatig/21b_kluissoorten_startrijen.sql
@@ -0,0 +1,36 @@
+-- MijnBesluit - startrijen voor de kluissoorten (eenmalig, als root)
+--
+-- Hoort bij 21_kluissoorten.sql, dat de tabellen maakt. Dit script zet de twaalf soorten erin die
+-- er altijd al waren: de elf ingebouwde en "Overig" (nummer 99). De nummers zijn die van
+-- VaultCategory in de code en van vault_items.category; wijzig ze nooit.
+--
+-- Dit staat in de submap handmatig\ en niet in db\ zelf: de uitrolagent draait alles in db\, met een
+-- beperkt account dat geen gegevens mag invoegen. Zie de kop van 21_kluissoorten.sql.
+--
+-- Draaien met (in cmd, NIET in PowerShell):
+-- mysql -u root -p mijnbesluit < db\handmatig\21b_kluissoorten_startrijen.sql
+--
+-- Opnieuw draaien mag: bestaande soorten blijven zoals ze zijn (ook als je er een hernoemd of
+-- op niet-actief gezet hebt).
+
+USE `mijnbesluit`;
+
+-- De volgorde is die van de keuzelijst in de app. Alleen uitvaartwens en donorcodicil kennen
+-- "Bespreken met", precies zoals voorheen.
+INSERT IGNORE INTO `vault_categories`
+ (`id`, `naam`, `actief`, `volgorde`, `bespreken_met`, `ingebouwd`, `aangemaakt_op`, `gewijzigd_op`) VALUES
+ ( 3, 'Wachtwoord', 1, 10, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 8, 'Sociale media', 1, 20, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 4, 'Apparaat', 1, 30, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 5, 'Pincode', 1, 40, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 2, 'Polis', 1, 50, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 1, 'Uitvaartwens', 1, 60, 1, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 9, 'Donorcodicil', 1, 70, 1, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ (10, 'Rijbewijs', 1, 80, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ (11, 'Paspoort', 1, 90, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 6, 'Document', 1, 100, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ ( 7, 'Notitie', 1, 110, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
+ (99, 'Overig', 1, 120, 0, 1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3));
+
+-- Controle: dit hoort twaalf te zijn (of meer, als de beheerder er al een bij zette).
+SELECT COUNT(*) AS aantal_soorten FROM `vault_categories`;
diff --git a/docs/Beveiliging.md b/docs/Beveiliging.md
index 88e2a7e..fcf74fa 100644
--- a/docs/Beveiliging.md
+++ b/docs/Beveiliging.md
@@ -104,6 +104,7 @@ in plaats van dat er stilletjes onzin uit komt.
Bewust leesbaar, omdat er anders niet te zoeken of te sorteren valt:
- de titel en de categorie van een kluisitem;
+- de naam van een kluissoort die de beheerder zelf maakte (zie [Kluissoorten](Kluissoorten.md));
- het onderwerp, de afzender, de ontvanger en de bezorgdatum van een bericht;
- namen, e-mailadressen en relaties.
diff --git a/docs/Kluissoorten.md b/docs/Kluissoorten.md
new file mode 100644
index 0000000..d848012
--- /dev/null
+++ b/docs/Kluissoorten.md
@@ -0,0 +1,74 @@
+# Kluissoorten
+
+Wat iemand in zijn kluis kan vastleggen (Wachtwoord, Polis, Uitvaartwens, ... en alles wat de
+beheerder er zelf bij zet, zoals "Niet reanimeren" of "Dementie") staat in de database en niet in de
+code. Een nieuwe soort vraagt daarom **geen nieuwe versie van de app** in de Play Store of App Store.
+
+## Hoe het werkt
+
+* Tabel `vault_categories`: één rij per soort. Nummers 1-11 en 99 (Overig) zijn de ingebouwde
+ soorten en houden hun oude nummer; soorten die de beheerder maakt beginnen bij 100.
+ `vault_items.category` blijft een gewoon getal.
+* Tabel `vault_category_fields`: de velden van een eigen soort (label, soort veld, keuzes, hulptekst,
+ verplicht, actief, volgorde).
+* De app en de website vragen de lijst op bij de server (`vault.categories`) en tekenen het
+ formulier van een eigen soort naar zijn velden. Een ingebouwde soort heeft zijn invulscherm in de
+ app zelf; die velden zijn niet te wijzigen. Wil je voor iets als "Polis" andere velden, maak dan
+ een eigen soort.
+* De ingevulde waarden van een eigen soort staan versleuteld in `VaultPayload.Velden`, op de vaste
+ `sleutel` van het veld. Een label hernoemen mag dus zonder dat iemands gegevens loskomen van hun veld.
+
+## Regels
+
+1. **Wat in gebruik is verdwijnt nooit.** Een soort met kluisitems kan niet worden verwijderd,
+ alleen op niet-actief gezet. Een niet-actieve soort staat niet meer in de lijst van wie hem nog
+ nooit gebruikte; wie er al een item van heeft, ziet hem nog en kan zijn items gewoon bekijken en
+ wijzigen. Hetzelfde geldt voor een veld: met items eronder kan het alleen op niet-actief.
+2. **De beheerder ziet aantallen, nooit inhoud.** Per soort zie je hoeveel items en hoeveel mensen,
+ niet wie en niet wat.
+3. **"Bespreken met" is een instelling per soort.** Startwaarden: aan bij Uitvaartwens en
+ Donorcodicil, uit bij de rest.
+4. **De server controleert alles.** Een oudere of andere app stuurt wat hij wil: alleen bekende
+ velden blijven over, verplichte velden moeten gevuld zijn en datums moeten kloppen.
+
+## Privacy: de naam van een soort is leesbaar voor de server
+
+Alleen de *inhoud* van een kluisitem is versleuteld. De titel en de soort staan leesbaar in de
+database, net als de naam die de beheerder aan een soort geeft. Een soort die "Dementieverklaring"
+heet, zet dat woord dus in de database en laat zien hoe vaak het gebruikt wordt. Kies een neutrale
+naam als de soort zelf iets over gezondheid zegt, en zet het gevoelige deel in de velden: die
+waarden blijven versleuteld. Het beheerscherm waarschuwt hiervoor.
+
+## Beheren
+
+Website, ingelogd als beheerder: **Beheer > Kluissoorten** (`/beheer/soorten`). Daar maak je een
+soort, hernoem je hem, zet je hem actief of niet-actief, kies je de plek in de lijst, zet je
+"bespreken met" aan of uit en beheer je de velden. Elke wijziging komt in het logboek
+(`KluisSoortBeheerd`).
+
+## Uitrollen
+
+1. **Database, eenmalig als root** (in cmd, niet in PowerShell). De uitrolagent heeft een beperkt
+ account dat tabellen mag maken maar geen gegevens mag invoegen en geen rechten mag uitdelen;
+ daarom zitten deze twee stappen niet in de uitrol zelf:
+
+ ```
+ mysql -u root -p < db\02_applicatiegebruiker.sql
+ mysql -u root -p mijnbesluit < db\handmatig\21b_kluissoorten_startrijen.sql
+ ```
+
+ De eerste geeft de applicatiegebruiker rechten op de twee nieuwe tabellen (zonder die rechten
+ krijgt de server "SELECT command denied"), de tweede zet de twaalf bestaande soorten erin.
+ Beide mogen opnieuw gedraaid worden. Controle: de tweede meldt `aantal_soorten` 12.
+2. **Uitroller:** `21_kluissoorten.sql` maakt de tabellen (de uitrolagent draait dit mee),
+ daarna komen de website en de SocketServer. Draai stap 1 vóór je uitrolt: de nieuwe server leest
+ die tabellen meteen.
+3. Nieuwe versie van de app (verhoog `ApplicationVersion`) voor Android en iOS. Die versie kan het
+ formulier van een eigen soort tekenen. Oudere versies blijven werken: een eigen soort toont
+ daar als "Overig", de items zijn zichtbaar maar niet te wijzigen (de server weigert, zodat er niets
+ van de ingevulde velden verloren gaat). Alleen de soorten zelf hoeven daarna nooit meer een release.
+
+## Wat nog meer zonder release kan
+
+Niet gebouwd, wel mogelijk op dezelfde manier: een mededelingenbalk in de app, een minimaal vereiste
+appversie, de lijst met sociale media en instelwaarden (bezwaartermijn, limieten).
diff --git a/docs/Socketprotocol.md b/docs/Socketprotocol.md
index 61c3000..f717167 100644
--- a/docs/Socketprotocol.md
+++ b/docs/Socketprotocol.md
@@ -74,6 +74,8 @@ aanvoelt. Bij `"Error"` staat in `message` een tekst die je zo aan de gebruiker
| `vault.list` | ja | Titels en categorieën (geen inhoud) |
| `vault.open` | ja | Eén item ontsleutelen |
| `vault.save` | ja | Item versleuteld opslaan |
+| `vault.categories` | ja | De soorten kluisitems die jij te zien krijgt: de actieve plus die waar je al een item van hebt, met naam, uitleg, "bespreken met" en bij een eigen soort de velden van het formulier |
+| `vault.category` | ja | Eén soort op nummer, ook als hij niet actief is (nodig om het item van een ander te lezen); alleen de beschrijving, nooit inhoud |
| `message.inbox` / `message.outbox` | ja | Berichten voor en door jou |
| `message.read` | ja | Bericht lezen en als gelezen markeren |
| `message.compose` | ja | Bericht klaarzetten |
diff --git a/src/MijnBesluit.App/MijnBesluit.App.csproj b/src/MijnBesluit.App/MijnBesluit.App.csproj
index e458fea..bd0788e 100644
--- a/src/MijnBesluit.App/MijnBesluit.App.csproj
+++ b/src/MijnBesluit.App/MijnBesluit.App.csproj
@@ -28,8 +28,8 @@
MijnBesluitnl.mijnbesluit.app
- 1.0
- 2
+ 1.1
+ 324.0
diff --git a/src/MijnBesluit.AppUi/SchermBasis.cs b/src/MijnBesluit.AppUi/SchermBasis.cs
index abc0d67..1840097 100644
--- a/src/MijnBesluit.AppUi/SchermBasis.cs
+++ b/src/MijnBesluit.AppUi/SchermBasis.cs
@@ -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}";
- /// De nummers komen overeen met VaultCategory in het domein.
- 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"
- };
+ ///
+ /// De ingebouwde naam bij een soortnummer. Een soort die de beheerder zelf maakte staat hier niet
+ /// in; die naam reist met het item mee ().
+ ///
+ public static string Categorie(int categorie) => MijnBesluit.Client.IngebouwdeSoorten.Naam(categorie);
///
/// Hoe een relatie op het scherm heet. De lijst staat in MijnBesluit.Client, zodat de app en
diff --git a/src/MijnBesluit.AppUi/Schermen/Kluis.razor b/src/MijnBesluit.AppUi/Schermen/Kluis.razor
index 476b866..1e41bcd 100644
--- a/src/MijnBesluit.AppUi/Schermen/Kluis.razor
+++ b/src/MijnBesluit.AppUi/Schermen/Kluis.razor
@@ -22,18 +22,10 @@
@@ -52,22 +44,19 @@
- @* 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. *@
+ @if (!string.IsNullOrWhiteSpace(HuidigeSoort?.Uitleg))
+ {
+
@HuidigeSoort!.Uitleg
+ }
@@ -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)
{
@* stopPropagation, want anders opent een druk op de prullenbak ook meteen
@@ -342,7 +341,16 @@
/// Op welk soort de lijst gefilterd staat; leeg is alles. Zelfde opzet als op de website.
private string Filter = "";
+ /// De soorten in de keuzelijst; zie .
+ private IReadOnlyList Soorten = IngebouwdeSoorten.Lijst;
+
private int Categorie = 3;
+
+ /// De soort die in het formulier gekozen is. Null als de lijst hem niet kent.
+ private KluisSoort? HuidigeSoort => Soorten.FirstOrDefault(s => s.Id == Categorie);
+
+ /// De naam van een soort uit de lijst, of de ingebouwde naam als de lijst hem niet kent.
+ private string NaamVan(int soort) => Soorten.FirstOrDefault(s => s.Id == soort)?.Naam ?? IngebouwdeSoorten.Naam(soort);
private IReadOnlyList Beheerders = Array.Empty();
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();
+ }
+
+ /// De opgeslagen datum (jjjj-mm-dd) als datum voor de kiezer; null als er niets staat.
+ 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));
+
+ ///
+ /// 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.
+ ///
+ private RenderFragment VeldInvoer(KluisVeld veld) => @
;
+
+ private static string VeldId(KluisVeld veld) => "veld-" + veld.Sleutel;
+
///
/// 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
diff --git a/src/MijnBesluit.AppUi/Schermen/Nalatenschap.razor b/src/MijnBesluit.AppUi/Schermen/Nalatenschap.razor
index 4092231..a81f44c 100644
--- a/src/MijnBesluit.AppUi/Schermen/Nalatenschap.razor
+++ b/src/MijnBesluit.AppUi/Schermen/Nalatenschap.razor
@@ -156,7 +156,7 @@ else if (Stand is { IsBevestigd: true })
@item.Title
-
@Tonen.Categorie(item.Category)
+
@item.SoortNaam(IngebouwdeSoorten.Naam)
@@ -187,7 +187,7 @@ else if (Stand is { IsBevestigd: true })
@item.Title
-
@Tonen.Categorie(item.Category)
+
@item.SoortNaam(IngebouwdeSoorten.Naam)
@@ -205,7 +205,7 @@ else if (Stand is { IsBevestigd: true })
- @foreach (var regel in Regels(Inhoud))
+ @foreach (var regel in Regels(Inhoud).Concat(Inhoud.EigenRegels(GeopendSoort)))
{
@@ -482,6 +482,9 @@ else if (Stand is { IsBevestigd: true })
private long? Bekeken, GeopendItem;
private IReadOnlyList Nagelaten = Array.Empty();
private KluisInhoud? Inhoud;
+
+ /// De soort van het geopende item, voor de labels van de velden van een eigen soort.
+ private KluisSoort? GeopendSoort;
private string? GeopendTitel;
private IReadOnlyList GeopendBijlagen = Array.Empty();
@@ -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();
BijlageDichtdoen();
diff --git a/src/MijnBesluit.Application/Abstractions/Repositories.cs b/src/MijnBesluit.Application/Abstractions/Repositories.cs
index a89be45..45ae769 100644
--- a/src/MijnBesluit.Application/Abstractions/Repositories.cs
+++ b/src/MijnBesluit.Application/Abstractions/Repositories.cs
@@ -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);
}
+/// De soorten kluisitems en hun velden, zoals de beheerder ze instelt.
+public interface IVaultCategoryRepository
+{
+ /// Alle soorten, met hun velden, op volgorde.
+ Task> ListAsync(CancellationToken ct = default);
+
+ Task FindAsync(int id, CancellationToken ct = default);
+
+ Task FindFieldAsync(long fieldId, CancellationToken ct = default);
+
+ /// Per soort hoeveel kluisitems (niet verwijderd) en hoeveel eigenaren. Alleen aantallen.
+ Task> GebruikAsync(CancellationToken ct = default);
+
+ /// De soorten waar deze persoon minstens één kluisitem (niet verwijderd) van heeft.
+ Task> InGebruikDoorAsync(long personId, CancellationToken ct = default);
+
+ /// Hoeveel kluisitems (niet verwijderd) er van deze soort zijn.
+ Task AantalItemsAsync(int categoryId, CancellationToken ct = default);
+
+ void Add(VaultCategoryDefinition soort);
+ void Remove(VaultCategoryDefinition soort);
+ void Add(VaultCategoryField veld);
+ void Remove(VaultCategoryField veld);
+}
+
/// Versturen van uitnodigingen en meldingen.
///
/// Adverteerders, hun campagnes en de tellers per uur. Zie docs/Adverteren.md.
diff --git a/src/MijnBesluit.Application/Models/KluisSoorten.cs b/src/MijnBesluit.Application/Models/KluisSoorten.cs
new file mode 100644
index 0000000..103ea0d
--- /dev/null
+++ b/src/MijnBesluit.Application/Models/KluisSoorten.cs
@@ -0,0 +1,28 @@
+namespace MijnBesluit.Application.Models;
+
+///
+/// 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.
+///
+public record KluisSoort(int Id, string Naam, string? Uitleg, bool BesprekenMet, bool Ingebouwd,
+ IReadOnlyList Velden);
+
+/// Eén veld van het formulier van een nieuwe soort.
+public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyList Opties,
+ string? Hulptekst, bool Verplicht, bool Actief);
+
+/// Hoe vaak een soort in gebruik is: aantallen, nooit inhoud of namen.
+public record SoortGebruik(int CategoryId, int Items, int Eigenaren);
+
+/// Een soort in het beheerscherm, met hoe vaak hij gebruikt wordt.
+public record SoortBeheerbeeld(int Id, string Naam, string? Uitleg, bool Actief, int Volgorde,
+ bool BesprekenMet, bool Ingebouwd, int Items, int Eigenaren,
+ IReadOnlyList Velden)
+{
+ /// Verwijderen kan alleen als niemand er een item mee heeft gemaakt en hij niet ingebouwd is.
+ public bool KanVerwijderen => !Ingebouwd && Items == 0;
+}
+
+/// Een veld in het beheerscherm.
+public record VeldBeheerbeeld(long Id, string Sleutel, string Label, string Soort, string? Opties,
+ string? Hulptekst, bool Verplicht, bool Actief, int Volgorde);
diff --git a/src/MijnBesluit.Application/Models/VaultPayloads.cs b/src/MijnBesluit.Application/Models/VaultPayloads.cs
index 0c1b758..215b907 100644
--- a/src/MijnBesluit.Application/Models/VaultPayloads.cs
+++ b/src/MijnBesluit.Application/Models/VaultPayloads.cs
@@ -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? Velden { get; set; }
}
diff --git a/src/MijnBesluit.Application/Services/VaultCategoryService.cs b/src/MijnBesluit.Application/Services/VaultCategoryService.cs
new file mode 100644
index 0000000..3b4f9c2
--- /dev/null
+++ b/src/MijnBesluit.Application/Services/VaultCategoryService.cs
@@ -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;
+
+///
+/// De soorten kluisitems. Wat je in de keuzelijst ziet, en bij een door de beheerder gemaakte soort
+/// ook welke velden het formulier heeft, komt hier vandaan en niet meer uit de code. Zo vraagt een
+/// nieuwe soort ("Dementie", "Niet reanimeren") geen nieuwe versie van de app.
+///
+/// Twee regels bepalen alles wat hier gebeurt:
+///
+/// 1. Wat in gebruik is, verdwijnt nooit. Een soort waar al een kluisitem mee is gemaakt kan niet
+/// worden verwijderd, alleen op niet-actief gezet. Wie er nog nooit een item mee maakte ziet hem
+/// dan niet meer in zijn keuzelijst; wie dat wel deed, blijft hem zien en kan zijn items
+/// gewoon bekijken en wijzigen.
+/// 2. De beheerder ziet aantallen, nooit inhoud. Hoe vaak een soort gebruikt wordt is een tel;
+/// wie het deed en wat er in staat, blijft versleuteld en onbereikbaar.
+///
+public class VaultCategoryService
+{
+ /// Meer velden dan dit maakt van een formulier een enquête.
+ public const int MaxVelden = 40;
+
+ /// Hoe lang een ingevulde waarde in een eigen veld mag zijn.
+ public const int MaxWaardelengte = 4000;
+
+ private readonly IVaultCategoryRepository _soorten;
+ private readonly IUserRepository _users;
+ private readonly IAuditLog _audit;
+ private readonly IUnitOfWork _uow;
+ private readonly IClock _clock;
+
+ public VaultCategoryService(IVaultCategoryRepository soorten, IUserRepository users, IAuditLog audit,
+ IUnitOfWork uow, IClock clock)
+ {
+ _soorten = soorten; _users = users; _audit = audit; _uow = uow; _clock = clock;
+ }
+
+ // ------------------------------------------------------------------ voor gebruikers
+
+ ///
+ /// De soorten die deze persoon te zien krijgt: alle actieve, plus de niet-actieve waar hij al
+ /// een item van heeft. Op volgorde van de beheerder.
+ ///
+ public async Task> ZichtbaarVoorAsync(long personId, CancellationToken ct = default)
+ {
+ var alle = await _soorten.ListAsync(ct);
+ var gebruikt = await _soorten.InGebruikDoorAsync(personId, ct);
+
+ return alle.Where(s => s.Actief || gebruikt.Contains(s.Id))
+ .Select(NaarSoort)
+ .ToList();
+ }
+
+ /// Alle namen op nummer, voor onder een kluisitem. Ook van soorten die niet meer actief zijn.
+ public async Task> NamenAsync(CancellationToken ct = default) =>
+ (await _soorten.ListAsync(ct)).ToDictionary(s => s.Id, s => s.Naam);
+
+ ///
+ /// Eén soort op nummer, ook als hij niet actief is of de vrager hem nooit gebruikte. Nodig om
+ /// het item van een ander te kunnen lezen (een nalatenschap, iets dat met je is gedeeld): de
+ /// namen van de velden zijn geen geheim, de ingevulde waarden wel, en die blijven versleuteld.
+ ///
+ public async Task SoortAsync(int id, CancellationToken ct = default)
+ {
+ var soort = await _soorten.FindAsync(id, ct);
+ return soort is null ? null : NaarSoort(soort);
+ }
+
+ /// De soort met dit nummer, met velden. Null als hij niet bestaat.
+ public Task VindAsync(int id, CancellationToken ct = default) =>
+ _soorten.FindAsync(id, ct);
+
+ ///
+ /// Mag deze persoon een nieuw kluisitem in deze soort maken? Alleen als de soort bestaat en
+ /// actief is, of als hij er al een item van heeft - dan is het voor hem nog gewoon een soort
+ /// die hij kent.
+ ///
+ public async Task> MagGebruikenAsync(long personId, int categoryId,
+ CancellationToken ct = default)
+ {
+ var soort = await _soorten.FindAsync(categoryId, ct);
+ if (soort is null) return Result.Fail("Deze soort bestaat niet.");
+
+ if (!soort.Actief && !(await _soorten.InGebruikDoorAsync(personId, ct)).Contains(soort.Id))
+ return Result.Fail($"{soort.Naam} wordt niet meer aangeboden.");
+
+ return Result.Ok(soort);
+ }
+
+ ///
+ /// Controleert wat er in de eigen velden van een door de beheerder gemaakte soort is ingevuld
+ /// en geeft de schone versie terug: alleen bekende velden, bijgesneden, met de verplichte
+ /// velden ingevuld en datums die kloppen. Op de server en niet alleen in het scherm, want
+ /// een oudere of andere app stuurt wat hij wil.
+ ///
+ public static Result> ControleerVelden(
+ VaultCategoryDefinition soort, IReadOnlyDictionary? ingevuld)
+ {
+ var schoon = new Dictionary();
+
+ foreach (var veld in soort.Velden.Where(v => v.Soort != VeldSoort.Uitleg))
+ {
+ ingevuld ??= new Dictionary();
+ var waarde = ingevuld.TryGetValue(veld.Sleutel, out var w) ? w?.Trim() : null;
+
+ if (string.IsNullOrEmpty(waarde))
+ {
+ if (veld.Verplicht && veld.Actief)
+ return Result>.Fail($"Vul in: {veld.Label}.");
+ continue;
+ }
+
+ if (waarde.Length > MaxWaardelengte)
+ return Result>.Fail($"{veld.Label} is te lang.");
+
+ if (veld.Soort == VeldSoort.Datum &&
+ !DateOnly.TryParseExact(waarde, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
+ return Result>.Fail($"{veld.Label}: dat is geen geldige datum.");
+
+ schoon[veld.Sleutel] = waarde;
+ }
+
+ return Result>.Ok(schoon);
+ }
+
+ // ------------------------------------------------------------------ voor de beheerder
+
+ /// Alle soorten met hoe vaak ze gebruikt worden. Alleen voor beheerders.
+ public async Task>> OverzichtAsync(long currentUserId,
+ CancellationToken ct = default)
+ {
+ if (!await IsBeheerderAsync(currentUserId, ct))
+ return Result>.Fail("Je bent geen beheerder.");
+
+ var alle = await _soorten.ListAsync(ct);
+ var gebruik = (await _soorten.GebruikAsync(ct)).ToDictionary(g => g.CategoryId);
+
+ var lijst = alle.Select(s =>
+ {
+ gebruik.TryGetValue(s.Id, out var g);
+ return new SoortBeheerbeeld(
+ s.Id, s.Naam, s.Uitleg, s.Actief, s.Volgorde, s.BesprekenMet, s.Ingebouwd,
+ g?.Items ?? 0, g?.Eigenaren ?? 0,
+ s.Velden.OrderBy(v => v.Volgorde).ThenBy(v => v.Id)
+ .Select(v => new VeldBeheerbeeld(v.Id, v.Sleutel, v.Label, v.Soort, v.Opties,
+ v.Hulptekst, v.Verplicht, v.Actief, v.Volgorde))
+ .ToList());
+ }).ToList();
+
+ return Result>.Ok(lijst);
+ }
+
+ ///
+ /// Maakt een nieuwe soort. Hij begint actief, onderaan de lijst. De naam is leesbaar voor de
+ /// server (het item zelf blijft versleuteld), dus een naam die iets over iemands gezondheid
+ /// zegt, zegt dat ook tegen de server.
+ ///
+ public async Task> MaakAsync(long currentUserId, string naam, string? uitleg, bool besprekenMet,
+ CancellationToken ct = default)
+ {
+ if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
+
+ var fout = await ControleerNaamAsync(null, naam, uitleg, ct);
+ if (fout is not null) return Result.Fail(fout);
+
+ var alle = await _soorten.ListAsync(ct);
+ var nu = _clock.UtcNow;
+ var soort = new VaultCategoryDefinition
+ {
+ // Nummers van eigen soorten beginnen bij 100; de database kiest het volgende vrije.
+ Naam = naam.Trim(),
+ Uitleg = Schoon(uitleg),
+ Actief = true,
+ Volgorde = (alle.Count == 0 ? 0 : alle.Max(s => s.Volgorde)) + 10,
+ BesprekenMet = besprekenMet,
+ Ingebouwd = false,
+ AangemaaktOp = nu,
+ GewijzigdOp = nu
+ };
+
+ _soorten.Add(soort);
+ await _uow.SaveChangesAsync(ct);
+ await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
+ soort.Id, $"gemaakt: {soort.Naam}", ct);
+ await _uow.SaveChangesAsync(ct);
+ return Result.Ok(soort.Id);
+ }
+
+ ///
+ /// Wijzigt een soort: de naam, de uitleg, of hij nog wordt aangeboden, waar hij in de lijst
+ /// staat en of er "Bespreken met" bij zit. Dit kan ook bij de ingebouwde soorten.
+ ///
+ public async Task WijzigAsync(long currentUserId, int id, string naam, string? uitleg, bool actief,
+ int volgorde, bool besprekenMet, CancellationToken ct = default)
+ {
+ if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
+
+ var soort = await _soorten.FindAsync(id, ct);
+ if (soort is null) return Result.Fail("Deze soort bestaat niet.");
+
+ var fout = await ControleerNaamAsync(id, naam, uitleg, ct);
+ if (fout is not null) return Result.Fail(fout);
+
+ var wijzigingen = new List();
+ if (soort.Naam != naam.Trim()) wijzigingen.Add($"naam {soort.Naam} -> {naam.Trim()}");
+ if (soort.Actief != actief) wijzigingen.Add(actief ? "actief" : "niet-actief");
+ if (soort.BesprekenMet != besprekenMet) wijzigingen.Add(besprekenMet ? "bespreken met aan" : "bespreken met uit");
+
+ soort.Naam = naam.Trim();
+ soort.Uitleg = Schoon(uitleg);
+ soort.Actief = actief;
+ soort.Volgorde = volgorde;
+ soort.BesprekenMet = besprekenMet;
+ soort.GewijzigdOp = _clock.UtcNow;
+
+ await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), id,
+ wijzigingen.Count == 0 ? "gewijzigd" : string.Join(", ", wijzigingen), ct);
+ await _uow.SaveChangesAsync(ct);
+ return Result.Ok();
+ }
+
+ ///
+ /// Verwijdert een soort - maar alleen een door de beheerder gemaakte waar nog nooit een
+ /// kluisitem mee is gemaakt. Is hij in gebruik, dan blijft alleen "niet-actief" over, zodat
+ /// wie hem gebruikt hem houdt.
+ ///
+ public async Task VerwijderAsync(long currentUserId, int id, CancellationToken ct = default)
+ {
+ if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
+
+ var soort = await _soorten.FindAsync(id, ct);
+ if (soort is null) return Result.Fail("Deze soort bestaat niet.");
+ if (soort.Ingebouwd) return Result.Fail("Een ingebouwde soort kun je niet verwijderen, alleen niet-actief maken.");
+
+ var aantal = await _soorten.AantalItemsAsync(id, ct);
+ if (aantal > 0)
+ return Result.Fail($"{soort.Naam} is in gebruik ({aantal} " +
+ (aantal == 1 ? "item" : "items") + "). Zet hem op niet-actief; dan zien alleen de mensen die hem gebruiken hem nog.");
+
+ await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), id,
+ $"verwijderd: {soort.Naam}", ct);
+ _soorten.Remove(soort);
+ await _uow.SaveChangesAsync(ct);
+ return Result.Ok();
+ }
+
+ /// Voegt een veld toe aan het formulier van een door de beheerder gemaakte soort.
+ public async Task VeldToevoegenAsync(long currentUserId, int categoryId, string label, string soortVeld,
+ string? opties, string? hulptekst, bool verplicht,
+ CancellationToken ct = default)
+ {
+ if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
+
+ var soort = await _soorten.FindAsync(categoryId, ct);
+ if (soort is null) return Result.Fail("Deze soort bestaat niet.");
+ if (soort.Ingebouwd) return Result.Fail("De velden van een ingebouwde soort zitten in de app en zijn hier niet te wijzigen.");
+ if (soort.Velden.Count >= MaxVelden) return Result.Fail($"Meer dan {MaxVelden} velden is te veel voor één formulier.");
+
+ var fout = ControleerVeld(label, soortVeld, opties, hulptekst, verplicht);
+ if (fout is not null) return Result.Fail(fout);
+
+ var veld = new VaultCategoryField
+ {
+ CategoryId = categoryId,
+ Sleutel = NieuweSleutel(label, soort.Velden.Select(v => v.Sleutel)),
+ Label = label.Trim(),
+ Soort = soortVeld,
+ Opties = soortVeld == VeldSoort.Keuze ? SchoneOpties(opties) : null,
+ Hulptekst = Schoon(hulptekst),
+ Verplicht = verplicht && soortVeld != VeldSoort.Uitleg,
+ Actief = true,
+ Volgorde = (soort.Velden.Count == 0 ? 0 : soort.Velden.Max(v => v.Volgorde)) + 10
+ };
+
+ _soorten.Add(veld);
+ soort.GewijzigdOp = _clock.UtcNow;
+ await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), categoryId,
+ $"veld toegevoegd: {veld.Label}", ct);
+ await _uow.SaveChangesAsync(ct);
+ return Result.Ok();
+ }
+
+ ///
+ /// Wijzigt een veld. De sleutel en de soort veld staan vast, want daaraan hangt wat mensen al
+ /// hebben ingevuld; het label, de opties, de hulptekst, verplicht, actief en de plek mogen wel.
+ ///
+ public async Task VeldWijzigenAsync(long currentUserId, long veldId, string label, string? opties,
+ string? hulptekst, bool verplicht, bool actief, int volgorde,
+ CancellationToken ct = default)
+ {
+ if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
+
+ var veld = await _soorten.FindFieldAsync(veldId, ct);
+ if (veld is null) return Result.Fail("Dit veld bestaat niet.");
+
+ var fout = ControleerVeld(label, veld.Soort, opties, hulptekst, verplicht);
+ if (fout is not null) return Result.Fail(fout);
+
+ veld.Label = label.Trim();
+ veld.Opties = veld.Soort == VeldSoort.Keuze ? SchoneOpties(opties) : null;
+ veld.Hulptekst = Schoon(hulptekst);
+ veld.Verplicht = verplicht && veld.Soort != VeldSoort.Uitleg;
+ veld.Actief = actief;
+ veld.Volgorde = volgorde;
+
+ var soort = await _soorten.FindAsync(veld.CategoryId, ct);
+ if (soort is not null) soort.GewijzigdOp = _clock.UtcNow;
+
+ await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
+ veld.CategoryId, $"veld gewijzigd: {veld.Label}", ct);
+ await _uow.SaveChangesAsync(ct);
+ return Result.Ok();
+ }
+
+ ///
+ /// Haalt een veld weg. Alleen zolang er geen kluisitem van deze soort bestaat: anders staan
+ /// er ingevulde gegevens onder en is "niet-actief" de weg - die waarden blijven dan gewoon
+ /// bewaard en zichtbaar bij de items die ze hebben.
+ ///
+ public async Task VeldVerwijderenAsync(long currentUserId, long veldId, CancellationToken ct = default)
+ {
+ if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
+
+ var veld = await _soorten.FindFieldAsync(veldId, ct);
+ if (veld is null) return Result.Fail("Dit veld bestaat niet.");
+
+ if (await _soorten.AantalItemsAsync(veld.CategoryId, ct) > 0)
+ return Result.Fail("Er zijn al items van deze soort; zet het veld op niet-actief in plaats van het te verwijderen. Wat er is ingevuld blijft dan bewaard.");
+
+ await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
+ veld.CategoryId, $"veld verwijderd: {veld.Label}", ct);
+ _soorten.Remove(veld);
+ await _uow.SaveChangesAsync(ct);
+ return Result.Ok();
+ }
+
+ // ------------------------------------------------------------------ hulp
+
+ private async Task IsBeheerderAsync(long userId, CancellationToken ct) =>
+ (await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true;
+
+ private async Task ControleerNaamAsync(int? eigenId, string? naam, string? uitleg, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(naam)) return "Geef de soort een naam.";
+ if (naam.Trim().Length > 80) return "Een naam van meer dan 80 tekens is te lang.";
+ if (uitleg is { Length: > 500 }) return "De uitleg mag niet langer zijn dan 500 tekens.";
+
+ var dubbel = (await _soorten.ListAsync(ct))
+ .Any(s => s.Id != eigenId && string.Equals(s.Naam, naam.Trim(), StringComparison.OrdinalIgnoreCase));
+ return dubbel ? "Er bestaat al een soort met deze naam." : null;
+ }
+
+ private static string? ControleerVeld(string? label, string? soortVeld, string? opties, string? hulptekst, bool verplicht)
+ {
+ if (string.IsNullOrWhiteSpace(label)) return "Geef het veld een naam.";
+ if (label.Trim().Length > 100) return "De naam van een veld mag niet langer zijn dan 100 tekens.";
+ if (!VeldSoort.Bekend(soortVeld)) return "Dit soort veld kennen we niet.";
+ if (hulptekst is { Length: > 300 }) return "De hulptekst mag niet langer zijn dan 300 tekens.";
+
+ if (soortVeld == VeldSoort.Keuze)
+ {
+ var lijst = (SchoneOpties(opties) ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ if (lijst.Length < 2) return "Een keuzelijst heeft minstens twee opties nodig, één per regel.";
+ if ((SchoneOpties(opties) ?? "").Length > 1000) return "De opties zijn samen te lang.";
+ }
+
+ if (soortVeld == VeldSoort.Uitleg && string.IsNullOrWhiteSpace(hulptekst))
+ return "Een uitlegtekst heeft een tekst nodig; vul die in bij de hulptekst.";
+
+ return null;
+ }
+
+ /// Opties één per regel, zonder lege regels en dubbelen.
+ private static string? SchoneOpties(string? opties) =>
+ opties is null
+ ? null
+ : string.Join('\n', opties.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct(StringComparer.OrdinalIgnoreCase));
+
+ private static string? Schoon(string? tekst) => string.IsNullOrWhiteSpace(tekst) ? null : tekst.Trim();
+
+ private static readonly Dictionary Accenten = BouwAccenten();
+
+ private static Dictionary BouwAccenten()
+ {
+ var d = new Dictionary();
+ void Voeg(string letters, string door) { foreach (var l in letters) d[l] = door; }
+ Voeg("àáâãäåāăą", "a"); Voeg("çćĉċč", "c"); Voeg("ďđ", "d"); Voeg("èéêëēĕėęě", "e");
+ Voeg("ĝğġģ", "g"); Voeg("ĥħ", "h"); Voeg("ìíîïĩīĭįı", "i"); Voeg("ĵ", "j"); Voeg("ķ", "k");
+ Voeg("ĺļľŀł", "l"); Voeg("ñńņňʼn", "n"); Voeg("òóôõöøōŏő", "o"); Voeg("ŕŗř", "r");
+ Voeg("śŝşš", "s"); Voeg("ţťŧ", "t"); Voeg("ùúûüũūŭůűų", "u"); Voeg("ŵ", "w");
+ Voeg("ýÿŷ", "y"); Voeg("źżž", "z"); d['ß'] = "ss"; d['æ'] = "ae"; d['œ'] = "oe"; d['þ'] = "th";
+ return d;
+ }
+
+ ///
+ /// Een vaste, korte naam voor een veld, afgeleid van het label: "Wil je gereanimeerd worden?"
+ /// wordt "wil_je_gereanimeerd_worden". Is die al in gebruik in deze soort, dan komt er een
+ /// nummer achter.
+ ///
+ public static string NieuweSleutel(string label, IEnumerable bestaand)
+ {
+ var bouw = new StringBuilder();
+ foreach (var teken in label.ToLowerInvariant())
+ {
+ // Accenten zelf wegwerken in plaats van via string.Normalize: dat werkt niet overal
+ // hetzelfde zodra InvariantGlobalization aan staat (zoals hier), en de sleutel mag niet
+ // per machine verschillen.
+ var ascii = Accenten.TryGetValue(teken, out var zonder) ? zonder : teken.ToString();
+ foreach (var t in ascii)
+ {
+ if (char.IsAsciiLetterOrDigit(t)) bouw.Append(t);
+ else if (bouw.Length > 0 && bouw[^1] != '_') bouw.Append('_');
+ }
+ }
+
+ var basis = bouw.ToString().Trim('_');
+ if (basis.Length == 0) basis = "veld";
+ if (basis.Length > 30) basis = basis[..30].TrimEnd('_');
+
+ var gebruikt = new HashSet(bestaand, StringComparer.OrdinalIgnoreCase);
+ var sleutel = basis;
+ for (var n = 2; gebruikt.Contains(sleutel); n++) sleutel = $"{basis}_{n}";
+ return sleutel;
+ }
+
+ private static KluisSoort NaarSoort(VaultCategoryDefinition s) =>
+ new(s.Id, s.Naam, s.Uitleg, s.BesprekenMet, s.Ingebouwd,
+ s.Velden.OrderBy(v => v.Volgorde).ThenBy(v => v.Id)
+ .Select(v => new KluisVeld(v.Sleutel, v.Label, v.Soort, v.OptieLijst, v.Hulptekst, v.Verplicht, v.Actief))
+ .ToList());
+}
diff --git a/src/MijnBesluit.Application/Services/VaultService.cs b/src/MijnBesluit.Application/Services/VaultService.cs
index 507d2a2..943c026 100644
--- a/src/MijnBesluit.Application/Services/VaultService.cs
+++ b/src/MijnBesluit.Application/Services/VaultService.cs
@@ -26,6 +26,7 @@ public class VaultService
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly IAfbeeldingVerkleiner _verkleiner;
+ private readonly VaultCategoryService? _soorten;
/// De langste zijde van de kleine versie die bij een foto wordt bewaard.
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;
}
/// Titels en categorieën van de kluis. De inhoud blijft versleuteld tot hij echt geopend wordt.
@@ -71,6 +74,10 @@ public class VaultService
if (release == ReleasePolicy.OpDatum && releaseAt is null)
return Result.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.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
}
}
+ ///
+ /// 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.
+ ///
+ private async Task 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;
+ }
+
+ ///
+ /// Kijkt of dit soort item gemaakt of gewijzigd mag worden en maakt de eigen velden schoon.
+ ///
+ /// : 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 .
+ /// 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.
+ ///
+ private async Task 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();
+ }
+
///
/// 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.
///
- ///
- /// 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.
- ///
- 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)
diff --git a/src/MijnBesluit.Client/MijnBesluitClient.cs b/src/MijnBesluit.Client/MijnBesluitClient.cs
index 5ecb21c..9f8be95 100644
--- a/src/MijnBesluit.Client/MijnBesluitClient.cs
+++ b/src/MijnBesluit.Client/MijnBesluitClient.cs
@@ -333,6 +333,21 @@ public class MijnBesluitClient : IAsyncDisposable
public Task>> KluisAsync(int? categorie = null, CancellationToken ct = default) =>
StuurAsync>(SocketActions.VaultList, new { category = categorie }, ct);
+ ///
+ /// 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
+ /// .
+ ///
+ public Task>> KluisSoortenAsync(CancellationToken ct = default) =>
+ StuurAsync>(SocketActions.VaultCategories, null, ct);
+
+ ///
+ /// 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.
+ ///
+ public Task> KluisSoortAsync(int soortId, CancellationToken ct = default) =>
+ StuurAsync(SocketActions.VaultCategory, new { id = soortId }, ct);
+
public Task> KluisOpenenAsync(long itemId, CancellationToken ct = default) =>
StuurAsync(SocketActions.VaultOpen, new { id = itemId }, ct);
diff --git a/src/MijnBesluit.Client/Modellen.cs b/src/MijnBesluit.Client/Modellen.cs
index 461a119..c597b7f 100644
--- a/src/MijnBesluit.Client/Modellen.cs
+++ b/src/MijnBesluit.Client/Modellen.cs
@@ -246,7 +246,58 @@ public record KluisItem(
int ReleasePolicy,
DateTime? ReleaseAt,
DateTime UpdatedAt,
- long? SharedManagerPersonId = null);
+ long? SharedManagerPersonId = null,
+ string? CategoryName = null)
+{
+ ///
+ /// 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.
+ ///
+ public string SoortNaam(Func ingebouwd) =>
+ string.IsNullOrWhiteSpace(CategoryName) ? ingebouwd(Category) : CategoryName;
+}
+
+///
+/// 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 ; een soort die de
+/// beheerder maakte heeft een nummer vanaf 100 en beschrijft zijn formulier hier.
+///
+public record KluisSoort(int Id, string Naam, string? Uitleg, bool BesprekenMet, bool Ingebouwd,
+ IReadOnlyList Velden);
+
+/// Eén veld van het formulier van een soort die de beheerder maakte.
+public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyList 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";
+}
+
+///
+/// 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.
+///
+public static class IngebouwdeSoorten
+{
+ private static KluisSoort S(int id, string naam, bool bespreken = false) =>
+ new(id, naam, null, bespreken, true, Array.Empty());
+
+ public static readonly IReadOnlyList 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")
+ };
+
+ /// De naam bij een nummer; "Overig" voor wat we niet kennen.
+ public static string Naam(int id) => Lijst.FirstOrDefault(s => s.Id == id)?.Naam ?? "Overig";
+}
/// De ontsleutelde inhoud van een kluisitem. Per soort is maar een deel gevuld.
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; }
+
+ ///
+ /// De velden van een soort die de beheerder zelf maakte, op de vaste sleutel van het veld.
+ /// Bij de ingebouwde soorten blijft dit leeg.
+ ///
+ public Dictionary Velden { get; set; } = new();
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
}
/// Een bijlage bij een kluisitem, zonder de inhoud zelf.
diff --git a/src/MijnBesluit.Domain/Entities/VaultCategoryDefinition.cs b/src/MijnBesluit.Domain/Entities/VaultCategoryDefinition.cs
new file mode 100644
index 0000000..69293b6
--- /dev/null
+++ b/src/MijnBesluit.Domain/Entities/VaultCategoryDefinition.cs
@@ -0,0 +1,117 @@
+namespace MijnBesluit.Domain.Entities;
+
+///
+/// Eén soort kluisitem zoals de beheerder die instelt: "Wachtwoord", "Polis", of een nieuwe als
+/// "Niet reanimeren". Het nummer is hetzelfde nummer als VaultItem.Category; 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 .
+/// 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.
+///
+public class VaultCategoryDefinition
+{
+ /// Vanaf dit nummer zijn soorten door de beheerder gemaakt.
+ public const int EersteEigenNummer = 100;
+
+ public int Id { get; set; }
+
+ public string Naam { get; set; } = string.Empty;
+
+ /// Korte uitleg die in de app onder de keuzelijst staat. Mag leeg.
+ public string? Uitleg { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool Actief { get; set; } = true;
+
+ public int Volgorde { get; set; } = 1000;
+
+ /// Mag de eigenaar bij dit soort een beheerder kiezen om het vooraf mee te bespreken?
+ public bool BesprekenMet { get; set; }
+
+ /// Invulscherm zit in de app; velden zijn hier niet te wijzigen en de soort is niet te verwijderen.
+ public bool Ingebouwd { get; set; }
+
+ public DateTime AangemaaktOp { get; set; } = DateTime.UtcNow;
+ public DateTime GewijzigdOp { get; set; } = DateTime.UtcNow;
+
+ public List Velden { get; set; } = new();
+}
+
+/// Een veld in het formulier van een door de beheerder gemaakte soort.
+public class VaultCategoryField
+{
+ public long Id { get; set; }
+ public int CategoryId { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public string Sleutel { get; set; } = string.Empty;
+
+ public string Label { get; set; } = string.Empty;
+
+ /// Een van . Verandert na het maken niet meer.
+ public string Soort { get; set; } = VeldSoort.Tekst;
+
+ /// Bij : de opties, één per regel.
+ public string? Opties { get; set; }
+
+ public string? Hulptekst { get; set; }
+ public bool Verplicht { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool Actief { get; set; } = true;
+
+ public int Volgorde { get; set; } = 1000;
+
+ public VaultCategoryDefinition? Category { get; set; }
+
+ /// De opties als lijst, zonder lege regels.
+ public IReadOnlyList OptieLijst =>
+ string.IsNullOrWhiteSpace(Opties)
+ ? Array.Empty()
+ : Opties.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+}
+
+/// De soorten invoerveld die een formulier kent.
+public static class VeldSoort
+{
+ /// Eén regel tekst.
+ public const string Tekst = "tekst";
+
+ /// Meerdere regels tekst.
+ public const string Lang = "lang";
+
+ /// Een datum; wordt als jjjj-mm-dd bewaard.
+ public const string Datum = "datum";
+
+ /// Kiezen uit een lijst, bijvoorbeeld Ja / Nee / Alleen na overleg.
+ public const string Keuze = "keuze";
+
+ /// Geen invoer maar een stukje uitleg tussen de velden.
+ 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);
+
+ /// Hoe de soort op het scherm heet, voor in het beheerscherm.
+ public static string Naam(string soort) => soort switch
+ {
+ Tekst => "Korte tekst",
+ Lang => "Lange tekst",
+ Datum => "Datum",
+ Keuze => "Keuzelijst",
+ Uitleg => "Uitlegtekst (geen invoer)",
+ _ => soort
+ };
+}
diff --git a/src/MijnBesluit.Domain/Enums/Enums.cs b/src/MijnBesluit.Domain/Enums/Enums.cs
index eadb16b..f8ddfa9 100644
--- a/src/MijnBesluit.Domain/Enums/Enums.cs
+++ b/src/MijnBesluit.Domain/Enums/Enums.cs
@@ -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
}
///
diff --git a/src/MijnBesluit.Infrastructure/DependencyInjection.cs b/src/MijnBesluit.Infrastructure/DependencyInjection.cs
index 6001574..ad84fe1 100644
--- a/src/MijnBesluit.Infrastructure/DependencyInjection.cs
+++ b/src/MijnBesluit.Infrastructure/DependencyInjection.cs
@@ -93,6 +93,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -103,6 +104,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
// De link naar de landingspagina voor iemand zonder account moet naar de echte site wijzen.
services.AddScoped(sp =>
diff --git a/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs b/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs
index e97a0ca..3824d4c 100644
--- a/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs
+++ b/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs
@@ -18,6 +18,8 @@ public class MijnBesluitDbContext : DbContext
public DbSet Invitations => Set();
public DbSet VaultItems => Set();
public DbSet VaultFiles => Set();
+ public DbSet VaultCategories => Set();
+ public DbSet VaultCategoryFields => Set();
public DbSet Toestellen => Set();
public DbSet Sessies => Set();
public DbSet ContentGrants => Set();
@@ -218,6 +220,37 @@ public class MijnBesluitDbContext : DbContext
e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId);
});
+ b.Entity(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(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(e =>
{
e.ToTable("content_grants");
diff --git a/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs b/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs
index a525c10..1ffc584 100644
--- a/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs
+++ b/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs
@@ -229,6 +229,44 @@ public class InvitationRepository : IInvitationRepository
public void Remove(Invitation invitation) => _db.Invitations.Remove(invitation);
}
+/// De soorten kluisitems; zie .
+public class VaultCategoryRepository : IVaultCategoryRepository
+{
+ private readonly MijnBesluitDbContext _db;
+ public VaultCategoryRepository(MijnBesluitDbContext db) => _db = db;
+
+ public async Task> ListAsync(CancellationToken ct = default) =>
+ await _db.VaultCategories.Include(c => c.Velden)
+ .OrderBy(c => c.Volgorde).ThenBy(c => c.Naam).ToListAsync(ct);
+
+ public Task FindAsync(int id, CancellationToken ct = default) =>
+ _db.VaultCategories.Include(c => c.Velden).FirstOrDefaultAsync(c => c.Id == id, ct);
+
+ public Task FindFieldAsync(long fieldId, CancellationToken ct = default) =>
+ _db.VaultCategoryFields.FirstOrDefaultAsync(f => f.Id == fieldId, ct);
+
+ public async Task> GebruikAsync(CancellationToken ct = default) =>
+ await _db.VaultItems.Where(v => v.DeletedAt == null)
+ .GroupBy(v => v.Category)
+ .Select(g => new SoortGebruik((int)g.Key, g.Count(), g.Select(v => v.OwnerPersonId).Distinct().Count()))
+ .ToListAsync(ct);
+
+ public async Task> InGebruikDoorAsync(long personId, CancellationToken ct = default)
+ {
+ var lijst = await _db.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null)
+ .Select(v => v.Category).Distinct().ToListAsync(ct);
+ return lijst.Select(c => (int)c).ToHashSet();
+ }
+
+ public Task AantalItemsAsync(int categoryId, CancellationToken ct = default) =>
+ _db.VaultItems.CountAsync(v => v.Category == (VaultCategory)categoryId && v.DeletedAt == null, ct);
+
+ public void Add(VaultCategoryDefinition soort) => _db.VaultCategories.Add(soort);
+ public void Remove(VaultCategoryDefinition soort) => _db.VaultCategories.Remove(soort);
+ public void Add(VaultCategoryField veld) => _db.VaultCategoryFields.Add(veld);
+ public void Remove(VaultCategoryField veld) => _db.VaultCategoryFields.Remove(veld);
+}
+
public class VaultRepository : IVaultRepository
{
private readonly MijnBesluitDbContext _db;
diff --git a/src/MijnBesluit.Protocol/Envelope.cs b/src/MijnBesluit.Protocol/Envelope.cs
index 3858100..0adb28d 100644
--- a/src/MijnBesluit.Protocol/Envelope.cs
+++ b/src/MijnBesluit.Protocol/Envelope.cs
@@ -98,6 +98,19 @@ public static class SocketActions
/// Kluisitems die iemand rechtstreeks met jou heeft gedeeld, vooraf te bespreken.
public const string VaultSharedWithMe = "vault.shared-with-me";
+ ///
+ /// 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.
+ ///
+ public const string VaultCategories = "vault.categories";
+
+ ///
+ /// 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.
+ ///
+ public const string VaultCategory = "vault.category";
+
/// Nalatenschappen die aan jou zijn toevertrouwd, en wat daarvan is vrijgegeven.
public const string LegacyEntrusted = "legacy.entrusted";
public const string LegacyEntrustedItems = "legacy.entrusted-items";
diff --git a/src/MijnBesluit.Server/SocketDispatcher.cs b/src/MijnBesluit.Server/SocketDispatcher.cs
index 3e45649..603a9c6 100644
--- a/src/MijnBesluit.Server/SocketDispatcher.cs
+++ b/src/MijnBesluit.Server/SocketDispatcher.cs
@@ -15,6 +15,12 @@ public class RequestScope
public required LegacyService Legacy { get; init; }
public required OverzichtService Overzicht { get; init; }
+ ///
+ /// 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.
+ ///
+ public VaultCategoryService? Soorten { get; init; }
+
///
/// 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
});
+ ///
+ /// 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.
+ ///
+ private static async Task> SoortNamenAsync(RequestScope scope, CancellationToken ct) =>
+ scope.Soorten is null ? new Dictionary() : await scope.Soorten.NamenAsync(ct);
+
+ private static string? SoortNaam(IReadOnlyDictionary namen, int soort) =>
+ namen.TryGetValue(soort, out var naam) ? naam : null;
+
/// Voor als er geen klok wordt meegegeven; de overdracht heeft er wel een nodig.
private sealed class SysteemKlok : MijnBesluit.Application.Abstractions.IClock
{
@@ -451,12 +468,31 @@ public class SocketDispatcher
{
var p = request.PayloadAs();
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();
+ 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()
+ : (await scope.Soorten.ZichtbaarVoorAsync(session!.PersonId, ct)).ToArray();
+ return SocketResponse.Ok(id, lijst);
+ }
+
case SocketActions.VaultOpen:
{
var p = request.PayloadAs();
@@ -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)
}));
}
diff --git a/src/MijnBesluit.SocketServer/SocketServerService.cs b/src/MijnBesluit.SocketServer/SocketServerService.cs
index 371b4f0..dd42654 100644
--- a/src/MijnBesluit.SocketServer/SocketServerService.cs
+++ b/src/MijnBesluit.SocketServer/SocketServerService.cs
@@ -44,6 +44,7 @@ public class SocketServerService : BackgroundService
Accounts = scope.ServiceProvider.GetRequiredService(),
Families = scope.ServiceProvider.GetRequiredService(),
Vault = scope.ServiceProvider.GetRequiredService(),
+ Soorten = scope.ServiceProvider.GetRequiredService(),
Messages = scope.ServiceProvider.GetRequiredService(),
Legacy = scope.ServiceProvider.GetRequiredService(),
Meldingen = scope.ServiceProvider.GetRequiredService(),
diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Kluisweergave.cs b/src/MijnBesluit.Web/Components/Onderdelen/Kluisweergave.cs
index 2dd3156..0b7c2f8 100644
--- a/src/MijnBesluit.Web/Components/Onderdelen/Kluisweergave.cs
+++ b/src/MijnBesluit.Web/Components/Onderdelen/Kluisweergave.cs
@@ -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;
///
public static class Kluisweergave
{
- /// De ingevulde velden van een item, in de volgorde waarin je ze wilt lezen.
- public static IEnumerable<(string Label, string Waarde)> Velden(VaultPayload p)
+ ///
+ /// 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.
+ ///
+ 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);
}
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ /// De naam van een soort: die van de beheerder als de server hem kent, anders de vaste.
+ public static string Naam(VaultCategory c, IReadOnlyDictionary? 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",
diff --git a/src/MijnBesluit.Web/Components/Pages/Beheer.razor b/src/MijnBesluit.Web/Components/Pages/Beheer.razor
index 4573a8c..3001a0f 100644
--- a/src/MijnBesluit.Web/Components/Pages/Beheer.razor
+++ b/src/MijnBesluit.Web/Components/Pages/Beheer.razor
@@ -31,6 +31,10 @@ else
hoge uitzondering. *@
+ @* Kluissoorten: wat mensen in hun kluis kunnen vastleggen, en wat je daar zelf bij zet.
+ Ook een eigen scherm: dat is inrichten, geen terugdraaien. *@
+
}
@* ------------------------------------------------------------ statistieken *@
diff --git a/src/MijnBesluit.Web/Components/Pages/BeheerSoorten.razor b/src/MijnBesluit.Web/Components/Pages/BeheerSoorten.razor
new file mode 100644
index 0000000..f2c2003
--- /dev/null
+++ b/src/MijnBesluit.Web/Components/Pages/BeheerSoorten.razor
@@ -0,0 +1,486 @@
+@page "/beheer/soorten"
+@attribute [Authorize]
+@inject BeheerService Beheerkant
+@inject VaultCategoryService SoortService
+@rendermode InteractiveServer
+
+Kluissoorten - Beheer
+
+@* 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)
+{
+
Niet gevonden
+
Deze pagina bestaat niet, of hij is niet voor jou.
+}
+else
+{
+
Kluissoorten
+
+ 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.
+
+ De naam van een soort is leesbaar voor de server. 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.
+
+
+ @if (Melding is not null) {
@Melding
}
+
+ @* ------------------------------------------------------------ nieuwe soort *@
+
+
Nieuwe soort
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+ @* ------------------------------------------------------------ de lijst *@
+ @if (Lijst is null)
+ {
+
Bezig met laden…
+ }
+ else
+ {
+
+ @foreach (var s in Lijst)
+ {
+
+
+
+
@s.Naam
+
@Gebruik(s)
+
+ @if (s.Ingebouwd) { Ingebouwd }
+ else { Eigen soort }
+ @if (!s.Actief) { Niet actief }
+ @if (s.BesprekenMet) { Bespreken met }
+
+
+
+
+
+ @if (Open == s.Id)
+ {
+
+
+
+
+
+
+
+
+
+
+
Kleinste nummer staat bovenaan.
+
+
+
+
+
+
+
+
+
+
+ @if (!Bewerken.Actief)
+ {
+
+ 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.
+
+ }
+
+
+
+
+
+
+
+ @if (s.KanVerwijderen)
+ {
+
+ }
+
+
+ @if (!s.KanVerwijderen)
+ {
+
+ @if (s.Ingebouwd) { Een ingebouwde soort kun je niet verwijderen, alleen niet-actief maken. }
+ else { Deze soort is in gebruik en kan dus niet worden verwijderd. Zet hem op niet-actief als je hem niet meer wilt aanbieden. }
+
+ }
+
+ @if (WeghalenSoort == s.Id)
+ {
+
+ @s.Naam verwijderen? Dit kan, omdat nog niemand er een item mee heeft gemaakt.
+
+
+
+
+
+ }
+
+
+
Velden van het formulier
+
+ @if (s.Ingebouwd)
+ {
+
+ 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.
+
+ }
+ else
+ {
+ @if (s.Velden.Count == 0)
+ {
+
Nog geen velden. Zonder velden vult iemand alleen een titel en een toelichting in.
+ De naam hernoemen mag: wat mensen al hebben ingevuld blijft bij het veld horen.
+ Het soort veld ligt vast.
+
+
+ @if (v.Soort == VeldSoort.Keuze)
+ {
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+ @if (v.Soort != VeldSoort.Uitleg)
+ {
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @if (s.Items > 0)
+ {
+ Er zijn al items van deze soort: een veld verwijderen kan dan niet. Zet het op niet-actief; wat er is ingevuld blijft bewaard.
+ }
+ else
+ {
+ Er zijn nog geen items van deze soort, dus je kunt het veld nog verwijderen.
+ }
+
@@ -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)
{
@@ -256,7 +272,7 @@ else
{
- @foreach (var (label, waarde) in Velden(Inhoud))
+ @foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud, GeopendSoort))
{
@label
@@ -358,13 +374,85 @@ else
/// Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.
private const int MaxTegelijk = 10;
- private static readonly VaultCategory[] Categorieen =
+ ///
+ /// 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.
+ ///
+ private IReadOnlyList Soorten = Array.Empty();
+
+ /// Alle namen op nummer, ook van soorten die niet meer actief zijn.
+ private IReadOnlyDictionary Namen = new Dictionary();
+
+ /// De soort van het item dat openstaat, voor de namen van zijn eigen velden.
+ private KluisSoort? GeopendSoort;
+
+ private KluisSoort? HuidigeSoort => Soorten.FirstOrDefault(s => s.Id == (int)Nieuw.Categorie);
+
+ ///
+ /// "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.
+ ///
+ 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();
+ }
+
+ /// Een leeg nieuw item, in de eerste soort die bruikbaar is (liefst uitvaartwens).
+ 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));
+
+ /// Eén invoerveld van een eigen soort, getekend naar de beschrijving van de beheerder.
+ private RenderFragment VeldInvoer(KluisVeld veld) => @
;
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() : new Dictionary(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;
+
+ ///
+ /// 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.
+ ///
+ public int CategorieNummer
+ {
+ get => (int)Categorie;
+ set => Categorie = (VaultCategory)value;
+ }
+
+ /// De ingevulde velden van een soort die de beheerder zelf maakte, op sleutel.
+ public Dictionary Velden { get; set; } = new();
public string Titel { get; set; } = "";
public string? Notitie { get; set; }
public string? Website { get; set; }
diff --git a/src/MijnBesluit.Web/Components/Pages/Nalatenschap.razor b/src/MijnBesluit.Web/Components/Pages/Nalatenschap.razor
index b509124..e5ebb4a 100644
--- a/src/MijnBesluit.Web/Components/Pages/Nalatenschap.razor
+++ b/src/MijnBesluit.Web/Components/Pages/Nalatenschap.razor
@@ -7,6 +7,7 @@
@inject ILegacyRepository LegacyOpslag
@inject IPersonRepository Personen
@inject VaultService Kluis
+@inject VaultCategoryService SoortService
@rendermode InteractiveServer
Nalatenschap - MijnBesluit
@@ -126,7 +127,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
@item.Title
-
@Kluisweergave.Naam(item.Category)
+
@Kluisweergave.Naam(item.Category, Namen)
@@ -155,7 +156,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
@item.Title
-
@Kluisweergave.Naam(item.Category)
+
@Kluisweergave.Naam(item.Category, Namen)
@@ -173,7 +174,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
- @foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud))
+ @foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud, GeopendSoort))
{
@@ -388,6 +389,12 @@ else if (Stand.State == DeathProcedureState.Lopend)
private IReadOnlyList? Nagelaten;
private VaultPayload? Inhoud;
private string? GeopendTitel;
+
+ /// Namen van alle soorten op nummer, ook die de beheerder zelf maakte.
+ private IReadOnlyDictionary Namen = new Dictionary();
+
+ /// De soort van het geopende item, voor de namen van zijn eigen velden.
+ private KluisSoort? GeopendSoort;
private IReadOnlyList? 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;
}
diff --git a/tests/MijnBesluit.SelfCheck/ClientChecks.cs b/tests/MijnBesluit.SelfCheck/ClientChecks.cs
index 63df223..6155f47 100644
--- a/tests/MijnBesluit.SelfCheck/ClientChecks.cs
+++ b/tests/MijnBesluit.SelfCheck/ClientChecks.cs
@@ -31,6 +31,7 @@ public static class ClientChecks
Accounts = Wereld.Accounts,
Families = Wereld.Families,
Vault = Wereld.Vault,
+ Soorten = Wereld.Soorten,
Messages = Wereld.Messages,
Legacy = Wereld.Legacy,
Overzicht = Wereld.Overzicht,
@@ -64,6 +65,67 @@ public static class ClientChecks
public static void Register(Runner runner)
{
+ runner.TestAsync("App: de soortenlijst komt over de lijn en de ingebouwde lijst in de app klopt ermee", async () =>
+ {
+ await using var proef = new Proef();
+ await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
+
+ var lijst = await app.KluisSoortenAsync();
+ Assert.True(lijst.Gelukt, $"de lijst komt binnen ({lijst.Melding})");
+
+ var server = lijst.Waarde!;
+ var ingebouwd = IngebouwdeSoorten.Lijst;
+ Assert.True(server.Select(s => (s.Id, s.Naam, s.BesprekenMet)).SequenceEqual(
+ ingebouwd.Select(s => (s.Id, s.Naam, s.BesprekenMet))),
+ "de ingebouwde lijst in de app is gelijk aan wat de server als startlijst heeft");
+ Assert.True(IngebouwdeSoorten.Naam(11) == "Paspoort" && IngebouwdeSoorten.Naam(4242) == "Overig",
+ "een onbekend nummer heet Overig");
+ });
+
+ runner.TestAsync("App: een eigen soort met velden, en de naam reist mee met het item", async () =>
+ {
+ await using var proef = new Proef();
+ var w = proef.Wereld;
+ await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
+
+ var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Beheer");
+ w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true;
+ var soortId = (await w.Soorten.MaakAsync(beheerder.Id, "Niet reanimeren", "Alleen een papieren verklaring is leidend.", false)).Value;
+ await w.Soorten.VeldToevoegenAsync(beheerder.Id, soortId, "Wil je gereanimeerd worden?", "keuze",
+ "Ja\nNee\nAlleen na overleg", "Kies wat het beste past.", true);
+ await w.Soorten.VeldToevoegenAsync(beheerder.Id, soortId, "Datum verklaring", "datum", null, null, false);
+
+ var lijst = (await app.KluisSoortenAsync()).Waarde!;
+ var eigen = lijst.Single(s => s.Id == soortId);
+ Assert.True(!eigen.Ingebouwd && eigen.Uitleg!.Contains("papieren"), "de soort komt met zijn uitleg binnen");
+ Assert.True(eigen.Velden.Count == 2, "met twee velden");
+ Assert.True(eigen.Velden[0].Soort == KluisVeld.Keuze && eigen.Velden[0].Verplicht
+ && eigen.Velden[0].Opties.SequenceEqual(new[] { "Ja", "Nee", "Alleen na overleg" }),
+ "het keuzeveld heeft zijn opties en is verplicht");
+ Assert.True(eigen.Velden[1].Soort == KluisVeld.Datum && !eigen.Velden[1].Verplicht, "het datumveld is niet verplicht");
+
+ var inhoud = new KluisInhoud
+ {
+ Velden = new() { ["wil_je_gereanimeerd_worden"] = "Nee", ["datum_verklaring"] = "2026-10-12" }
+ };
+ var opgeslagen = await app.KluisOpslaanAsync(soortId, "Mijn verklaring", inhoud, 0);
+ Assert.True(opgeslagen.Gelukt, $"het item wordt bewaard ({opgeslagen.Melding})");
+
+ var items = (await app.KluisAsync()).Waarde!;
+ var item = items.Single(i => i.Title == "Mijn verklaring");
+ Assert.True(item.Category == soortId, "het item staat in de nieuwe soort");
+ Assert.True(item.CategoryName == "Niet reanimeren", "en de naam reist met het item mee");
+ Assert.True(item.SoortNaam(IngebouwdeSoorten.Naam) == "Niet reanimeren", "ook zonder de lijst van de app");
+
+ var open = (await app.KluisOpenenAsync(item.Id)).Waarde!;
+ Assert.True(open.Velden["wil_je_gereanimeerd_worden"] == "Nee" && open.Velden["datum_verklaring"] == "2026-10-12",
+ "de velden komen er precies zo uit als ze erin gingen");
+
+ // Een ingebouwd item krijgt zijn ingebouwde naam, ook zonder CategoryName van de server.
+ var polis = new KluisItem(1, "Polis", 2, 0, null, DateTime.UtcNow);
+ Assert.True(polis.SoortNaam(IngebouwdeSoorten.Naam) == "Polis", "terugval op de ingebouwde naam");
+ });
+
runner.TestAsync("App: registreren met een code en de sessie bewaren", async () =>
{
await using var proef = new Proef();
diff --git a/tests/MijnBesluit.SelfCheck/InMemory.cs b/tests/MijnBesluit.SelfCheck/InMemory.cs
index 1f9c32c..3de302c 100644
--- a/tests/MijnBesluit.SelfCheck/InMemory.cs
+++ b/tests/MijnBesluit.SelfCheck/InMemory.cs
@@ -25,6 +25,8 @@ public class Store
public List Adverteerders = new();
public List Campagnes = new();
public List CampagneUren = new();
+ public List Soorten = VaultCategorySaatgoed.Maak();
+ public List SoortVelden = new();
private long _seq;
public long NextId() => ++_seq;
@@ -528,3 +530,66 @@ public class TestPush : IPushSender
return Task.FromResult(true);
}
}
+
+
+/// De startrijen uit db/21_kluissoorten.sql, zodat de tests met dezelfde soorten beginnen als de echte database.
+public static class VaultCategorySaatgoed
+{
+ public static List Maak()
+ {
+ VaultCategoryDefinition S(int id, string naam, int volgorde, bool bespreken = false) => new()
+ {
+ Id = id, Naam = naam, Actief = true, Volgorde = volgorde, BesprekenMet = bespreken, Ingebouwd = true
+ };
+
+ return new List
+ {
+ S(3, "Wachtwoord", 10), S(8, "Sociale media", 20), S(4, "Apparaat", 30), S(5, "Pincode", 40),
+ S(2, "Polis", 50), S(1, "Uitvaartwens", 60, true), S(9, "Donorcodicil", 70, true),
+ S(10, "Rijbewijs", 80), S(11, "Paspoort", 90), S(6, "Document", 100), S(7, "Notitie", 110),
+ S(99, "Overig", 120)
+ };
+ }
+}
+
+public class VaultCategoryRepo : IVaultCategoryRepository
+{
+ private readonly Store _s; public VaultCategoryRepo(Store s) => _s = s;
+
+ private VaultCategoryDefinition Met(VaultCategoryDefinition d)
+ {
+ d.Velden = _s.SoortVelden.Where(v => v.CategoryId == d.Id).ToList();
+ return d;
+ }
+
+ public Task> ListAsync(CancellationToken ct = default) =>
+ Task.FromResult>(
+ _s.Soorten.OrderBy(c => c.Volgorde).ThenBy(c => c.Naam).Select(Met).ToList());
+
+ public Task FindAsync(int id, CancellationToken ct = default) =>
+ Task.FromResult(_s.Soorten.Where(c => c.Id == id).Select(Met).FirstOrDefault());
+
+ public Task FindFieldAsync(long fieldId, CancellationToken ct = default) =>
+ Task.FromResult(_s.SoortVelden.FirstOrDefault(f => f.Id == fieldId));
+
+ public Task> GebruikAsync(CancellationToken ct = default) =>
+ Task.FromResult>(
+ _s.VaultItems.Where(v => v.DeletedAt == null).GroupBy(v => v.Category)
+ .Select(g => new MijnBesluit.Application.Models.SoortGebruik(
+ (int)g.Key, g.Count(), g.Select(v => v.OwnerPersonId).Distinct().Count())).ToList());
+
+ public Task> InGebruikDoorAsync(long personId, CancellationToken ct = default) =>
+ Task.FromResult>(
+ _s.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null)
+ .Select(v => (int)v.Category).ToHashSet());
+
+ public Task AantalItemsAsync(int categoryId, CancellationToken ct = default) =>
+ Task.FromResult(_s.VaultItems.Count(v => (int)v.Category == categoryId && v.DeletedAt == null));
+
+ private int _volgende = VaultCategoryDefinition.EersteEigenNummer;
+
+ public void Add(VaultCategoryDefinition soort) { if (soort.Id == 0) soort.Id = _volgende++; _s.Soorten.Add(soort); }
+ public void Remove(VaultCategoryDefinition soort) { _s.Soorten.Remove(soort); _s.SoortVelden.RemoveAll(v => v.CategoryId == soort.Id); }
+ public void Add(VaultCategoryField veld) { if (veld.Id == 0) veld.Id = _s.NextId(); _s.SoortVelden.Add(veld); }
+ public void Remove(VaultCategoryField veld) => _s.SoortVelden.Remove(veld);
+}
diff --git a/tests/MijnBesluit.SelfCheck/Program.cs b/tests/MijnBesluit.SelfCheck/Program.cs
index 37cdcd3..8e05434 100644
--- a/tests/MijnBesluit.SelfCheck/Program.cs
+++ b/tests/MijnBesluit.SelfCheck/Program.cs
@@ -1942,5 +1942,6 @@ ClientChecks.Register(runner);
ServiceChecks.Register(runner);
AdvertentieChecks.Register(runner);
UitrolChecks.Register(runner);
+SoortenChecks.Register(runner);
return runner.Summary();
diff --git a/tests/MijnBesluit.SelfCheck/Runner.cs b/tests/MijnBesluit.SelfCheck/Runner.cs
index 26b9e04..a76f6a8 100644
--- a/tests/MijnBesluit.SelfCheck/Runner.cs
+++ b/tests/MijnBesluit.SelfCheck/Runner.cs
@@ -25,6 +25,7 @@ public class World
public BeheerService Beheer { get; }
public AdvertentieService Adverteren { get; }
public AccountOpheffenService Opheffen { get; }
+ public VaultCategoryService Soorten { get; }
/// Maakt in één stap een account aan; scheelt in de scenario's het hele codegedoe.
public async Task MaakGebruikerAsync(string email, string voornaam, string achternaam = "")
@@ -106,7 +107,8 @@ public class World
Accounts = new AccountService(users, people, families, codes, keys, Email, audit, uow, Clock, vault);
Families = new FamilyService(families, people, users, invitations, messages, legacy,
Email, audit, uow, Clock);
- Vault = new VaultService(vault, people, messages, users, keys, files, audit, uow, Clock, Verkleiner, families, legacy);
+ Soorten = new VaultCategoryService(new VaultCategoryRepo(Store), users, audit, uow, Clock);
+ Vault = new VaultService(vault, people, messages, users, keys, files, audit, uow, Clock, Verkleiner, families, legacy, Soorten);
Meldingen = new MeldingService(new ToestelRepo(Store), users, Push, uow, Clock);
Messages = new MessageService(messages, people, users, vault, keys, files, Email, audit, uow, Clock, Meldingen);
Legacy = new LegacyService(legacy, people, users, vault, messages, Email, audit, uow, Clock, Vault, families);
diff --git a/tests/MijnBesluit.SelfCheck/SoortenChecks.cs b/tests/MijnBesluit.SelfCheck/SoortenChecks.cs
new file mode 100644
index 0000000..0007f79
--- /dev/null
+++ b/tests/MijnBesluit.SelfCheck/SoortenChecks.cs
@@ -0,0 +1,446 @@
+using MijnBesluit.Application.Models;
+using MijnBesluit.Application.Services;
+using MijnBesluit.Domain.Entities;
+using MijnBesluit.Domain.Enums;
+
+namespace MijnBesluit.SelfCheck;
+
+///
+/// De soorten kluisitems uit de database: wat de beheerder kan instellen en welke regels daarbij
+/// vastliggen.
+///
+/// Het hart is één afspraak: wat in gebruik is, verdwijnt nooit. Een soort waar een item mee is
+/// gemaakt kan niet worden verwijderd, en niet-actief zetten haalt hem alleen weg bij wie hem nog
+/// nooit gebruikte. De rest hier is daar de omlijsting van: wie het mag instellen, hoe de velden
+/// van een eigen soort worden gecontroleerd, en dat "Bespreken met" een instelling per soort is.
+///
+public static class SoortenChecks
+{
+ private static async Task BeheerderAsync(World w, string email = "beheer@example.nl")
+ {
+ var u = await w.MaakGebruikerAsync(email, "Beheer", "Er");
+ w.Store.Users.Single(x => x.Id == u.Id).IsBeheerder = true;
+ return u;
+ }
+
+ private static async Task MaakAsync(World w, User beheerder, string naam, bool bespreken = false)
+ {
+ var r = await w.Soorten.MaakAsync(beheerder.Id, naam, null, bespreken);
+ if (!r.Succeeded) throw new AssertionException($"soort maken mislukte: {r.Error}");
+ return r.Value;
+ }
+
+ public static void Register(Runner runner)
+ {
+ runner.TestAsync("Soorten: de startlijst is precies wat er voorheen vast in de code stond", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+
+ var lijst = await w.Soorten.ZichtbaarVoorAsync(jan.PersonId);
+ Assert.True(lijst.Count == 12, "twaalf soorten: elf ingebouwde en Overig");
+ Assert.True(lijst.First().Naam == "Wachtwoord" && lijst.Last().Naam == "Overig",
+ "de volgorde is die van de oude keuzelijst");
+
+ var bespreekbaar = lijst.Where(s => s.BesprekenMet).Select(s => s.Naam).OrderBy(n => n).ToArray();
+ Assert.True(bespreekbaar.SequenceEqual(new[] { "Donorcodicil", "Uitvaartwens" }),
+ "alleen uitvaartwens en donorcodicil kennen Bespreken met, zoals voorheen");
+ Assert.True(lijst.All(s => s.Ingebouwd && s.Velden.Count == 0), "ingebouwde soorten hebben geen eigen velden");
+ });
+
+ runner.TestAsync("Soorten: alleen een beheerder maakt of wijzigt soorten", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var beheer = await BeheerderAsync(w);
+
+ Assert.True(!(await w.Soorten.MaakAsync(jan.Id, "Dementie", null, false)).Succeeded,
+ "een gewone gebruiker maakt geen soort");
+ Assert.True(!(await w.Soorten.OverzichtAsync(jan.Id)).Succeeded, "en ziet het beheeroverzicht niet");
+ Assert.True(!(await w.Soorten.WijzigAsync(jan.Id, 3, "Wachtwoord", null, false, 10, false)).Succeeded,
+ "en zet geen soort uit");
+
+ var id = await MaakAsync(w, beheer, "Dementie");
+ Assert.True(id >= VaultCategoryDefinition.EersteEigenNummer, "een eigen soort begint bij 100");
+ Assert.True(!(await w.Soorten.VerwijderAsync(jan.Id, id)).Succeeded, "een gewone gebruiker verwijdert er geen");
+ });
+
+ runner.TestAsync("Soorten: een naam is verplicht en kan niet dubbel", async () =>
+ {
+ var w = new World();
+ var beheer = await BeheerderAsync(w);
+
+ Assert.True(!(await w.Soorten.MaakAsync(beheer.Id, " ", null, false)).Succeeded, "zonder naam kan het niet");
+ Assert.True(!(await w.Soorten.MaakAsync(beheer.Id, "polis", null, false)).Succeeded,
+ "Polis bestaat al, ook in kleine letters");
+ await MaakAsync(w, beheer, "Dementie");
+ Assert.True(!(await w.Soorten.MaakAsync(beheer.Id, "DEMENTIE", null, false)).Succeeded, "een tweede Dementie kan niet");
+ });
+
+ runner.TestAsync("Soorten: een nieuwe soort staat meteen in de lijst van iedereen", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var beheer = await BeheerderAsync(w);
+
+ var id = await MaakAsync(w, beheer, "Niet reanimeren");
+ var lijst = await w.Soorten.ZichtbaarVoorAsync(jan.PersonId);
+ Assert.True(lijst.Any(s => s.Id == id && s.Naam == "Niet reanimeren" && !s.Ingebouwd),
+ "de nieuwe soort staat erbij");
+ Assert.True(lijst.Last().Naam == "Niet reanimeren", "hij staat onderaan");
+ });
+
+ runner.TestAsync("Soorten: het beheeroverzicht telt items en eigenaren, niet meer", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
+ var beheer = await BeheerderAsync(w);
+
+ await w.Vault.CreateAsync(jan.Id, VaultCategory.Polis, "A", new VaultPayload());
+ await w.Vault.CreateAsync(jan.Id, VaultCategory.Polis, "B", new VaultPayload());
+ await w.Vault.CreateAsync(piet.Id, VaultCategory.Polis, "C", new VaultPayload());
+ var weg = (await w.Vault.CreateAsync(piet.Id, VaultCategory.Polis, "D", new VaultPayload())).Value!;
+ await w.Vault.DeleteAsync(weg.Id, piet.Id);
+
+ var overzicht = (await w.Soorten.OverzichtAsync(beheer.Id)).Value!;
+ var polis = overzicht.Single(s => s.Naam == "Polis");
+ Assert.True(polis.Items == 3, "drie items: het verwijderde telt niet mee");
+ Assert.True(polis.Eigenaren == 2, "van twee mensen");
+ Assert.True(!polis.KanVerwijderen, "een ingebouwde soort in gebruik is niet te verwijderen");
+ Assert.True(overzicht.Single(s => s.Naam == "Paspoort").Items == 0, "wat niemand gebruikt staat op nul");
+ });
+
+ runner.TestAsync("Soorten: wat in gebruik is kan niet worden verwijderd, wat ongebruikt is wel", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var beheer = await BeheerderAsync(w);
+
+ var gebruikt = await MaakAsync(w, beheer, "Dementie");
+ var ongebruikt = await MaakAsync(w, beheer, "Proef");
+
+ var item = (await w.Vault.CreateAsync(jan.Id, (VaultCategory)gebruikt, "Mijn wens",
+ new VaultPayload { Velden = new() })).Value!;
+ Assert.True(item.Id != 0, "Jan maakt een item in de nieuwe soort");
+
+ var geweigerd = await w.Soorten.VerwijderAsync(beheer.Id, gebruikt);
+ Assert.True(!geweigerd.Succeeded && geweigerd.Error!.Contains("niet-actief"),
+ "een soort in gebruik wordt geweigerd, met de uitleg wat wel kan");
+ Assert.True(await w.Soorten.VindAsync(gebruikt) is not null, "hij staat er nog");
+
+ Assert.True((await w.Soorten.VerwijderAsync(beheer.Id, ongebruikt)).Succeeded, "een ongebruikte kan weg");
+ Assert.True(await w.Soorten.VindAsync(ongebruikt) is null, "en is weg");
+
+ Assert.True(!(await w.Soorten.VerwijderAsync(beheer.Id, 3)).Succeeded,
+ "een ingebouwde soort verwijder je nooit, ook niet zonder items");
+
+ // En zodra het laatste item weg is, mag het weer.
+ await w.Vault.DeleteAsync(item.Id, jan.Id);
+ Assert.True((await w.Soorten.VerwijderAsync(beheer.Id, gebruikt)).Succeeded,
+ "zonder items is hij weer te verwijderen");
+ });
+
+ runner.TestAsync("Soorten: niet-actief verdwijnt alleen bij wie hem nooit gebruikte", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
+ var beheer = await BeheerderAsync(w);
+
+ var id = await MaakAsync(w, beheer, "Dementie");
+ var item = (await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn wens",
+ new VaultPayload { Velden = new() })).Value!;
+
+ Assert.True((await w.Soorten.WijzigAsync(beheer.Id, id, "Dementie", null, false, 1000, false)).Succeeded,
+ "de beheerder zet hem op niet-actief");
+
+ Assert.True((await w.Soorten.ZichtbaarVoorAsync(jan.PersonId)).Any(s => s.Id == id),
+ "Jan heeft er een item van en blijft de soort zien");
+ Assert.True(!(await w.Soorten.ZichtbaarVoorAsync(piet.PersonId)).Any(s => s.Id == id),
+ "Piet heeft hem nooit gebruikt en ziet hem niet meer");
+
+ Assert.True(!(await w.Vault.CreateAsync(piet.Id, (VaultCategory)id, "Nieuw", new VaultPayload { Velden = new() })).Succeeded,
+ "Piet kan er ook geen nieuw item meer in maken");
+
+ var tweede = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Nog een", new VaultPayload { Velden = new() });
+ Assert.True(tweede.Succeeded, "Jan kan er nog wel nieuwe items in maken: het is een soort die hij kent");
+
+ var wijzigen = await w.Vault.UpdateAsync(item.Id, jan.Id, (VaultCategory)id, "Mijn wens (nieuw)",
+ new VaultPayload { Velden = new() }, ReleasePolicy.AlleenIkzelf);
+ Assert.True(wijzigen.Succeeded, "en zijn bestaande item kan hij gewoon wijzigen");
+
+ Assert.True((await w.Vault.OpenAsync(item.Id, jan.Id)).Succeeded, "en openen");
+
+ // Weer aanzetten: iedereen ziet hem weer.
+ await w.Soorten.WijzigAsync(beheer.Id, id, "Dementie", null, true, 1000, false);
+ Assert.True((await w.Soorten.ZichtbaarVoorAsync(piet.PersonId)).Any(s => s.Id == id),
+ "na weer aanzetten ziet Piet hem weer");
+ });
+
+ runner.TestAsync("Soorten: ook een ingebouwde soort kan op niet-actief, met dezelfde regel", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
+ var beheer = await BeheerderAsync(w);
+
+ await w.Vault.CreateAsync(jan.Id, VaultCategory.Paspoort, "Mijn paspoort", new VaultPayload());
+ await w.Soorten.WijzigAsync(beheer.Id, (int)VaultCategory.Paspoort, "Paspoort", null, false, 90, false);
+
+ Assert.True((await w.Soorten.ZichtbaarVoorAsync(jan.PersonId)).Any(s => s.Naam == "Paspoort"),
+ "Jan houdt Paspoort");
+ Assert.True(!(await w.Soorten.ZichtbaarVoorAsync(piet.PersonId)).Any(s => s.Naam == "Paspoort"),
+ "Piet ziet het niet meer");
+ });
+
+ runner.TestAsync("Soorten: Bespreken met is een instelling per soort", async () =>
+ {
+ var w = new World();
+ var opa = await w.MaakGebruikerAsync("opa@example.nl", "Klaas", "de Vries");
+ var zoon = await w.MaakFamilielidAsync(opa, "Peter", "de Vries", "zoon@example.nl");
+ await w.BeheerderAsync(opa, zoon);
+ var beheer = await BeheerderAsync(w);
+
+ // Standaard: een uitvaartwens kan het, een polis niet - zoals voorheen.
+ var wens = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Uitvaartwens, "Uitvaart", new VaultPayload(),
+ ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
+ Assert.True((await w.Vault.OpenAsync(wens.Id, zoon.Id)).Succeeded, "de gekozen beheerder leest de uitvaartwens mee");
+
+ var polis = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Polis, "Polis", new VaultPayload(),
+ ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
+ Assert.True(polis.SharedManagerPersonId is null, "bij een polis wordt de keuze genegeerd");
+ Assert.True(!(await w.Vault.OpenAsync(polis.Id, zoon.Id)).Succeeded, "en de beheerder ziet hem niet");
+
+ // De beheerder zet het bij Polis aan: nu werkt het ook daar.
+ await w.Soorten.WijzigAsync(beheer.Id, (int)VaultCategory.Polis, "Polis", null, true, 50, true);
+ var polis2 = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Polis, "Polis 2", new VaultPayload(),
+ ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
+ Assert.True(polis2.SharedManagerPersonId == zoon.PersonId, "bij Polis wordt de keuze nu bewaard");
+ Assert.True((await w.Vault.OpenAsync(polis2.Id, zoon.Id)).Succeeded, "en de beheerder leest hem mee");
+
+ // En bij Uitvaartwens uit: dan niet meer.
+ await w.Soorten.WijzigAsync(beheer.Id, (int)VaultCategory.Uitvaartwens, "Uitvaartwens", null, true, 60, false);
+ var wens2 = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Uitvaartwens, "Uitvaart 2", new VaultPayload(),
+ ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
+ Assert.True(wens2.SharedManagerPersonId is null, "met de schakelaar uit wordt de keuze genegeerd");
+ });
+
+ runner.TestAsync("Soorten: Bespreken met werkt ook bij een soort die de beheerder zelf maakte", async () =>
+ {
+ var w = new World();
+ var opa = await w.MaakGebruikerAsync("opa@example.nl", "Klaas", "de Vries");
+ var zoon = await w.MaakFamilielidAsync(opa, "Peter", "de Vries", "zoon@example.nl");
+ await w.BeheerderAsync(opa, zoon);
+ var beheer = await BeheerderAsync(w);
+
+ var met = await MaakAsync(w, beheer, "Niet reanimeren", bespreken: true);
+ var zonder = await MaakAsync(w, beheer, "Dementie", bespreken: false);
+
+ var a = (await w.Vault.CreateAsync(opa.Id, (VaultCategory)met, "NR", new VaultPayload { Velden = new() },
+ ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
+ var b = (await w.Vault.CreateAsync(opa.Id, (VaultCategory)zonder, "DM", new VaultPayload { Velden = new() },
+ ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
+ Assert.True((await w.Vault.OpenAsync(a.Id, zoon.Id)).Succeeded, "met de schakelaar aan leest hij mee");
+ Assert.True(!(await w.Vault.OpenAsync(b.Id, zoon.Id)).Succeeded, "met de schakelaar uit niet");
+ });
+
+ runner.TestAsync("Soorten: een soort die niet bestaat is geen soort", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var r = await w.Vault.CreateAsync(jan.Id, (VaultCategory)4242, "Onzin", new VaultPayload());
+ Assert.True(!r.Succeeded, "een onbekend soortnummer wordt geweigerd");
+ });
+
+ runner.TestAsync("Soorten: de sleutel van een veld komt uit het label en is uniek", () =>
+ {
+ Assert.True(VaultCategoryService.NieuweSleutel("Wil je gereanimeerd worden?", Array.Empty())
+ == "wil_je_gereanimeerd_worden", "een vraag wordt een leesbare sleutel");
+ Assert.True(VaultCategoryService.NieuweSleutel("Café — één", Array.Empty()) == "cafe_een",
+ "accenten en streepjes verdwijnen");
+ Assert.True(VaultCategoryService.NieuweSleutel("???", Array.Empty()) == "veld",
+ "zonder bruikbare tekens blijft 'veld' over");
+ Assert.True(VaultCategoryService.NieuweSleutel("Arts", new[] { "arts" }) == "arts_2",
+ "een bestaande sleutel krijgt een nummer");
+ Assert.True(VaultCategoryService.NieuweSleutel(new string('x', 80), Array.Empty()).Length <= 30,
+ "en een lang label wordt bijgesneden");
+ return Task.CompletedTask;
+ });
+
+ runner.TestAsync("Velden: een eigen soort bewaart zijn velden versleuteld en geeft ze terug", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var beheer = await BeheerderAsync(w);
+
+ var id = await MaakAsync(w, beheer, "Niet reanimeren");
+ Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Wil je gereanimeerd worden?", VeldSoort.Keuze,
+ "Ja\nNee\nAlleen na overleg", null, true)).Succeeded, "keuzeveld toevoegen");
+ Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Datum verklaring", VeldSoort.Datum,
+ null, null, false)).Succeeded, "datumveld toevoegen");
+ Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Toelichting voor de arts", VeldSoort.Lang,
+ null, "Zo precies mogelijk.", false)).Succeeded, "lang veld toevoegen");
+
+ var soort = (await w.Soorten.ZichtbaarVoorAsync(jan.PersonId)).Single(s => s.Id == id);
+ Assert.True(soort.Velden.Select(v => v.Sleutel).SequenceEqual(
+ new[] { "wil_je_gereanimeerd_worden", "datum_verklaring", "toelichting_voor_de_arts" }),
+ "de velden staan in de volgorde van toevoegen");
+ Assert.True(soort.Velden[0].Opties.SequenceEqual(new[] { "Ja", "Nee", "Alleen na overleg" }), "de opties kloppen");
+
+ var item = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn verklaring", new VaultPayload
+ {
+ Velden = new()
+ {
+ ["wil_je_gereanimeerd_worden"] = "Nee",
+ ["datum_verklaring"] = "2026-10-12",
+ ["onbekend_veld"] = "hoort er niet"
+ }
+ });
+ Assert.True(item.Succeeded, "het item wordt bewaard");
+
+ var open = (await w.Vault.OpenAsync(item.Value!.Id, jan.Id)).Value!;
+ Assert.True(open.Velden is not null && open.Velden["wil_je_gereanimeerd_worden"] == "Nee", "de waarde komt terug");
+ Assert.True(open.Velden!["datum_verklaring"] == "2026-10-12", "ook de datum");
+ Assert.True(!open.Velden.ContainsKey("onbekend_veld"), "een veld dat niet bestaat wordt weggelaten");
+
+ // En het staat echt versleuteld in de opslag, niet leesbaar.
+ var opgeslagen = w.Store.VaultItems.Single(v => v.Id == item.Value.Id);
+ Assert.True(!System.Text.Encoding.UTF8.GetString(opgeslagen.Ciphertext).Contains("Nee"),
+ "in de opslag staat niets leesbaars");
+ });
+
+ runner.TestAsync("Velden: verplicht, datum en lengte worden op de server gecontroleerd", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var beheer = await BeheerderAsync(w);
+
+ var id = await MaakAsync(w, beheer, "Niet reanimeren");
+ await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Keuze", VeldSoort.Keuze, "Ja\nNee", null, true);
+ await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Datum", VeldSoort.Datum, null, null, false);
+
+ var leeg = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload { Velden = new() });
+ Assert.True(!leeg.Succeeded && leeg.Error!.Contains("Keuze"), "een leeg verplicht veld wordt geweigerd, met de naam erbij");
+
+ var slechteDatum = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload
+ { Velden = new() { ["keuze"] = "Ja", ["datum"] = "morgen" } });
+ Assert.True(!slechteDatum.Succeeded && slechteDatum.Error!.Contains("datum"), "een datum die geen datum is wordt geweigerd");
+
+ var teLang = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload
+ { Velden = new() { ["keuze"] = new string('a', VaultCategoryService.MaxWaardelengte + 1) } });
+ Assert.True(!teLang.Succeeded, "een te lange waarde wordt geweigerd");
+
+ var goed = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload
+ { Velden = new() { ["keuze"] = "Ja", ["datum"] = "2026-02-28" } });
+ Assert.True(goed.Succeeded, "met alles goed ingevuld lukt het");
+ });
+
+ runner.TestAsync("Velden: een app die de soort niet kent kan geen gegevens overschrijven met niets", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var beheer = await BeheerderAsync(w);
+
+ var id = await MaakAsync(w, beheer, "Dementie");
+ await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Wens", VeldSoort.Lang, null, null, false);
+ var item = (await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn wens", new VaultPayload
+ { Velden = new() { ["wens"] = "Thuis blijven wonen." } })).Value!;
+
+ // Een oudere app stuurt de inhoud zonder het onderdeel Velden mee.
+ var oud = await w.Vault.UpdateAsync(item.Id, jan.Id, (VaultCategory)id, "Mijn wens",
+ new VaultPayload { Notes = "alleen een notitie" }, ReleasePolicy.AlleenIkzelf);
+ Assert.True(!oud.Succeeded && oud.Error!.Contains("app"), "het wijzigen wordt geweigerd met het advies de app bij te werken");
+
+ var open = (await w.Vault.OpenAsync(item.Id, jan.Id)).Value!;
+ Assert.True(open.Velden!["wens"] == "Thuis blijven wonen.", "wat er stond is niet overschreven");
+ });
+
+ runner.TestAsync("Velden: een ingebouwde soort bewaart geen eigen velden", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+
+ var item = (await w.Vault.CreateAsync(jan.Id, VaultCategory.Polis, "Polis", new VaultPayload
+ { Insurer = "Zilveren Kruis", Velden = new() { ["iets"] = "weg ermee" } })).Value!;
+ var open = (await w.Vault.OpenAsync(item.Id, jan.Id)).Value!;
+ Assert.True(open.Velden is null, "bij een ingebouwde soort wordt Velden leeggemaakt");
+ Assert.True(open.Insurer == "Zilveren Kruis", "de eigen velden van de soort blijven gewoon");
+ });
+
+ runner.TestAsync("Velden: ingebouwde soorten hebben geen velden om te wijzigen", async () =>
+ {
+ var w = new World();
+ var beheer = await BeheerderAsync(w);
+
+ var r = await w.Soorten.VeldToevoegenAsync(beheer.Id, (int)VaultCategory.Polis, "Extra", VeldSoort.Tekst, null, null, false);
+ Assert.True(!r.Succeeded, "een veld toevoegen aan een ingebouwde soort kan niet");
+ });
+
+ runner.TestAsync("Velden: een veld met ingevulde gegevens wordt niet verwijderd maar niet-actief", async () =>
+ {
+ var w = new World();
+ var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
+ var beheer = await BeheerderAsync(w);
+
+ var id = await MaakAsync(w, beheer, "Dementie");
+ await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Wens", VeldSoort.Lang, null, null, false);
+ await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Arts", VeldSoort.Tekst, null, null, false);
+ var velden = (await w.Soorten.OverzichtAsync(beheer.Id)).Value!.Single(s => s.Id == id).Velden;
+ var wens = velden.Single(v => v.Sleutel == "wens");
+ var arts = velden.Single(v => v.Sleutel == "arts");
+
+ // Zonder items mag een veld gewoon weg.
+ Assert.True((await w.Soorten.VeldVerwijderenAsync(beheer.Id, arts.Id)).Succeeded, "zonder items kan een veld weg");
+
+ await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn wens", new VaultPayload
+ { Velden = new() { ["wens"] = "Thuis blijven wonen." } });
+
+ var geweigerd = await w.Soorten.VeldVerwijderenAsync(beheer.Id, wens.Id);
+ Assert.True(!geweigerd.Succeeded && geweigerd.Error!.Contains("niet-actief"),
+ "met items erbij wordt verwijderen geweigerd, met de uitleg");
+
+ // Niet-actief mag. De waarde blijft bewaard en bij het wijzigen nog bruikbaar.
+ Assert.True((await w.Soorten.VeldWijzigenAsync(beheer.Id, wens.Id, "Wens", null, null, false, false, 10)).Succeeded,
+ "op niet-actief zetten kan");
+ var item = w.Store.VaultItems.Single(v => v.Title == "Mijn wens");
+ var wijzig = await w.Vault.UpdateAsync(item.Id, jan.Id, (VaultCategory)id, "Mijn wens",
+ new VaultPayload { Velden = new() { ["wens"] = "Thuis blijven wonen, met hulp." } }, ReleasePolicy.AlleenIkzelf);
+ Assert.True(wijzig.Succeeded, "het item is nog te wijzigen");
+ var open = (await w.Vault.OpenAsync(item.Id, jan.Id)).Value!;
+ Assert.True(open.Velden!["wens"] == "Thuis blijven wonen, met hulp.",
+ "en de waarde van het niet-actieve veld blijft bewaard");
+ });
+
+ runner.TestAsync("Velden: een keuzelijst heeft minstens twee opties, een uitleg heeft tekst", async () =>
+ {
+ var w = new World();
+ var beheer = await BeheerderAsync(w);
+ var id = await MaakAsync(w, beheer, "Dementie");
+
+ Assert.True(!(await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Keuze", VeldSoort.Keuze, "Alleen deze", null, false)).Succeeded,
+ "één optie is geen keuze");
+ Assert.True(!(await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Uitleg", VeldSoort.Uitleg, null, null, false)).Succeeded,
+ "een uitleg zonder tekst kan niet");
+ Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Uitleg", VeldSoort.Uitleg, null,
+ "Een papieren verklaring is leidend.", true)).Succeeded, "met tekst kan het wel");
+ var uitleg = (await w.Soorten.OverzichtAsync(beheer.Id)).Value!.Single(s => s.Id == id).Velden.Single();
+ Assert.True(!uitleg.Verplicht, "een uitlegtekst kan nooit verplicht zijn");
+ Assert.True(!(await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Raar", "zweef", null, null, false)).Succeeded,
+ "een soort veld die we niet kennen wordt geweigerd");
+ });
+
+ runner.TestAsync("Soorten: wijzigingen door de beheerder staan in het logboek", async () =>
+ {
+ var w = new World();
+ var beheer = await BeheerderAsync(w);
+ var id = await MaakAsync(w, beheer, "Dementie");
+ await w.Soorten.WijzigAsync(beheer.Id, id, "Dementie", null, false, 1000, false);
+ await w.Soorten.VerwijderAsync(beheer.Id, id);
+
+ var regels = w.Store.Audit.Where(a => a.Action == AuditAction.KluisSoortBeheerd).ToList();
+ Assert.True(regels.Count == 3, "gemaakt, gewijzigd en verwijderd staan er alle drie in");
+ Assert.True(regels.All(a => a.UserId == beheer.Id), "met de beheerder erbij");
+ });
+ }
+}