Compare commits

..
12 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
53 changed files with 1393 additions and 119 deletions
+1
View File
@@ -55,6 +55,7 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`campagne_uren` TO 'm
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_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`.`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';
+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,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;
+1
View File
@@ -76,6 +76,7 @@ aanvoelt. Bij `"Error"` staat in `message` een tekst die je zo aan de gebruiker
| `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.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 | | `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
+5 -2
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>
@@ -29,7 +32,7 @@
<ApplicationTitle>MijnBesluit</ApplicationTitle> <ApplicationTitle>MijnBesluit</ApplicationTitle>
<ApplicationId>nl.mijnbesluit.app</ApplicationId> <ApplicationId>nl.mijnbesluit.app</ApplicationId>
<ApplicationDisplayVersion>1.1</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.1</ApplicationDisplayVersion>
<ApplicationVersion>3</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,61 +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 oktober 2026 &middot; versie 1.1</h2> @if (Fout)
{
<p class="zacht">De versiehistorie kon niet worden opgehaald. Probeer het later nog eens.</p>
}
else if (Regels is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else if (Regels.Count == 0)
{
<p class="zacht">Er staat nog niets in de versiehistorie.</p>
}
else
{
@foreach (var groep in Groepen())
{
<h2 style="@(groep.Eerste ? "margin-top:0" : null)">@groep.Kop</h2>
<ul style="padding-left:1.2rem"> <ul style="padding-left:1.2rem">
<li> @foreach (var regel in groep.Regels)
<strong>Meer soorten in de kluis.</strong> Naast wachtwoorden, polissen en uitvaartwensen {
kan er voortaan een nieuwe soort bijkomen, met eigen invulvelden, zonder dat je de app <li style="margin-bottom:.5rem"><strong>@Punt(regel.Kop)</strong> @regel.Tekst</li>
daarvoor opnieuw hoeft te updaten. Je bestaande kluisitems blijven zoals ze waren. }
</li>
</ul> </ul>
}
}
<h2>20 september 2026</h2> @code {
<ul style="padding-left:1.2rem"> private IReadOnlyList<VersieRegel>? Regels;
<li style="margin-bottom:.5rem"> private bool Fout;
<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
of te kiezen.
</li>
<li style="margin-bottom:.5rem">
<strong>Mijn profiel als pop-up.</strong> 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.
</li>
<li style="margin-bottom:.5rem">
<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.
</li>
<li>
<strong>Muziek en verdere wensen groeien mee.</strong> Die velden bij een kluisitem
passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website.
</li>
</ul>
<h2>19 september 2026</h2> protected override async Task OnInitializedAsync()
<ul style="padding-left:1.2rem"> {
<li style="margin-bottom:.5rem"> var uitkomst = await Toestand.Client.VersiehistorieAsync();
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks if (uitkomst.Gelukt) Regels = uitkomst.Waarde;
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je else Fout = true;
dat voortaan ook op je telefoon, niet meer alleen op de website. }
</li>
<li style="margin-bottom:.5rem"> private sealed record Groep(string Kop, bool Eerste, List<VersieRegel> Regels);
<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 /// <summary>
horen. /// 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.
<li> /// </summary>
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis, private IEnumerable<Groep> Groepen()
Mijn mensen, Momenten en Nalatenschap. {
</li> var uit = new List<Groep>();
</ul> 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 + ".";
}
}
+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"
@@ -287,6 +287,21 @@ public interface IVaultCategoryRepository
void Remove(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,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,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;
}
}
@@ -348,6 +348,13 @@ public class MijnBesluitClient : IAsyncDisposable
public Task<Uitkomst<KluisSoort>> KluisSoortAsync(int soortId, CancellationToken ct = default) => public Task<Uitkomst<KluisSoort>> KluisSoortAsync(int soortId, CancellationToken ct = default) =>
StuurAsync<KluisSoort>(SocketActions.VaultCategory, new { id = soortId }, ct); 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);
+6
View File
@@ -277,6 +277,12 @@ public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyLis
public const string Uitleg = "uitleg"; 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> /// <summary>
/// De ingebouwde soorten kluisitems, voor als de server er geen lijst van geeft: een oudere /// 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 /// server, of geen verbinding op het moment dat het scherm opent. Dezelfde nummers, namen en
@@ -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;
}
+2 -1
View File
@@ -74,7 +74,8 @@ public enum AuditAction
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 KluisSoortBeheerd = 80,
VersiehistorieBeheerd = 81
} }
/// <summary> /// <summary>
@@ -94,6 +94,7 @@ public static class DependencyInjection
services.AddScoped<IInvitationRepository, InvitationRepository>(); services.AddScoped<IInvitationRepository, InvitationRepository>();
services.AddScoped<IVaultRepository, VaultRepository>(); services.AddScoped<IVaultRepository, VaultRepository>();
services.AddScoped<IVaultCategoryRepository, VaultCategoryRepository>(); 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>();
@@ -105,6 +106,7 @@ public static class DependencyInjection
services.AddScoped<AccountService>(); services.AddScoped<AccountService>();
services.AddScoped<VaultCategoryService>(); 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 =>
@@ -20,6 +20,7 @@ public class MijnBesluitDbContext : DbContext
public DbSet<VaultFile> VaultFiles => Set<VaultFile>(); public DbSet<VaultFile> VaultFiles => Set<VaultFile>();
public DbSet<VaultCategoryDefinition> VaultCategories => Set<VaultCategoryDefinition>(); public DbSet<VaultCategoryDefinition> VaultCategories => Set<VaultCategoryDefinition>();
public DbSet<VaultCategoryField> VaultCategoryFields => Set<VaultCategoryField>(); 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>();
@@ -220,6 +221,19 @@ 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 => b.Entity<VaultCategoryDefinition>(e =>
{ {
e.ToTable("vault_categories"); e.ToTable("vault_categories");
@@ -267,6 +267,26 @@ public class VaultCategoryRepository : IVaultCategoryRepository
public void Remove(VaultCategoryField veld) => _db.VaultCategoryFields.Remove(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;
+6
View File
@@ -111,6 +111,12 @@ public static class SocketActions
/// </summary> /// </summary>
public const string VaultCategory = "vault.category"; 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";
@@ -21,6 +21,9 @@ public class RequestScope
/// </summary> /// </summary>
public VaultCategoryService? Soorten { get; init; } 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.
@@ -493,6 +496,14 @@ public class SocketDispatcher
return SocketResponse.Ok(id, lijst); 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>();
@@ -45,6 +45,7 @@ public class SocketServerService : BackgroundService
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>(), 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,61 +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 oktober 2026 &middot; versie 1.1</h2> @if (Fout)
{
<p class="zacht">De versiehistorie kon niet worden opgehaald. Probeer het later nog eens.</p>
}
else if (Regels is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else if (Regels.Count == 0)
{
<p class="zacht">Er staat nog niets in de versiehistorie.</p>
}
else
{
@foreach (var groep in Groepen())
{
<h2 style="@(groep.Eerste ? "margin-top:0" : null)">@groep.Kop</h2>
<ul style="padding-left:1.2rem"> <ul style="padding-left:1.2rem">
<li> @foreach (var regel in groep.Regels)
<strong>Meer soorten in de kluis.</strong> Naast wachtwoorden, polissen en uitvaartwensen {
kan er voortaan een nieuwe soort bijkomen, met eigen invulvelden, zonder dat je de app <li style="margin-bottom:.5rem"><strong>@Punt(regel.Kop)</strong> @regel.Tekst</li>
daarvoor opnieuw hoeft te updaten. Je bestaande kluisitems blijven zoals ze waren. }
</li>
</ul> </ul>
}
}
<h2>20 september 2026</h2> @code {
<ul style="padding-left:1.2rem"> private IReadOnlyList<VersieRegelWeergave>? Regels;
<li style="margin-bottom:.5rem"> private bool Fout;
<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
of te kiezen.
</li>
<li style="margin-bottom:.5rem">
<strong>Mijn profiel als pop-up.</strong> 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.
</li>
<li style="margin-bottom:.5rem">
<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.
</li>
<li>
<strong>Muziek en verdere wensen groeien mee.</strong> Die velden bij een kluisitem
passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website.
</li>
</ul>
<h2>19 september 2026</h2> protected override async Task OnInitializedAsync()
<ul style="padding-left:1.2rem"> {
<li style="margin-bottom:.5rem"> try { Regels = await Historie.ZichtbaarAsync(); }
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks catch { Fout = true; }
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.
</li> private sealed record Groep(string Kop, bool Eerste, List<VersieRegelWeergave> Regels);
<li style="margin-bottom:.5rem">
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in /// <summary>
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij /// Opeenvolgende regels met dezelfde dag (en dezelfde versie) vormen samen één kop. De volgorde
horen. /// komt van de server: nieuwste dag bovenaan, binnen een dag in de volgorde van invoer.
</li> /// </summary>
<li> private IEnumerable<Groep> Groepen()
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis, {
Mijn mensen, Momenten en Nalatenschap. var uit = new List<Groep>();
</li> string? vorige = null;
</ul> 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 + ".";
}
}
@@ -35,6 +35,9 @@ else
Ook een eigen scherm: dat is inrichten, geen terugdraaien. *@ Ook een eigen scherm: dat is inrichten, geen terugdraaien. *@
<p><a href="/beheer/soorten">Kluissoorten beheren &rsaquo;</a></p> <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,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 {
@@ -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

+22
View File
@@ -27,6 +27,7 @@ public class Store
public List<CampagneUur> CampagneUren = new(); public List<CampagneUur> CampagneUren = new();
public List<VaultCategoryDefinition> Soorten = VaultCategorySaatgoed.Maak(); public List<VaultCategoryDefinition> Soorten = VaultCategorySaatgoed.Maak();
public List<VaultCategoryField> SoortVelden = new(); public List<VaultCategoryField> SoortVelden = new();
public List<VersieRegel> VersieRegels = new();
private long _seq; private long _seq;
public long NextId() => ++_seq; public long NextId() => ++_seq;
@@ -533,6 +534,27 @@ public class TestPush : IPushSender
/// <summary>De startrijen uit db/21_kluissoorten.sql, zodat de tests met dezelfde soorten beginnen als de echte database.</summary> /// <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 class VaultCategorySaatgoed
{ {
public static List<VaultCategoryDefinition> Maak() public static List<VaultCategoryDefinition> Maak()
+2 -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);
@@ -1943,5 +1943,6 @@ ServiceChecks.Register(runner);
AdvertentieChecks.Register(runner); AdvertentieChecks.Register(runner);
UitrolChecks.Register(runner); UitrolChecks.Register(runner);
SoortenChecks.Register(runner); SoortenChecks.Register(runner);
VersiehistorieChecks.Register(runner);
return runner.Summary(); return runner.Summary();
+2
View File
@@ -26,6 +26,7 @@ public class World
public AdvertentieService Adverteren { get; } public AdvertentieService Adverteren { get; }
public AccountOpheffenService Opheffen { get; } public AccountOpheffenService Opheffen { get; }
public VaultCategoryService Soorten { 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 = "")
@@ -108,6 +109,7 @@ public class World
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);
Soorten = new VaultCategoryService(new VaultCategoryRepo(Store), users, audit, uow, Clock); 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); 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);
@@ -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");
});
}
}
+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