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 @@ MijnBesluit nl.mijnbesluit.app - 1.0 - 2 + 1.1 + 3 24.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) {
@@ -278,7 +277,7 @@ @onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(item))">
- @Tonen.Categorie(item.Category) + @item.SoortNaam(NaamVan)
@item.Title
@* 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) => @
+ @if (veld.Soort == KluisVeld.Uitleg) + { +
@veld.Hulptekst
+ } + else + { + + + @switch (veld.Soort) + { + case KluisVeld.Lang: + + break; + + case KluisVeld.Datum: + + break; + + case KluisVeld.Keuze: + + break; + + default: + + break; + } + + @if (!string.IsNullOrWhiteSpace(veld.Hulptekst)) + { +
@veld.Hulptekst
+ } + } +
; + + 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. *@

Advertenties beheren ›

+ @* Kluissoorten: wat mensen in hun kluis kunnen vastleggen, en wat je daar zelf bij zet. + Ook een eigen scherm: dat is inrichten, geen terugdraaien. *@ +

Kluissoorten beheren ›

+ @if (Melding is not null) {
@Melding
} @* ------------------------------------------------------------ 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. +

+

‹ Terug naar beheer

+ +
+ 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.

+ } + +
+ @foreach (var v in s.Velden) + { +
+
+
+
@v.Label
+
+ @VeldSoort.Naam(v.Soort) + @if (v.Verplicht) { · verplicht } + @if (!v.Actief) { · niet actief } +
+
+ +
+ + @if (VeldBewerkId == v.Id) + { +
+ + +
+ 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. + } +

+ + @if (WeghalenVeld == v.Id) + { +
+ @v.Label verwijderen? +
+ + +
+
+ } + } +
+ } +
+ + @* ---------------------------------------------------- veld toevoegen *@ +

Veld toevoegen

