Compare commits

...
21 Commits
Author SHA1 Message Date
jf fc31949d84 Uitrol 10-11-2026 16:37 2026-11-10 16:39:27 +01:00
jf 95e496134b Uitrol 01-11-2026 13:32 2026-11-01 13:32:49 +01:00
jf 2b429cb36d App Store: iPhone-schermen 2026-11-01 12:53:51 +01:00
jfandClaude Sonnet 5.5 8eb0badd5d iOS: voorlopig alleen iPhone, bouwnummer 4
De iPad-weergave toont een lege pagina; iPad later weer toevoegen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-11-01 11:38:56 +00:00
jf 5b791d5244 Uitrol 31-10-2026 12:23 2026-10-31 12:23:34 +01:00
jf a0d36083e1 Uitrol 30-10-2026 08:39 2026-10-30 08:39:29 +01:00
jf a206bde3c5 Uitrol 30-10-2026 08:13 2026-10-30 08:13:30 +01:00
jfandClaude Sonnet 5.5 44064d2ea2 iOS: archief-optie voor een .ipa (App Store Connect) en stappen in docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1ukfnarqhut9ifcx
2026-10-30 05:18:33 +00:00
jfandClaude Sonnet 5.5 88a89bfa01 Changelog voor SideKick: view serverchangelog bovenop version_history
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-23 14:01:19 +00:00
jfandClaude Sonnet 5.5 4918df574a docs: iOS bouwen op een gehuurde Mac zonder beheerrechten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 12:56:49 +00:00
jfandClaude Sonnet 5.5 706403ba31 iOS: bouwen op een Mac zonder Android-werkbelasting, simulator zonder certificaat
MetAndroid=false laat Android weg uit de TargetFrameworks (anders weigert een Mac
zonder Android-werkbelasting de build). ios-bouwen.sh bouwt nu eerst en start daarna,
en gebruikt voor de simulator een lokale handtekening (CodesignKey=-).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 12:51:47 +00:00
jfandClaude Sonnet 5.5 5446dd4170 Versiehistorie in een tabel, voor site en app
De versiehistorie stond als tekst in zowel de app als de website. Nu staat hij in
tabel version_history, met een beheerpagina (Beheer > Versiehistorie) op de site.
De site leest de tabel rechtstreeks, de app via de nieuwe actie app.versiehistorie.
Startrijen: db/handmatig/22b_versiehistorie_startrijen.sql (als root).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 11:47:06 +00:00
jfandClaude Sonnet 5.5 90c58fc42e Versiehistorie: versie 1.1 (meer soorten in de kluis), site en app gelijk
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 09:06:56 +00:00
jfandClaude Sonnet 5.5 ae7b30b745 Play-release-opmerking voor versie 1.1 (kluissoorten)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 08:48:33 +00:00
jfandClaude Sonnet 5.5 4bf1ee1060 Android-app.md: Play-koppeling via Winkelkoppelingen in SideKick
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaFmzMN5i7qyZihKBsksxz
2026-10-20 08:27:25 +00:00
jfandClaude Sonnet 5.5 fe7ad9e97b Play Store: script Bouwen-playstore.ps1 vervangen door de tegel in SideKick; docs (release-opmerking in docs/Play-release-opmerking.txt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaFmzMN5i7qyZihKBsksxz
2026-10-20 08:22:37 +00:00
jfandClaude Sonnet 5.5 af13d0e533 Flexibele kluissoorten: soorten in de database, beheer op de website
- Tabellen vault_categories en vault_category_fields (db/21), startrijen handmatig (db/handmatig/21b)
- VaultCategoryService met regels voor zichtbaarheid, gebruik en eigen velden
- Beheer > Kluissoorten (/beheer/soorten) met gebruikstellers; soort in gebruik niet te verwijderen
- Kluis en Nalatenschap (web en app) tonen soorten en eigen velden uit de database
- Socketprotocol: vault.categories; GRANTs voor mijnbesluit_app in db/02
- db/20 herhaalbaar gemaakt
- App versie 1.1 (versiecode 3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 08:15:40 +00:00
jfandClaude Sonnet 5.5 a3a0bcfc05 Bouwen-playstore.ps1: AAB naar de Play Console met versiecontrole (niet getest)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaFmzMN5i7qyZihKBsksxz
2026-10-20 07:58:09 +00:00
jfandClaude Sonnet 5.5 3b7f0412b6 Bouwen-android-aab.ps1: AAB voor de Play Store via SideKick
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaFmzMN5i7qyZihKBsksxz
2026-10-20 07:54:25 +00:00
jfandClaude Sonnet 5.5 28d96a483b docs: Android-app bouwen vanuit SideKick
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaFmzMN5i7qyZihKBsksxz
2026-10-20 07:51:40 +00:00
jfandClaude Sonnet 5.5 7b60d7583e Bouwen-android.ps1: bouwstap voor SideKick (onderdeel mijnbesluit-app, Sleutel android) via Apk-maken.ps1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaFmzMN5i7qyZihKBsksxz
2026-10-20 07:48:54 +00:00
77 changed files with 4222 additions and 195 deletions
+205
View File
@@ -0,0 +1,205 @@
<#
Zet de nieuwste AAB in de Google Play Console (Android Publisher API), met versiecontrole.
Voor SideKick: onderdeel mijnbesluit-playstore, Sleutel playstore. NIET GETEST tegen Google.
Wat het doet
1. Leest ApplicationVersion uit MijnBesluit.App.csproj en vraagt aan Google welke versiecodes
al op de tracks en in de bundels staan. Is de versie niet hoger dan de hoogste: stoppen.
2. Uploadt de nieuwste *.aab uit -AabMap.
3. Zet hem op de track (standaard intern testen) als CONCEPT: er gaat niets live. Publiceren
doe je zelf in de Play Console.
4. Schrijft playstore-upload.txt in -Uitvoer (zo ziet SideKick dat het gelukt is).
Eenmalig regelen (zie docs\Android-app.md)
* De app staat al in de Play Console en de eerste AAB is met de hand geupload.
* Een serviceaccount met sleutelbestand (JSON) en recht om releases te beheren.
* Gebruikersvariabele MIJNBESLUIT_PLAY_SLEUTEL = pad naar dat JSON-bestand (buiten de repo).
SideKick opnieuw starten na het zetten van de variabele.
Het sleutelbestand wordt alleen gelezen en komt nooit in een log of de repo.
#>
[CmdletBinding()]
param(
[string] $Configuratie = 'Release',
# Waar playstore-upload.txt komt (SideKick: <publicatiemap>\playstore).
[string] $Uitvoer,
# De map met de AAB van Bouwen-android-aab.ps1.
[string] $AabMap = 'D:\MijnBesluit VS\MijnBesluitpublish\android-aab',
[string] $Pakket = 'nl.mijnbesluit.app',
# internal = intern testen, alpha = gesloten testen, production = productie.
[ValidateSet('internal', 'alpha', 'beta', 'production')]
[string] $Track = 'internal',
# draft = concept, nog te publiceren in de Play Console. completed = meteen uitrollen
# (bij een app die nog nooit is gepubliceerd weigert Google dat).
[ValidateSet('draft', 'completed')]
[string] $Status = 'draft',
[string] $Notities = 'Nieuwe versie.',
[string] $Taal = 'nl-NL',
# Alleen controleren (versies ophalen en vergelijken); niets uploaden.
[switch] $Controle
)
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$csproj = Join-Path $PSScriptRoot '..\src\MijnBesluit.App\MijnBesluit.App.csproj' | Resolve-Path
# ------------------------------------------------------------------ versie uit de csproj
$tekst = Get-Content $csproj -Raw
if ($tekst -notmatch '<ApplicationVersion>\s*(\d+)\s*</ApplicationVersion>') {
throw "Geen ApplicationVersion (geheel getal) gevonden in $csproj."
}
$nieuweVersie = [int]$Matches[1]
$weergave = if ($tekst -match '<ApplicationDisplayVersion>\s*([^<]+?)\s*</ApplicationDisplayVersion>') { $Matches[1] } else { '?' }
Write-Host "Versie in de csproj: $weergave (versiecode $nieuweVersie)"
# ------------------------------------------------------------------ welke AAB
if (-not (Test-Path $AabMap)) { throw "De AAB-map bestaat niet: $AabMap. Bouw eerst de AAB (mijnbesluit-aab)." }
$aab = Get-ChildItem $AabMap -Filter '*.aab' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $aab) { throw "Geen .aab gevonden in $AabMap. Bouw eerst de AAB (mijnbesluit-aab)." }
Write-Host "AAB: $($aab.FullName) ($([math]::Round($aab.Length / 1MB, 1)) MB, $($aab.LastWriteTime))"
if ($aab.LastWriteTime -lt (Get-Item $csproj).LastWriteTime) {
throw "De AAB is ouder dan de csproj. Bouw de AAB opnieuw (mijnbesluit-aab), anders zit er een andere versiecode in dan hierboven."
}
# ------------------------------------------------------------------ sleutelbestand en token
$sleutelPad = $env:MIJNBESLUIT_PLAY_SLEUTEL
if (-not $sleutelPad) {
throw @"
De gebruikersvariabele MIJNBESLUIT_PLAY_SLEUTEL ontbreekt.
Zet hem op het pad van het JSON-sleutelbestand van het serviceaccount (buiten de repo):
[Environment]::SetEnvironmentVariable('MIJNBESLUIT_PLAY_SLEUTEL', 'D:\pad\naar\sleutel.json', 'User')
Start SideKick daarna opnieuw.
"@
}
if (-not (Test-Path $sleutelPad)) { throw "Het sleutelbestand bestaat niet: $sleutelPad" }
$sa = Get-Content $sleutelPad -Raw | ConvertFrom-Json
if (-not $sa.client_email -or -not $sa.private_key) { throw "Dit is geen sleutelbestand van een serviceaccount (client_email/private_key ontbreken)." }
function ConvertTo-Base64Url([byte[]] $bytes) {
[Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
function Get-Token {
$nu = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$kop = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes('{"alg":"RS256","typ":"JWT"}'))
$aud = if ($sa.token_uri) { $sa.token_uri } else { 'https://oauth2.googleapis.com/token' }
$claims = @{ iss = $sa.client_email; scope = 'https://www.googleapis.com/auth/androidpublisher'; aud = $aud; iat = $nu; exp = $nu + 3000 } | ConvertTo-Json -Compress
$inhoud = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($claims))
$teTekenen = [Text.Encoding]::ASCII.GetBytes("$kop.$inhoud")
$pem = ($sa.private_key -replace '-----[A-Z ]+-----', '' -replace '\s', '')
$sleutel = [Security.Cryptography.CngKey]::Import([Convert]::FromBase64String($pem), [Security.Cryptography.CngKeyBlobFormat]::Pkcs8PrivateBlob)
$rsa = New-Object Security.Cryptography.RSACng($sleutel)
$handtekening = $rsa.SignData($teTekenen, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)
$jwt = "$kop.$inhoud." + (ConvertTo-Base64Url $handtekening)
$antwoord = Invoke-RestMethod -Method Post -Uri $aud -ContentType 'application/x-www-form-urlencoded' `
-Body "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$jwt"
return $antwoord.access_token
}
function Invoke-Play([string] $methode, [string] $url, $body = $null, [string] $infile = $null) {
$kopjes = @{ Authorization = "Bearer $script:token" }
try {
if ($infile) {
return Invoke-RestMethod -Method $methode -Uri $url -Headers $kopjes -ContentType 'application/octet-stream' -InFile $infile -TimeoutSec 900
}
if ($null -ne $body) {
return Invoke-RestMethod -Method $methode -Uri $url -Headers $kopjes -ContentType 'application/json' -Body ($body | ConvertTo-Json -Depth 10)
}
return Invoke-RestMethod -Method $methode -Uri $url -Headers $kopjes
}
catch {
$detail = $_.ErrorDetails.Message
if (-not $detail -and $_.Exception.Response) {
try { $detail = (New-Object IO.StreamReader($_.Exception.Response.GetResponseStream())).ReadToEnd() } catch { }
}
throw "Google Play weigerde $methode $url : $($_.Exception.Message) $detail"
}
}
Write-Host "Inloggen bij Google Play als $($sa.client_email)..."
$token = Get-Token
$basis = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$Pakket"
$edit = Invoke-Play 'POST' "$basis/edits" @{}
$editId = $edit.id
Write-Host "Bewerking $editId geopend."
try {
# -------------------------------------------------------------- versiecontrole
$hoogste = 0
$gevonden = @()
$tracks = Invoke-Play 'GET' "$basis/edits/$editId/tracks"
foreach ($t in @($tracks.tracks)) {
foreach ($r in @($t.releases)) {
foreach ($v in @($r.versionCodes)) {
$gevonden += "$($t.track): $v ($($r.status))"
if ([int]$v -gt $hoogste) { $hoogste = [int]$v }
}
}
}
$bundels = Invoke-Play 'GET' "$basis/edits/$editId/bundles"
foreach ($b in @($bundels.bundles)) {
if ([int]$b.versionCode -gt $hoogste) { $hoogste = [int]$b.versionCode }
}
if ($gevonden.Count) { Write-Host "Al bij Google: $($gevonden -join '; ')" } else { Write-Host "Nog geen versies bij Google gevonden." }
Write-Host "Hoogste versiecode bij Google: $hoogste; nieuwe versiecode: $nieuweVersie"
if ($nieuweVersie -le $hoogste) {
throw "De versiecode in de csproj ($nieuweVersie) is niet hoger dan wat Google al heeft ($hoogste). Verhoog ApplicationVersion in MijnBesluit.App.csproj en bouw de AAB opnieuw."
}
if ($Controle) {
Write-Host "Controle gelukt: versiecode $nieuweVersie mag geupload worden. Er is niets geupload." -ForegroundColor Green
Invoke-Play 'DELETE' "$basis/edits/$editId" | Out-Null
return
}
# -------------------------------------------------------------- uploaden
Write-Host "AAB uploaden..."
$bundel = Invoke-Play 'POST' "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/$Pakket/edits/$editId/bundles?uploadType=media" -infile $aab.FullName
$code = [int]$bundel.versionCode
Write-Host "Geupload als versiecode $code."
if ($code -ne $nieuweVersie) {
Write-Host "Let op: de AAB heeft versiecode $code, de csproj zegt $nieuweVersie. Bouw de AAB opnieuw als dat niet de bedoeling is." -ForegroundColor Yellow
}
# -------------------------------------------------------------- op de track zetten
$release = @{
name = "$weergave ($code)"
versionCodes = @("$code")
status = $Status
releaseNotes = @(@{ language = $Taal; text = $Notities })
}
Invoke-Play 'PUT' "$basis/edits/$editId/tracks/$Track" @{ track = $Track; releases = @($release) } | Out-Null
Write-Host "Op track '$Track' gezet (status: $Status)."
Invoke-Play 'POST' "$basis/edits/$editId`:commit" @{} | Out-Null
Write-Host "Vastgelegd bij Google Play." -ForegroundColor Green
}
catch {
try { Invoke-Play 'DELETE' "$basis/edits/$editId" | Out-Null } catch { }
throw
}
$samenvatting = "Geupload naar Google Play ($Pakket): versie $weergave, versiecode $code, track $Track, status $Status, op $(Get-Date -Format 'yyyy-MM-dd HH:mm')."
if ($Uitvoer) {
New-Item -ItemType Directory -Path $Uitvoer -Force | Out-Null
Set-Content -Path (Join-Path $Uitvoer 'playstore-upload.txt') -Value $samenvatting -Encoding UTF8
}
Write-Host $samenvatting
if ($Status -eq 'draft') { Write-Host "Het is een concept: publiceer de release zelf in de Play Console." }
+3
View File
@@ -53,6 +53,9 @@ 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`.`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`.`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`.`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';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`version_history` TO 'mijnbesluit_app'@'localhost';
-- Het logboek mag er alleen bij komen en uit gelezen worden, niet uit verdwijnen. -- 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'; GRANT SELECT, INSERT ON `mijnbesluit`.`audit_log` TO 'mijnbesluit_app'@'localhost';
+30 -3
View File
@@ -3,6 +3,33 @@
-- Wie een bezorgd bericht "verwijdert" haalt het alleen bij zichzelf weg; de ander heeft er nog -- 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 -- 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. -- 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`, -- Draaien met:
ADD COLUMN `hidden_by_recipient` TINYINT(1) NOT NULL DEFAULT 0 AFTER `hidden_by_sender`; -- 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;
+76
View File
@@ -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;
+45
View File
@@ -0,0 +1,45 @@
-- MijnBesluit - de versiehistorie in de database in plaats van in de code
--
-- De lijst "wat is er nieuw" stond vast in twee bestanden (Versiehistorie.razor in de site en in de
-- app), en moest dus bij elke wijziging op twee plekken worden aangepast. En de app die al in de
-- Play Store staat kon een nieuwe regel nooit tonen. Nu staat de lijst hier: de beheerder
-- onderhoudt hem op de website (/beheer/versiehistorie), de site leest hem uit de database en de
-- app vraagt hem aan de server. Een nieuwe regel is meteen overal te zien.
--
-- version_history een regel per nieuwe of wezenlijk gewijzigde functie. Regels met dezelfde
-- datum (en versie) worden samen onder één kop getoond.
--
-- Regels die de server afdwingt (niet de database):
-- * Alleen een beheerder maakt, wijzigt of verwijdert een regel.
-- * Een verborgen regel (zichtbaar = 0) ziet alleen de beheerder.
-- * Platte tekst: er gaat geen opmaak of HTML door.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\22_versiehistorie.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 tabel)
-- mysql -u root -p mijnbesluit < db\handmatig\22b_versiehistorie_startrijen.sql (de bestaande regels)
USE `mijnbesluit`;
CREATE TABLE IF NOT EXISTS `version_history` (
`id` INT NOT NULL AUTO_INCREMENT,
`datum` DATE NOT NULL, -- de dag waarop de functie er kwam
`versie` VARCHAR(20) NULL, -- bijvoorbeeld 1.1; leeg als het geen app-versie betreft
`kop` VARCHAR(120) NOT NULL, -- het vette stukje: wat kun je nu
`tekst` VARCHAR(1000) NOT NULL, -- de uitleg erachter, platte tekst
`zichtbaar` TINYINT(1) NOT NULL DEFAULT 1,
`aangemaakt_op` DATETIME(3) NOT NULL,
`gewijzigd_op` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_version_history_datum_kop` (`datum`, `kop`),
KEY `ix_version_history_datum` (`datum`)
) ENGINE=InnoDB;
-- Startrijen staan NIET hier maar in handmatig\22b_versiehistorie_startrijen.sql: de uitrolagent
-- draait dit script met een account dat geen gegevens mag invoegen (zie 21_kluissoorten.sql).
SELECT 'Klaar. De tabel voor de versiehistorie staat klaar. Vergeet handmatig\\22b_versiehistorie_startrijen.sql en 02_applicatiegebruiker.sql niet (als root).' AS melding;
@@ -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`;
@@ -0,0 +1,38 @@
-- MijnBesluit - de bestaande versiehistorie als startrijen
--
-- Dit zijn de regels die tot nu toe in Versiehistorie.razor stonden, plus die van versie 1.1.
-- Eenmalig te draaien als root, ná db\22_versiehistorie.sql en db\02_applicatiegebruiker.sql:
-- mysql -u root -p mijnbesluit < db\handmatig\22b_versiehistorie_startrijen.sql
--
-- Opnieuw draaien mag: INSERT IGNORE slaat regels over die er al staan (zelfde datum en kop).
-- Daarna onderhoud je de lijst op de website: Beheer > Versiehistorie.
USE `mijnbesluit`;
INSERT IGNORE INTO `version_history` (`datum`, `versie`, `kop`, `tekst`, `zichtbaar`, `aangemaakt_op`, `gewijzigd_op`) VALUES
('2026-09-19', NULL, 'Met mij gedeeld, ook in de app.',
'Heeft iemand een kluisitem rechtstreeks met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je dat voortaan ook op je telefoon, niet meer alleen op de website.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
('2026-09-19', NULL, 'Een gedeeld of nagelaten kluisitem bekijken.',
'Dat opent nu als pop-up in dezelfde opmaak als het invulformulier, inclusief de foto''s en documenten die erbij horen.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
('2026-09-19', NULL, 'Met de vinger naar beneden trekken ververst het scherm.',
'Werkt op Kluis, Mijn mensen, Momenten en Nalatenschap.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
('2026-09-20', NULL, 'Rijbewijs en paspoort in de kluis.',
'Twee nieuwe categorieën, met een knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken of te kiezen.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
('2026-09-20', NULL, 'Mijn profiel als pop-up.',
'Je eigen profiel bewerk je nu in dezelfde pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de app en op de website.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
('2026-09-20', NULL, 'Duidelijker voor de nalatenschapsbeheerder.',
'Bij "Aan mij toevertrouwd" zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
('2026-09-20', NULL, 'Muziek en verdere wensen groeien mee.',
'Die velden bij een kluisitem passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)),
('2026-10-20', '1.1', 'Meer soorten in de kluis.',
'Naast wachtwoorden, polissen en uitvaartwensen kan er voortaan een nieuwe soort bijkomen, met eigen invulvelden, zonder dat je de app daarvoor opnieuw hoeft te updaten. Je bestaande kluisitems blijven zoals ze waren.',
1, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3));
SELECT COUNT(*) AS aantal_regels FROM `version_history`;
+61
View File
@@ -0,0 +1,61 @@
-- MijnBesluit - de versiehistorie als changelog voor SideKick
--
-- SideKick toont op het tabblad Changelog de regels van een tabel of view met een vast formaat
-- (zie de SideKick-handleiding, sectie 5: Datum, Versie, Product, Soort, Onderwerp, Inhoud, Actief,
-- Aanmaakdatum, Updatedatum). MijnBesluit heeft geen tweede changelog: de versiehistorie
-- (version_history, onderhouden op de website onder Beheer > Versiehistorie) IS de changelog.
-- Deze view geeft haar in dat formaat. SideKick bewerkt niets; de agent op de server leest alleen.
--
-- Datum datum
-- Versie versie, leeg als het alleen de website betreft
-- Product vast: MijnBesluit (app en website samen)
-- Soort vast: Nieuw (de versiehistorie bevat alleen nieuwe of wezenlijk gewijzigde
-- functies, geen bugfixes)
-- Onderwerp de kop (per datum uniek, daar zorgt version_history voor)
-- Inhoud de uitleg, platte tekst
-- Actief zichtbaar (0 = verborgen)
-- Aanmaakdatum aangemaakt_op (UTC)
-- Updatedatum gewijzigd_op (UTC); de website zet hem bij elke wijziging opnieuw, ook bij
-- verbergen of tonen. Een verwijderde regel valt weg; SideKick merkt dat aan het
-- afwijkende aantal en leest dan alles opnieuw.
--
-- De view draait met de rechten van wie hem maakt (root, SQL SECURITY DEFINER). Wie hem leest hoeft
-- dus alleen SELECT op de view te hebben, niet op version_history.
--
-- Eenmalig te draaien als root, ná db\22_versiehistorie.sql:
-- mysql -u root -p mijnbesluit < db\handmatig\22c_serverchangelog_view.sql
--
-- Opnieuw draaien mag (CREATE OR REPLACE). Het staat in handmatig\ omdat het uitrolaccount wel
-- tabellen maar niet per se views mag maken, en geen rechten mag geven.
--
-- Daarna in SideKick bij de agent van MijnBesluit: "Changelog: tabel" = serverchangelog, met
-- DbNaam = mijnbesluit en een gebruiker die de view mag lezen (zie onderaan).
USE `mijnbesluit`;
CREATE OR REPLACE
SQL SECURITY DEFINER
VIEW `serverchangelog` AS
SELECT
`datum` AS `Datum`,
COALESCE(`versie`, '') AS `Versie`,
'MijnBesluit' AS `Product`,
'Nieuw' AS `Soort`,
`kop` AS `Onderwerp`,
`tekst` AS `Inhoud`,
`zichtbaar` AS `Actief`,
`aangemaakt_op` AS `Aanmaakdatum`,
`gewijzigd_op` AS `Updatedatum`
FROM `version_history`;
-- De gebruiker die de agent gebruikt om te lezen. Dit geeft de bestaande applicatiegebruiker leesrecht
-- op alleen de view. Liever een eigen leesgebruiker? Maak die zelf aan met een wachtwoord dat alleen
-- jij kent en geef hem hetzelfde recht, bijvoorbeeld:
-- CREATE USER 'mijnbesluit_changelog'@'localhost' IDENTIFIED BY '<kies-zelf-een-wachtwoord>';
-- GRANT SELECT ON `mijnbesluit`.`serverchangelog` TO 'mijnbesluit_changelog'@'localhost';
GRANT SELECT ON `mijnbesluit`.`serverchangelog` TO 'mijnbesluit_app'@'localhost';
FLUSH PRIVILEGES;
-- Controle: de regels van de versiehistorie, nieuwste bovenaan.
SELECT `Datum`, `Versie`, `Product`, `Soort`, `Onderwerp`, `Actief`, `Updatedatum`
FROM `serverchangelog` ORDER BY `Datum` DESC, `Aanmaakdatum` DESC LIMIT 20;
+16
View File
@@ -324,3 +324,19 @@ meldingstoestemming wordt op het moment zelf gevraagd.
6. **"Bevat advertenties" aanvinken** zodra de advertentiebalk meedraait. De inhoud van die 6. **"Bevat advertenties" aanvinken** zodra de advertentiebalk meedraait. De inhoud van die
advertenties valt dan onder het beleid van Google; dat is een reden te meer om ze vooraf zelf advertenties valt dan onder het beleid van Google; dat is een reden te meer om ze vooraf zelf
goed te keuren. goed te keuren.
## Bouwen vanuit SideKick
In SideKick (project MijnBesluit) heet het onderdeel `mijnbesluit-app`, met Sleutel `android`. SideKick zoekt voor zo'n onderdeel eerst `tools\Bouwen-<Sleutel>.ps1`; dat is hier `tools\Bouwen-android.ps1`, dat `tools\Apk-maken.ps1` aanroept en de ondertekende APK in `D:\MijnBesluit VS\MijnBesluitpublish\android` zet. Zonder dat script bouwt SideKick een Windows-programma (win-x64), en dat faalt op een Android-project met `NU1102 ... Mono.win-x64`.
Het wachtwoord van het sleutelbestand komt uit de gebruikersvariabele `MIJNBESLUIT_KEYSTORE_WACHTWOORD`; start SideKick opnieuw als je die net hebt gezet. Voor een AAB (Play Store) draai je `Apk-maken.ps1 -Aab` zelf.
## Uploaden naar de Play Store vanuit SideKick (niet getest tegen Google)
SideKick heeft hiervoor een eigen Play Store-tegel bij Publiceren (het oude script `Bouwen-playstore.ps1` en de rij `mijnbesluit-playstore` zijn vervangen en niet meer nodig; het script staat in `_te_verwijderen`). Zie `Handleiding-voor-claude.md` van SideKick, hoofdstuk 6c.
Kort: bij het onderdeel `mijnbesluit-aab` staat in Instellingen het vinkje **Play** aan (Track leeg = intern testen). De koppeling met Google Play vul je eenmalig in bij Instellingen > Algemeen > **Winkelkoppelingen...** (inhoud van het JSON-bestand van het serviceaccount plakken of het bestand kiezen; het komt alleen in `secrets.json` buiten git; **Verbinding testen** controleert het). Bij Publiceren: de AAB bouwen, dan **Play-versie controleren** (`ApplicationVersion` moet hoger zijn dan de hoogste versiecode in de Play Console) en **Naar Play Store zetten**. De release komt als concept op de track; publiceren doe je in de Play Console.
**Release-opmerking:** zet de tekst voor de release in `docs\Play-release-opmerking.txt` (UTF-8, maximaal 500 tekens, in het Nederlands, bijvoorbeeld "Nieuwe kluissoorten, beheerbaar via de website"). Jonathan bekijkt en wijzigt hem in SideKick met de knop **Release-opmerking...**; hij gaat mee met de upload. Pas hem aan voor elke nieuwe versie.
Eenmalig: de app staat in de Play Console met een eerste, met de hand geuploade AAB; het serviceaccount is uitgenodigd onder Gebruikers en machtigingen met het recht om releases te beheren.
+1
View File
@@ -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: Bewust leesbaar, omdat er anders niet te zoeken of te sorteren valt:
- de titel en de categorie van een kluisitem; - 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; - het onderwerp, de afzender, de ontvanger en de bezorgdatum van een bericht;
- namen, e-mailadressen en relaties. - namen, e-mailadressen en relaties.
+74
View File
@@ -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).
+1
View File
@@ -0,0 +1 @@
Nieuw: de kluis ondersteunt nu meer soorten gegevens. Voortaan kunnen er nieuwe soorten bijkomen, met eigen invulvelden, zonder dat je daarvoor opnieuw de app hoeft te updaten. Je bestaande kluisitems blijven ongewijzigd. Verder kleine verbeteringen.
+3
View File
@@ -74,6 +74,9 @@ aanvoelt. Bij `"Error"` staat in `message` een tekst die je zo aan de gebruiker
| `vault.list` | ja | Titels en categorieën (geen inhoud) | | `vault.list` | ja | Titels en categorieën (geen inhoud) |
| `vault.open` | ja | Eén item ontsleutelen | | `vault.open` | ja | Eén item ontsleutelen |
| `vault.save` | ja | Item versleuteld opslaan | | `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 |
| `app.versiehistorie` | ja | De zichtbare regels van de versiehistorie (datum jjjj-mm-dd, versie, kop, tekst), nieuwste dag bovenaan; staat in tabel `version_history`, zie docs/Versiehistorie.md |
| `message.inbox` / `message.outbox` | ja | Berichten voor en door jou | | `message.inbox` / `message.outbox` | ja | Berichten voor en door jou |
| `message.read` | ja | Bericht lezen en als gelezen markeren | | `message.read` | ja | Bericht lezen en als gelezen markeren |
| `message.compose` | ja | Bericht klaarzetten | | `message.compose` | ja | Bericht klaarzetten |
+58
View File
@@ -0,0 +1,58 @@
# Versiehistorie
De lijst "wat is er nieuw" die onder **Versiehistorie** staat, op de website (pop-up vanaf het
overzicht) en in de app (onderaan het startscherm), staat in de database. Een nieuwe regel vraagt
daarom **geen nieuwe versie van de app** in de Play Store of App Store, en je houdt het op één
plek bij.
## Hoe het werkt
* Tabel `version_history`: één rij per regel, met `datum`, `versie` (mag leeg), `kop` (het vette
kopje), `tekst`, `zichtbaar`. Gewone tekst, geen opmaak. Dezelfde kop op dezelfde dag kan maar
één keer voorkomen.
* De website leest de tabel rechtstreeks (`VersiehistorieService.ZichtbaarAsync`). De app vraagt
hem op bij de server met de actie `app.versiehistorie` (zie `Socketprotocol.md`) en toont een
melding als dat niet lukt.
* Volgorde: nieuwste dag bovenaan, binnen één dag in de volgorde van invoer. Opeenvolgende regels
met dezelfde dag en versie komen onder één kop te staan ("20 oktober 2026 · versie 1.1").
* Beheren doet een beheerder op de website: **Beheer > Versiehistorie beheren**. Toevoegen,
wijzigen, verbergen en verwijderen; elke wijziging komt in het auditlogboek
(`VersiehistorieBeheerd`). Een verborgen regel zien lezers niet meer, maar is niet kwijt.
## Wat erin hoort
Nieuwe of wezenlijk gewijzigde functies: "wat kan ik nu wat ik eerst niet kon". Geen bugfixes,
geen tekstuele correcties, geen opmaak die is rechtgezet.
## Uitrollen
1. `db\22_versiehistorie.sql` via de databasescripts van SideKick (maakt de tabel; verandert niets
aan bestaande gegevens en is opnieuw te draaien).
2. Als root: `mysql -u root -p < db\02_applicatiegebruiker.sql` (rechten voor `mijnbesluit_app`
op de nieuwe tabel) en `mysql -u root -p mijnbesluit < db\handmatig\22b_versiehistorie_startrijen.sql`
(de bestaande regels; `INSERT IGNORE`, dus opnieuw draaien is veilig).
3. Voor de changelog in SideKick (optioneel, kan ook later): als root
`mysql -u root -p mijnbesluit < db\handmatig\22c_serverchangelog_view.sql` (zie hieronder).
4. Daarna pas de website en de SocketServer uitrollen. Andersom leest de nieuwe code een tabel
die er nog niet is, of waar de applicatiegebruiker nog niet bij mag.
## Changelog in SideKick
Het tabblad Changelog van SideKick leest een tabel of view `serverchangelog` met vaste kolommen. De
versiehistorie is de changelog van MijnBesluit, dus er is geen tweede lijst: de view
`serverchangelog` (script `db\handmatig\22c_serverchangelog_view.sql`, als root) geeft
`version_history` in dat formaat terug. `Product` is vast "MijnBesluit", `Soort` vast "Nieuw" (de
lijst bevat geen bugfixes), `Actief` is `zichtbaar` en `Updatedatum` is `gewijzigd_op`, dat de site
bij elke wijziging opnieuw zet. De agent leest de view alleen.
Instellen in SideKick bij de agent van MijnBesluit: "Changelog: tabel" = `serverchangelog`, `DbNaam` =
`mijnbesluit`, en een gebruiker met SELECT op de view (het script geeft dat aan `mijnbesluit_app`;
een aparte leesgebruiker kan ook, het voorbeeld staat in het script). Het wachtwoord alleen op de
server invullen. Voorwaarden: de agent kent de actie `ChangelogLezen` en het project staat op Weg hub.
De inhoud gaat naar het SideKick-scherm van de beheerder: geen persoonsgegevens in een regel.
## Oudere apps
De tekst van de versiehistorie zat tot en met appversie 1.1 (versiecode 3) in de app zelf. Die
apps lezen de tabel nooit en tonen dus hun ingebakken tekst, ook als je hier iets bijzet. Vanaf
versiecode 4 haalt de app de lijst op bij de server.
+81
View File
@@ -0,0 +1,81 @@
APP STORE-VERMELDING - MijnBesluit (nl.mijnbesluit.app)
Taal: Nederlands (Nederland). Concept; in App Store Connect onder Distribution > de versie.
Afgeleid van docs\playstore\winkelvermelding.txt, met de limieten van Apple.
NAAM (max 30) - nu 11
MijnBesluit
ONDERTITEL (max 30) - nu 30
Wachtwoorden en wensen bewaard
PROMOTIETEKST (max 170, mag altijd worden aangepast zonder nieuwe versie) - nu 145
Wachtwoorden, wensen en berichten veilig bewaard, en pas vrij te geven wanneer jij dat wilt. Inloggen zonder wachtwoord, met een code per e-mail.
TREFWOORDEN (max 100, komma's zonder spaties; woorden uit de naam en ondertitel niet herhalen) - nu 99
kluis,wachtwoorden,nalatenschap,uitvaart,testament,berichten,familie,polissen,overlijden,documenten
BESCHRIJVING (max 4000) - nu 2689
Er zit van alles in je hoofd waar niemand anders bij kan. Het wachtwoord van je e-mail, waar de polissen liggen, hoe je je uitvaart voor je ziet, en de dingen die je nog tegen iemand had willen zeggen. Zolang alles goed gaat maakt dat niet uit. Gaat het niet goed, dan staan de mensen die van je houden met lege handen, op het slechtst denkbare moment.
MijnBesluit is de plek waar dat wél ergens staat. Veilig opgeborgen zolang het van jou alleen is, en beschikbaar op het moment dat jij hebt bepaald.
WAT KUN JE ERMEE?
Je kluis
Bewaar wachtwoorden voor websites en apparaten, de pincode van je telefoon, je sociale media, polissen, documenten en je uitvaartwensen. Alles wordt versleuteld opgeslagen. Per onderdeel kies je zelf of het alleen voor jou is, pas na je overlijden vrijkomt, of op een datum die jij bepaalt.
Je mensen
Maak profielen aan voor de familie en vrienden die erbij horen en nodig ze uit. Zodra iemand de uitnodiging accepteert, is het profiel van hem of haar en vult die persoon zijn of haar eigen MijnBesluit in.
Momenten
Zet een bericht of video klaar voor iemand, om te bezorgen op een datum die jij kiest, of pas wanneer jij er niet meer bent. Een achttiende verjaardag, een huwelijk, of gewoon iets wat je nog wilde zeggen. Je kunt ook berichten ontvangen die een ander voor jou heeft klaargezet.
Je nalatenschap
Bepaal wie straks mag regelen wat er geregeld moet worden. Die persoon krijgt niet zomaar toegang: meerdere mensen moeten onafhankelijk van elkaar melden dat je bent overleden, waaronder iemand die jij zelf hebt aangewezen. Daarna heb jij nog een termijn om te laten weten dat je er gewoon nog bent. Pas als die voorbij is, gaat het open.
HOE VEILIG IS HET?
- Alles wordt versleuteld opgeslagen, ook bijlagen zoals een foto van je polis.
- Je hebt geen wachtwoord nodig: je logt in met je e-mailadres en een code die een kwartier geldig is en maar één keer werkt. Er is dus niets te onthouden, te raden of te hergebruiken.
- Wat je op "alleen voor mijzelf" zet, blijft dat. Je nalatenschapsbeheerder krijgt alleen wat jij hebt vrijgegeven, en pas op het moment dat jij hebt bepaald.
- Er wordt bijgehouden wie wat opent, wijzigt of vrijgeeft.
Volledige zekerheid bestaat niet, en dat beweren we ook niet. Wat we wel doen is elke keuze zo maken dat een fout of een inbraak zo min mogelijk oplevert.
JOUW GEGEVENS, JOUW KEUZE
Een eigen MijnBesluit is gratis. Je kunt alles wat je hebt ingevoerd altijd zelf wijzigen of verwijderen, en je kunt je account en bijbehorende gegevens in de app of via mijnbesluit.nl laten verwijderen.
MijnBesluit is een product van Kwik-Bit Automatisering B.V.
Privacyverklaring: https://mijnbesluit.nl/privacy
WAT IS ER NIEUW (versie 1.1) - nu 117
Eerste versie voor iOS: je kluis, je mensen, momenten en je nalatenschap, net als in de Android-app en op de website.
ADRESSEN
Ondersteunings-URL: https://mijnbesluit.nl/ (moet een pagina zijn waar iemand hulp of contact vindt; nog te kiezen)
Marketing-URL: https://mijnbesluit.nl/ (mag leeg)
Privacybeleid-URL: https://mijnbesluit.nl/privacy
Copyright: 2026 Kwik-Bit Automatisering B.V.
CATEGORIE
Primair: Productiviteit (zoals in Google Play). Secundair: Levensstijl.
SCHERMAFBEELDINGEN (nog te maken, niet te hergebruiken van Android: 1180 x 2276 is geen toegestane maat)
- iPhone 6,9 inch: 1320 x 2868 (of 1290 x 2796); verplicht. De kleinere iPhone-maten schaalt App Store Connect hieruit af.
- iPad 13 inch: 2064 x 2752; verplicht zolang de app op iPad draait (Info.plist: UIDeviceFamily 1 en 2).
- 1 tot 10 per soort; zelfde vijf schermen als bij Google: overzicht, mensen, kluis, momenten, nalatenschap.
TOEGANG VOOR DE REVIEWER (App Review Information)
Inloggen gaat met e-mailadres en een code die per e-mail komt. Reviewaccount review@mijnbesluit.nl; de code komt in de reviewmailbox:
webmail https://oma.kwik-bit.nl:8443/webmail/ (zie MijnBesluit-playstore.md). Zet die uitleg en het mailboxwachtwoord in de velden van App Review.
Wachtwoord van de reviewmailbox pas na de review wijzigen.
LEEFTIJDSCLASSIFICATIE (door Jonathan te beantwoorden)
Doelgroep 18+ en "Contains ads" zoals bij Google; de vragenlijst van Apple over o.a. user-generated content, advertenties en
communicatie tussen gebruikers loopt anders dan bij Google, dus de antwoorden zelf doornemen.
APP PRIVACY (door Jonathan te beantwoorden; voorstel, controleren)
Verzameld en gekoppeld aan de gebruiker, niet voor tracking: e-mailadres, naam (profiel), foto's of video's (alleen wat de gebruiker zelf kiest),
gebruikersinhoud (kluisitems, berichten), gebruiks- en logboekgegevens voor beveiliging. Tracking: nee (geen advertentie-SDK; de advertentie komt van de
eigen server). Verwijderen van account en gegevens kan in de app en via mijnbesluit.nl.
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+58
View File
@@ -41,6 +41,28 @@ gecompileerd is, geeft meestal een handvol kleine dingen (een ontbrekende using,
API-naam). `PrivacyInfo.xcprivacy` moet na het bouwen in de `.app` zitten API-naam). `PrivacyInfo.xcprivacy` moet na het bouwen in de `.app` zitten
(`find . -name PrivacyInfo.xcprivacy`). (`find . -name PrivacyInfo.xcprivacy`).
## Een gehuurde Mac (MacinCloud) zonder beheerrechten
Beproefd op 20 oktober 2026: een Managed Server van MacinCloud (Mac mini M2, macOS Tahoe 26.x,
Xcode 26.6), met Windows via RDP. Daar heb je geen beheerrechten, en de .NET die erop staat heeft
een kapotte werkbelastinginstelling. Wat wel werkt:
1. Een eigen .NET in je eigen map, in dezelfde versie als de systeem-.NET:
`bash ~/dotnet-install.sh --version 10.0.302 --install-dir ~/dotnet302`
(script van https://dot.net/v1/dotnet-install.sh), en in elk Terminal-venster:
`export DOTNET_ROOT=$HOME/dotnet302 PATH=$HOME/dotnet302:$PATH`.
2. `dotnet workload install maui-ios` daarmee. **Let op de Xcode-versie:** de nieuwste .NET
(10.0.4xx) levert iOS 27.1 en eist Xcode 27.1 ("requires Xcode 27.1"). De 10.0.3xx-band levert
iOS 26.5 en past bij Xcode 26.x. Gebruik dus de oudere SDK zolang Xcode 26 de nieuwste stabiele is.
3. `tools/ios-bouwen.sh simulator`. Het script zet `MetAndroid=false` (anders eist de build de
Android-werkbelasting) en `CodesignKey=-` (lokale handtekening; de simulator wil geen
certificaat, maar de Keychain-rechten in `Entitlements.plist` vragen om een handtekening).
Geef nooit `-p:TargetFrameworks=...` op de opdrachtregel mee: dat gaat ook naar de gedeelde
projecten (net8.0) en breekt hun herstel.
Resultaat: de app bouwt en draait in de iPhone 16 Pro-simulator (iOS 18.0), inclusief inloggen en
de overzichtsschermen.
## Wat er nog moet ## Wat er nog moet
**Een Mac.** Apple's bouwgereedschap draait alleen op macOS. Visual Studio op Windows verbindt **Een Mac.** Apple's bouwgereedschap draait alleen op macOS. Visual Studio op Windows verbindt
@@ -58,6 +80,42 @@ meldingen, en verder werkt alles. Dat komt erbij zodra het account er is, want h
vast: een APNs-sleutel, de Push Notifications-capability, een provisioning profile en een vast: een APNs-sleutel, de Push Notifications-capability, een provisioning profile en een
`Entitlements.plist`. `Entitlements.plist`.
## Een .ipa voor TestFlight en de App Store
Kan zodra het Apple Developer Program actief is (Kwik-Bit Automatisering B.V., aangemeld op
27 oktober 2026, betaald; de accountpagina toont geen "Pending" meer). Stappen, in deze volgorde:
1. **Voorwaarden accepteren** op developer.apple.com/account (de nieuwe licentieovereenkomst,
uiterlijk 14 november 2026). Alleen de accounthouder kan dat. Zonder dit werken Certificates,
App Store Connect en de API niet meer.
2. **App-ID registreren**: Certificates, IDs & Profiles > Identifiers > App IDs, bundel-ID
`nl.mijnbesluit.app` (gelijk aan `ApplicationId`). Capabilities: voorlopig geen extra;
Push Notifications komt later met APNs.
3. **Distributiecertificaat** (Apple Distribution) maken: Xcode > Settings > Accounts > het team >
Manage Certificates > + > Apple Distribution, op de Mac waar je bouwt. De private sleutel
blijft in de sleutelhanger van die Mac; bij een gehuurde Mac is die weg als de Mac weg is,
dus bewaar er een export van (met wachtwoord, buiten git).
4. **App Store-profiel** (Provisioning Profiles > + > App Store Connect) voor `nl.mijnbesluit.app`
met dat certificaat; downloaden en dubbelklikken op de Mac. Noteer de naam van het profiel.
5. **App-record** in App Store Connect (Apps > + > Nieuwe app): naam MijnBesluit, bundel-ID
`nl.mijnbesluit.app`, primaire taal Nederlands, SKU naar keuze.
6. **Bouwen**: op de Mac, met de namen uit stap 3 en 4:
```
export IOS_SIGN_KEY="Apple Distribution: Kwik-Bit Automatisering B.V. (TEAMID)"
export IOS_PROFILE="<naam van het profiel>"
tools/ios-bouwen.sh archief
```
Het script toont eerst welke certificaten en profielen er staan, maakt dan de `.ipa`
(`dotnet publish`, ios-arm64, Release, `ArchiveOnBuild`) en zegt waar hij staat. Het bouwnummer
(`ApplicationVersion`) moet bij elke upload hoger zijn dan de vorige.
7. **Uploaden** met de app Transporter (Mac App Store, gratis): inloggen met het Apple-account,
`.ipa` erin slepen. Na enkele minuten staat de build onder TestFlight; de exportverklaring en
de App Privacy-antwoorden volgen in App Store Connect (zie hieronder).
Niet gedaan en niet getest: dit script en deze stappen zijn nog nooit tegen Apple gedraaid.
## Twee vallen waar je vroeg in loopt ## Twee vallen waar je vroeg in loopt
**Ontbrekende gebruikstoelichting = de app sluit.** Vraag je om Face ID zonder **Ontbrekende gebruikstoelichting = de app sluit.** Vraag je om Face ID zonder
+6 -3
View File
@@ -16,7 +16,10 @@
of blijvend, door MetIos in een Directory.Build.props of in de omgeving te zetten op de of blijvend, door MetIos in een Directory.Build.props of in de omgeving te zetten op de
machine die wél aan een Mac hangt. Zie docs/iOS-app.md. --> machine die wél aan een Mac hangt. Zie docs/iOS-app.md. -->
<TargetFrameworks>net10.0-android</TargetFrameworks> <!-- MetAndroid=false laat Android weg: op een Mac die alleen iOS bouwt staat de Android-
werkbelasting er niet, en dan weigert de hele build (NETSDK1147). Zonder deze schakelaar
verandert er niets: Android blijft standaard aan. -->
<TargetFrameworks Condition="'$(MetAndroid)' != 'false'">net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="'$(MetIos)' == 'true'">$(TargetFrameworks);net10.0-ios</TargetFrameworks> <TargetFrameworks Condition="'$(MetIos)' == 'true'">$(TargetFrameworks);net10.0-ios</TargetFrameworks>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -28,8 +31,8 @@
<ApplicationTitle>MijnBesluit</ApplicationTitle> <ApplicationTitle>MijnBesluit</ApplicationTitle>
<ApplicationId>nl.mijnbesluit.app</ApplicationId> <ApplicationId>nl.mijnbesluit.app</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.1</ApplicationDisplayVersion>
<ApplicationVersion>2</ApplicationVersion> <ApplicationVersion>4</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
+3 -3
View File
@@ -7,10 +7,10 @@
<key>UIDeviceFamily</key> <key>UIDeviceFamily</key>
<array> <array>
<!-- 1 = iPhone, 2 = iPad. De schermen zijn gemaakt voor een telefoon, maar een iPad <!-- 1 = iPhone, 2 = iPad. Voorlopig alleen iPhone: de indeling op een iPad (simulator, 1 nov 2026)
weigeren levert alleen een gebruiker op die het niet snapt. --> toont een lege pagina met de onderbalk midden in beeld. Waarde 2 er pas weer bij als de
iPad-weergave klopt en er iPad-schermafbeeldingen zijn. -->
<integer>1</integer> <integer>1</integer>
<integer>2</integer>
</array> </array>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
@@ -1,52 +1,90 @@
@* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. @* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
Zelfde opzet als Productuitleg.razor en Privacytekst.razor: op de website een popup vanaf De regels staan in een tabel op de server (version_history) en worden beheerd op de website
het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site onder Beheer > Versiehistorie. Site en app lezen dezelfde tabel; dit bestand bevat zelf geen
delen geen project - en een zelfcontrole vergelijkt ze letterlijk. tekst meer, dus een nieuwe regel vraagt geen nieuwe versie van de app. Zie docs/Versiehistorie.md.
Wat hier NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor Wat daar NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor
"wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@ "wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@
@inject AppToestand Toestand
<p class="zacht" style="margin-top:0"> <p class="zacht" style="margin-top:0">
Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
</p> </p>
<h2 style="margin-top:0">20 september 2026</h2> @if (Fout)
<ul style="padding-left:1.2rem"> {
<li style="margin-bottom:.5rem"> <p class="zacht">De versiehistorie kon niet worden opgehaald. Probeer het later nog eens.</p>
<strong>Rijbewijs en paspoort in de kluis.</strong> Twee nieuwe categorieën, met een }
knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken else if (Regels is null)
of te kiezen. {
</li> <p class="zacht">Bezig met laden&hellip;</p>
<li style="margin-bottom:.5rem"> }
<strong>Mijn profiel als pop-up.</strong> Je eigen profiel bewerk je nu in dezelfde else if (Regels.Count == 0)
pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de {
app en op de website. <p class="zacht">Er staat nog niets in de versiehistorie.</p>
</li> }
<li style="margin-bottom:.5rem"> else
<strong>Duidelijker voor de nalatenschapsbeheerder.</strong> Bij "Aan mij toevertrouwd" {
zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is. @foreach (var groep in Groepen())
</li> {
<li> <h2 style="@(groep.Eerste ? "margin-top:0" : null)">@groep.Kop</h2>
<strong>Muziek en verdere wensen groeien mee.</strong> Die velden bij een kluisitem <ul style="padding-left:1.2rem">
passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website. @foreach (var regel in groep.Regels)
</li> {
</ul> <li style="margin-bottom:.5rem"><strong>@Punt(regel.Kop)</strong> @regel.Tekst</li>
}
</ul>
}
}
<h2>19 september 2026</h2> @code {
<ul style="padding-left:1.2rem"> private IReadOnlyList<VersieRegel>? Regels;
<li style="margin-bottom:.5rem"> private bool Fout;
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je protected override async Task OnInitializedAsync()
dat voortaan ook op je telefoon, niet meer alleen op de website. {
</li> var uitkomst = await Toestand.Client.VersiehistorieAsync();
<li style="margin-bottom:.5rem"> if (uitkomst.Gelukt) Regels = uitkomst.Waarde;
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in else Fout = true;
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij }
horen.
</li> private sealed record Groep(string Kop, bool Eerste, List<VersieRegel> Regels);
<li>
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis, /// <summary>
Mijn mensen, Momenten en Nalatenschap. /// Opeenvolgende regels met dezelfde dag (en dezelfde versie) vormen samen één kop. De volgorde
</li> /// komt van de server: nieuwste dag bovenaan, binnen een dag in de volgorde van invoer.
</ul> /// </summary>
private IEnumerable<Groep> Groepen()
{
var uit = new List<Groep>();
string? vorige = null;
foreach (var r in Regels!)
{
var kop = Koptekst(r);
if (kop != vorige)
{
uit.Add(new Groep(kop, uit.Count == 0, new List<VersieRegel>()));
vorige = kop;
}
uit[^1].Regels.Add(r);
}
return uit;
}
private static string Koptekst(VersieRegel r)
{
var dag = DateOnly.TryParseExact(r.Datum, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var d)
? $"{d.Day} {Datums.Maanden[d.Month - 1]} {d.Year}"
: r.Datum;
return string.IsNullOrWhiteSpace(r.Versie) ? dag : $"{dag} \u00b7 versie {r.Versie}";
}
/// <summary>De kop eindigt op een punt, zodat de tekst erachter als gewone zin leest.</summary>
private static string Punt(string kop)
{
var k = kop.Trim();
return k.Length == 0 || ".!?".Contains(k[^1]) ? k : k + ".";
}
}
+5 -16
View File
@@ -140,22 +140,11 @@ public static class Tonen
public static string Kort(DateOnly? dag) => public static string Kort(DateOnly? dag) =>
dag is null ? "" : $"{dag.Value.Day} {KorteMaanden[dag.Value.Month - 1]} {dag.Value.Year}"; dag is null ? "" : $"{dag.Value.Day} {KorteMaanden[dag.Value.Month - 1]} {dag.Value.Year}";
/// <summary>De nummers komen overeen met VaultCategory in het domein.</summary> /// <summary>
public static string Categorie(int categorie) => categorie switch /// De ingebouwde naam bij een soortnummer. Een soort die de beheerder zelf maakte staat hier niet
{ /// in; die naam reist met het item mee (<see cref="MijnBesluit.Client.KluisItem.SoortNaam"/>).
1 => "Uitvaartwens", /// </summary>
2 => "Polis", public static string Categorie(int categorie) => MijnBesluit.Client.IngebouwdeSoorten.Naam(categorie);
3 => "Wachtwoord",
4 => "Apparaat",
5 => "Pincode",
6 => "Document",
7 => "Notitie",
8 => "Sociale media",
9 => "Donorcodicil",
10 => "Rijbewijs",
11 => "Paspoort",
_ => "Overig"
};
/// <summary> /// <summary>
/// Hoe een relatie op het scherm heet. De lijst staat in MijnBesluit.Client, zodat de app en /// Hoe een relatie op het scherm heet. De lijst staat in MijnBesluit.Client, zodat de app en
+131 -29
View File
@@ -22,18 +22,10 @@
<div class="rij kluiskop"> <div class="rij kluiskop">
<select class="groei" aria-label="Filter op soort" disabled="@Bezig" @bind="Filter" @bind:after="LaadAsync"> <select class="groei" aria-label="Filter op soort" disabled="@Bezig" @bind="Filter" @bind:after="LaadAsync">
<option value="">Alles</option> <option value="">Alles</option>
<option value="3">Wachtwoord</option> @foreach (var soort in Soorten)
<option value="8">Sociale media</option> {
<option value="4">Apparaat</option> <option value="@soort.Id">@soort.Naam</option>
<option value="5">Pincode</option> }
<option value="2">Polis</option>
<option value="1">Uitvaartwens</option>
<option value="9">Donorcodicil</option>
<option value="10">Rijbewijs</option>
<option value="11">Paspoort</option>
<option value="6">Document</option>
<option value="7">Notitie</option>
<option value="99">Overig</option>
</select> </select>
<button class="knop" disabled="@Bezig" @onclick="() => Nieuw()">Toevoegen</button> <button class="knop" disabled="@Bezig" @onclick="() => Nieuw()">Toevoegen</button>
@@ -52,22 +44,19 @@
<div class="veld"> <div class="veld">
<label for="soort">Wat leg je vast?</label> <label for="soort">Wat leg je vast?</label>
@* De nummers komen overeen met VaultCategory in het domein. Overig is 99, niet 9: @* De lijst komt van de server: de soorten die de beheerder aanbiedt, plus de soorten
dat stond hier eerder verkeerd en leverde een categorie op die nergens bij hoort. *@ waar je al een item van hebt. Kan de server er geen geven, dan staat hier de
ingebouwde lijst (IngebouwdeSoorten), met dezelfde nummers. *@
<select id="soort" @bind="Categorie"> <select id="soort" @bind="Categorie">
<option value="3">Wachtwoord</option> @foreach (var soort in Soorten)
<option value="8">Sociale media</option> {
<option value="4">Apparaat</option> <option value="@soort.Id">@soort.Naam</option>
<option value="5">Pincode</option> }
<option value="2">Polis</option>
<option value="1">Uitvaartwens</option>
<option value="9">Donorcodicil</option>
<option value="10">Rijbewijs</option>
<option value="11">Paspoort</option>
<option value="6">Document</option>
<option value="7">Notitie</option>
<option value="99">Overig</option>
</select> </select>
@if (!string.IsNullOrWhiteSpace(HuidigeSoort?.Uitleg))
{
<div class="uitleg">@HuidigeSoort!.Uitleg</div>
}
</div> </div>
<div class="veld"> <div class="veld">
@@ -153,7 +142,17 @@
break; break;
} }
@if (Categorie is 1 or 9) @* Een soort die de beheerder zelf maakte beschrijft zijn formulier op de server; hier wordt het
getekend. Een ingebouwde soort heeft zijn velden hierboven in de switch. *@
@if (HuidigeSoort is { Ingebouwd: false } eigen)
{
@foreach (var veld in eigen.Velden.Where(v => v.Actief || HeeftWaarde(v)))
{
@VeldInvoer(veld)
}
}
@if (HuidigeSoort?.BesprekenMet == true)
{ {
<div class="veld"> <div class="veld">
<label for="bespreken">Bespreken met</label> <label for="bespreken">Bespreken met</label>
@@ -278,7 +277,7 @@
@onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(item))"> @onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(item))">
<div class="rij"> <div class="rij">
<div class="groei"> <div class="groei">
<span class="groot">@Tonen.Categorie(item.Category)</span> <span class="groot">@item.SoortNaam(NaamVan)</span>
<div class="zacht" title="@item.Title">@item.Title</div> <div class="zacht" title="@item.Title">@item.Title</div>
@* stopPropagation, want anders opent een druk op de prullenbak ook meteen @* stopPropagation, want anders opent een druk op de prullenbak ook meteen
@@ -342,7 +341,16 @@
/// <summary>Op welk soort de lijst gefilterd staat; leeg is alles. Zelfde opzet als op de website.</summary> /// <summary>Op welk soort de lijst gefilterd staat; leeg is alles. Zelfde opzet als op de website.</summary>
private string Filter = ""; private string Filter = "";
/// <summary>De soorten in de keuzelijst; zie <see cref="LaadAsync"/>.</summary>
private IReadOnlyList<KluisSoort> Soorten = IngebouwdeSoorten.Lijst;
private int Categorie = 3; private int Categorie = 3;
/// <summary>De soort die in het formulier gekozen is. Null als de lijst hem niet kent.</summary>
private KluisSoort? HuidigeSoort => Soorten.FirstOrDefault(s => s.Id == Categorie);
/// <summary>De naam van een soort uit de lijst, of de ingebouwde naam als de lijst hem niet kent.</summary>
private string NaamVan(int soort) => Soorten.FirstOrDefault(s => s.Id == soort)?.Naam ?? IngebouwdeSoorten.Naam(soort);
private IReadOnlyList<Beheerder> Beheerders = Array.Empty<Beheerder>(); private IReadOnlyList<Beheerder> Beheerders = Array.Empty<Beheerder>();
private string BesprekenMetTekst = ""; private string BesprekenMetTekst = "";
private long? BesprekenMetPersonId => long.TryParse(BesprekenMetTekst, out var p) ? p : null; private long? BesprekenMetPersonId => long.TryParse(BesprekenMetTekst, out var p) ? p : null;
@@ -383,6 +391,11 @@
if (lijst.Gelukt) Items = lijst.Waarde!; if (lijst.Gelukt) Items = lijst.Waarde!;
else Fout = lijst.Melding; 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(); var beheerders = await Toestand.Client.BeheerdersAsync();
if (beheerders.Gelukt) Beheerders = beheerders.Waarde!; if (beheerders.Gelukt) Beheerders = beheerders.Waarde!;
@@ -405,7 +418,7 @@
Bewerken = new KluisInhoud(); Bewerken = new KluisInhoud();
ItemId = 0; ItemId = 0;
Titel = ""; Titel = "";
Categorie = 3; Categorie = Soorten.Any(s => s.Id == 3) ? 3 : Soorten[0].Id;
Vrijgave = 1; Vrijgave = 1;
VrijgaveDatum = DateTime.Today.AddYears(1); VrijgaveDatum = DateTime.Today.AddYears(1);
BesprekenMetTekst = ""; BesprekenMetTekst = "";
@@ -456,6 +469,15 @@
var datum = Vrijgave == 2 ? VrijgaveDatum : null; var datum = Vrijgave == 2 ? VrijgaveDatum : null;
if (Vrijgave == 2 && datum is null) { Fout = "Kies een datum waarop dit vrijkomt."; return; } 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; long nieuwItemId = 0;
if (await ProbeerAsync(async () => if (await ProbeerAsync(async () =>
@@ -592,6 +614,86 @@
} }
} }
// ------------------------------------------------------------------ eigen velden
private string Waarde(KluisVeld veld) =>
Bewerken is not null && Bewerken.Velden.TryGetValue(veld.Sleutel, out var w) ? w : "";
private bool HeeftWaarde(KluisVeld veld) => !string.IsNullOrWhiteSpace(Waarde(veld));
private void Zet(KluisVeld veld, string? waarde)
{
if (Bewerken is null) return;
if (string.IsNullOrWhiteSpace(waarde)) Bewerken.Velden.Remove(veld.Sleutel);
else Bewerken.Velden[veld.Sleutel] = waarde.Trim();
}
/// <summary>De opgeslagen datum (jjjj-mm-dd) als datum voor de kiezer; null als er niets staat.</summary>
private DateTime? DatumWaarde(KluisVeld veld) =>
DateOnly.TryParseExact(Waarde(veld), "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dag)
? dag.ToDateTime(TimeOnly.MinValue)
: null;
private void ZetDatum(KluisVeld veld, DateTime? datum) =>
Zet(veld, datum?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture));
/// <summary>
/// Eén veld van een door de beheerder gemaakte soort. De invoer gaat via Zet in plaats van @bind:
/// de waarden staan in een woordenboek op de sleutel van het veld, en een veld dat leeg wordt
/// gemaakt hoort er dan uit te verdwijnen in plaats van als lege tekst te blijven staan.
/// </summary>
private RenderFragment VeldInvoer(KluisVeld veld) => @<div class="veld">
@if (veld.Soort == KluisVeld.Uitleg)
{
<div class="uitleg">@veld.Hulptekst</div>
}
else
{
<label for="@VeldId(veld)">@veld.Label@(veld.Verplicht ? " *" : "")</label>
@switch (veld.Soort)
{
case KluisVeld.Lang:
<textarea id="@VeldId(veld)" rows="3" value="@Waarde(veld)"
@onchange="e => Zet(veld, e.Value?.ToString())"></textarea>
break;
case KluisVeld.Datum:
<Datumkiezer Waarde="@DatumWaarde(veld)" WaardeChanged="d => ZetDatum(veld, d)"
Optioneel="true" VanJaar="@(DateTime.Today.Year - 80)" TotJaar="@(DateTime.Today.Year + 5)"
Aflopend="true" />
break;
case KluisVeld.Keuze:
<select id="@VeldId(veld)" value="@Waarde(veld)" @onchange="e => Zet(veld, e.Value?.ToString())">
<option value="">Kies&hellip;</option>
@foreach (var optie in veld.Opties)
{
<option value="@optie" selected="@(optie == Waarde(veld))">@optie</option>
}
@* Stond er een waarde die niet meer in de lijst staat, dan blijft die zichtbaar. *@
@if (HeeftWaarde(veld) && !veld.Opties.Contains(Waarde(veld)))
{
<option value="@Waarde(veld)" selected="selected">@Waarde(veld)</option>
}
</select>
break;
default:
<input id="@VeldId(veld)" value="@Waarde(veld)" @onchange="e => Zet(veld, e.Value?.ToString())" />
break;
}
@if (!string.IsNullOrWhiteSpace(veld.Hulptekst))
{
<div class="uitleg">@veld.Hulptekst</div>
}
}
</div>;
private static string VeldId(KluisVeld veld) => "veld-" + veld.Sleutel;
/// <summary> /// <summary>
/// Muziek en Wensen mogen meerdere regels worden; dan is een vast aantal regels niet /// 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 /// genoeg en een schuifbalk niet fijn op een telefoon. Deze twee velden groeien daarom
@@ -156,7 +156,7 @@ else if (Stand is { IsBevestigd: true })
<div class="rij"> <div class="rij">
<div class="groei"> <div class="groei">
<span>@item.Title</span> <span>@item.Title</span>
<div class="zacht">@Tonen.Categorie(item.Category)</div> <div class="zacht">@item.SoortNaam(IngebouwdeSoorten.Naam)</div>
</div> </div>
<button class="knop rustig klein" disabled="@Bezig" @onclick:stopPropagation="true" <button class="knop rustig klein" disabled="@Bezig" @onclick:stopPropagation="true"
@onclick="() => ItemOpenenAsync(item)">Openen</button> @onclick="() => ItemOpenenAsync(item)">Openen</button>
@@ -187,7 +187,7 @@ else if (Stand is { IsBevestigd: true })
<div class="rij"> <div class="rij">
<div class="groei"> <div class="groei">
<span>@item.Title</span> <span>@item.Title</span>
<div class="zacht">@Tonen.Categorie(item.Category)</div> <div class="zacht">@item.SoortNaam(IngebouwdeSoorten.Naam)</div>
</div> </div>
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => ItemOpenenAsync(item)">Openen</button> <button class="knop rustig klein" disabled="@Bezig" @onclick="() => ItemOpenenAsync(item)">Openen</button>
</div> </div>
@@ -205,7 +205,7 @@ else if (Stand is { IsBevestigd: true })
<Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true" <Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen"> OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen">
<div class="stapel"> <div class="stapel">
@foreach (var regel in Regels(Inhoud)) @foreach (var regel in Regels(Inhoud).Concat(Inhoud.EigenRegels(GeopendSoort)))
{ {
<div class="veld"> <div class="veld">
<label>@regel.Naam</label> <label>@regel.Naam</label>
@@ -482,6 +482,9 @@ else if (Stand is { IsBevestigd: true })
private long? Bekeken, GeopendItem; private long? Bekeken, GeopendItem;
private IReadOnlyList<KluisItem> Nagelaten = Array.Empty<KluisItem>(); private IReadOnlyList<KluisItem> Nagelaten = Array.Empty<KluisItem>();
private KluisInhoud? Inhoud; private KluisInhoud? Inhoud;
/// <summary>De soort van het geopende item, voor de labels van de velden van een eigen soort.</summary>
private KluisSoort? GeopendSoort;
private string? GeopendTitel; private string? GeopendTitel;
private IReadOnlyList<Bijlage> GeopendBijlagen = Array.Empty<Bijlage>(); private IReadOnlyList<Bijlage> GeopendBijlagen = Array.Empty<Bijlage>();
@@ -604,6 +607,16 @@ else if (Stand is { IsBevestigd: true })
Inhoud = r.Waarde; Inhoud = r.Waarde;
GeopendTitel = item.Title; 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 // Bijlagen zijn hier optioneel: als ophalen mislukt tonen we het item gewoon
// zonder bijlagenlijst, in plaats van de hele popup te blokkeren. // zonder bijlagenlijst, in plaats van de hele popup te blokkeren.
var bijlagen = await Toestand.Client.BijlagenAsync(item.Id); var bijlagen = await Toestand.Client.BijlagenAsync(item.Id);
@@ -616,6 +629,7 @@ else if (Stand is { IsBevestigd: true })
{ {
GeopendItem = null; GeopendItem = null;
Inhoud = null; Inhoud = null;
GeopendSoort = null;
GeopendTitel = null; GeopendTitel = null;
GeopendBijlagen = Array.Empty<Bijlage>(); GeopendBijlagen = Array.Empty<Bijlage>();
BijlageDichtdoen(); BijlageDichtdoen();
+1 -2
View File
@@ -174,8 +174,7 @@
</Popup> </Popup>
} }
@* Zelfde tekst als op de website - Versiehistorie.razor staat twee keer op schijf en een @* De tekst komt van de server (tabel version_history), net als op de website. *@
zelfcontrole vergelijkt ze letterlijk. *@
@if (VersiehistorieOpen) @if (VersiehistorieOpen)
{ {
<Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true" <Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true"
@@ -1,4 +1,5 @@
using MijnBesluit.Domain.Entities; using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums; using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Abstractions; namespace MijnBesluit.Application.Abstractions;
@@ -261,6 +262,46 @@ public interface IAuditLog
string? details = null, CancellationToken ct = default); string? details = null, CancellationToken ct = default);
} }
/// <summary>De soorten kluisitems en hun velden, zoals de beheerder ze instelt.</summary>
public interface IVaultCategoryRepository
{
/// <summary>Alle soorten, met hun velden, op volgorde.</summary>
Task<IReadOnlyList<VaultCategoryDefinition>> ListAsync(CancellationToken ct = default);
Task<VaultCategoryDefinition?> FindAsync(int id, CancellationToken ct = default);
Task<VaultCategoryField?> FindFieldAsync(long fieldId, CancellationToken ct = default);
/// <summary>Per soort hoeveel kluisitems (niet verwijderd) en hoeveel eigenaren. Alleen aantallen.</summary>
Task<IReadOnlyList<SoortGebruik>> GebruikAsync(CancellationToken ct = default);
/// <summary>De soorten waar deze persoon minstens één kluisitem (niet verwijderd) van heeft.</summary>
Task<IReadOnlySet<int>> InGebruikDoorAsync(long personId, CancellationToken ct = default);
/// <summary>Hoeveel kluisitems (niet verwijderd) er van deze soort zijn.</summary>
Task<int> AantalItemsAsync(int categoryId, CancellationToken ct = default);
void Add(VaultCategoryDefinition soort);
void Remove(VaultCategoryDefinition soort);
void Add(VaultCategoryField veld);
void Remove(VaultCategoryField veld);
}
/// <summary>De versiehistorie: één tabel voor site en app. Zie docs/Versiehistorie.md.</summary>
public interface IVersiehistorieRepository
{
/// <summary>Nieuwste dag bovenaan; binnen één dag in de volgorde van invoer.</summary>
Task<IReadOnlyList<VersieRegel>> ListAsync(bool alleenZichtbaar, CancellationToken ct = default);
Task<VersieRegel?> FindAsync(int id, CancellationToken ct = default);
/// <summary>Is er al een regel met dezelfde datum en kop (behalve de regel met dit nummer)?</summary>
Task<bool> BestaatAsync(DateOnly datum, string kop, int? behalveId, CancellationToken ct = default);
void Add(VersieRegel regel);
void Remove(VersieRegel regel);
}
/// <summary>Versturen van uitnodigingen en meldingen.</summary> /// <summary>Versturen van uitnodigingen en meldingen.</summary>
/// <summary> /// <summary>
/// Adverteerders, hun campagnes en de tellers per uur. Zie docs/Adverteren.md. /// Adverteerders, hun campagnes en de tellers per uur. Zie docs/Adverteren.md.
@@ -0,0 +1,28 @@
namespace MijnBesluit.Application.Models;
/// <summary>
/// Een soort kluisitem zoals een gebruiker die te zien krijgt: de naam, de uitleg en bij een
/// nieuwe soort de velden van het formulier. Dit is ook wat er over de lijn naar de app gaat.
/// </summary>
public record KluisSoort(int Id, string Naam, string? Uitleg, bool BesprekenMet, bool Ingebouwd,
IReadOnlyList<KluisVeld> Velden);
/// <summary>Eén veld van het formulier van een nieuwe soort.</summary>
public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyList<string> Opties,
string? Hulptekst, bool Verplicht, bool Actief);
/// <summary>Hoe vaak een soort in gebruik is: aantallen, nooit inhoud of namen.</summary>
public record SoortGebruik(int CategoryId, int Items, int Eigenaren);
/// <summary>Een soort in het beheerscherm, met hoe vaak hij gebruikt wordt.</summary>
public record SoortBeheerbeeld(int Id, string Naam, string? Uitleg, bool Actief, int Volgorde,
bool BesprekenMet, bool Ingebouwd, int Items, int Eigenaren,
IReadOnlyList<VeldBeheerbeeld> Velden)
{
/// <summary>Verwijderen kan alleen als niemand er een item mee heeft gemaakt en hij niet ingebouwd is.</summary>
public bool KanVerwijderen => !Ingebouwd && Items == 0;
}
/// <summary>Een veld in het beheerscherm.</summary>
public record VeldBeheerbeeld(long Id, string Sleutel, string Label, string Soort, string? Opties,
string? Hulptekst, bool Verplicht, bool Actief, int Volgorde);
@@ -35,4 +35,10 @@ public class VaultPayload
public string? Music { get; set; } public string? Music { get; set; }
public string? Speakers { get; set; } public string? Speakers { get; set; }
public string? Wishes { get; set; } public string? Wishes { get; set; }
// Soorten die de beheerder zelf heeft gemaakt (nummer 100 en hoger). Hun velden staan niet als
// eigen eigenschap hierboven maar hier, op de vaste sleutel van het veld
// (VaultCategoryField.Sleutel). Zo kan er een veld bij zonder dat de code verandert, en blijft
// het net zo versleuteld als de rest van de inhoud. Bij de ingebouwde soorten is dit leeg.
public Dictionary<string, string>? Velden { get; set; }
} }
@@ -0,0 +1,11 @@
namespace MijnBesluit.Application.Models;
/// <summary>
/// Een regel van de versiehistorie zoals een gebruiker die ziet. Dit is ook wat er over de lijn
/// naar de app gaat; de datum is als tekst (jjjj-mm-dd) om tijdzones buiten de deur te houden:
/// 20 oktober is 20 oktober, waar je ook bent.
/// </summary>
public record VersieRegelWeergave(string Datum, string? Versie, string Kop, string Tekst);
/// <summary>Een regel in het beheerscherm, ook als hij verborgen is.</summary>
public record VersieRegelBeheerbeeld(int Id, DateOnly Datum, string? Versie, string Kop, string Tekst, bool Zichtbaar);
@@ -0,0 +1,438 @@
using System.Globalization;
using System.Text;
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Services;
/// <summary>
/// De soorten kluisitems. Wat je in de keuzelijst ziet, en bij een door de beheerder gemaakte soort
/// ook welke velden het formulier heeft, komt hier vandaan en niet meer uit de code. Zo vraagt een
/// nieuwe soort ("Dementie", "Niet reanimeren") geen nieuwe versie van de app.
///
/// Twee regels bepalen alles wat hier gebeurt:
///
/// 1. Wat in gebruik is, verdwijnt nooit. Een soort waar al een kluisitem mee is gemaakt kan niet
/// worden verwijderd, alleen op niet-actief gezet. Wie er nog nooit een item mee maakte ziet hem
/// dan niet meer in zijn keuzelijst; wie dat wel deed, blijft hem zien en kan zijn items
/// gewoon bekijken en wijzigen.
/// 2. De beheerder ziet aantallen, nooit inhoud. Hoe vaak een soort gebruikt wordt is een tel;
/// wie het deed en wat er in staat, blijft versleuteld en onbereikbaar.
/// </summary>
public class VaultCategoryService
{
/// <summary>Meer velden dan dit maakt van een formulier een enquête.</summary>
public const int MaxVelden = 40;
/// <summary>Hoe lang een ingevulde waarde in een eigen veld mag zijn.</summary>
public const int MaxWaardelengte = 4000;
private readonly IVaultCategoryRepository _soorten;
private readonly IUserRepository _users;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
public VaultCategoryService(IVaultCategoryRepository soorten, IUserRepository users, IAuditLog audit,
IUnitOfWork uow, IClock clock)
{
_soorten = soorten; _users = users; _audit = audit; _uow = uow; _clock = clock;
}
// ------------------------------------------------------------------ voor gebruikers
/// <summary>
/// De soorten die deze persoon te zien krijgt: alle actieve, plus de niet-actieve waar hij al
/// een item van heeft. Op volgorde van de beheerder.
/// </summary>
public async Task<IReadOnlyList<KluisSoort>> ZichtbaarVoorAsync(long personId, CancellationToken ct = default)
{
var alle = await _soorten.ListAsync(ct);
var gebruikt = await _soorten.InGebruikDoorAsync(personId, ct);
return alle.Where(s => s.Actief || gebruikt.Contains(s.Id))
.Select(NaarSoort)
.ToList();
}
/// <summary>Alle namen op nummer, voor onder een kluisitem. Ook van soorten die niet meer actief zijn.</summary>
public async Task<IReadOnlyDictionary<int, string>> NamenAsync(CancellationToken ct = default) =>
(await _soorten.ListAsync(ct)).ToDictionary(s => s.Id, s => s.Naam);
/// <summary>
/// Eén soort op nummer, ook als hij niet actief is of de vrager hem nooit gebruikte. Nodig om
/// het item van een ander te kunnen lezen (een nalatenschap, iets dat met je is gedeeld): de
/// namen van de velden zijn geen geheim, de ingevulde waarden wel, en die blijven versleuteld.
/// </summary>
public async Task<KluisSoort?> SoortAsync(int id, CancellationToken ct = default)
{
var soort = await _soorten.FindAsync(id, ct);
return soort is null ? null : NaarSoort(soort);
}
/// <summary>De soort met dit nummer, met velden. Null als hij niet bestaat.</summary>
public Task<VaultCategoryDefinition?> VindAsync(int id, CancellationToken ct = default) =>
_soorten.FindAsync(id, ct);
/// <summary>
/// Mag deze persoon een nieuw kluisitem in deze soort maken? Alleen als de soort bestaat en
/// actief is, of als hij er al een item van heeft - dan is het voor hem nog gewoon een soort
/// die hij kent.
/// </summary>
public async Task<Result<VaultCategoryDefinition>> MagGebruikenAsync(long personId, int categoryId,
CancellationToken ct = default)
{
var soort = await _soorten.FindAsync(categoryId, ct);
if (soort is null) return Result<VaultCategoryDefinition>.Fail("Deze soort bestaat niet.");
if (!soort.Actief && !(await _soorten.InGebruikDoorAsync(personId, ct)).Contains(soort.Id))
return Result<VaultCategoryDefinition>.Fail($"{soort.Naam} wordt niet meer aangeboden.");
return Result<VaultCategoryDefinition>.Ok(soort);
}
/// <summary>
/// Controleert wat er in de eigen velden van een door de beheerder gemaakte soort is ingevuld
/// en geeft de schone versie terug: alleen bekende velden, bijgesneden, met de verplichte
/// velden ingevuld en datums die kloppen. Op de server en niet alleen in het scherm, want
/// een oudere of andere app stuurt wat hij wil.
/// </summary>
public static Result<Dictionary<string, string>> ControleerVelden(
VaultCategoryDefinition soort, IReadOnlyDictionary<string, string?>? ingevuld)
{
var schoon = new Dictionary<string, string>();
foreach (var veld in soort.Velden.Where(v => v.Soort != VeldSoort.Uitleg))
{
ingevuld ??= new Dictionary<string, string?>();
var waarde = ingevuld.TryGetValue(veld.Sleutel, out var w) ? w?.Trim() : null;
if (string.IsNullOrEmpty(waarde))
{
if (veld.Verplicht && veld.Actief)
return Result<Dictionary<string, string>>.Fail($"Vul in: {veld.Label}.");
continue;
}
if (waarde.Length > MaxWaardelengte)
return Result<Dictionary<string, string>>.Fail($"{veld.Label} is te lang.");
if (veld.Soort == VeldSoort.Datum &&
!DateOnly.TryParseExact(waarde, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
return Result<Dictionary<string, string>>.Fail($"{veld.Label}: dat is geen geldige datum.");
schoon[veld.Sleutel] = waarde;
}
return Result<Dictionary<string, string>>.Ok(schoon);
}
// ------------------------------------------------------------------ voor de beheerder
/// <summary>Alle soorten met hoe vaak ze gebruikt worden. Alleen voor beheerders.</summary>
public async Task<Result<IReadOnlyList<SoortBeheerbeeld>>> OverzichtAsync(long currentUserId,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result<IReadOnlyList<SoortBeheerbeeld>>.Fail("Je bent geen beheerder.");
var alle = await _soorten.ListAsync(ct);
var gebruik = (await _soorten.GebruikAsync(ct)).ToDictionary(g => g.CategoryId);
var lijst = alle.Select(s =>
{
gebruik.TryGetValue(s.Id, out var g);
return new SoortBeheerbeeld(
s.Id, s.Naam, s.Uitleg, s.Actief, s.Volgorde, s.BesprekenMet, s.Ingebouwd,
g?.Items ?? 0, g?.Eigenaren ?? 0,
s.Velden.OrderBy(v => v.Volgorde).ThenBy(v => v.Id)
.Select(v => new VeldBeheerbeeld(v.Id, v.Sleutel, v.Label, v.Soort, v.Opties,
v.Hulptekst, v.Verplicht, v.Actief, v.Volgorde))
.ToList());
}).ToList();
return Result<IReadOnlyList<SoortBeheerbeeld>>.Ok(lijst);
}
/// <summary>
/// Maakt een nieuwe soort. Hij begint actief, onderaan de lijst. De naam is leesbaar voor de
/// server (het item zelf blijft versleuteld), dus een naam die iets over iemands gezondheid
/// zegt, zegt dat ook tegen de server.
/// </summary>
public async Task<Result<int>> MaakAsync(long currentUserId, string naam, string? uitleg, bool besprekenMet,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result<int>.Fail("Je bent geen beheerder.");
var fout = await ControleerNaamAsync(null, naam, uitleg, ct);
if (fout is not null) return Result<int>.Fail(fout);
var alle = await _soorten.ListAsync(ct);
var nu = _clock.UtcNow;
var soort = new VaultCategoryDefinition
{
// Nummers van eigen soorten beginnen bij 100; de database kiest het volgende vrije.
Naam = naam.Trim(),
Uitleg = Schoon(uitleg),
Actief = true,
Volgorde = (alle.Count == 0 ? 0 : alle.Max(s => s.Volgorde)) + 10,
BesprekenMet = besprekenMet,
Ingebouwd = false,
AangemaaktOp = nu,
GewijzigdOp = nu
};
_soorten.Add(soort);
await _uow.SaveChangesAsync(ct);
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
soort.Id, $"gemaakt: {soort.Naam}", ct);
await _uow.SaveChangesAsync(ct);
return Result<int>.Ok(soort.Id);
}
/// <summary>
/// Wijzigt een soort: de naam, de uitleg, of hij nog wordt aangeboden, waar hij in de lijst
/// staat en of er "Bespreken met" bij zit. Dit kan ook bij de ingebouwde soorten.
/// </summary>
public async Task<Result> WijzigAsync(long currentUserId, int id, string naam, string? uitleg, bool actief,
int volgorde, bool besprekenMet, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var soort = await _soorten.FindAsync(id, ct);
if (soort is null) return Result.Fail("Deze soort bestaat niet.");
var fout = await ControleerNaamAsync(id, naam, uitleg, ct);
if (fout is not null) return Result.Fail(fout);
var wijzigingen = new List<string>();
if (soort.Naam != naam.Trim()) wijzigingen.Add($"naam {soort.Naam} -> {naam.Trim()}");
if (soort.Actief != actief) wijzigingen.Add(actief ? "actief" : "niet-actief");
if (soort.BesprekenMet != besprekenMet) wijzigingen.Add(besprekenMet ? "bespreken met aan" : "bespreken met uit");
soort.Naam = naam.Trim();
soort.Uitleg = Schoon(uitleg);
soort.Actief = actief;
soort.Volgorde = volgorde;
soort.BesprekenMet = besprekenMet;
soort.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), id,
wijzigingen.Count == 0 ? "gewijzigd" : string.Join(", ", wijzigingen), ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Verwijdert een soort - maar alleen een door de beheerder gemaakte waar nog nooit een
/// kluisitem mee is gemaakt. Is hij in gebruik, dan blijft alleen "niet-actief" over, zodat
/// wie hem gebruikt hem houdt.
/// </summary>
public async Task<Result> VerwijderAsync(long currentUserId, int id, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var soort = await _soorten.FindAsync(id, ct);
if (soort is null) return Result.Fail("Deze soort bestaat niet.");
if (soort.Ingebouwd) return Result.Fail("Een ingebouwde soort kun je niet verwijderen, alleen niet-actief maken.");
var aantal = await _soorten.AantalItemsAsync(id, ct);
if (aantal > 0)
return Result.Fail($"{soort.Naam} is in gebruik ({aantal} " +
(aantal == 1 ? "item" : "items") + "). Zet hem op niet-actief; dan zien alleen de mensen die hem gebruiken hem nog.");
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), id,
$"verwijderd: {soort.Naam}", ct);
_soorten.Remove(soort);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>Voegt een veld toe aan het formulier van een door de beheerder gemaakte soort.</summary>
public async Task<Result> VeldToevoegenAsync(long currentUserId, int categoryId, string label, string soortVeld,
string? opties, string? hulptekst, bool verplicht,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var soort = await _soorten.FindAsync(categoryId, ct);
if (soort is null) return Result.Fail("Deze soort bestaat niet.");
if (soort.Ingebouwd) return Result.Fail("De velden van een ingebouwde soort zitten in de app en zijn hier niet te wijzigen.");
if (soort.Velden.Count >= MaxVelden) return Result.Fail($"Meer dan {MaxVelden} velden is te veel voor één formulier.");
var fout = ControleerVeld(label, soortVeld, opties, hulptekst, verplicht);
if (fout is not null) return Result.Fail(fout);
var veld = new VaultCategoryField
{
CategoryId = categoryId,
Sleutel = NieuweSleutel(label, soort.Velden.Select(v => v.Sleutel)),
Label = label.Trim(),
Soort = soortVeld,
Opties = soortVeld == VeldSoort.Keuze ? SchoneOpties(opties) : null,
Hulptekst = Schoon(hulptekst),
Verplicht = verplicht && soortVeld != VeldSoort.Uitleg,
Actief = true,
Volgorde = (soort.Velden.Count == 0 ? 0 : soort.Velden.Max(v => v.Volgorde)) + 10
};
_soorten.Add(veld);
soort.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition), categoryId,
$"veld toegevoegd: {veld.Label}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Wijzigt een veld. De sleutel en de soort veld staan vast, want daaraan hangt wat mensen al
/// hebben ingevuld; het label, de opties, de hulptekst, verplicht, actief en de plek mogen wel.
/// </summary>
public async Task<Result> VeldWijzigenAsync(long currentUserId, long veldId, string label, string? opties,
string? hulptekst, bool verplicht, bool actief, int volgorde,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var veld = await _soorten.FindFieldAsync(veldId, ct);
if (veld is null) return Result.Fail("Dit veld bestaat niet.");
var fout = ControleerVeld(label, veld.Soort, opties, hulptekst, verplicht);
if (fout is not null) return Result.Fail(fout);
veld.Label = label.Trim();
veld.Opties = veld.Soort == VeldSoort.Keuze ? SchoneOpties(opties) : null;
veld.Hulptekst = Schoon(hulptekst);
veld.Verplicht = verplicht && veld.Soort != VeldSoort.Uitleg;
veld.Actief = actief;
veld.Volgorde = volgorde;
var soort = await _soorten.FindAsync(veld.CategoryId, ct);
if (soort is not null) soort.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
veld.CategoryId, $"veld gewijzigd: {veld.Label}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Haalt een veld weg. Alleen zolang er geen kluisitem van deze soort bestaat: anders staan
/// er ingevulde gegevens onder en is "niet-actief" de weg - die waarden blijven dan gewoon
/// bewaard en zichtbaar bij de items die ze hebben.
/// </summary>
public async Task<Result> VeldVerwijderenAsync(long currentUserId, long veldId, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var veld = await _soorten.FindFieldAsync(veldId, ct);
if (veld is null) return Result.Fail("Dit veld bestaat niet.");
if (await _soorten.AantalItemsAsync(veld.CategoryId, ct) > 0)
return Result.Fail("Er zijn al items van deze soort; zet het veld op niet-actief in plaats van het te verwijderen. Wat er is ingevuld blijft dan bewaard.");
await _audit.WriteAsync(AuditAction.KluisSoortBeheerd, currentUserId, nameof(VaultCategoryDefinition),
veld.CategoryId, $"veld verwijderd: {veld.Label}", ct);
_soorten.Remove(veld);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
// ------------------------------------------------------------------ hulp
private async Task<bool> IsBeheerderAsync(long userId, CancellationToken ct) =>
(await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true;
private async Task<string?> ControleerNaamAsync(int? eigenId, string? naam, string? uitleg, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(naam)) return "Geef de soort een naam.";
if (naam.Trim().Length > 80) return "Een naam van meer dan 80 tekens is te lang.";
if (uitleg is { Length: > 500 }) return "De uitleg mag niet langer zijn dan 500 tekens.";
var dubbel = (await _soorten.ListAsync(ct))
.Any(s => s.Id != eigenId && string.Equals(s.Naam, naam.Trim(), StringComparison.OrdinalIgnoreCase));
return dubbel ? "Er bestaat al een soort met deze naam." : null;
}
private static string? ControleerVeld(string? label, string? soortVeld, string? opties, string? hulptekst, bool verplicht)
{
if (string.IsNullOrWhiteSpace(label)) return "Geef het veld een naam.";
if (label.Trim().Length > 100) return "De naam van een veld mag niet langer zijn dan 100 tekens.";
if (!VeldSoort.Bekend(soortVeld)) return "Dit soort veld kennen we niet.";
if (hulptekst is { Length: > 300 }) return "De hulptekst mag niet langer zijn dan 300 tekens.";
if (soortVeld == VeldSoort.Keuze)
{
var lijst = (SchoneOpties(opties) ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lijst.Length < 2) return "Een keuzelijst heeft minstens twee opties nodig, één per regel.";
if ((SchoneOpties(opties) ?? "").Length > 1000) return "De opties zijn samen te lang.";
}
if (soortVeld == VeldSoort.Uitleg && string.IsNullOrWhiteSpace(hulptekst))
return "Een uitlegtekst heeft een tekst nodig; vul die in bij de hulptekst.";
return null;
}
/// <summary>Opties één per regel, zonder lege regels en dubbelen.</summary>
private static string? SchoneOpties(string? opties) =>
opties is null
? null
: string.Join('\n', opties.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase));
private static string? Schoon(string? tekst) => string.IsNullOrWhiteSpace(tekst) ? null : tekst.Trim();
private static readonly Dictionary<char, string> Accenten = BouwAccenten();
private static Dictionary<char, string> BouwAccenten()
{
var d = new Dictionary<char, string>();
void Voeg(string letters, string door) { foreach (var l in letters) d[l] = door; }
Voeg("àáâãäåāăą", "a"); Voeg("çćĉċč", "c"); Voeg("ďđ", "d"); Voeg("èéêëēĕėęě", "e");
Voeg("ĝğġģ", "g"); Voeg("ĥħ", "h"); Voeg("ìíîïĩīĭįı", "i"); Voeg("ĵ", "j"); Voeg("ķ", "k");
Voeg("ĺļľŀł", "l"); Voeg("ñńņňʼn", "n"); Voeg("òóôõöøōŏő", "o"); Voeg("ŕŗř", "r");
Voeg("śŝşš", "s"); Voeg("ţťŧ", "t"); Voeg("ùúûüũūŭůűų", "u"); Voeg("ŵ", "w");
Voeg("ýÿŷ", "y"); Voeg("źżž", "z"); d['ß'] = "ss"; d['æ'] = "ae"; d['œ'] = "oe"; d['þ'] = "th";
return d;
}
/// <summary>
/// Een vaste, korte naam voor een veld, afgeleid van het label: "Wil je gereanimeerd worden?"
/// wordt "wil_je_gereanimeerd_worden". Is die al in gebruik in deze soort, dan komt er een
/// nummer achter.
/// </summary>
public static string NieuweSleutel(string label, IEnumerable<string> bestaand)
{
var bouw = new StringBuilder();
foreach (var teken in label.ToLowerInvariant())
{
// Accenten zelf wegwerken in plaats van via string.Normalize: dat werkt niet overal
// hetzelfde zodra InvariantGlobalization aan staat (zoals hier), en de sleutel mag niet
// per machine verschillen.
var ascii = Accenten.TryGetValue(teken, out var zonder) ? zonder : teken.ToString();
foreach (var t in ascii)
{
if (char.IsAsciiLetterOrDigit(t)) bouw.Append(t);
else if (bouw.Length > 0 && bouw[^1] != '_') bouw.Append('_');
}
}
var basis = bouw.ToString().Trim('_');
if (basis.Length == 0) basis = "veld";
if (basis.Length > 30) basis = basis[..30].TrimEnd('_');
var gebruikt = new HashSet<string>(bestaand, StringComparer.OrdinalIgnoreCase);
var sleutel = basis;
for (var n = 2; gebruikt.Contains(sleutel); n++) sleutel = $"{basis}_{n}";
return sleutel;
}
private static KluisSoort NaarSoort(VaultCategoryDefinition s) =>
new(s.Id, s.Naam, s.Uitleg, s.BesprekenMet, s.Ingebouwd,
s.Velden.OrderBy(v => v.Volgorde).ThenBy(v => v.Id)
.Select(v => new KluisVeld(v.Sleutel, v.Label, v.Soort, v.OptieLijst, v.Hulptekst, v.Verplicht, v.Actief))
.ToList());
}
@@ -26,6 +26,7 @@ public class VaultService
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IClock _clock; private readonly IClock _clock;
private readonly IAfbeeldingVerkleiner _verkleiner; private readonly IAfbeeldingVerkleiner _verkleiner;
private readonly VaultCategoryService? _soorten;
/// <summary>De langste zijde van de kleine versie die bij een foto wordt bewaard.</summary> /// <summary>De langste zijde van de kleine versie die bij een foto wordt bewaard.</summary>
public const int ThumbnailZijde = 160; public const int ThumbnailZijde = 160;
@@ -46,12 +47,14 @@ public class VaultService
public VaultService(IVaultRepository vault, IPersonRepository people, IMessageRepository messages, public VaultService(IVaultRepository vault, IPersonRepository people, IMessageRepository messages,
IUserRepository users, IMasterKeyProtector keys, IFileStore files, IAuditLog audit, IUserRepository users, IMasterKeyProtector keys, IFileStore files, IAuditLog audit,
IUnitOfWork uow, IClock clock, IAfbeeldingVerkleiner? verkleiner = null, 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; _vault = vault; _people = people; _messages = messages; _users = users; _keys = keys;
_files = files; _audit = audit; _uow = uow; _clock = clock; _families = families; _files = files; _audit = audit; _uow = uow; _clock = clock; _families = families;
_verkleiner = verkleiner ?? new GeenAfbeeldingVerkleiner(); _verkleiner = verkleiner ?? new GeenAfbeeldingVerkleiner();
_legacy = legacy; _legacy = legacy;
_soorten = soorten;
} }
/// <summary>Titels en categorieën van de kluis. De inhoud blijft versleuteld tot hij echt geopend wordt.</summary> /// <summary>Titels en categorieën van de kluis. De inhoud blijft versleuteld tot hij echt geopend wordt.</summary>
@@ -71,6 +74,10 @@ public class VaultService
if (release == ReleasePolicy.OpDatum && releaseAt is null) if (release == ReleasePolicy.OpDatum && releaseAt is null)
return Result<VaultItem>.Fail("Kies een datum waarop dit item vrijkomt."); return Result<VaultItem>.Fail("Kies een datum waarop dit item vrijkomt.");
var soortControle = await ControleerSoortAsync(user, category, payload, nieuw: true, aanbodControleren: true, ct);
if (!soortControle.Succeeded) return Result<VaultItem>.Fail(soortControle.Error!);
var kanBespreken = await KanBesprekenMetAsync(category, ct);
var now = _clock.UtcNow; var now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey); var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey(); var contentKey = EnvelopeCrypto.NewKey();
@@ -89,7 +96,7 @@ public class VaultService
WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey), WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey),
ReleasePolicy = release, ReleasePolicy = release,
ReleaseAt = releaseAt, ReleaseAt = releaseAt,
SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null, SharedManagerPersonId = kanBespreken ? sharedManagerPersonId : null,
CreatedAt = now, CreatedAt = now,
UpdatedAt = now UpdatedAt = now
}; };
@@ -132,6 +139,12 @@ public class VaultService
if (release == ReleasePolicy.OpDatum && releaseAt is null) if (release == ReleasePolicy.OpDatum && releaseAt is null)
return Result.Fail("Kies een datum waarop dit item vrijkomt."); 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 now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey); var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey(); var contentKey = EnvelopeCrypto.NewKey();
@@ -147,7 +160,7 @@ public class VaultService
item.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey); item.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey);
item.ReleasePolicy = release; item.ReleasePolicy = release;
item.ReleaseAt = releaseAt; item.ReleaseAt = releaseAt;
item.SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null; item.SharedManagerPersonId = kanBespreken ? sharedManagerPersonId : null;
item.UpdatedAt = now; item.UpdatedAt = now;
// Eerder verleende toegang was op de oude sleutel; die is nu waardeloos. Wie het item // Eerder verleende toegang was op de oude sleutel; die is nu waardeloos. Wie het item
@@ -320,6 +333,66 @@ public class VaultService
} }
} }
/// <summary>
/// Bij welke soorten kluisitems een beheerder te kiezen is om vooraf mee te bespreken. Dat is
/// een instelling per soort die de beheerder op de website zet; voorheen stond hij vast op
/// uitvaartwens en donorcodicil, en dat zijn ook de startwaarden. Zonder soortenlijst (een
/// oude opzet, of een test) geldt nog die oude regel.
/// </summary>
private async Task<bool> KanBesprekenMetAsync(VaultCategory category, CancellationToken ct)
{
if (_soorten is null)
return category is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil;
return (await _soorten.VindAsync((int)category, ct))?.BesprekenMet == true;
}
/// <summary>
/// Kijkt of dit soort item gemaakt of gewijzigd mag worden en maakt de eigen velden schoon.
///
/// <paramref name="aanbodControleren"/>: wordt de soort nog aangeboden aan deze persoon? Dat
/// geldt bij een nieuw item, en bij een item dat van soort wisselt. Een bestaand item dat in
/// zijn eigen soort blijft, hoeft dat niet: wie een soort al gebruikt, houdt hem.
///
/// Een door de beheerder gemaakte soort bewaart zijn velden in <see cref="VaultPayload.Velden"/>.
/// Komt er bij het wijzigen niets mee, dan is dat een app die de soort niet kent - die zou de
/// ingevulde gegevens overschrijven met niets, dus dat weigeren we.
/// </summary>
private async Task<Result> ControleerSoortAsync(User user, VaultCategory category, VaultPayload payload,
bool nieuw, bool aanbodControleren, CancellationToken ct)
{
if (_soorten is null) return Result.Ok();
VaultCategoryDefinition? soort;
if (aanbodControleren)
{
var mag = await _soorten.MagGebruikenAsync(user.PersonId, (int)category, ct);
if (!mag.Succeeded) return Result.Fail(mag.Error!);
soort = mag.Value;
}
else
{
soort = await _soorten.VindAsync((int)category, ct);
if (soort is null) return Result.Ok(); // een nummer dat nergens meer bij hoort: niet in de weg zitten
}
if (soort!.Ingebouwd)
{
payload.Velden = null;
return Result.Ok();
}
if (!nieuw && payload.Velden is null)
return Result.Fail("Dit item kan alleen met een bijgewerkte versie van de app worden gewijzigd. Werk de app bij en probeer het opnieuw.");
var schoon = VaultCategoryService.ControleerVelden(
soort, payload.Velden?.ToDictionary(v => v.Key, v => (string?)v.Value));
if (!schoon.Succeeded) return Result.Fail(schoon.Error!);
payload.Velden = schoon.Value;
return Result.Ok();
}
/// <summary> /// <summary>
/// Geeft de gekozen nalatenschapsbeheerder van een Uitvaartwens meteen leestoegang tot dit /// 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 /// 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 /// 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. /// beide, dan wordt de keuze stil overgeslagen; het item zelf blijft wel bewaard staan.
/// </summary> /// </summary>
/// <summary>
/// Bij welke soorten kluisitems een beheerder te kiezen is om vooraf mee te bespreken. Op dit
/// moment een uitvaartwens en een donorcodicil - allebei dingen waar je iemand graag vooraf
/// bij betrekt, in plaats van dat diegene het pas na een overlijden voor het eerst ziet.
/// </summary>
private static bool KanBesprekenMet(VaultCategory category) =>
category is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil;
private async Task DeelMetBeheerderAsync(VaultItem item, User owner, long beheerderPersonId, CancellationToken ct) private async Task DeelMetBeheerderAsync(VaultItem item, User owner, long beheerderPersonId, CancellationToken ct)
{ {
if (_legacy is not null) if (_legacy is not null)
@@ -0,0 +1,157 @@
using System.Globalization;
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Services;
/// <summary>
/// De versiehistorie: wat er nieuw is in MijnBesluit, voor wie dat wil lezen. De lijst staat in de
/// database en wordt door de beheerder onderhouden, zodat de website en de app dezelfde bron
/// hebben en een nieuwe regel meteen overal staat - ook in een app die al in de winkel staat.
///
/// Twee regels:
///
/// 1. Alleen een beheerder maakt, wijzigt of verwijdert. Een gebruiker kan alleen lezen, en ziet
/// dan alleen de regels die zichtbaar zijn.
/// 2. Platte tekst. Wat hier staat wordt door de schermen als tekst getoond, nooit als opmaak;
/// zo kan een typefout of een geplakte tekst het scherm niet stukmaken.
/// </summary>
public class VersiehistorieService
{
public const int MaxKop = 120;
public const int MaxTekst = 1000;
public const int MaxVersie = 20;
private readonly IVersiehistorieRepository _regels;
private readonly IUserRepository _users;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
public VersiehistorieService(IVersiehistorieRepository regels, IUserRepository users, IAuditLog audit,
IUnitOfWork uow, IClock clock)
{
_regels = regels; _users = users; _audit = audit; _uow = uow; _clock = clock;
}
// ------------------------------------------------------------------ voor gebruikers
/// <summary>
/// De zichtbare regels, de nieuwste dag bovenaan. Binnen een dag staan ze in de volgorde waarin
/// ze zijn ingevoerd.
/// </summary>
public async Task<IReadOnlyList<VersieRegelWeergave>> ZichtbaarAsync(CancellationToken ct = default) =>
(await _regels.ListAsync(alleenZichtbaar: true, ct))
.Select(r => new VersieRegelWeergave(Dag(r.Datum), r.Versie, r.Kop, r.Tekst))
.ToList();
// ------------------------------------------------------------------ voor de beheerder
/// <summary>Alle regels, ook de verborgen. Alleen voor beheerders.</summary>
public async Task<Result<IReadOnlyList<VersieRegelBeheerbeeld>>> OverzichtAsync(long currentUserId,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result<IReadOnlyList<VersieRegelBeheerbeeld>>.Fail("Je bent geen beheerder.");
var lijst = (await _regels.ListAsync(alleenZichtbaar: false, ct))
.Select(r => new VersieRegelBeheerbeeld(r.Id, r.Datum, r.Versie, r.Kop, r.Tekst, r.Zichtbaar))
.ToList();
return Result<IReadOnlyList<VersieRegelBeheerbeeld>>.Ok(lijst);
}
/// <summary>Voegt een regel toe. Hij is meteen zichtbaar tenzij je hem verbergt.</summary>
public async Task<Result<int>> MaakAsync(long currentUserId, DateOnly datum, string? versie, string kop,
string tekst, bool zichtbaar = true, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result<int>.Fail("Je bent geen beheerder.");
var fout = await ControleerAsync(null, datum, versie, kop, tekst, ct);
if (fout is not null) return Result<int>.Fail(fout);
var nu = _clock.UtcNow;
var regel = new VersieRegel
{
Datum = datum, Versie = SchoneVersie(versie), Kop = kop.Trim(), Tekst = tekst.Trim(),
Zichtbaar = zichtbaar, AangemaaktOp = nu, GewijzigdOp = nu
};
_regels.Add(regel);
await _uow.SaveChangesAsync(ct);
await _audit.WriteAsync(AuditAction.VersiehistorieBeheerd, currentUserId, nameof(VersieRegel), regel.Id,
$"gemaakt: {Dag(datum)} {regel.Kop}", ct);
await _uow.SaveChangesAsync(ct);
return Result<int>.Ok(regel.Id);
}
/// <summary>Wijzigt een regel, en kan hem verbergen of weer tonen.</summary>
public async Task<Result> WijzigAsync(long currentUserId, int id, DateOnly datum, string? versie, string kop,
string tekst, bool zichtbaar, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var regel = await _regels.FindAsync(id, ct);
if (regel is null) return Result.Fail("Deze regel bestaat niet.");
var fout = await ControleerAsync(id, datum, versie, kop, tekst, ct);
if (fout is not null) return Result.Fail(fout);
regel.Datum = datum;
regel.Versie = SchoneVersie(versie);
regel.Kop = kop.Trim();
regel.Tekst = tekst.Trim();
regel.Zichtbaar = zichtbaar;
regel.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.VersiehistorieBeheerd, currentUserId, nameof(VersieRegel), id,
$"gewijzigd: {Dag(datum)} {regel.Kop}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>Haalt een regel weg. Wil je hem alleen even niet tonen, verberg hem dan.</summary>
public async Task<Result> VerwijderAsync(long currentUserId, int id, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder.");
var regel = await _regels.FindAsync(id, ct);
if (regel is null) return Result.Fail("Deze regel bestaat niet.");
await _audit.WriteAsync(AuditAction.VersiehistorieBeheerd, currentUserId, nameof(VersieRegel), id,
$"verwijderd: {Dag(regel.Datum)} {regel.Kop}", ct);
_regels.Remove(regel);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
// ------------------------------------------------------------------ hulp
private static string Dag(DateOnly datum) => datum.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
private static string? SchoneVersie(string? versie) =>
string.IsNullOrWhiteSpace(versie) ? null : versie.Trim();
private async Task<bool> IsBeheerderAsync(long userId, CancellationToken ct) =>
(await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true;
private async Task<string?> ControleerAsync(int? eigenId, DateOnly datum, string? versie, string? kop,
string? tekst, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(kop)) return "Geef de regel een kop.";
if (kop.Trim().Length > MaxKop) return $"De kop is te lang (hoogstens {MaxKop} tekens).";
if (string.IsNullOrWhiteSpace(tekst)) return "Schrijf er een uitleg bij.";
if (tekst.Trim().Length > MaxTekst) return $"De uitleg is te lang (hoogstens {MaxTekst} tekens).";
if (versie is not null && versie.Trim().Length > MaxVersie) return $"Het versienummer is te lang (hoogstens {MaxVersie} tekens).";
// Een jaar vooruit mag, voor wie een regel klaarzet; verder is het een typefout.
var vandaag = DateOnly.FromDateTime(_clock.UtcNow);
if (datum.Year < 2020 || datum > vandaag.AddYears(1)) return "Deze datum klopt niet.";
if (await _regels.BestaatAsync(datum, kop.Trim(), eigenId, ct))
return "Op deze dag staat al een regel met precies deze kop.";
return null;
}
}
@@ -333,6 +333,28 @@ public class MijnBesluitClient : IAsyncDisposable
public Task<Uitkomst<IReadOnlyList<KluisItem>>> KluisAsync(int? categorie = null, CancellationToken ct = default) => public Task<Uitkomst<IReadOnlyList<KluisItem>>> KluisAsync(int? categorie = null, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<KluisItem>>(SocketActions.VaultList, new { category = categorie }, ct); StuurAsync<IReadOnlyList<KluisItem>>(SocketActions.VaultList, new { category = categorie }, ct);
/// <summary>
/// De soorten kluisitems die ik te zien krijg. Faalt dit of komt er niets terug (een oudere
/// server), dan hoort het scherm op zijn ingebouwde lijst terug te vallen - zie
/// <see cref="IngebouwdeSoorten"/>.
/// </summary>
public Task<Uitkomst<IReadOnlyList<KluisSoort>>> KluisSoortenAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<KluisSoort>>(SocketActions.VaultCategories, null, ct);
/// <summary>
/// Eén soort op nummer, met zijn velden - ook als die soort niet meer wordt aangeboden. Voor het
/// lezen van het item van een ander, waar je eigen lijst de soort niet hoeft te kennen.
/// </summary>
public Task<Uitkomst<KluisSoort>> KluisSoortAsync(int soortId, CancellationToken ct = default) =>
StuurAsync<KluisSoort>(SocketActions.VaultCategory, new { id = soortId }, ct);
/// <summary>
/// De versiehistorie zoals de server die bijhoudt, nieuwste dag bovenaan. Een oudere server
/// kent de actie niet; het scherm toont dan een melding in plaats van een lijst.
/// </summary>
public Task<Uitkomst<IReadOnlyList<VersieRegel>>> VersiehistorieAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<VersieRegel>>(SocketActions.Versiehistorie, null, ct);
public Task<Uitkomst<KluisInhoud>> KluisOpenenAsync(long itemId, CancellationToken ct = default) => public Task<Uitkomst<KluisInhoud>> KluisOpenenAsync(long itemId, CancellationToken ct = default) =>
StuurAsync<KluisInhoud>(SocketActions.VaultOpen, new { id = itemId }, ct); StuurAsync<KluisInhoud>(SocketActions.VaultOpen, new { id = itemId }, ct);
+86 -1
View File
@@ -246,7 +246,64 @@ public record KluisItem(
int ReleasePolicy, int ReleasePolicy,
DateTime? ReleaseAt, DateTime? ReleaseAt,
DateTime UpdatedAt, DateTime UpdatedAt,
long? SharedManagerPersonId = null); long? SharedManagerPersonId = null,
string? CategoryName = null)
{
/// <summary>
/// Hoe de soort heet. De server stuurt de naam mee, zodat een soort die de beheerder zelf
/// maakte ook in een oudere lijst een naam heeft; ontbreekt hij, dan geldt de ingebouwde naam
/// van het nummer.
/// </summary>
public string SoortNaam(Func<int, string> ingebouwd) =>
string.IsNullOrWhiteSpace(CategoryName) ? ingebouwd(Category) : CategoryName;
}
/// <summary>
/// Een soort kluisitem zoals de server hem aanbiedt. Een ingebouwde soort (nummer 1 tot en met 11
/// en 99) heeft zijn invulscherm in de app en geen <see cref="Velden"/>; een soort die de
/// beheerder maakte heeft een nummer vanaf 100 en beschrijft zijn formulier hier.
/// </summary>
public record KluisSoort(int Id, string Naam, string? Uitleg, bool BesprekenMet, bool Ingebouwd,
IReadOnlyList<KluisVeld> Velden);
/// <summary>Eén veld van het formulier van een soort die de beheerder maakte.</summary>
public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyList<string> Opties,
string? Hulptekst, bool Verplicht, bool Actief)
{
public const string Tekst = "tekst";
public const string Lang = "lang";
public const string Datum = "datum";
public const string Keuze = "keuze";
public const string Uitleg = "uitleg";
}
/// <summary>
/// Eén regel van de versiehistorie. <c>Datum</c> staat als jjjj-mm-dd; <c>Versie</c> is leeg als
/// de regel niet bij een uitgebrachte appversie hoort.
/// </summary>
public record VersieRegel(string Datum, string? Versie, string Kop, string Tekst);
/// <summary>
/// De ingebouwde soorten kluisitems, voor als de server er geen lijst van geeft: een oudere
/// server, of geen verbinding op het moment dat het scherm opent. Dezelfde nummers, namen en
/// volgorde als de startrijen in db/21_kluissoorten.sql; een controle in de zelftest bewaakt dat
/// ze gelijk blijven.
/// </summary>
public static class IngebouwdeSoorten
{
private static KluisSoort S(int id, string naam, bool bespreken = false) =>
new(id, naam, null, bespreken, true, Array.Empty<KluisVeld>());
public static readonly IReadOnlyList<KluisSoort> Lijst = new[]
{
S(3, "Wachtwoord"), S(8, "Sociale media"), S(4, "Apparaat"), S(5, "Pincode"), S(2, "Polis"),
S(1, "Uitvaartwens", true), S(9, "Donorcodicil", true), S(10, "Rijbewijs"), S(11, "Paspoort"),
S(6, "Document"), S(7, "Notitie"), S(99, "Overig")
};
/// <summary>De naam bij een nummer; "Overig" voor wat we niet kennen.</summary>
public static string Naam(int id) => Lijst.FirstOrDefault(s => s.Id == id)?.Naam ?? "Overig";
}
/// <summary>De ontsleutelde inhoud van een kluisitem. Per soort is maar een deel gevuld.</summary> /// <summary>De ontsleutelde inhoud van een kluisitem. Per soort is maar een deel gevuld.</summary>
public class KluisInhoud public class KluisInhoud
@@ -273,6 +330,34 @@ public class KluisInhoud
public string? Music { get; set; } public string? Music { get; set; }
public string? Speakers { get; set; } public string? Speakers { get; set; }
public string? Wishes { get; set; } public string? Wishes { get; set; }
/// <summary>
/// De velden van een soort die de beheerder zelf maakte, op de vaste sleutel van het veld.
/// Bij de ingebouwde soorten blijft dit leeg.
/// </summary>
public Dictionary<string, string> Velden { get; set; } = new();
/// <summary>
/// De ingevulde velden van een soort die de beheerder maakte, met hun label, in de volgorde van
/// het formulier. Alleen wat is ingevuld; datums staan er leesbaar, zoals "12 oktober 2026".
/// Een veld dat niet meer in de soort staat, wordt niet getoond.
/// </summary>
public IEnumerable<(string Naam, string Waarde)> EigenRegels(KluisSoort? soort)
{
if (soort is null) yield break;
foreach (var veld in soort.Velden.Where(v => v.Soort != KluisVeld.Uitleg))
{
if (!Velden.TryGetValue(veld.Sleutel, out var waarde) || string.IsNullOrWhiteSpace(waarde)) continue;
if (veld.Soort == KluisVeld.Datum &&
DateOnly.TryParseExact(waarde, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dag))
waarde = $"{dag.Day} {Datums.Maanden[dag.Month - 1]} {dag.Year}";
yield return (veld.Label, waarde);
}
}
} }
/// <summary>Een bijlage bij een kluisitem, zonder de inhoud zelf.</summary> /// <summary>Een bijlage bij een kluisitem, zonder de inhoud zelf.</summary>
@@ -0,0 +1,117 @@
namespace MijnBesluit.Domain.Entities;
/// <summary>
/// Eén soort kluisitem zoals de beheerder die instelt: "Wachtwoord", "Polis", of een nieuwe als
/// "Niet reanimeren". Het nummer is hetzelfde nummer als <c>VaultItem.Category</c>; de elf
/// ingebouwde soorten en Overig (99) houden hun oude nummer, nieuwe soorten beginnen bij 100.
///
/// Een ingebouwde soort heeft zijn invulscherm in de app zelf en dus geen <see cref="Velden"/>.
/// Een nieuwe soort beschrijft zijn velden hier, en de app tekent het formulier daarnaar - zodat
/// een nieuwe soort geen nieuwe versie van de app vraagt.
/// </summary>
public class VaultCategoryDefinition
{
/// <summary>Vanaf dit nummer zijn soorten door de beheerder gemaakt.</summary>
public const int EersteEigenNummer = 100;
public int Id { get; set; }
public string Naam { get; set; } = string.Empty;
/// <summary>Korte uitleg die in de app onder de keuzelijst staat. Mag leeg.</summary>
public string? Uitleg { get; set; }
/// <summary>
/// Wordt de soort nog aangeboden? Niet-actief verdwijnt uit de keuzelijst van iedereen die er
/// nog nooit een item van maakte; wie dat wel deed, blijft de soort zien.
/// </summary>
public bool Actief { get; set; } = true;
public int Volgorde { get; set; } = 1000;
/// <summary>Mag de eigenaar bij dit soort een beheerder kiezen om het vooraf mee te bespreken?</summary>
public bool BesprekenMet { get; set; }
/// <summary>Invulscherm zit in de app; velden zijn hier niet te wijzigen en de soort is niet te verwijderen.</summary>
public bool Ingebouwd { get; set; }
public DateTime AangemaaktOp { get; set; } = DateTime.UtcNow;
public DateTime GewijzigdOp { get; set; } = DateTime.UtcNow;
public List<VaultCategoryField> Velden { get; set; } = new();
}
/// <summary>Een veld in het formulier van een door de beheerder gemaakte soort.</summary>
public class VaultCategoryField
{
public long Id { get; set; }
public int CategoryId { get; set; }
/// <summary>
/// De vaste naam waaronder de waarde in de versleutelde inhoud staat. Wordt bij het maken
/// van het veld uit het label afgeleid en wijzigt daarna nooit meer: een label hernoemen mag,
/// maar mag geen ingevulde gegevens losmaken van hun veld.
/// </summary>
public string Sleutel { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
/// <summary>Een van <see cref="VeldSoort"/>. Verandert na het maken niet meer.</summary>
public string Soort { get; set; } = VeldSoort.Tekst;
/// <summary>Bij <see cref="VeldSoort.Keuze"/>: de opties, één per regel.</summary>
public string? Opties { get; set; }
public string? Hulptekst { get; set; }
public bool Verplicht { get; set; }
/// <summary>
/// Een niet-actief veld staat niet meer in nieuwe formulieren, maar wat er al is ingevuld
/// blijft staan en wordt bij het wijzigen van zo'n item nog getoond.
/// </summary>
public bool Actief { get; set; } = true;
public int Volgorde { get; set; } = 1000;
public VaultCategoryDefinition? Category { get; set; }
/// <summary>De opties als lijst, zonder lege regels.</summary>
public IReadOnlyList<string> OptieLijst =>
string.IsNullOrWhiteSpace(Opties)
? Array.Empty<string>()
: Opties.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>De soorten invoerveld die een formulier kent.</summary>
public static class VeldSoort
{
/// <summary>Eén regel tekst.</summary>
public const string Tekst = "tekst";
/// <summary>Meerdere regels tekst.</summary>
public const string Lang = "lang";
/// <summary>Een datum; wordt als jjjj-mm-dd bewaard.</summary>
public const string Datum = "datum";
/// <summary>Kiezen uit een lijst, bijvoorbeeld Ja / Nee / Alleen na overleg.</summary>
public const string Keuze = "keuze";
/// <summary>Geen invoer maar een stukje uitleg tussen de velden.</summary>
public const string Uitleg = "uitleg";
public static readonly string[] Alle = { Tekst, Lang, Datum, Keuze, Uitleg };
public static bool Bekend(string? soort) => soort is not null && Alle.Contains(soort);
/// <summary>Hoe de soort op het scherm heet, voor in het beheerscherm.</summary>
public static string Naam(string soort) => soort switch
{
Tekst => "Korte tekst",
Lang => "Lange tekst",
Datum => "Datum",
Keuze => "Keuzelijst",
Uitleg => "Uitlegtekst (geen invoer)",
_ => soort
};
}
@@ -0,0 +1,33 @@
namespace MijnBesluit.Domain.Entities;
/// <summary>
/// Eén regel in de versiehistorie: een nieuwe of wezenlijk gewijzigde functie, zoals een gebruiker
/// die zou willen lezen ("wat kan ik nu wat ik eerst niet kon"). Geen bugfixes, geen opmaak die
/// rechtgezet is.
///
/// De lijst staat in de database en wordt door de beheerder onderhouden; de website leest hem
/// rechtstreeks en de app vraagt hem aan de server. Regels met dezelfde datum en versie worden
/// samen onder één kop getoond.
/// </summary>
public class VersieRegel
{
public int Id { get; set; }
/// <summary>De dag waarop de functie er kwam.</summary>
public DateOnly Datum { get; set; }
/// <summary>Het versienummer van de app, bijvoorbeeld "1.1". Leeg als het alleen de website betreft.</summary>
public string? Versie { get; set; }
/// <summary>Het vette stukje: wat kun je nu.</summary>
public string Kop { get; set; } = string.Empty;
/// <summary>De uitleg erachter. Platte tekst.</summary>
public string Tekst { get; set; } = string.Empty;
/// <summary>Een verborgen regel ziet alleen de beheerder; handig voor iets dat nog niet live is.</summary>
public bool Zichtbaar { get; set; } = true;
public DateTime AangemaaktOp { get; set; } = DateTime.UtcNow;
public DateTime GewijzigdOp { get; set; } = DateTime.UtcNow;
}
+3 -1
View File
@@ -73,7 +73,9 @@ public enum AuditAction
OverlijdenGemeld = 50, OverlijdenBetwist = 51, OverlijdenBevestigd = 52, OverlijdenGemeld = 50, OverlijdenBetwist = 51, OverlijdenBevestigd = 52,
NalatenschapToegangVerleend = 60, NalatenschapToegangVerleend = 60,
AdvertentieIngediend = 70, AdvertentieBetaald = 71, AdvertentieGoedgekeurd = 72, AdvertentieIngediend = 70, AdvertentieBetaald = 71, AdvertentieGoedgekeurd = 72,
AdvertentieAfgewezen = 73, AdvertentieGestopt = 74 AdvertentieAfgewezen = 73, AdvertentieGestopt = 74,
KluisSoortBeheerd = 80,
VersiehistorieBeheerd = 81
} }
/// <summary> /// <summary>
@@ -93,6 +93,8 @@ public static class DependencyInjection
services.AddScoped<IFamilyRepository, FamilyRepository>(); services.AddScoped<IFamilyRepository, FamilyRepository>();
services.AddScoped<IInvitationRepository, InvitationRepository>(); services.AddScoped<IInvitationRepository, InvitationRepository>();
services.AddScoped<IVaultRepository, VaultRepository>(); services.AddScoped<IVaultRepository, VaultRepository>();
services.AddScoped<IVaultCategoryRepository, VaultCategoryRepository>();
services.AddScoped<IVersiehistorieRepository, VersiehistorieRepository>();
services.AddScoped<IMessageRepository, MessageRepository>(); services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<ILegacyRepository, LegacyRepository>(); services.AddScoped<ILegacyRepository, LegacyRepository>();
services.AddScoped<IToestelRepository, ToestelRepository>(); services.AddScoped<IToestelRepository, ToestelRepository>();
@@ -103,6 +105,8 @@ public static class DependencyInjection
services.AddScoped<IAuditLog, EfAuditLog>(); services.AddScoped<IAuditLog, EfAuditLog>();
services.AddScoped<AccountService>(); services.AddScoped<AccountService>();
services.AddScoped<VaultCategoryService>();
services.AddScoped<VersiehistorieService>();
services.AddScoped<VaultService>(); services.AddScoped<VaultService>();
// De link naar de landingspagina voor iemand zonder account moet naar de echte site wijzen. // De link naar de landingspagina voor iemand zonder account moet naar de echte site wijzen.
services.AddScoped(sp => services.AddScoped(sp =>
@@ -18,6 +18,9 @@ public class MijnBesluitDbContext : DbContext
public DbSet<Invitation> Invitations => Set<Invitation>(); public DbSet<Invitation> Invitations => Set<Invitation>();
public DbSet<VaultItem> VaultItems => Set<VaultItem>(); public DbSet<VaultItem> VaultItems => Set<VaultItem>();
public DbSet<VaultFile> VaultFiles => Set<VaultFile>(); public DbSet<VaultFile> VaultFiles => Set<VaultFile>();
public DbSet<VaultCategoryDefinition> VaultCategories => Set<VaultCategoryDefinition>();
public DbSet<VaultCategoryField> VaultCategoryFields => Set<VaultCategoryField>();
public DbSet<VersieRegel> VersieRegels => Set<VersieRegel>();
public DbSet<Toestel> Toestellen => Set<Toestel>(); public DbSet<Toestel> Toestellen => Set<Toestel>();
public DbSet<Sessie> Sessies => Set<Sessie>(); public DbSet<Sessie> Sessies => Set<Sessie>();
public DbSet<ContentGrant> ContentGrants => Set<ContentGrant>(); public DbSet<ContentGrant> ContentGrants => Set<ContentGrant>();
@@ -218,6 +221,50 @@ public class MijnBesluitDbContext : DbContext
e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId); e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId);
}); });
b.Entity<VersieRegel>(e =>
{
e.ToTable("version_history");
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Datum).HasColumnName("datum");
e.Property(x => x.Versie).HasColumnName("versie").HasMaxLength(20);
e.Property(x => x.Kop).HasColumnName("kop").HasMaxLength(120).IsRequired();
e.Property(x => x.Tekst).HasColumnName("tekst").HasMaxLength(1000).IsRequired();
e.Property(x => x.Zichtbaar).HasColumnName("zichtbaar");
e.Property(x => x.AangemaaktOp).HasColumnName("aangemaakt_op");
e.Property(x => x.GewijzigdOp).HasColumnName("gewijzigd_op");
});
b.Entity<VaultCategoryDefinition>(e =>
{
e.ToTable("vault_categories");
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Naam).HasColumnName("naam").HasMaxLength(80).IsRequired();
e.Property(x => x.Uitleg).HasColumnName("uitleg").HasMaxLength(500);
e.Property(x => x.Actief).HasColumnName("actief");
e.Property(x => x.Volgorde).HasColumnName("volgorde");
e.Property(x => x.BesprekenMet).HasColumnName("bespreken_met");
e.Property(x => x.Ingebouwd).HasColumnName("ingebouwd");
e.Property(x => x.AangemaaktOp).HasColumnName("aangemaakt_op");
e.Property(x => x.GewijzigdOp).HasColumnName("gewijzigd_op");
e.HasMany(x => x.Velden).WithOne(v => v.Category).HasForeignKey(v => v.CategoryId);
});
b.Entity<VaultCategoryField>(e =>
{
e.ToTable("vault_category_fields");
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.CategoryId).HasColumnName("category_id");
e.Property(x => x.Sleutel).HasColumnName("sleutel").HasMaxLength(40).IsRequired();
e.Property(x => x.Label).HasColumnName("label").HasMaxLength(100).IsRequired();
e.Property(x => x.Soort).HasColumnName("soort").HasMaxLength(10).IsRequired();
e.Property(x => x.Opties).HasColumnName("opties").HasMaxLength(1000);
e.Property(x => x.Hulptekst).HasColumnName("hulptekst").HasMaxLength(300);
e.Property(x => x.Verplicht).HasColumnName("verplicht");
e.Property(x => x.Actief).HasColumnName("actief");
e.Property(x => x.Volgorde).HasColumnName("volgorde");
e.Ignore(x => x.OptieLijst);
});
b.Entity<ContentGrant>(e => b.Entity<ContentGrant>(e =>
{ {
e.ToTable("content_grants"); e.ToTable("content_grants");
@@ -229,6 +229,64 @@ public class InvitationRepository : IInvitationRepository
public void Remove(Invitation invitation) => _db.Invitations.Remove(invitation); public void Remove(Invitation invitation) => _db.Invitations.Remove(invitation);
} }
/// <summary>De soorten kluisitems; zie <see cref="IVaultCategoryRepository"/>.</summary>
public class VaultCategoryRepository : IVaultCategoryRepository
{
private readonly MijnBesluitDbContext _db;
public VaultCategoryRepository(MijnBesluitDbContext db) => _db = db;
public async Task<IReadOnlyList<VaultCategoryDefinition>> ListAsync(CancellationToken ct = default) =>
await _db.VaultCategories.Include(c => c.Velden)
.OrderBy(c => c.Volgorde).ThenBy(c => c.Naam).ToListAsync(ct);
public Task<VaultCategoryDefinition?> FindAsync(int id, CancellationToken ct = default) =>
_db.VaultCategories.Include(c => c.Velden).FirstOrDefaultAsync(c => c.Id == id, ct);
public Task<VaultCategoryField?> FindFieldAsync(long fieldId, CancellationToken ct = default) =>
_db.VaultCategoryFields.FirstOrDefaultAsync(f => f.Id == fieldId, ct);
public async Task<IReadOnlyList<SoortGebruik>> GebruikAsync(CancellationToken ct = default) =>
await _db.VaultItems.Where(v => v.DeletedAt == null)
.GroupBy(v => v.Category)
.Select(g => new SoortGebruik((int)g.Key, g.Count(), g.Select(v => v.OwnerPersonId).Distinct().Count()))
.ToListAsync(ct);
public async Task<IReadOnlySet<int>> InGebruikDoorAsync(long personId, CancellationToken ct = default)
{
var lijst = await _db.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null)
.Select(v => v.Category).Distinct().ToListAsync(ct);
return lijst.Select(c => (int)c).ToHashSet();
}
public Task<int> AantalItemsAsync(int categoryId, CancellationToken ct = default) =>
_db.VaultItems.CountAsync(v => v.Category == (VaultCategory)categoryId && v.DeletedAt == null, ct);
public void Add(VaultCategoryDefinition soort) => _db.VaultCategories.Add(soort);
public void Remove(VaultCategoryDefinition soort) => _db.VaultCategories.Remove(soort);
public void Add(VaultCategoryField veld) => _db.VaultCategoryFields.Add(veld);
public void Remove(VaultCategoryField veld) => _db.VaultCategoryFields.Remove(veld);
}
/// <summary>De versiehistorie; zie <see cref="IVersiehistorieRepository"/>.</summary>
public class VersiehistorieRepository : IVersiehistorieRepository
{
private readonly MijnBesluitDbContext _db;
public VersiehistorieRepository(MijnBesluitDbContext db) => _db = db;
public async Task<IReadOnlyList<VersieRegel>> ListAsync(bool alleenZichtbaar, CancellationToken ct = default) =>
await _db.VersieRegels.Where(r => !alleenZichtbaar || r.Zichtbaar)
.OrderByDescending(r => r.Datum).ThenBy(r => r.Id).ToListAsync(ct);
public Task<VersieRegel?> FindAsync(int id, CancellationToken ct = default) =>
_db.VersieRegels.FirstOrDefaultAsync(r => r.Id == id, ct);
public Task<bool> BestaatAsync(DateOnly datum, string kop, int? behalveId, CancellationToken ct = default) =>
_db.VersieRegels.AnyAsync(r => r.Datum == datum && r.Kop == kop && (behalveId == null || r.Id != behalveId), ct);
public void Add(VersieRegel regel) => _db.VersieRegels.Add(regel);
public void Remove(VersieRegel regel) => _db.VersieRegels.Remove(regel);
}
public class VaultRepository : IVaultRepository public class VaultRepository : IVaultRepository
{ {
private readonly MijnBesluitDbContext _db; private readonly MijnBesluitDbContext _db;
+19
View File
@@ -98,6 +98,25 @@ public static class SocketActions
/// <summary>Kluisitems die iemand rechtstreeks met jou heeft gedeeld, vooraf te bespreken.</summary> /// <summary>Kluisitems die iemand rechtstreeks met jou heeft gedeeld, vooraf te bespreken.</summary>
public const string VaultSharedWithMe = "vault.shared-with-me"; public const string VaultSharedWithMe = "vault.shared-with-me";
/// <summary>
/// De soorten kluisitems die deze gebruiker te zien krijgt, met bij een door de beheerder gemaakte
/// soort de velden van het formulier. Een oudere server kent dit niet; de app valt dan terug op
/// de ingebouwde lijst.
/// </summary>
public const string VaultCategories = "vault.categories";
/// <summary>
/// Eén soort op nummer, met zijn velden, ook als die soort niet meer wordt aangeboden. Voor het
/// lezen van het item van een ander, waar de lijst van jezelf de soort niet hoeft te bevatten.
/// </summary>
public const string VaultCategory = "vault.category";
/// <summary>
/// De versiehistorie: de zichtbare regels, nieuwste dag bovenaan. Staat in een tabel op de
/// server, zodat een nieuwe regel zonder nieuwe app te zien is. Zie docs/Versiehistorie.md.
/// </summary>
public const string Versiehistorie = "app.versiehistorie";
/// <summary>Nalatenschappen die aan jou zijn toevertrouwd, en wat daarvan is vrijgegeven.</summary> /// <summary>Nalatenschappen die aan jou zijn toevertrouwd, en wat daarvan is vrijgegeven.</summary>
public const string LegacyEntrusted = "legacy.entrusted"; public const string LegacyEntrusted = "legacy.entrusted";
public const string LegacyEntrustedItems = "legacy.entrusted-items"; public const string LegacyEntrustedItems = "legacy.entrusted-items";
+54 -3
View File
@@ -15,6 +15,15 @@ public class RequestScope
public required LegacyService Legacy { get; init; } public required LegacyService Legacy { get; init; }
public required OverzichtService Overzicht { get; init; } public required OverzichtService Overzicht { get; init; }
/// <summary>
/// De soorten kluisitems. Optioneel: zonder deze dienst werkt de kluis met de ingebouwde soorten
/// en krijgen items geen soortnaam mee, en de app valt terug op haar eigen lijst.
/// </summary>
public VaultCategoryService? Soorten { get; init; }
/// <summary>Optioneel: zonder deze dienst geeft de versiehistorie een lege lijst.</summary>
public VersiehistorieService? Versiehistorie { get; init; }
/// <summary> /// <summary>
/// Voor het samenvoegen van dubbele profielen. Optioneel: zonder deze dienst werkt alles, /// Voor het samenvoegen van dubbele profielen. Optioneel: zonder deze dienst werkt alles,
/// alleen wordt een tweede uitnodiging dan geweigerd in plaats van samengevoegd. /// alleen wordt een tweede uitnodiging dan geweigerd in plaats van samengevoegd.
@@ -76,6 +85,17 @@ public class SocketDispatcher
f.Id, f.FileName, f.ContentType, f.SizeBytes, f.CreatedAt, f.IsProfilePhoto f.Id, f.FileName, f.ContentType, f.SizeBytes, f.CreatedAt, f.IsProfilePhoto
}); });
/// <summary>
/// De namen van alle soorten op nummer, om onder elk kluisitem te kunnen zetten hoe het heet.
/// Een soort die de beheerder zelf maakte staat niet in de lijst van de app zelf, dus de naam
/// reist met het item mee. Zonder soortendienst is de lijst leeg en valt de app terug.
/// </summary>
private static async Task<IReadOnlyDictionary<int, string>> SoortNamenAsync(RequestScope scope, CancellationToken ct) =>
scope.Soorten is null ? new Dictionary<int, string>() : await scope.Soorten.NamenAsync(ct);
private static string? SoortNaam(IReadOnlyDictionary<int, string> namen, int soort) =>
namen.TryGetValue(soort, out var naam) ? naam : null;
/// <summary>Voor als er geen klok wordt meegegeven; de overdracht heeft er wel een nodig.</summary> /// <summary>Voor als er geen klok wordt meegegeven; de overdracht heeft er wel een nodig.</summary>
private sealed class SysteemKlok : MijnBesluit.Application.Abstractions.IClock private sealed class SysteemKlok : MijnBesluit.Application.Abstractions.IClock
{ {
@@ -451,12 +471,39 @@ public class SocketDispatcher
{ {
var p = request.PayloadAs<VaultListPayload>(); var p = request.PayloadAs<VaultListPayload>();
var items = await scope.Vault.ListAsync(session!.PersonId, p?.Category, ct); 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 return SocketResponse.Ok(id, items.Select(i => new
{ {
i.Id, i.Title, i.Category, i.ReleasePolicy, i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId i.Id, i.Title, i.Category, i.ReleasePolicy, i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId,
CategoryName = SoortNaam(namen, (int)i.Category)
})); }));
} }
case SocketActions.VaultCategory:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen soort opgegeven.");
var soort = scope.Soorten is null ? null : await scope.Soorten.SoortAsync((int)p.Id, ct);
return soort is null ? SocketResponse.Error(id, "Deze soort bestaat niet.") : SocketResponse.Ok(id, soort);
}
case SocketActions.VaultCategories:
{
// Zonder soortendienst een lege lijst: de app valt dan terug op haar ingebouwde soorten.
var lijst = scope.Soorten is null
? Array.Empty<KluisSoort>()
: (await scope.Soorten.ZichtbaarVoorAsync(session!.PersonId, ct)).ToArray();
return SocketResponse.Ok(id, lijst);
}
case SocketActions.Versiehistorie:
{
var lijst = scope.Versiehistorie is null
? Array.Empty<VersieRegelWeergave>()
: (await scope.Versiehistorie.ZichtbaarAsync(ct)).ToArray();
return SocketResponse.Ok(id, lijst);
}
case SocketActions.VaultOpen: case SocketActions.VaultOpen:
{ {
var p = request.PayloadAs<IdPayload>(); var p = request.PayloadAs<IdPayload>();
@@ -807,10 +854,12 @@ public class SocketDispatcher
var r = await scope.Legacy.NalatenschapItemsAsync(p.PersonId, session!.UserId, ct); var r = await scope.Legacy.NalatenschapItemsAsync(p.PersonId, session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!); if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
var nalatenschapNamen = await SoortNamenAsync(scope, ct);
return SocketResponse.Ok(id, r.Value!.Select(i => new return SocketResponse.Ok(id, r.Value!.Select(i => new
{ {
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy, 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 +877,12 @@ public class SocketDispatcher
var r = await scope.Vault.ListSharedWithMeAsync(session!.UserId, ct); var r = await scope.Vault.ListSharedWithMeAsync(session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!); if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
var gedeeldNamen = await SoortNamenAsync(scope, ct);
return SocketResponse.Ok(id, r.Value!.Select(i => new return SocketResponse.Ok(id, r.Value!.Select(i => new
{ {
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy, i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy,
i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId,
CategoryName = SoortNaam(gedeeldNamen, (int)i.Category)
})); }));
} }
@@ -44,6 +44,8 @@ public class SocketServerService : BackgroundService
Accounts = scope.ServiceProvider.GetRequiredService<AccountService>(), Accounts = scope.ServiceProvider.GetRequiredService<AccountService>(),
Families = scope.ServiceProvider.GetRequiredService<FamilyService>(), Families = scope.ServiceProvider.GetRequiredService<FamilyService>(),
Vault = scope.ServiceProvider.GetRequiredService<VaultService>(), Vault = scope.ServiceProvider.GetRequiredService<VaultService>(),
Soorten = scope.ServiceProvider.GetRequiredService<VaultCategoryService>(),
Versiehistorie = scope.ServiceProvider.GetRequiredService<VersiehistorieService>(),
Messages = scope.ServiceProvider.GetRequiredService<MessageService>(), Messages = scope.ServiceProvider.GetRequiredService<MessageService>(),
Legacy = scope.ServiceProvider.GetRequiredService<LegacyService>(), Legacy = scope.ServiceProvider.GetRequiredService<LegacyService>(),
Meldingen = scope.ServiceProvider.GetRequiredService<MeldingService>(), Meldingen = scope.ServiceProvider.GetRequiredService<MeldingService>(),
@@ -0,0 +1,48 @@
@inject IConfiguration Instellingen
@inject IWebHostEnvironment Omgeving
@* De "Download in de App Store" en "Ontdek het op Google Play"-knoppen.
Elke knop verschijnt pas als er twee dingen zijn: een adres in appsettings.json (Apps:AppStoreUrl
en Apps:GooglePlayUrl, leeg zolang de app daar niet staat) en het officiële plaatje van de winkel
in wwwroot/badges. De plaatjes tekenen we niet zelf na: Apple en Google leveren ze kant-en-klaar
aan en eisen dat ze onaangepast worden gebruikt. Zie wwwroot/badges/LEESMIJ.txt. *@
@if (_knoppen.Count > 0)
{
<div class="appknoppen">
@foreach (var knop in _knoppen)
{
<a href="@knop.Adres" target="_blank" rel="noopener" title="@knop.Titel">
<img src="@knop.Plaatje" alt="@knop.Titel" />
</a>
}
</div>
}
@code {
private readonly List<(string Adres, string Plaatje, string Titel)> _knoppen = new();
protected override void OnInitialized()
{
Voeg("Apps:AppStoreUrl", "app-store", "Download in de App Store");
Voeg("Apps:GooglePlayUrl", "google-play", "Ontdek het op Google Play");
}
private void Voeg(string instelling, string bestandsnaam, string titel)
{
var adres = Instellingen[instelling];
if (string.IsNullOrWhiteSpace(adres)) return;
// Het plaatje mag .svg of .png zijn, wat je van de winkel hebt gekregen.
foreach (var extensie in new[] { "svg", "png" })
{
var pad = $"badges/{bestandsnaam}.{extensie}";
if (Omgeving.WebRootFileProvider.GetFileInfo(pad).Exists)
{
_knoppen.Add((adres, "/" + pad, titel));
return;
}
}
}
}
@@ -1,5 +1,7 @@
using MijnBesluit.Application.Models; using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums; using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Web.Components.Onderdelen; namespace MijnBesluit.Web.Components.Onderdelen;
@@ -14,8 +16,11 @@ namespace MijnBesluit.Web.Components.Onderdelen;
/// </summary> /// </summary>
public static class Kluisweergave public static class Kluisweergave
{ {
/// <summary>De ingevulde velden van een item, in de volgorde waarin je ze wilt lezen.</summary> /// <summary>
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.
/// </summary>
public static IEnumerable<(string Label, string Waarde)> Velden(VaultPayload p, KluisSoort? soort = null)
{ {
if (p.Website is not null) yield return ("Website", p.Website); if (p.Website is not null) yield return ("Website", p.Website);
if (p.Username is not null) yield return ("Gebruikersnaam", p.Username); 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.Music is not null) yield return ("Muziek", p.Music);
if (p.Speakers is not null) yield return ("Sprekers", p.Speakers); if (p.Speakers is not null) yield return ("Sprekers", p.Speakers);
if (p.Wishes is not null) yield return ("Wensen", p.Wishes); 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); if (p.Notes is not null) yield return ("Toelichting", p.Notes);
} }
/// <summary>
/// De velden van een soort die de beheerder zelf maakte, met de naam die hij ze gaf. Alleen wat
/// is ingevuld; ook een veld dat inmiddels niet-actief is blijft zichtbaar zolang er iets in staat.
/// </summary>
public static IEnumerable<(string Label, string Waarde)> EigenVelden(VaultPayload p, KluisSoort? soort)
{
if (soort is null || p.Velden is null) yield break;
foreach (var veld in soort.Velden.Where(v => v.Soort != VeldSoort.Uitleg))
{
if (!p.Velden.TryGetValue(veld.Sleutel, out var waarde) || string.IsNullOrWhiteSpace(waarde)) continue;
if (veld.Soort == VeldSoort.Datum &&
DateOnly.TryParseExact(waarde, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dag))
waarde = $"{dag.Day} {Datums.Maanden[dag.Month - 1]} {dag.Year}";
yield return (veld.Label, waarde);
}
}
/// <summary>De naam van een soort: die van de beheerder als de server hem kent, anders de vaste.</summary>
public static string Naam(VaultCategory c, IReadOnlyDictionary<int, string>? namen) =>
namen is not null && namen.TryGetValue((int)c, out var naam) ? naam : Naam(c);
public static string Naam(VaultCategory c) => c switch public static string Naam(VaultCategory c) => c switch
{ {
VaultCategory.Uitvaartwens => "Uitvaartwens", VaultCategory.Uitvaartwens => "Uitvaartwens",
@@ -1,52 +1,89 @@
@* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. @* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
Zelfde opzet als Productuitleg.razor en Privacytekst.razor: op de website een popup vanaf De regels staan in een tabel op de server (version_history) en worden beheerd op de website
het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site onder Beheer > Versiehistorie. Site en app lezen dezelfde tabel; dit bestand bevat zelf geen
delen geen project - en een zelfcontrole vergelijkt ze letterlijk. tekst meer, dus een nieuwe regel vraagt geen nieuwe versie van de app. Zie docs/Versiehistorie.md.
Wat hier NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor Wat daar NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor
"wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@ "wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@
@inject VersiehistorieService Historie
<p class="zacht" style="margin-top:0"> <p class="zacht" style="margin-top:0">
Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
</p> </p>
<h2 style="margin-top:0">20 september 2026</h2> @if (Fout)
<ul style="padding-left:1.2rem"> {
<li style="margin-bottom:.5rem"> <p class="zacht">De versiehistorie kon niet worden opgehaald. Probeer het later nog eens.</p>
<strong>Rijbewijs en paspoort in de kluis.</strong> Twee nieuwe categorieën, met een }
knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken else if (Regels is null)
of te kiezen. {
</li> <p class="zacht">Bezig met laden&hellip;</p>
<li style="margin-bottom:.5rem"> }
<strong>Mijn profiel als pop-up.</strong> Je eigen profiel bewerk je nu in dezelfde else if (Regels.Count == 0)
pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de {
app en op de website. <p class="zacht">Er staat nog niets in de versiehistorie.</p>
</li> }
<li style="margin-bottom:.5rem"> else
<strong>Duidelijker voor de nalatenschapsbeheerder.</strong> Bij "Aan mij toevertrouwd" {
zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is. @foreach (var groep in Groepen())
</li> {
<li> <h2 style="@(groep.Eerste ? "margin-top:0" : null)">@groep.Kop</h2>
<strong>Muziek en verdere wensen groeien mee.</strong> Die velden bij een kluisitem <ul style="padding-left:1.2rem">
passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website. @foreach (var regel in groep.Regels)
</li> {
</ul> <li style="margin-bottom:.5rem"><strong>@Punt(regel.Kop)</strong> @regel.Tekst</li>
}
</ul>
}
}
<h2>19 september 2026</h2> @code {
<ul style="padding-left:1.2rem"> private IReadOnlyList<VersieRegelWeergave>? Regels;
<li style="margin-bottom:.5rem"> private bool Fout;
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je protected override async Task OnInitializedAsync()
dat voortaan ook op je telefoon, niet meer alleen op de website. {
</li> try { Regels = await Historie.ZichtbaarAsync(); }
<li style="margin-bottom:.5rem"> catch { Fout = true; }
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in }
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij
horen. private sealed record Groep(string Kop, bool Eerste, List<VersieRegelWeergave> Regels);
</li>
<li> /// <summary>
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis, /// Opeenvolgende regels met dezelfde dag (en dezelfde versie) vormen samen één kop. De volgorde
Mijn mensen, Momenten en Nalatenschap. /// komt van de server: nieuwste dag bovenaan, binnen een dag in de volgorde van invoer.
</li> /// </summary>
</ul> private IEnumerable<Groep> Groepen()
{
var uit = new List<Groep>();
string? vorige = null;
foreach (var r in Regels!)
{
var kop = Koptekst(r);
if (kop != vorige)
{
uit.Add(new Groep(kop, uit.Count == 0, new List<VersieRegelWeergave>()));
vorige = kop;
}
uit[^1].Regels.Add(r);
}
return uit;
}
private static string Koptekst(VersieRegelWeergave r)
{
var dag = DateOnly.TryParseExact(r.Datum, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var d)
? $"{d.Day} {Datums.Maanden[d.Month - 1]} {d.Year}"
: r.Datum;
return string.IsNullOrWhiteSpace(r.Versie) ? dag : $"{dag} \u00b7 versie {r.Versie}";
}
/// <summary>De kop eindigt op een punt, zodat de tekst erachter als gewone zin leest.</summary>
private static string Punt(string kop)
{
var k = kop.Trim();
return k.Length == 0 || ".!?".Contains(k[^1]) ? k : k + ".";
}
}
@@ -31,6 +31,13 @@ else
hoge uitzondering. *@ hoge uitzondering. *@
<p><a href="/beheer/adverteren">Advertenties beheren &rsaquo;</a></p> <p><a href="/beheer/adverteren">Advertenties beheren &rsaquo;</a></p>
@* Kluissoorten: wat mensen in hun kluis kunnen vastleggen, en wat je daar zelf bij zet.
Ook een eigen scherm: dat is inrichten, geen terugdraaien. *@
<p><a href="/beheer/soorten">Kluissoorten beheren &rsaquo;</a></p>
@* Versiehistorie: de lijst "wat is er nieuw" op de website en in de app. Eén tabel voor beide. *@
<p><a href="/beheer/versiehistorie">Versiehistorie beheren &rsaquo;</a></p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> } @if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@* ------------------------------------------------------------ statistieken *@ @* ------------------------------------------------------------ statistieken *@
@@ -0,0 +1,486 @@
@page "/beheer/soorten"
@attribute [Authorize]
@inject BeheerService Beheerkant
@inject VaultCategoryService SoortService
@rendermode InteractiveServer
<PageTitle>Kluissoorten - Beheer</PageTitle>
@* Het beheerscherm voor de soorten kluisitems: wat mensen in hun kluis kunnen vastleggen
(Wachtwoord, Polis, Uitvaartwens, en wat je er zelf bij zet, zoals "Niet reanimeren").
Drie regels, in de volgorde waarin ze ertoe doen:
1. Wat in gebruik is, verdwijnt nooit. Een soort met items is alleen op niet-actief te
zetten; wie hem al gebruikt blijft hem zien, wie niet ziet hem niet meer.
2. Je ziet hier aantallen, nooit inhoud of namen. Hoe vaak is een tel, wie en wat niet.
3. Een nieuwe soort vraagt geen nieuwe versie van de app: de app tekent het formulier naar de
velden die je hier opgeeft.
Zie docs/Kluissoorten.md. *@
@if (!Magdit)
{
<h1>Niet gevonden</h1>
<p class="zacht">Deze pagina bestaat niet, of hij is niet voor jou.</p>
}
else
{
<h1>Kluissoorten</h1>
<p class="zacht">
Wat mensen in hun kluis kunnen vastleggen. Een nieuwe soort verschijnt meteen in de app en op
de website, zonder dat er een nieuwe versie van de app nodig is. Bij elke soort zie je hoe
vaak hij gebruikt wordt; wat erin staat zie je nooit.
</p>
<p><a href="/beheer">&lsaquo; Terug naar beheer</a></p>
<div class="melding let-op">
<strong>De naam van een soort is leesbaar voor de server.</strong> Alleen de inhoud van een
kluisitem is versleuteld, de naam van de soort niet. Wie "Dementieverklaring" als soort
aanmaakt, zet dat woord dus in de database. Kies een neutrale naam als de soort zelf iets
over iemands gezondheid zegt, en zet het gevoelige deel in de velden: die waarden blijven
versleuteld.
</div>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@* ------------------------------------------------------------ nieuwe soort *@
<div class="kaart">
<h2 style="margin-top:0">Nieuwe soort</h2>
<div class="raster twee">
<div class="veld">
<label for="nieuwenaam">Naam</label>
<input id="nieuwenaam" type="text" maxlength="80" @bind="NieuweSoort.Naam" @bind:event="oninput"
placeholder="Bijvoorbeeld: Niet reanimeren" />
</div>
<div class="veld">
<label for="nieuweuitleg">Uitleg onder de keuzelijst (mag leeg)</label>
<input id="nieuweuitleg" type="text" maxlength="500" @bind="NieuweSoort.Uitleg" @bind:event="oninput" />
</div>
</div>
<div class="keuze">
<input type="checkbox" id="nieuwbespreken" @bind="NieuweSoort.BesprekenMet" />
<label for="nieuwbespreken">Bij deze soort kan de eigenaar iemand kiezen om het vooraf mee te bespreken</label>
</div>
<div class="rij" style="margin-top:.8rem">
<button class="knop" disabled="@Bezig" @onclick="Aanmaken">Soort toevoegen</button>
</div>
<p class="zacht klein" style="margin-bottom:0">
Na het toevoegen kun je de velden van het formulier invullen en bepalen of de soort
actief is. Een soort zonder velden toont alleen de titel en de toelichting.
</p>
</div>
@* ------------------------------------------------------------ de lijst *@
@if (Lijst is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else
{
<div class="stapel">
@foreach (var s in Lijst)
{
<div class="kaart">
<div class="rij">
<div class="groei">
<div class="groot">@s.Naam</div>
<div class="zacht">@Gebruik(s)</div>
<div style="margin-top:.35rem;display:flex;gap:.4rem;flex-wrap:wrap">
@if (s.Ingebouwd) { <span class="label">Ingebouwd</span> }
else { <span class="label geclaimd">Eigen soort</span> }
@if (!s.Actief) { <span class="label uitgenodigd">Niet actief</span> }
@if (s.BesprekenMet) { <span class="label">Bespreken met</span> }
</div>
</div>
<button class="knop rustig" @onclick="() => Openen(s)">
@(Open == s.Id ? "Sluiten" : "Beheren")
</button>
</div>
@if (Open == s.Id)
{
<hr />
<div class="raster twee">
<div class="veld">
<label>Naam</label>
<input type="text" maxlength="80" @bind="Bewerken.Naam" @bind:event="oninput" />
</div>
<div class="veld">
<label>Plek in de lijst</label>
<input type="number" @bind="Bewerken.Volgorde" />
<div class="uitleg">Kleinste nummer staat bovenaan.</div>
</div>
</div>
<div class="veld">
<label>Uitleg onder de keuzelijst</label>
<input type="text" maxlength="500" @bind="Bewerken.Uitleg" @bind:event="oninput" />
</div>
<div class="keuze">
<input type="checkbox" id="actief@(s.Id)" @bind="Bewerken.Actief" />
<label for="actief@(s.Id)">Actief: nieuwe gebruikers kunnen deze soort kiezen</label>
</div>
@if (!Bewerken.Actief)
{
<p class="zacht klein" style="margin:.2rem 0 .6rem 1.7rem">
Wie al een item van deze soort heeft, blijft hem zien en kan zijn items gewoon
bekijken en wijzigen. Voor iedereen die hem nog nooit gebruikte verdwijnt hij uit de lijst.
</p>
}
<div class="keuze">
<input type="checkbox" id="bespreken@(s.Id)" @bind="Bewerken.BesprekenMet" />
<label for="bespreken@(s.Id)">Bespreken met: de eigenaar kan iemand kiezen om dit vooraf mee te bespreken</label>
</div>
<div class="rij" style="margin-top:.8rem">
<button class="knop" disabled="@Bezig" @onclick="() => Opslaan(s.Id)">Wijzigingen opslaan</button>
@if (s.KanVerwijderen)
{
<button class="knop rustig" @onclick="() => WeghalenSoort = s.Id">Soort verwijderen</button>
}
</div>
@if (!s.KanVerwijderen)
{
<p class="zacht klein" style="margin-bottom:0">
@if (s.Ingebouwd) { <text>Een ingebouwde soort kun je niet verwijderen, alleen niet-actief maken.</text> }
else { <text>Deze soort is in gebruik en kan dus niet worden verwijderd. Zet hem op niet-actief als je hem niet meer wilt aanbieden.</text> }
</p>
}
@if (WeghalenSoort == s.Id)
{
<div class="melding let-op" style="margin-top:.9rem">
<strong>@s.Naam</strong> verwijderen? Dit kan, omdat nog niemand er een item mee heeft gemaakt.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" @onclick="() => Verwijderen(s.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => WeghalenSoort = null">Nee, bewaren</button>
</div>
</div>
}
<hr />
<h3>Velden van het formulier</h3>
@if (s.Ingebouwd)
{
<p class="zacht">
Het invulscherm van een ingebouwde soort zit in de app zelf en is hier niet te
wijzigen. Wil je voor zoiets als "Polis" andere velden, maak dan een eigen soort.
</p>
}
else
{
@if (s.Velden.Count == 0)
{
<p class="zacht">Nog geen velden. Zonder velden vult iemand alleen een titel en een toelichting in.</p>
}
<div class="stapel">
@foreach (var v in s.Velden)
{
<div class="kaart" style="background:var(--vlak-zacht)">
<div class="rij">
<div class="groei">
<div>@v.Label</div>
<div class="zacht klein">
@VeldSoort.Naam(v.Soort)
@if (v.Verplicht) { <text> &middot; verplicht</text> }
@if (!v.Actief) { <text> &middot; niet actief</text> }
</div>
</div>
<button class="knop rustig" @onclick="() => VeldOpenen(v)">
@(VeldBewerkId == v.Id ? "Sluiten" : "Wijzigen")
</button>
</div>
@if (VeldBewerkId == v.Id)
{
<div class="veld" style="margin-top:.8rem">
<label>Naam van het veld</label>
<input type="text" maxlength="100" @bind="VeldBewerken.Label" @bind:event="oninput" />
<div class="uitleg">
De naam hernoemen mag: wat mensen al hebben ingevuld blijft bij het veld horen.
Het soort veld ligt vast.
</div>
</div>
@if (v.Soort == VeldSoort.Keuze)
{
<div class="veld">
<label>Keuzes, één per regel</label>
<textarea rows="4" @bind="VeldBewerken.Opties" @bind:event="oninput"></textarea>
</div>
}
<div class="veld">
<label>@(v.Soort == VeldSoort.Uitleg ? "Tekst" : "Hulptekst onder het veld")</label>
<input type="text" maxlength="300" @bind="VeldBewerken.Hulptekst" @bind:event="oninput" />
</div>
<div class="raster twee">
<div class="veld">
<label>Plek in het formulier</label>
<input type="number" @bind="VeldBewerken.Volgorde" />
</div>
<div>
@if (v.Soort != VeldSoort.Uitleg)
{
<div class="keuze">
<input type="checkbox" id="vv@(v.Id)" @bind="VeldBewerken.Verplicht" />
<label for="vv@(v.Id)">Verplicht</label>
</div>
}
<div class="keuze">
<input type="checkbox" id="va@(v.Id)" @bind="VeldBewerken.Actief" />
<label for="va@(v.Id)">Actief</label>
</div>
</div>
</div>
<div class="rij">
<button class="knop" disabled="@Bezig" @onclick="() => VeldOpslaan(v.Id)">Veld opslaan</button>
<button class="knop rustig" @onclick="() => WeghalenVeld = v.Id">Veld verwijderen</button>
</div>
<p class="zacht klein" style="margin-bottom:0">
@if (s.Items > 0)
{
<text>Er zijn al items van deze soort: een veld verwijderen kan dan niet. Zet het op niet-actief; wat er is ingevuld blijft bewaard.</text>
}
else
{
<text>Er zijn nog geen items van deze soort, dus je kunt het veld nog verwijderen.</text>
}
</p>
@if (WeghalenVeld == v.Id)
{
<div class="melding let-op" style="margin-top:.7rem">
<strong>@v.Label</strong> verwijderen?
<div class="rij" style="margin-top:.6rem">
<button class="knop gevaar" @onclick="() => VeldVerwijderen(v.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => WeghalenVeld = null">Nee, bewaren</button>
</div>
</div>
}
}
</div>
}
</div>
@* ---------------------------------------------------- veld toevoegen *@
<h3 style="margin-top:1.2rem">Veld toevoegen</h3>
<div class="raster twee">
<div class="veld">
<label>Naam van het veld</label>
<input type="text" maxlength="100" @bind="NieuwVeld.Label" @bind:event="oninput"
placeholder="Bijvoorbeeld: Wil je gereanimeerd worden?" />
</div>
<div class="veld">
<label>Soort veld</label>
<select @bind="NieuwVeld.Soort">
@foreach (var soort in VeldSoort.Alle)
{
<option value="@soort">@VeldSoort.Naam(soort)</option>
}
</select>
</div>
</div>
@if (NieuwVeld.Soort == VeldSoort.Keuze)
{
<div class="veld">
<label>Keuzes, één per regel</label>
<textarea rows="4" @bind="NieuwVeld.Opties" @bind:event="oninput"
placeholder="Ja&#10;Nee&#10;Alleen na overleg"></textarea>
</div>
}
<div class="veld">
<label>@(NieuwVeld.Soort == VeldSoort.Uitleg ? "Tekst" : "Hulptekst onder het veld (mag leeg)")</label>
<input type="text" maxlength="300" @bind="NieuwVeld.Hulptekst" @bind:event="oninput" />
</div>
@if (NieuwVeld.Soort != VeldSoort.Uitleg)
{
<div class="keuze">
<input type="checkbox" id="nieuwverplicht@(s.Id)" @bind="NieuwVeld.Verplicht" />
<label for="nieuwverplicht@(s.Id)">Verplicht</label>
</div>
}
<div class="rij" style="margin-top:.8rem">
<button class="knop" disabled="@Bezig" @onclick="() => VeldToevoegen(s.Id)">Veld toevoegen</button>
</div>
}
}
</div>
}
</div>
}
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId;
private bool Magdit, Bezig;
private string? Melding, MeldingSoort;
private IReadOnlyList<SoortBeheerbeeld>? Lijst;
/// <summary>Welke soort openstaat voor beheer.</summary>
private int? Open;
private int? WeghalenSoort;
private long? VeldBewerkId, WeghalenVeld;
private SoortFormulier NieuweSoort = new();
private SoortFormulier Bewerken = new();
private VeldFormulier NieuwVeld = new();
private VeldFormulier VeldBewerken = new();
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
_gebruikerId = (await AuthState).User.UserId() ?? 0;
Magdit = await Beheerkant.IsBeheerderAsync(_gebruikerId);
if (!Magdit) return;
await Laden();
}
private async Task Laden()
{
var resultaat = await SoortService.OverzichtAsync(_gebruikerId);
if (resultaat.Succeeded) Lijst = resultaat.Value;
else Meld(resultaat.Error, false);
}
private void Meld(string? tekst, bool goed)
{
Melding = tekst;
MeldingSoort = goed ? "goed" : "fout";
}
private static string Aantal(int n, string een, string meer) => $"{n} {(n == 1 ? een : meer)}";
private static string Gebruik(SoortBeheerbeeld s) => s.Items == 0
? "Nog nooit gebruikt"
: $"Gebruikt in {Aantal(s.Items, "item", "items")} door {Aantal(s.Eigenaren, "persoon", "mensen")}";
private void Openen(SoortBeheerbeeld s)
{
if (Open == s.Id) { Open = null; return; }
Open = s.Id;
WeghalenSoort = null;
VeldBewerkId = null;
WeghalenVeld = null;
NieuwVeld = new VeldFormulier();
Bewerken = new SoortFormulier
{
Naam = s.Naam, Uitleg = s.Uitleg ?? "", Actief = s.Actief,
Volgorde = s.Volgorde, BesprekenMet = s.BesprekenMet
};
}
private async Task Aanmaken()
{
Bezig = true;
try
{
var resultaat = await SoortService.MaakAsync(_gebruikerId, NieuweSoort.Naam, NieuweSoort.Uitleg,
NieuweSoort.BesprekenMet);
if (resultaat.Succeeded)
{
Meld($"{NieuweSoort.Naam.Trim()} is toegevoegd. Vul nu de velden in.", true);
NieuweSoort = new SoortFormulier();
await Laden();
var gemaakt = Lijst?.FirstOrDefault(x => x.Id == resultaat.Value);
if (gemaakt is not null) { Open = null; Openen(gemaakt); }
}
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task Opslaan(int id)
{
Bezig = true;
try
{
var resultaat = await SoortService.WijzigAsync(_gebruikerId, id, Bewerken.Naam, Bewerken.Uitleg,
Bewerken.Actief, Bewerken.Volgorde, Bewerken.BesprekenMet);
if (resultaat.Succeeded) { Meld("Opgeslagen.", true); await Laden(); }
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task Verwijderen(int id)
{
var resultaat = await SoortService.VerwijderAsync(_gebruikerId, id);
WeghalenSoort = null;
if (resultaat.Succeeded) { Open = null; Meld("De soort is verwijderd.", true); await Laden(); }
else Meld(resultaat.Error, false);
}
private void VeldOpenen(VeldBeheerbeeld v)
{
if (VeldBewerkId == v.Id) { VeldBewerkId = null; return; }
VeldBewerkId = v.Id;
WeghalenVeld = null;
VeldBewerken = new VeldFormulier
{
Label = v.Label, Soort = v.Soort, Opties = v.Opties ?? "", Hulptekst = v.Hulptekst ?? "",
Verplicht = v.Verplicht, Actief = v.Actief, Volgorde = v.Volgorde
};
}
private async Task VeldToevoegen(int soortId)
{
Bezig = true;
try
{
var resultaat = await SoortService.VeldToevoegenAsync(_gebruikerId, soortId, NieuwVeld.Label, NieuwVeld.Soort,
NieuwVeld.Opties, NieuwVeld.Hulptekst, NieuwVeld.Verplicht);
if (resultaat.Succeeded) { Meld($"{NieuwVeld.Label.Trim()} is toegevoegd.", true); NieuwVeld = new VeldFormulier(); await Laden(); }
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task VeldOpslaan(long veldId)
{
Bezig = true;
try
{
var resultaat = await SoortService.VeldWijzigenAsync(_gebruikerId, veldId, VeldBewerken.Label, VeldBewerken.Opties,
VeldBewerken.Hulptekst, VeldBewerken.Verplicht,
VeldBewerken.Actief, VeldBewerken.Volgorde);
if (resultaat.Succeeded) { Meld("Het veld is opgeslagen.", true); VeldBewerkId = null; await Laden(); }
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task VeldVerwijderen(long veldId)
{
var resultaat = await SoortService.VeldVerwijderenAsync(_gebruikerId, veldId);
WeghalenVeld = null;
if (resultaat.Succeeded) { VeldBewerkId = null; Meld("Het veld is verwijderd.", true); await Laden(); }
else Meld(resultaat.Error, false);
}
public class SoortFormulier
{
public string Naam { get; set; } = "";
public string Uitleg { get; set; } = "";
public bool Actief { get; set; } = true;
public int Volgorde { get; set; }
public bool BesprekenMet { get; set; }
}
public class VeldFormulier
{
public string Label { get; set; } = "";
public string Soort { get; set; } = VeldSoort.Tekst;
public string Opties { get; set; } = "";
public string Hulptekst { get; set; } = "";
public bool Verplicht { get; set; }
public bool Actief { get; set; } = true;
public int Volgorde { get; set; }
}
}
@@ -0,0 +1,256 @@
@page "/beheer/versiehistorie"
@attribute [Authorize]
@inject BeheerService Beheerkant
@inject VersiehistorieService Historie
@rendermode InteractiveServer
<PageTitle>Versiehistorie - Beheer</PageTitle>
@* Het beheerscherm voor de versiehistorie: de lijst "wat is er nieuw" die op de website en in de
app onder "Versiehistorie" staat. Eén tabel voor beide, dus hier een regel toevoegen is genoeg.
Een regel is: een datum, eventueel een appversie, een vet kopje en een tekst. Gewone tekst,
geen opmaak. Wat erin hoort: nieuwe of wezenlijk gewijzigde functies; geen bugfixes.
Een regel verbergen haalt hem weg bij de lezers zonder hem kwijt te raken. Wie de app al
heeft van vóór deze tabel (versiecode 3 of ouder) ziet de tekst die in die app is ingebakken.
Zie docs/Versiehistorie.md. *@
@if (!Magdit)
{
<h1>Niet gevonden</h1>
<p class="zacht">Deze pagina bestaat niet, of hij is niet voor jou.</p>
}
else
{
<h1>Versiehistorie</h1>
<p class="zacht">
Wat er nieuw is, nieuwste bovenaan. Een nieuwe regel staat meteen op de website en in de app,
zonder dat er een nieuwe versie van de app nodig is.
</p>
<p><a href="/beheer">&lsaquo; Terug naar beheer</a></p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@* ------------------------------------------------------------ nieuwe of bestaande regel *@
<div class="kaart">
<h2 style="margin-top:0">@(BewerkId is null ? "Nieuwe regel" : "Regel wijzigen")</h2>
<div class="raster twee">
<div class="veld">
<label for="vh-datum">Datum</label>
<input id="vh-datum" type="date" @bind="Formulier.Datum" />
</div>
<div class="veld">
<label for="vh-versie">Versie (mag leeg)</label>
<input id="vh-versie" type="text" maxlength="20" @bind="Formulier.Versie" @bind:event="oninput"
placeholder="Bijvoorbeeld: 1.2" />
<div class="uitleg">Alleen invullen als dit bij een uitgebrachte versie van de app hoort.</div>
</div>
</div>
<div class="veld">
<label for="vh-kop">Vet kopje</label>
<input id="vh-kop" type="text" maxlength="@VersiehistorieService.MaxKop" @bind="Formulier.Kop" @bind:event="oninput"
placeholder="Bijvoorbeeld: Meer soorten in de kluis." />
</div>
<div class="veld">
<label for="vh-tekst">Tekst</label>
<textarea id="vh-tekst" rows="4" maxlength="@VersiehistorieService.MaxTekst" @bind="Formulier.Tekst" @bind:event="oninput"></textarea>
<div class="uitleg">@Formulier.Tekst.Length van @VersiehistorieService.MaxTekst tekens. Gewone tekst, geen opmaak.</div>
</div>
<div class="keuze">
<input type="checkbox" id="vh-zichtbaar" @bind="Formulier.Zichtbaar" />
<label for="vh-zichtbaar">Zichtbaar voor iedereen</label>
</div>
<div class="rij" style="margin-top:.8rem">
<button class="knop" disabled="@Bezig" @onclick="Opslaan">@(BewerkId is null ? "Regel toevoegen" : "Wijzigingen opslaan")</button>
@if (BewerkId is not null)
{
<button class="knop rustig" disabled="@Bezig" @onclick="Annuleren">Annuleren</button>
}
</div>
</div>
@* ------------------------------------------------------------ de lijst *@
@if (Lijst is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else if (Lijst.Count == 0)
{
<p class="zacht">Er staat nog niets in de versiehistorie.</p>
}
else
{
<div class="stapel">
@foreach (var r in Lijst)
{
<div class="kaart">
<div class="rij">
<div class="groei">
<div class="zacht">
@Datums.Datum(r.Datum)
@if (!string.IsNullOrWhiteSpace(r.Versie)) { <text> &middot; versie @r.Versie</text> }
</div>
<div class="groot">@r.Kop</div>
<div>@r.Tekst</div>
@if (!r.Zichtbaar)
{
<div style="margin-top:.35rem"><span class="label uitgenodigd">Verborgen</span></div>
}
</div>
</div>
<div class="rij" style="margin-top:.6rem">
<button class="knop rustig" disabled="@Bezig" @onclick="() => Wijzigen(r)">Wijzigen</button>
<button class="knop rustig" disabled="@Bezig" @onclick="() => ZichtbaarOmzetten(r)">
@(r.Zichtbaar ? "Verbergen" : "Tonen")
</button>
@if (Weghalen == r.Id)
{
<span class="zacht">Echt verwijderen?</span>
<button class="knop gevaar" disabled="@Bezig" @onclick="() => Verwijderen(r)">Ja, verwijderen</button>
<button class="knop rustig" disabled="@Bezig" @onclick="() => Weghalen = null">Nee</button>
}
else
{
<button class="knop gevaar" disabled="@Bezig" @onclick="() => Weghalen = r.Id">Verwijderen</button>
}
</div>
</div>
}
</div>
}
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId;
private bool Magdit, Bezig;
private string? Melding, MeldingSoort;
private IReadOnlyList<VersieRegelBeheerbeeld>? Lijst;
private int? BewerkId;
private int? Weghalen;
private RegelFormulier Formulier = NieuwFormulier();
private sealed class RegelFormulier
{
public DateOnly Datum { get; set; }
public string Versie { get; set; } = "";
public string Kop { get; set; } = "";
public string Tekst { get; set; } = "";
public bool Zichtbaar { get; set; } = true;
}
private static RegelFormulier NieuwFormulier() =>
new() { Datum = DateOnly.FromDateTime(DateTime.Today) };
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 Historie.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 void Wijzigen(VersieRegelBeheerbeeld r)
{
BewerkId = r.Id;
Weghalen = null;
Melding = null;
Formulier = new RegelFormulier
{
Datum = r.Datum, Versie = r.Versie ?? "", Kop = r.Kop, Tekst = r.Tekst, Zichtbaar = r.Zichtbaar
};
}
private void Annuleren()
{
BewerkId = null;
Formulier = NieuwFormulier();
}
private async Task Opslaan()
{
Bezig = true;
try
{
var versie = string.IsNullOrWhiteSpace(Formulier.Versie) ? null : Formulier.Versie.Trim();
bool gelukt;
string? fout;
if (BewerkId is null)
{
var gemaakt = await Historie.MaakAsync(_gebruikerId, Formulier.Datum, versie, Formulier.Kop,
Formulier.Tekst, Formulier.Zichtbaar);
gelukt = gemaakt.Succeeded;
fout = gemaakt.Error;
}
else
{
var gewijzigd = await Historie.WijzigAsync(_gebruikerId, BewerkId.Value, Formulier.Datum, versie,
Formulier.Kop, Formulier.Tekst, Formulier.Zichtbaar);
gelukt = gewijzigd.Succeeded;
fout = gewijzigd.Error;
}
if (gelukt)
{
Meld(BewerkId is null ? "De regel is toegevoegd." : "De wijziging is opgeslagen.", true);
BewerkId = null;
Formulier = NieuwFormulier();
await Laden();
}
else Meld(fout, false);
}
finally { Bezig = false; }
}
private async Task ZichtbaarOmzetten(VersieRegelBeheerbeeld r)
{
Bezig = true;
try
{
var resultaat = await Historie.WijzigAsync(_gebruikerId, r.Id, r.Datum, r.Versie, r.Kop, r.Tekst, !r.Zichtbaar);
if (resultaat.Succeeded)
{
Meld(r.Zichtbaar ? "De regel is verborgen." : "De regel is weer zichtbaar.", true);
await Laden();
}
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
private async Task Verwijderen(VersieRegelBeheerbeeld r)
{
Bezig = true;
try
{
var resultaat = await Historie.VerwijderAsync(_gebruikerId, r.Id);
if (resultaat.Succeeded)
{
Meld("De regel is verwijderd.", true);
Weghalen = null;
if (BewerkId == r.Id) Annuleren();
await Laden();
}
else Meld(resultaat.Error, false);
}
finally { Bezig = false; }
}
}
@@ -178,8 +178,7 @@
</Popup> </Popup>
} }
@* Zelfde tekst als in de app: Versiehistorie.razor staat twee keer op schijf en een @* De tekst komt uit de tabel version_history, net als in de app. *@
zelfcontrole vergelijkt ze letterlijk. *@
@if (VersiehistorieOpen) @if (VersiehistorieOpen)
{ {
<Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true" <Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true"
@@ -217,6 +216,9 @@
</li> </li>
</ul> </ul>
</div> </div>
@* De knoppen voor de app in de winkels; zie AppDownload.razor. *@
<AppDownload />
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -93,6 +93,8 @@
<p class="zacht"><a href="/wat-is-mijnbesluit">Wat is MijnBesluit?</a></p> <p class="zacht"><a href="/wat-is-mijnbesluit">Wat is MijnBesluit?</a></p>
<p class="zacht"><a href="/registreren">Ik ben nieuw en wil een account aanmaken</a></p> <p class="zacht"><a href="/registreren">Ik ben nieuw en wil een account aanmaken</a></p>
<p class="zacht"><a href="/adverteren">Ik wil adverteren op MijnBesluit</a></p> <p class="zacht"><a href="/adverteren">Ik wil adverteren op MijnBesluit</a></p>
<AppDownload />
</div> </div>
@code { @code {
+130 -18
View File
@@ -2,6 +2,7 @@
@attribute [Authorize] @attribute [Authorize]
@inject VaultService KluisService @inject VaultService KluisService
@inject LegacyService BeheerService @inject LegacyService BeheerService
@inject VaultCategoryService SoortService
@inject IJSRuntime JS @inject IJSRuntime JS
@rendermode InteractiveServer @rendermode InteractiveServer
@@ -21,9 +22,9 @@
<label for="cat">Soort</label> <label for="cat">Soort</label>
<select id="cat" @bind="Filter" @bind:after="Verversen"> <select id="cat" @bind="Filter" @bind:after="Verversen">
<option value="">Alles</option> <option value="">Alles</option>
@foreach (var c in Categorieen) @foreach (var s in Soorten)
{ {
<option value="@((int)c)">@Naam(c)</option> <option value="@s.Id">@s.Naam</option>
} }
</select> </select>
</div> </div>
@@ -43,9 +44,13 @@
OpBevestigen="Opslaan" OpSluiten="Annuleren"> OpBevestigen="Opslaan" OpSluiten="Annuleren">
<div class="veld"> <div class="veld">
<label for="soort">Wat wil je vastleggen?</label> <label for="soort">Wat wil je vastleggen?</label>
<InputSelect id="soort" @bind-Value="Nieuw.Categorie"> <InputSelect id="soort" @bind-Value="Nieuw.CategorieNummer">
@foreach (var c in Categorieen) { <option value="@c">@Naam(c)</option> } @foreach (var s in Soorten) { <option value="@s.Id">@s.Naam</option> }
</InputSelect> </InputSelect>
@if (!string.IsNullOrWhiteSpace(HuidigeSoort?.Uitleg))
{
<div class="uitleg">@HuidigeSoort!.Uitleg</div>
}
</div> </div>
<div class="veld"> <div class="veld">
<label for="titel">Titel</label> <label for="titel">Titel</label>
@@ -129,9 +134,20 @@
case VaultCategory.Donorcodicil: case VaultCategory.Donorcodicil:
break; break;
default:
@* Een soort die de beheerder zelf heeft gemaakt: het formulier komt uit zijn velden. *@
@if (HuidigeSoort is { Ingebouwd: false } eigen)
{
@foreach (var veld in eigen.Velden.Where(v => v.Actief || HeeftWaarde(v)))
{
@VeldInvoer(veld)
}
}
break;
} }
@if (Nieuw.Categorie is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil) @if (BesprekenMetAan)
{ {
<div class="veld"> <div class="veld">
<label>Bespreken met</label> <label>Bespreken met</label>
@@ -256,7 +272,7 @@ else
{ {
<hr /> <hr />
<dl style="margin:0"> <dl style="margin:0">
@foreach (var (label, waarde) in Velden(Inhoud)) @foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud, GeopendSoort))
{ {
<div class="rij" style="padding:.25rem 0"> <div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:150px">@label</span> <span class="zacht" style="min-width:150px">@label</span>
@@ -358,13 +374,85 @@ else
/// <summary>Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.</summary> /// <summary>Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.</summary>
private const int MaxTegelijk = 10; private const int MaxTegelijk = 10;
private static readonly VaultCategory[] Categorieen = /// <summary>
/// De soorten die deze persoon te zien krijgt: de actieve, plus de niet-actieve waar hij al een
/// item van heeft. De lijst komt van de server en niet meer uit de code, zodat de beheerder er
/// een soort bij kan zetten zonder dat er iets uitgerold hoeft te worden.
/// </summary>
private IReadOnlyList<KluisSoort> Soorten = Array.Empty<KluisSoort>();
/// <summary>Alle namen op nummer, ook van soorten die niet meer actief zijn.</summary>
private IReadOnlyDictionary<int, string> Namen = new Dictionary<int, string>();
/// <summary>De soort van het item dat openstaat, voor de namen van zijn eigen velden.</summary>
private KluisSoort? GeopendSoort;
private KluisSoort? HuidigeSoort => Soorten.FirstOrDefault(s => s.Id == (int)Nieuw.Categorie);
/// <summary>
/// "Bespreken met": bij een eigen soort bepaalt de beheerder dat per soort. Zonder soortenlijst
/// (die is er altijd, maar voor de zekerheid) geldt de oude regel.
/// </summary>
private bool BesprekenMetAan =>
HuidigeSoort?.BesprekenMet ?? Nieuw.Categorie is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil;
private async Task SoortenLaden()
{ {
VaultCategory.Uitvaartwens, VaultCategory.Donorcodicil, VaultCategory.Polis, VaultCategory.Wachtwoord, Soorten = await SoortService.ZichtbaarVoorAsync(_persoonId);
VaultCategory.SocialMedia, VaultCategory.Apparaat, VaultCategory.Pincode, Namen = await SoortService.NamenAsync();
VaultCategory.Rijbewijs, VaultCategory.Paspoort, }
VaultCategory.Document, VaultCategory.Notitie, VaultCategory.Overig
}; /// <summary>Een leeg nieuw item, in de eerste soort die bruikbaar is (liefst uitvaartwens).</summary>
private NieuwItem LeegItem()
{
var soort = Soorten.FirstOrDefault(s => s.Id == (int)VaultCategory.Uitvaartwens) ?? Soorten.FirstOrDefault();
return soort is null ? new NieuwItem() : new NieuwItem { CategorieNummer = soort.Id };
}
private string Waarde(string sleutel) => Nieuw.Velden.TryGetValue(sleutel, out var w) ? w : "";
private void Zet(string sleutel, string? waarde) => Nieuw.Velden[sleutel] = waarde ?? "";
private bool HeeftWaarde(KluisVeld veld) => !string.IsNullOrWhiteSpace(Waarde(veld.Sleutel));
/// <summary>Eén invoerveld van een eigen soort, getekend naar de beschrijving van de beheerder.</summary>
private RenderFragment VeldInvoer(KluisVeld veld) => @<div class="veld">
@if (veld.Soort == VeldSoort.Uitleg)
{
<div class="uitleg">@veld.Hulptekst</div>
}
else
{
<label>@veld.Label@(veld.Verplicht ? " *" : "")</label>
@if (veld.Soort == VeldSoort.Lang)
{
<textarea rows="4" value="@Waarde(veld.Sleutel)"
@oninput="e => Zet(veld.Sleutel, e.Value?.ToString())"></textarea>
}
else if (veld.Soort == VeldSoort.Datum)
{
<input type="date" value="@Waarde(veld.Sleutel)"
@onchange="e => Zet(veld.Sleutel, e.Value?.ToString())" />
}
else if (veld.Soort == VeldSoort.Keuze)
{
<select @onchange="e => Zet(veld.Sleutel, e.Value?.ToString())">
<option value="" selected="@(Waarde(veld.Sleutel) == "")">Nog niet gekozen</option>
@foreach (var optie in veld.Opties)
{
<option value="@optie" selected="@(Waarde(veld.Sleutel) == optie)">@optie</option>
}
</select>
}
else
{
<input type="text" value="@Waarde(veld.Sleutel)"
@oninput="e => Zet(veld.Sleutel, e.Value?.ToString())" />
}
@if (!string.IsNullOrWhiteSpace(veld.Hulptekst))
{
<div class="uitleg">@veld.Hulptekst</div>
}
}
</div>;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@@ -382,6 +470,8 @@ else
_gebruikerId = user.UserId() ?? 0; _gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0; _persoonId = user.PersonId() ?? 0;
Beheerders = await BeheerService.ListManagersAsync(_persoonId); Beheerders = await BeheerService.ListManagersAsync(_persoonId);
await SoortenLaden();
Nieuw = LeegItem();
await Verversen(); await Verversen();
} }
@@ -402,6 +492,8 @@ else
{ {
Geopend = id; Geopend = id;
Inhoud = resultaat.Value; Inhoud = resultaat.Value;
var gekozen = Items?.FirstOrDefault(i => i.Id == id);
GeopendSoort = gekozen is null ? null : await SoortService.SoortAsync((int)gekozen.Category);
Melding = null; Melding = null;
await BijlagenLaden(); await BijlagenLaden();
} }
@@ -514,6 +606,7 @@ else
Muziek = p.Music ?? "", Muziek = p.Music ?? "",
Sprekers = p.Speakers ?? "", Sprekers = p.Speakers ?? "",
Wensen = p.Wishes ?? "", Wensen = p.Wishes ?? "",
Velden = p.Velden is null ? new Dictionary<string, string>() : new Dictionary<string, string>(p.Velden),
BesprekenMetPersonId = item.SharedManagerPersonId, BesprekenMetPersonId = item.SharedManagerPersonId,
Vrijgave = item.ReleasePolicy, Vrijgave = item.ReleasePolicy,
VrijgaveDatum = item.ReleaseAt is { } r ? DateOnly.FromDateTime(r) : null VrijgaveDatum = item.ReleaseAt is { } r ? DateOnly.FromDateTime(r) : null
@@ -533,7 +626,7 @@ else
private void Beginnen() private void Beginnen()
{ {
BewerkId = 0; BewerkId = 0;
Nieuw = new NieuwItem(); Nieuw = LeegItem();
Bijlagen = null; Bijlagen = null;
ToonFormulier = true; ToonFormulier = true;
} }
@@ -541,7 +634,7 @@ else
private void Annuleren() private void Annuleren()
{ {
BewerkId = 0; BewerkId = 0;
Nieuw = new NieuwItem(); Nieuw = LeegItem();
Bijlagen = null; Bijlagen = null;
ToonFormulier = false; ToonFormulier = false;
} }
@@ -575,7 +668,13 @@ else
Location = Leeg(Nieuw.Locatie), Location = Leeg(Nieuw.Locatie),
Music = Leeg(Nieuw.Muziek), Music = Leeg(Nieuw.Muziek),
Speakers = Leeg(Nieuw.Sprekers), 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 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."; Melding = BewerkId == 0 ? "Opgeslagen in je kluis." : "De wijziging is opgeslagen.";
MeldingSoort = "goed"; MeldingSoort = "goed";
BewerkId = 0; BewerkId = 0;
Nieuw = new NieuwItem(); Nieuw = LeegItem();
Bijlagen = null; Bijlagen = null;
ToonFormulier = false; ToonFormulier = false;
await SoortenLaden();
await Verversen(); await Verversen();
} }
else { Melding = resultaat.Error; MeldingSoort = "fout"; } else { Melding = resultaat.Error; MeldingSoort = "fout"; }
@@ -615,8 +715,7 @@ else
// Velden() en Naam() staan in Onderdelen/Kluisweergave.cs: de nalatenschapspagina toont // 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. // 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 string Naam(VaultCategory c) => Kluisweergave.Naam(c, Namen);
private static string Naam(VaultCategory c) => Kluisweergave.Naam(c);
private static string Voorbeeld(VaultCategory c) => c switch private static string Voorbeeld(VaultCategory c) => c switch
{ {
@@ -640,6 +739,19 @@ else
public class NieuwItem public class NieuwItem
{ {
public VaultCategory Categorie { get; set; } = VaultCategory.Uitvaartwens; public VaultCategory Categorie { get; set; } = VaultCategory.Uitvaartwens;
/// <summary>
/// Dezelfde keuze als nummer. De keuzelijst bindt hieraan en niet aan de enum: een soort
/// die de beheerder maakte heeft geen naam in de enum, alleen een nummer.
/// </summary>
public int CategorieNummer
{
get => (int)Categorie;
set => Categorie = (VaultCategory)value;
}
/// <summary>De ingevulde velden van een soort die de beheerder zelf maakte, op sleutel.</summary>
public Dictionary<string, string> Velden { get; set; } = new();
public string Titel { get; set; } = ""; public string Titel { get; set; } = "";
public string? Notitie { get; set; } public string? Notitie { get; set; }
public string? Website { get; set; } public string? Website { get; set; }
@@ -7,6 +7,7 @@
@inject ILegacyRepository LegacyOpslag @inject ILegacyRepository LegacyOpslag
@inject IPersonRepository Personen @inject IPersonRepository Personen
@inject VaultService Kluis @inject VaultService Kluis
@inject VaultCategoryService SoortService
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Nalatenschap - MijnBesluit</PageTitle> <PageTitle>Nalatenschap - MijnBesluit</PageTitle>
@@ -126,7 +127,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
<div class="rij"> <div class="rij">
<div class="groei"> <div class="groei">
<span>@item.Title</span> <span>@item.Title</span>
<div class="zacht">@Kluisweergave.Naam(item.Category)</div> <div class="zacht">@Kluisweergave.Naam(item.Category, Namen)</div>
</div> </div>
<button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button> <button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button>
</div> </div>
@@ -155,7 +156,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
<div class="rij"> <div class="rij">
<div class="groei"> <div class="groei">
<span>@item.Title</span> <span>@item.Title</span>
<div class="zacht">@Kluisweergave.Naam(item.Category)</div> <div class="zacht">@Kluisweergave.Naam(item.Category, Namen)</div>
</div> </div>
<button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button> <button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button>
</div> </div>
@@ -173,7 +174,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
<Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true" <Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen"> OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen">
<div class="stapel"> <div class="stapel">
@foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud)) @foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud, GeopendSoort))
{ {
<div class="veld"> <div class="veld">
<label>@label</label> <label>@label</label>
@@ -388,6 +389,12 @@ else if (Stand.State == DeathProcedureState.Lopend)
private IReadOnlyList<VaultItem>? Nagelaten; private IReadOnlyList<VaultItem>? Nagelaten;
private VaultPayload? Inhoud; private VaultPayload? Inhoud;
private string? GeopendTitel; private string? GeopendTitel;
/// <summary>Namen van alle soorten op nummer, ook die de beheerder zelf maakte.</summary>
private IReadOnlyDictionary<int, string> Namen = new Dictionary<int, string>();
/// <summary>De soort van het geopende item, voor de namen van zijn eigen velden.</summary>
private KluisSoort? GeopendSoort;
private IReadOnlyList<VaultFile>? GeopendBijlagen; private IReadOnlyList<VaultFile>? GeopendBijlagen;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -397,6 +404,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
_gebruikerId = user.UserId() ?? 0; _gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0; _persoonId = user.PersonId() ?? 0;
Namen = await SoortService.NamenAsync();
_familieId = (await FamilieOpslag.ListOwnedByPersonAsync(_persoonId)).FirstOrDefault()?.Id ?? 0; _familieId = (await FamilieOpslag.ListOwnedByPersonAsync(_persoonId)).FirstOrDefault()?.Id ?? 0;
if (_familieId != 0) Leden = await Families.ListMembersAsync(_familieId, _gebruikerId); if (_familieId != 0) Leden = await Families.ListMembersAsync(_familieId, _gebruikerId);
@@ -465,6 +473,9 @@ else if (Stand.State == DeathProcedureState.Lopend)
Inhoud = resultaat.Value; Inhoud = resultaat.Value;
GeopendTitel = item.Title; 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 // Bijlagen zijn optioneel: als ophalen mislukt (bv. geen toegang) tonen we het
// item gewoon zonder bijlagenlijst, in plaats van de hele popup te blokkeren. // item gewoon zonder bijlagenlijst, in plaats van de hele popup te blokkeren.
var bijlagen = await Kluis.ListFilesAsync(item.Id, _gebruikerId); var bijlagen = await Kluis.ListFilesAsync(item.Id, _gebruikerId);
@@ -476,6 +487,7 @@ else if (Stand.State == DeathProcedureState.Lopend)
GeopendItem = null; GeopendItem = null;
Inhoud = null; Inhoud = null;
GeopendTitel = null; GeopendTitel = null;
GeopendSoort = null;
GeopendBijlagen = null; GeopendBijlagen = null;
} }
@@ -15,5 +15,7 @@
<a class="knop rustig" href="/inloggen">Inloggen</a> <a class="knop rustig" href="/inloggen">Inloggen</a>
</p> </p>
<AppDownload />
<p class="voet"><a href="/inloggen">Terug naar inloggen</a></p> <p class="voet"><a href="/inloggen">Terug naar inloggen</a></p>
</div> </div>
+4
View File
@@ -11,6 +11,10 @@
"Site": { "Site": {
"Url": "https://localhost:7014" "Url": "https://localhost:7014"
}, },
"Apps": {
"GooglePlayUrl": "https://play.google.com/store/apps/details?id=nl.mijnbesluit.app",
"AppStoreUrl": "https://apps.apple.com/nl/app/id6828724812"
},
"Smtp": { "Smtp": {
"Host": "localhost", "Host": "localhost",
"Port": 587, "Port": 587,
+11
View File
@@ -575,3 +575,14 @@ hr { border: none; border-top: 1px solid var(--rand); margin: 1.25rem 0; }
.voet { text-align: center; margin-top: 2rem; font-size: .9rem; } .voet { text-align: center; margin-top: 2rem; font-size: .9rem; }
.voet a { color: var(--inkt-zacht); } .voet a { color: var(--inkt-zacht); }
/* Download-knoppen van de winkels (AppDownload.razor). De plaatjes zelf blijven onaangepast;
we bepalen alleen de hoogte en de ruimte eromheen. */
.appknoppen { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin-top: 1.25rem; }
.appknoppen a { display: inline-block; line-height: 0; }
.appknoppen img { height: 44px; width: auto; display: block; }
/* Het PNG van Google heeft een brede doorzichtige rand rondom de knop zelf (de knop is maar 241 van
de 572 pixels hoog). Daarom hier een grotere hoogte en een negatieve marge, zodat de zichtbare knop
net zo hoog wordt als die van Apple. Het plaatje zelf blijft onaangepast. Is het een bijgesneden
.svg, dan is dit niet nodig en geldt gewoon de 44px hierboven. */
.appknoppen img[src$="google-play.png"] { height: 104px; margin: -30px -6px; }
@@ -0,0 +1,11 @@
Hier horen de officiele knoppen van de winkels:
app-store.svg (of .png) "Download in de App Store" - Apple
google-play.svg (of .png) "Ontdek het op Google Play" - Google
Haal ze zelf op bij de winkels en pas ze niet aan (kleur, vorm, tekst):
Apple: developer.apple.com/app-store/marketing/guidelines (sectie "Download on the App Store", Nederlandse versie)
Google: play.google.com/intl/nl/badges (Nederlandse knop)
Een knop verschijnt op de inlogpagina pas als zijn bestand hier staat EN het adres in
appsettings.json is ingevuld (Apps:AppStoreUrl, Apps:GooglePlayUrl).
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -31,6 +31,7 @@ public static class ClientChecks
Accounts = Wereld.Accounts, Accounts = Wereld.Accounts,
Families = Wereld.Families, Families = Wereld.Families,
Vault = Wereld.Vault, Vault = Wereld.Vault,
Soorten = Wereld.Soorten,
Messages = Wereld.Messages, Messages = Wereld.Messages,
Legacy = Wereld.Legacy, Legacy = Wereld.Legacy,
Overzicht = Wereld.Overzicht, Overzicht = Wereld.Overzicht,
@@ -64,6 +65,67 @@ public static class ClientChecks
public static void Register(Runner runner) 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 () => runner.TestAsync("App: registreren met een code en de sessie bewaren", async () =>
{ {
await using var proef = new Proef(); await using var proef = new Proef();
+87
View File
@@ -25,6 +25,9 @@ public class Store
public List<Adverteerder> Adverteerders = new(); public List<Adverteerder> Adverteerders = new();
public List<Campagne> Campagnes = new(); public List<Campagne> Campagnes = new();
public List<CampagneUur> CampagneUren = new(); public List<CampagneUur> CampagneUren = new();
public List<VaultCategoryDefinition> Soorten = VaultCategorySaatgoed.Maak();
public List<VaultCategoryField> SoortVelden = new();
public List<VersieRegel> VersieRegels = new();
private long _seq; private long _seq;
public long NextId() => ++_seq; public long NextId() => ++_seq;
@@ -528,3 +531,87 @@ public class TestPush : IPushSender
return Task.FromResult(true); return Task.FromResult(true);
} }
} }
/// <summary>De startrijen uit db/21_kluissoorten.sql, zodat de tests met dezelfde soorten beginnen als de echte database.</summary>
public class VersiehistorieRepo : IVersiehistorieRepository
{
private readonly Store _s; public VersiehistorieRepo(Store s) => _s = s;
private int _volgende = 1;
public Task<IReadOnlyList<VersieRegel>> ListAsync(bool alleenZichtbaar, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<VersieRegel>>(
_s.VersieRegels.Where(r => !alleenZichtbaar || r.Zichtbaar)
.OrderByDescending(r => r.Datum).ThenBy(r => r.Id).ToList());
public Task<VersieRegel?> FindAsync(int id, CancellationToken ct = default) =>
Task.FromResult(_s.VersieRegels.FirstOrDefault(r => r.Id == id));
// Hoofdletterongevoelig, zoals de database (utf8mb4_unicode_ci) ook doet.
public Task<bool> BestaatAsync(DateOnly datum, string kop, int? behalveId, CancellationToken ct = default) =>
Task.FromResult(_s.VersieRegels.Any(r => r.Datum == datum && string.Equals(r.Kop, kop, StringComparison.OrdinalIgnoreCase) && (behalveId == null || r.Id != behalveId)));
public void Add(VersieRegel regel) { if (regel.Id == 0) regel.Id = _volgende++; _s.VersieRegels.Add(regel); }
public void Remove(VersieRegel regel) => _s.VersieRegels.Remove(regel);
}
public static class VaultCategorySaatgoed
{
public static List<VaultCategoryDefinition> 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<VaultCategoryDefinition>
{
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<IReadOnlyList<VaultCategoryDefinition>> ListAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<VaultCategoryDefinition>>(
_s.Soorten.OrderBy(c => c.Volgorde).ThenBy(c => c.Naam).Select(Met).ToList());
public Task<VaultCategoryDefinition?> FindAsync(int id, CancellationToken ct = default) =>
Task.FromResult(_s.Soorten.Where(c => c.Id == id).Select(Met).FirstOrDefault());
public Task<VaultCategoryField?> FindFieldAsync(long fieldId, CancellationToken ct = default) =>
Task.FromResult(_s.SoortVelden.FirstOrDefault(f => f.Id == fieldId));
public Task<IReadOnlyList<MijnBesluit.Application.Models.SoortGebruik>> GebruikAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<MijnBesluit.Application.Models.SoortGebruik>>(
_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<IReadOnlySet<int>> InGebruikDoorAsync(long personId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlySet<int>>(
_s.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null)
.Select(v => (int)v.Category).ToHashSet());
public Task<int> 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);
}
+3 -1
View File
@@ -473,7 +473,7 @@ runner.Test("Gedeelde onderdelen zijn in app en website letterlijk gelijk", () =
// Privacytekst: de privacyverklaring, op de site een pagina en in de app een popup. Twee // Privacytekst: de privacyverklaring, op de site een pagina en in de app een popup. Twee
// versies daarvan is het ergste geval van allemaal - het is het document waar iemand zich op // versies daarvan is het ergste geval van allemaal - het is het document waar iemand zich op
// beroept, en waarvan Google Play een adres wil. // beroept, en waarvan Google Play een adres wil.
foreach (var naam in new[] { "Penning.razor", "Productuitleg.razor", "Privacytekst.razor", "Versiehistorie.razor" }) foreach (var naam in new[] { "Penning.razor", "Productuitleg.razor", "Privacytekst.razor" })
{ {
var web = Path.Combine(map.FullName, "src", "MijnBesluit.Web", "Components", "Onderdelen", naam); var web = Path.Combine(map.FullName, "src", "MijnBesluit.Web", "Components", "Onderdelen", naam);
var app = Path.Combine(map.FullName, "src", "MijnBesluit.AppUi", "Onderdelen", naam); var app = Path.Combine(map.FullName, "src", "MijnBesluit.AppUi", "Onderdelen", naam);
@@ -1942,5 +1942,7 @@ ClientChecks.Register(runner);
ServiceChecks.Register(runner); ServiceChecks.Register(runner);
AdvertentieChecks.Register(runner); AdvertentieChecks.Register(runner);
UitrolChecks.Register(runner); UitrolChecks.Register(runner);
SoortenChecks.Register(runner);
VersiehistorieChecks.Register(runner);
return runner.Summary(); return runner.Summary();
+5 -1
View File
@@ -25,6 +25,8 @@ public class World
public BeheerService Beheer { get; } public BeheerService Beheer { get; }
public AdvertentieService Adverteren { get; } public AdvertentieService Adverteren { get; }
public AccountOpheffenService Opheffen { get; } public AccountOpheffenService Opheffen { get; }
public VaultCategoryService Soorten { get; }
public VersiehistorieService Versiehistorie { get; }
/// <summary>Maakt in één stap een account aan; scheelt in de scenario's het hele codegedoe.</summary> /// <summary>Maakt in één stap een account aan; scheelt in de scenario's het hele codegedoe.</summary>
public async Task<MijnBesluit.Domain.Entities.User> MaakGebruikerAsync(string email, string voornaam, string achternaam = "") public async Task<MijnBesluit.Domain.Entities.User> MaakGebruikerAsync(string email, string voornaam, string achternaam = "")
@@ -106,7 +108,9 @@ public class World
Accounts = new AccountService(users, people, families, codes, keys, Email, audit, uow, Clock, vault); Accounts = new AccountService(users, people, families, codes, keys, Email, audit, uow, Clock, vault);
Families = new FamilyService(families, people, users, invitations, messages, legacy, Families = new FamilyService(families, people, users, invitations, messages, legacy,
Email, audit, uow, Clock); 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);
Versiehistorie = new VersiehistorieService(new VersiehistorieRepo(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); Meldingen = new MeldingService(new ToestelRepo(Store), users, Push, uow, Clock);
Messages = new MessageService(messages, people, users, vault, keys, files, Email, audit, uow, Clock, Meldingen); 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); Legacy = new LegacyService(legacy, people, users, vault, messages, Email, audit, uow, Clock, Vault, families);
@@ -0,0 +1,446 @@
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Services;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// 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.
/// </summary>
public static class SoortenChecks
{
private static async Task<User> 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<int> 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<string>())
== "wil_je_gereanimeerd_worden", "een vraag wordt een leesbare sleutel");
Assert.True(VaultCategoryService.NieuweSleutel("Café — één", Array.Empty<string>()) == "cafe_een",
"accenten en streepjes verdwijnen");
Assert.True(VaultCategoryService.NieuweSleutel("???", Array.Empty<string>()) == "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<string>()).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");
});
}
}
@@ -0,0 +1,139 @@
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Services;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// De versiehistorie uit de database: wie hem mag beheren en wat een gebruiker te zien krijgt.
///
/// De afspraak is eenvoudig: één bron voor site en app. Een gebruiker leest alleen de zichtbare
/// regels, nieuwste dag bovenaan; alleen een beheerder maakt, wijzigt, verbergt of verwijdert.
/// </summary>
public static class VersiehistorieChecks
{
private static async Task<User> 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<int> MaakAsync(World w, User beheerder, DateOnly datum, string kop, string? versie = null,
string tekst = "Uitleg.", bool zichtbaar = true)
{
var r = await w.Versiehistorie.MaakAsync(beheerder.Id, datum, versie, kop, tekst, zichtbaar);
if (!r.Succeeded) throw new AssertionException($"regel maken mislukte: {r.Error}");
return r.Value;
}
public static void Register(Runner runner)
{
runner.TestAsync("Versiehistorie: zonder regels is de lijst leeg", async () =>
{
var w = new World();
Assert.True((await w.Versiehistorie.ZichtbaarAsync()).Count == 0, "een lege tabel geeft een lege lijst");
});
runner.TestAsync("Versiehistorie: alleen een beheerder maakt, wijzigt of verwijdert", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
var dag = new DateOnly(2026, 3, 1);
var id = await MaakAsync(w, beheer, dag, "Eerste");
Assert.True(!(await w.Versiehistorie.MaakAsync(jan.Id, dag, null, "Tweede", "Uitleg")).Succeeded,
"een gewone gebruiker maakt geen regel");
Assert.True(!(await w.Versiehistorie.OverzichtAsync(jan.Id)).Succeeded, "en ziet het beheeroverzicht niet");
Assert.True(!(await w.Versiehistorie.WijzigAsync(jan.Id, id, dag, null, "Anders", "Uitleg", true)).Succeeded,
"en wijzigt niets");
Assert.True(!(await w.Versiehistorie.VerwijderAsync(jan.Id, id)).Succeeded, "en verwijdert niets");
Assert.True((await w.Versiehistorie.ZichtbaarAsync()).Single().Kop == "Eerste", "de regel is onaangetast");
});
runner.TestAsync("Versiehistorie: nieuwste dag bovenaan, binnen een dag in invoervolgorde", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
await MaakAsync(w, beheer, new DateOnly(2026, 2, 1), "Oud, eerste");
await MaakAsync(w, beheer, new DateOnly(2026, 4, 1), "Nieuw, eerste", "1.1");
await MaakAsync(w, beheer, new DateOnly(2026, 2, 1), "Oud, tweede");
await MaakAsync(w, beheer, new DateOnly(2026, 4, 1), "Nieuw, tweede", "1.1");
var lijst = await w.Versiehistorie.ZichtbaarAsync();
Assert.True(lijst.Select(r => r.Kop).SequenceEqual(
new[] { "Nieuw, eerste", "Nieuw, tweede", "Oud, eerste", "Oud, tweede" }),
"de nieuwste dag staat bovenaan en binnen een dag de volgorde van invoer");
Assert.True(lijst[0].Datum == "2026-04-01" && lijst[0].Versie == "1.1", "datum als jjjj-mm-dd, met versie");
Assert.True(lijst[2].Versie is null, "een regel zonder versie heeft er geen");
});
runner.TestAsync("Versiehistorie: een verborgen regel ziet alleen de beheerder", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
var dag = new DateOnly(2026, 3, 1);
await MaakAsync(w, beheer, dag, "Zichtbaar");
var verborgen = await MaakAsync(w, beheer, dag, "Nog niet", zichtbaar: false);
Assert.True((await w.Versiehistorie.ZichtbaarAsync()).Select(r => r.Kop).SequenceEqual(new[] { "Zichtbaar" }),
"gebruikers zien de verborgen regel niet");
var overzicht = (await w.Versiehistorie.OverzichtAsync(beheer.Id)).Value!;
Assert.True(overzicht.Count == 2, "de beheerder ziet beide");
var w2 = await w.Versiehistorie.WijzigAsync(beheer.Id, verborgen, dag, null, "Nog niet", "Uitleg.", true);
Assert.True(w2.Succeeded, "tonen lukt");
Assert.True((await w.Versiehistorie.ZichtbaarAsync()).Count == 2, "daarna staat hij er ook voor gebruikers");
});
runner.TestAsync("Versiehistorie: wat niet klopt wordt geweigerd", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
var dag = new DateOnly(2026, 3, 1);
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, dag, null, " ", "Uitleg")).Succeeded, "een lege kop mag niet");
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, dag, null, "Kop", "")).Succeeded, "een lege uitleg mag niet");
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, dag, null,
new string('k', VersiehistorieService.MaxKop + 1), "Uitleg")).Succeeded, "een te lange kop mag niet");
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, dag, null, "Kop",
new string('t', VersiehistorieService.MaxTekst + 1))).Succeeded, "een te lange uitleg mag niet");
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, dag, new string('1', VersiehistorieService.MaxVersie + 1),
"Kop", "Uitleg")).Succeeded, "een te lang versienummer mag niet");
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, new DateOnly(2090, 1, 1), null, "Kop", "Uitleg")).Succeeded,
"een datum ver in de toekomst is een typefout");
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, new DateOnly(1999, 1, 1), null, "Kop", "Uitleg")).Succeeded,
"een datum van lang geleden ook");
await MaakAsync(w, beheer, dag, "Dubbel");
Assert.True(!(await w.Versiehistorie.MaakAsync(beheer.Id, dag, null, "dubbel", "Uitleg")).Succeeded,
"dezelfde kop op dezelfde dag staat er al");
Assert.True((await w.Versiehistorie.MaakAsync(beheer.Id, dag.AddDays(1), null, "Dubbel", "Uitleg")).Succeeded,
"op een andere dag mag dezelfde kop wel");
});
runner.TestAsync("Versiehistorie: wijzigen en verwijderen, en het blijft vastgelegd", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
var dag = new DateOnly(2026, 3, 1);
var id = await MaakAsync(w, beheer, dag, "Eerste", tekst: "Oude uitleg");
Assert.True((await w.Versiehistorie.WijzigAsync(beheer.Id, id, dag, " 1.2 ", " Nieuwe kop ", " Nieuwe uitleg ", true)).Succeeded,
"wijzigen lukt");
var regel = (await w.Versiehistorie.ZichtbaarAsync()).Single();
Assert.True(regel.Kop == "Nieuwe kop" && regel.Tekst == "Nieuwe uitleg" && regel.Versie == "1.2",
"spaties eromheen verdwijnen");
Assert.True(!(await w.Versiehistorie.WijzigAsync(beheer.Id, 9999, dag, null, "Kop", "Uitleg", true)).Succeeded,
"een regel die er niet is, is niet te wijzigen");
Assert.True((await w.Versiehistorie.VerwijderAsync(beheer.Id, id)).Succeeded, "verwijderen lukt");
Assert.True((await w.Versiehistorie.ZichtbaarAsync()).Count == 0, "en dan is hij weg");
var acties = w.Store.Audit.Count(a => a.Action == AuditAction.VersiehistorieBeheerd);
Assert.True(acties == 3, "maken, wijzigen en verwijderen staan alle drie in het logboek");
});
}
}
+31
View File
@@ -0,0 +1,31 @@
<#
Bouwt de Android-bundel (AAB) voor de Play Store, voor SideKick ("Bouwen en uitrollen",
onderdeel mijnbesluit-aab, Sleutel android-aab).
Zelfde werkwijze als Bouwen-android.ps1, maar met Apk-maken.ps1 -Aab. Het resultaat is een
nl.mijnbesluit.app-Signed.aab in de map die SideKick meegeeft (-Uitvoer). Een AAB kun je niet
op een telefoon zetten; hij gaat naar de Play Console.
Het wachtwoord van het sleutelbestand komt uit de gebruikersvariabele
MIJNBESLUIT_KEYSTORE_WACHTWOORD (zie Apk-maken.ps1).
#>
[CmdletBinding()]
param(
[string] $Configuratie = 'Release',
# Waar de AAB terechtkomt (SideKick: <publicatiemap>\android-aab).
[string] $Uitvoer
)
$ErrorActionPreference = 'Stop'
if ($env:RENH_UITROLVENSTER -and -not $env:MIJNBESLUIT_UITROLVENSTER) {
$env:MIJNBESLUIT_UITROLVENSTER = '1'
}
$params = @{ Aab = $true }
if ($Uitvoer) { $params['Uit'] = $Uitvoer }
& (Join-Path $PSScriptRoot 'Apk-maken.ps1') @params
if ($LASTEXITCODE -ne 0 -and $null -ne $LASTEXITCODE) { exit $LASTEXITCODE }
+36
View File
@@ -0,0 +1,36 @@
<#
Bouwt de Android-app voor SideKick ("Bouwen en uitrollen", onderdeel mijnbesluit-app, Sleutel android).
SideKick zoekt voor een onderdeel eerst tools\Bouwen-<Sleutel>.ps1. Dit script is die bouwstap:
het geeft het werk aan Apk-maken.ps1 (dotnet publish -f net10.0-android, ondertekend met het
sleutelbestand) en zet de APK in de map die SideKick meegeeft (-Uitvoer).
Zonder dit script bouwt SideKick een gewoon Windows-programma (win-x64), en dat kan een
Android-project niet: "Unable to find package Microsoft.NETCore.App.Runtime.Mono.win-x64".
Het wachtwoord van het sleutelbestand komt uit de gebruikersvariabele MIJNBESLUIT_KEYSTORE_WACHTWOORD
(zie Apk-maken.ps1). Start SideKick opnieuw als je die net hebt gezet.
#>
[CmdletBinding()]
param(
# SideKick geeft dit altijd mee; een Android-release is altijd Release.
[string] $Configuratie = 'Release',
# Waar de APK terechtkomt (SideKick: <publicatiemap>\android).
[string] $Uitvoer
)
$ErrorActionPreference = 'Stop'
# SideKick zet RENH_UITROLVENSTER; Apk-maken.ps1 kent MIJNBESLUIT_UITROLVENSTER. Zo stopt het script
# met een duidelijke melding als het wachtwoord ontbreekt, in plaats van te blijven wachten op invoer.
if ($env:RENH_UITROLVENSTER -and -not $env:MIJNBESLUIT_UITROLVENSTER) {
$env:MIJNBESLUIT_UITROLVENSTER = '1'
}
$params = @{}
if ($Uitvoer) { $params['Uit'] = $Uitvoer }
& (Join-Path $PSScriptRoot 'Apk-maken.ps1') @params
if ($LASTEXITCODE -ne 0 -and $null -ne $LASTEXITCODE) { exit $LASTEXITCODE }
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""iPhone-schermafbeeldingen klaarmaken voor de App Store.
Per bestand in de invoermap:
- de statusbalk bovenin (tijd, 5G, batterij, opname- en microfoonindicator) wordt overgeschilderd met
de kleur van de kop van de app, zodat er geen tijd, netwerk of toestelinfo in staat;
- het plaatje wordt op 1290 x 2796 gebracht (iPhone 6,9 inch, toegestaan door de App Store).
Alleen de statusbalk wordt aangepast; de rest van het scherm blijft zoals het is.
Gebruik: python tools/appstore-schermen.py <invoermap> <uitvoermap>
Invoer: ORIGINELE iPhone-schermafbeeldingen (1179 x 2556 of 1290 x 2796, dezelfde beeldverhouding).
Een verkleind plaatje (bijvoorbeeld uit een chat) wordt onscherp zodra het naar 1290 x 2796 groeit.
Nodig: Pillow (pip install pillow).
"""
import sys, pathlib
from PIL import Image
DOEL = (1290, 2796)
STATUSBALK = 0.055 # aandeel van de hoogte dat de statusbalk beslaat (iPhone met Dynamic Island)
def verwerk(pad, uit):
im = Image.open(pad).convert("RGB")
b, h = im.size
if abs(b / h - DOEL[0] / DOEL[1]) > 0.01:
raise SystemExit(f"{pad.name}: beeldverhouding {b}x{h} past niet bij een iPhone-schermafbeelding")
rand = int(h * STATUSBALK)
kleur = im.getpixel((b // 2, rand + 4))
im.paste(kleur, (0, 0, b, rand))
im = im.resize(DOEL, Image.LANCZOS)
im.save(uit / (pad.stem + ".png"), optimize=True)
if __name__ == "__main__":
inv, uit = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
uit.mkdir(parents=True, exist_ok=True)
for f in sorted(inv.iterdir()):
if f.suffix.lower() in (".png", ".jpg", ".jpeg"):
verwerk(f, uit)
print("klaar:", f.name)
+53 -6
View File
@@ -3,7 +3,12 @@
# #
# tools/ios-bouwen.sh controle alleen kijken of de Mac er klaar voor is # tools/ios-bouwen.sh controle alleen kijken of de Mac er klaar voor is
# tools/ios-bouwen.sh simulator bouwen en starten in de iPhone-simulator (geen account nodig) # tools/ios-bouwen.sh simulator bouwen en starten in de iPhone-simulator (geen account nodig)
# tools/ios-bouwen.sh ipad zoals 'simulator', maar in de iPad-simulator (voor App Store-schermen);
# andere simulator: IOS_SIMULATOR="iPad Pro 13-inch (M4)" tools/ios-bouwen.sh simulator
# tools/ios-bouwen.sh toestel bouwen en op een aangesloten iPhone zetten (Apple-ID / team nodig) # tools/ios-bouwen.sh toestel bouwen en op een aangesloten iPhone zetten (Apple-ID / team nodig)
# tools/ios-bouwen.sh archief een .ipa voor App Store Connect / TestFlight maken
# (betaald Apple Developer Program, distributiecertificaat en
# App Store-profiel nodig; zie docs/iOS-app.md)
# #
# Eerste keer: chmod +x tools/ios-bouwen.sh # Eerste keer: chmod +x tools/ios-bouwen.sh
set -euo pipefail set -euo pipefail
@@ -40,13 +45,31 @@ case "$MODUS" in
controle) controle)
controle controle
;; ;;
simulator) simulator|ipad)
controle controle
[[ "$MODUS" == "ipad" && -z "${IOS_SIMULATOR:-}" ]] && IOS_SIMULATOR="iPad Pro 13-inch (M4)"
stap "Bouwen en starten in de simulator" stap "Bouwen en starten in de simulator"
# Eerst de lijst met simulators tonen, zodat je ziet welke je kunt kiezen. # Eerst de lijst met simulators tonen, zodat je ziet welke je kunt kiezen.
xcrun simctl list devices available | grep -i iphone | head -5 || true xcrun simctl list devices available | grep -iE 'iphone|ipad' | head -12 || true
dotnet build "$PROJECT" -f "$TFM" -p:MetIos=true -t:Run \ APPARAAT=()
-p:RuntimeIdentifier=iossimulator-arm64 if [[ -n "${IOS_SIMULATOR:-}" ]]; then
UDID="$(xcrun simctl list devices available | grep -F " ${IOS_SIMULATOR} (" | head -1 | sed -E 's/.*\(([0-9A-F-]{36})\).*/\1/')"
[[ -n "$UDID" ]] || stop "simulator '${IOS_SIMULATOR}' niet gevonden; kies een naam uit de lijst hierboven (Xcode > Settings > Components voor extra simulators)."
echo "Simulator: ${IOS_SIMULATOR} ($UDID)"
xcrun simctl boot "$UDID" 2>/dev/null || true
open -a Simulator
APPARAAT=(-p:_DeviceName=":v2:udid=$UDID")
fi
# Eerst bouwen, daarna starten: met alleen -t:Run bouwt hij de app niet en weet hij niet wat
# hij moet starten. MetAndroid=false: op de Mac staat alleen de iOS-werkbelasting.
# CodesignKey=-: een eenvoudige lokale handtekening. De simulator wil geen certificaat, maar de
# app heeft wel een handtekening nodig vanwege de Keychain-rechten in Entitlements.plist.
dotnet build "$PROJECT" -f "$TFM" -p:MetIos=true -p:MetAndroid=false \
-p:RuntimeIdentifier=iossimulator-arm64 -p:CodesignKey=-
dotnet build "$PROJECT" -f "$TFM" -p:MetIos=true -p:MetAndroid=false -t:Run \
-p:RuntimeIdentifier=iossimulator-arm64 -p:CodesignKey=- ${APPARAAT[@]+"${APPARAAT[@]}"}
echo
echo "Schermafdruk (zonder statusbalk-ruis, op ware grootte): xcrun simctl io booted screenshot ~/Desktop/ipad-1.png"
;; ;;
toestel) toestel)
controle controle
@@ -58,13 +81,37 @@ case "$MODUS" in
echo "(Instellingen > Privacy en beveiliging > Ontwikkelaarsmodus)." echo "(Instellingen > Privacy en beveiliging > Ontwikkelaarsmodus)."
echo "Zonder betaald account werkt dit 7 dagen per installatie, en zonder meldingen." echo "Zonder betaald account werkt dit 7 dagen per installatie, en zonder meldingen."
stap "Bouwen voor het toestel" stap "Bouwen voor het toestel"
dotnet build "$PROJECT" -f "$TFM" -p:MetIos=true \ dotnet build "$PROJECT" -f "$TFM" -p:MetIos=true -p:MetAndroid=false \
-p:RuntimeIdentifier=ios-arm64 -p:CodesignKey="Apple Development" -p:RuntimeIdentifier=ios-arm64 -p:CodesignKey="Apple Development"
echo echo
echo "Gebouwd. Installeren gaat het makkelijkst via Visual Studio (Pair to Mac) of" echo "Gebouwd. Installeren gaat het makkelijkst via Visual Studio (Pair to Mac) of"
echo "Xcode > Window > Devices and Simulators, of: dotnet build -t:Run -p:_DeviceName=<UDID>" echo "Xcode > Window > Devices and Simulators, of: dotnet build -t:Run -p:_DeviceName=<UDID>"
;; ;;
archief)
controle
# Namen, geen geheimen: de exacte naam van het distributiecertificaat en van het App Store-profiel.
# export IOS_SIGN_KEY="Apple Distribution: Kwik-Bit Automatisering B.V. (TEAMID)"
# export IOS_PROFILE="MijnBesluit App Store"
# Zie de lijst hieronder voor wat er op deze Mac staat.
stap "Distributiecertificaten op deze Mac"
security find-identity -v -p codesigning | grep -i 'Apple Distribution' \
|| echo "(geen 'Apple Distribution'-certificaat gevonden; maak er een in Xcode > Settings > Accounts > Manage Certificates)"
stap "Provisioning profiles op deze Mac"
ls "$HOME/Library/MobileDevice/Provisioning Profiles" 2>/dev/null || echo "(geen profielen gevonden)"
[[ -n "${IOS_SIGN_KEY:-}" ]] || stop "zet IOS_SIGN_KEY op de exacte naam van het 'Apple Distribution'-certificaat (zie de lijst hierboven)."
[[ -n "${IOS_PROFILE:-}" ]] || stop "zet IOS_PROFILE op de naam van het App Store-profiel voor nl.mijnbesluit.app."
stap "Archief maken (ios-arm64, Release)"
# Let op: ApplicationVersion (bouwnummer) moet bij elke upload hoger zijn dan de vorige.
dotnet publish "$PROJECT" -f "$TFM" -c Release -p:MetIos=true -p:MetAndroid=false \
-p:RuntimeIdentifier=ios-arm64 -p:ArchiveOnBuild=true \
-p:CodesignKey="$IOS_SIGN_KEY" -p:CodesignProvision="$IOS_PROFILE"
echo
echo "Klaar. De .ipa staat onder:"
find "$(dirname "$PROJECT")/bin/Release" -name '*.ipa' -print 2>/dev/null || true
echo "Uploaden: open de app Transporter (gratis, Mac App Store), log in met het Apple-account en"
echo "sleep de .ipa erin. Daarna staat de build na enkele minuten in App Store Connect > TestFlight."
;;
*) *)
stop "onbekende optie '$MODUS' (kies: controle, simulator of toestel)" stop "onbekende optie '$MODUS' (kies: controle, simulator, ipad, toestel of archief)"
;; ;;
esac esac