+
+
+ + +
+
+ + +
+
+ @if (NieuwVeld.Soort == VeldSoort.Keuze) + { +
+ + +
+ } +
+ + +
+ @if (NieuwVeld.Soort != VeldSoort.Uitleg) + { +
+ + +
+ } +
+ +
+ } + } +
+ } +
+ } +} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId; + private bool Magdit, Bezig; + private string? Melding, MeldingSoort; + + private IReadOnlyList? Lijst; + + /// Welke soort openstaat voor beheer. + private int? Open; + private int? WeghalenSoort; + private long? VeldBewerkId, WeghalenVeld; + + private SoortFormulier NieuweSoort = new(); + private SoortFormulier Bewerken = new(); + private VeldFormulier NieuwVeld = new(); + private VeldFormulier VeldBewerken = new(); + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + _gebruikerId = (await AuthState).User.UserId() ?? 0; + Magdit = await Beheerkant.IsBeheerderAsync(_gebruikerId); + if (!Magdit) return; + await Laden(); + } + + private async Task Laden() + { + var resultaat = await SoortService.OverzichtAsync(_gebruikerId); + if (resultaat.Succeeded) Lijst = resultaat.Value; + else Meld(resultaat.Error, false); + } + + private void Meld(string? tekst, bool goed) + { + Melding = tekst; + MeldingSoort = goed ? "goed" : "fout"; + } + + private static string Aantal(int n, string een, string meer) => $"{n} {(n == 1 ? een : meer)}"; + + private static string Gebruik(SoortBeheerbeeld s) => s.Items == 0 + ? "Nog nooit gebruikt" + : $"Gebruikt in {Aantal(s.Items, "item", "items")} door {Aantal(s.Eigenaren, "persoon", "mensen")}"; + + private void Openen(SoortBeheerbeeld s) + { + if (Open == s.Id) { Open = null; return; } + + Open = s.Id; + WeghalenSoort = null; + VeldBewerkId = null; + WeghalenVeld = null; + NieuwVeld = new VeldFormulier(); + Bewerken = new SoortFormulier + { + Naam = s.Naam, Uitleg = s.Uitleg ?? "", Actief = s.Actief, + Volgorde = s.Volgorde, BesprekenMet = s.BesprekenMet + }; + } + + private async Task Aanmaken() + { + Bezig = true; + try + { + var resultaat = await SoortService.MaakAsync(_gebruikerId, NieuweSoort.Naam, NieuweSoort.Uitleg, + NieuweSoort.BesprekenMet); + if (resultaat.Succeeded) + { + Meld($"{NieuweSoort.Naam.Trim()} is toegevoegd. Vul nu de velden in.", true); + NieuweSoort = new SoortFormulier(); + await Laden(); + var gemaakt = Lijst?.FirstOrDefault(x => x.Id == resultaat.Value); + if (gemaakt is not null) { Open = null; Openen(gemaakt); } + } + else Meld(resultaat.Error, false); + } + finally { Bezig = false; } + } + + private async Task Opslaan(int id) + { + Bezig = true; + try + { + var resultaat = await SoortService.WijzigAsync(_gebruikerId, id, Bewerken.Naam, Bewerken.Uitleg, + Bewerken.Actief, Bewerken.Volgorde, Bewerken.BesprekenMet); + if (resultaat.Succeeded) { Meld("Opgeslagen.", true); await Laden(); } + else Meld(resultaat.Error, false); + } + finally { Bezig = false; } + } + + private async Task Verwijderen(int id) + { + var resultaat = await SoortService.VerwijderAsync(_gebruikerId, id); + WeghalenSoort = null; + if (resultaat.Succeeded) { Open = null; Meld("De soort is verwijderd.", true); await Laden(); } + else Meld(resultaat.Error, false); + } + + private void VeldOpenen(VeldBeheerbeeld v) + { + if (VeldBewerkId == v.Id) { VeldBewerkId = null; return; } + + VeldBewerkId = v.Id; + WeghalenVeld = null; + VeldBewerken = new VeldFormulier + { + Label = v.Label, Soort = v.Soort, Opties = v.Opties ?? "", Hulptekst = v.Hulptekst ?? "", + Verplicht = v.Verplicht, Actief = v.Actief, Volgorde = v.Volgorde + }; + } + + private async Task VeldToevoegen(int soortId) + { + Bezig = true; + try + { + var resultaat = await SoortService.VeldToevoegenAsync(_gebruikerId, soortId, NieuwVeld.Label, NieuwVeld.Soort, + NieuwVeld.Opties, NieuwVeld.Hulptekst, NieuwVeld.Verplicht); + if (resultaat.Succeeded) { Meld($"{NieuwVeld.Label.Trim()} is toegevoegd.", true); NieuwVeld = new VeldFormulier(); await Laden(); } + else Meld(resultaat.Error, false); + } + finally { Bezig = false; } + } + + private async Task VeldOpslaan(long veldId) + { + Bezig = true; + try + { + var resultaat = await SoortService.VeldWijzigenAsync(_gebruikerId, veldId, VeldBewerken.Label, VeldBewerken.Opties, + VeldBewerken.Hulptekst, VeldBewerken.Verplicht, + VeldBewerken.Actief, VeldBewerken.Volgorde); + if (resultaat.Succeeded) { Meld("Het veld is opgeslagen.", true); VeldBewerkId = null; await Laden(); } + else Meld(resultaat.Error, false); + } + finally { Bezig = false; } + } + + private async Task VeldVerwijderen(long veldId) + { + var resultaat = await SoortService.VeldVerwijderenAsync(_gebruikerId, veldId); + WeghalenVeld = null; + if (resultaat.Succeeded) { VeldBewerkId = null; Meld("Het veld is verwijderd.", true); await Laden(); } + else Meld(resultaat.Error, false); + } + + public class SoortFormulier + { + public string Naam { get; set; } = ""; + public string Uitleg { get; set; } = ""; + public bool Actief { get; set; } = true; + public int Volgorde { get; set; } + public bool BesprekenMet { get; set; } + } + + public class VeldFormulier + { + public string Label { get; set; } = ""; + public string Soort { get; set; } = VeldSoort.Tekst; + public string Opties { get; set; } = ""; + public string Hulptekst { get; set; } = ""; + public bool Verplicht { get; set; } + public bool Actief { get; set; } = true; + public int Volgorde { get; set; } + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Kluis.razor b/src/MijnBesluit.Web/Components/Pages/Kluis.razor index 88b9661..f654bc9 100644 --- a/src/MijnBesluit.Web/Components/Pages/Kluis.razor +++ b/src/MijnBesluit.Web/Components/Pages/Kluis.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @inject VaultService KluisService @inject LegacyService BeheerService +@inject VaultCategoryService SoortService @inject IJSRuntime JS @rendermode InteractiveServer @@ -21,9 +22,9 @@
@@ -43,9 +44,13 @@ OpBevestigen="Opslaan" OpSluiten="Annuleren">
- - @foreach (var c in Categorieen) { } + + @foreach (var s in Soorten) { } + @if (!string.IsNullOrWhiteSpace(HuidigeSoort?.Uitleg)) + { +
@HuidigeSoort!.Uitleg
+ }
@@ -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) => @
+ @if (veld.Soort == VeldSoort.Uitleg) + { +
@veld.Hulptekst
+ } + else + { + + @if (veld.Soort == VeldSoort.Lang) + { + + } + else if (veld.Soort == VeldSoort.Datum) + { + + } + else if (veld.Soort == VeldSoort.Keuze) + { + + } + else + { + + } + @if (!string.IsNullOrWhiteSpace(veld.Hulptekst)) + { +
@veld.Hulptekst
+ } + } +
; 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"); + }); + } +}