diff --git a/db/02_applicatiegebruiker.sql b/db/02_applicatiegebruiker.sql index 8cc48bd..35e6783 100644 --- a/db/02_applicatiegebruiker.sql +++ b/db/02_applicatiegebruiker.sql @@ -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`.`vault_categories` TO 'mijnbesluit_app'@'localhost'; GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`vault_category_fields` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`version_history` TO 'mijnbesluit_app'@'localhost'; -- Het logboek mag er alleen bij komen en uit gelezen worden, niet uit verdwijnen. GRANT SELECT, INSERT ON `mijnbesluit`.`audit_log` TO 'mijnbesluit_app'@'localhost'; diff --git a/db/22_versiehistorie.sql b/db/22_versiehistorie.sql new file mode 100644 index 0000000..b3a6a6a --- /dev/null +++ b/db/22_versiehistorie.sql @@ -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; diff --git a/db/handmatig/22b_versiehistorie_startrijen.sql b/db/handmatig/22b_versiehistorie_startrijen.sql new file mode 100644 index 0000000..ff7f95d --- /dev/null +++ b/db/handmatig/22b_versiehistorie_startrijen.sql @@ -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`; diff --git a/docs/Socketprotocol.md b/docs/Socketprotocol.md index f717167..51d39f5 100644 --- a/docs/Socketprotocol.md +++ b/docs/Socketprotocol.md @@ -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.categories` | ja | De soorten kluisitems die jij te zien krijgt: de actieve plus die waar je al een item van hebt, met naam, uitleg, "bespreken met" en bij een eigen soort de velden van het formulier | | `vault.category` | ja | Eén soort op nummer, ook als hij niet actief is (nodig om het item van een ander te lezen); alleen de beschrijving, nooit inhoud | +| `app.versiehistorie` | ja | De zichtbare regels van de versiehistorie (datum jjjj-mm-dd, versie, kop, tekst), nieuwste dag bovenaan; staat in tabel `version_history`, zie docs/Versiehistorie.md | | `message.inbox` / `message.outbox` | ja | Berichten voor en door jou | | `message.read` | ja | Bericht lezen en als gelezen markeren | | `message.compose` | ja | Bericht klaarzetten | diff --git a/docs/Versiehistorie.md b/docs/Versiehistorie.md new file mode 100644 index 0000000..d549fde --- /dev/null +++ b/docs/Versiehistorie.md @@ -0,0 +1,41 @@ +# 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. 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. + +## 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. diff --git a/src/MijnBesluit.AppUi/Onderdelen/Versiehistorie.razor b/src/MijnBesluit.AppUi/Onderdelen/Versiehistorie.razor index 1622119..3826635 100644 --- a/src/MijnBesluit.AppUi/Onderdelen/Versiehistorie.razor +++ b/src/MijnBesluit.AppUi/Onderdelen/Versiehistorie.razor @@ -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 - het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site - delen geen project - en een zelfcontrole vergelijkt ze letterlijk. + De regels staan in een tabel op de server (version_history) en worden beheerd op de website + onder Beheer > Versiehistorie. Site en app lezen dezelfde tabel; dit bestand bevat zelf geen + 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. *@ +@inject AppToestand Toestand

Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.

-

20 oktober 2026 · versie 1.1

- +@if (Fout) +{ +

De versiehistorie kon niet worden opgehaald. Probeer het later nog eens.

+} +else if (Regels is null) +{ +

Bezig met laden…

+} +else if (Regels.Count == 0) +{ +

Er staat nog niets in de versiehistorie.

+} +else +{ + @foreach (var groep in Groepen()) + { +

@groep.Kop

+ + } +} -

20 september 2026

- +@code { + private IReadOnlyList? Regels; + private bool Fout; -

19 september 2026

- + protected override async Task OnInitializedAsync() + { + var uitkomst = await Toestand.Client.VersiehistorieAsync(); + if (uitkomst.Gelukt) Regels = uitkomst.Waarde; + else Fout = true; + } + + private sealed record Groep(string Kop, bool Eerste, List Regels); + + /// + /// Opeenvolgende regels met dezelfde dag (en dezelfde versie) vormen samen één kop. De volgorde + /// komt van de server: nieuwste dag bovenaan, binnen een dag in de volgorde van invoer. + /// + private IEnumerable Groepen() + { + var uit = new List(); + 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())); + 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}"; + } + + /// De kop eindigt op een punt, zodat de tekst erachter als gewone zin leest. + private static string Punt(string kop) + { + var k = kop.Trim(); + return k.Length == 0 || ".!?".Contains(k[^1]) ? k : k + "."; + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Start.razor b/src/MijnBesluit.AppUi/Schermen/Start.razor index 6413d80..efb2d51 100644 --- a/src/MijnBesluit.AppUi/Schermen/Start.razor +++ b/src/MijnBesluit.AppUi/Schermen/Start.razor @@ -174,8 +174,7 @@ } -@* Zelfde tekst als op de website - Versiehistorie.razor staat twee keer op schijf en een - zelfcontrole vergelijkt ze letterlijk. *@ +@* De tekst komt van de server (tabel version_history), net als op de website. *@ @if (VersiehistorieOpen) { De versiehistorie: één tabel voor site en app. Zie docs/Versiehistorie.md. +public interface IVersiehistorieRepository +{ + /// Nieuwste dag bovenaan; binnen één dag in de volgorde van invoer. + Task> ListAsync(bool alleenZichtbaar, CancellationToken ct = default); + + Task FindAsync(int id, CancellationToken ct = default); + + /// Is er al een regel met dezelfde datum en kop (behalve de regel met dit nummer)? + Task BestaatAsync(DateOnly datum, string kop, int? behalveId, CancellationToken ct = default); + + void Add(VersieRegel regel); + void Remove(VersieRegel regel); +} + /// Versturen van uitnodigingen en meldingen. /// /// Adverteerders, hun campagnes en de tellers per uur. Zie docs/Adverteren.md. diff --git a/src/MijnBesluit.Application/Models/Versiehistorie.cs b/src/MijnBesluit.Application/Models/Versiehistorie.cs new file mode 100644 index 0000000..e9b7b80 --- /dev/null +++ b/src/MijnBesluit.Application/Models/Versiehistorie.cs @@ -0,0 +1,11 @@ +namespace MijnBesluit.Application.Models; + +/// +/// 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. +/// +public record VersieRegelWeergave(string Datum, string? Versie, string Kop, string Tekst); + +/// Een regel in het beheerscherm, ook als hij verborgen is. +public record VersieRegelBeheerbeeld(int Id, DateOnly Datum, string? Versie, string Kop, string Tekst, bool Zichtbaar); diff --git a/src/MijnBesluit.Application/Services/VersiehistorieService.cs b/src/MijnBesluit.Application/Services/VersiehistorieService.cs new file mode 100644 index 0000000..5dd7d22 --- /dev/null +++ b/src/MijnBesluit.Application/Services/VersiehistorieService.cs @@ -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; + +/// +/// 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. +/// +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 + + /// + /// De zichtbare regels, de nieuwste dag bovenaan. Binnen een dag staan ze in de volgorde waarin + /// ze zijn ingevoerd. + /// + public async Task> 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 + + /// Alle regels, ook de verborgen. Alleen voor beheerders. + public async Task>> OverzichtAsync(long currentUserId, + CancellationToken ct = default) + { + if (!await IsBeheerderAsync(currentUserId, ct)) + return Result>.Fail("Je bent geen beheerder."); + + var 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>.Ok(lijst); + } + + /// Voegt een regel toe. Hij is meteen zichtbaar tenzij je hem verbergt. + public async Task> MaakAsync(long currentUserId, DateOnly datum, string? versie, string kop, + string tekst, bool zichtbaar = true, CancellationToken ct = default) + { + if (!await IsBeheerderAsync(currentUserId, ct)) return Result.Fail("Je bent geen beheerder."); + + var fout = await ControleerAsync(null, datum, versie, kop, tekst, ct); + if (fout is not null) return Result.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.Ok(regel.Id); + } + + /// Wijzigt een regel, en kan hem verbergen of weer tonen. + public async Task 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(); + } + + /// Haalt een regel weg. Wil je hem alleen even niet tonen, verberg hem dan. + public async Task 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 IsBeheerderAsync(long userId, CancellationToken ct) => + (await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true; + + private async Task 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; + } +} diff --git a/src/MijnBesluit.Client/MijnBesluitClient.cs b/src/MijnBesluit.Client/MijnBesluitClient.cs index 9f8be95..c927e1d 100644 --- a/src/MijnBesluit.Client/MijnBesluitClient.cs +++ b/src/MijnBesluit.Client/MijnBesluitClient.cs @@ -348,6 +348,13 @@ public class MijnBesluitClient : IAsyncDisposable public Task> KluisSoortAsync(int soortId, CancellationToken ct = default) => StuurAsync(SocketActions.VaultCategory, new { id = soortId }, ct); + /// + /// 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. + /// + public Task>> VersiehistorieAsync(CancellationToken ct = default) => + StuurAsync>(SocketActions.Versiehistorie, null, ct); + public Task> KluisOpenenAsync(long itemId, CancellationToken ct = default) => StuurAsync(SocketActions.VaultOpen, new { id = itemId }, ct); diff --git a/src/MijnBesluit.Client/Modellen.cs b/src/MijnBesluit.Client/Modellen.cs index c597b7f..092896a 100644 --- a/src/MijnBesluit.Client/Modellen.cs +++ b/src/MijnBesluit.Client/Modellen.cs @@ -277,6 +277,12 @@ public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyLis public const string Uitleg = "uitleg"; } +/// +/// Eén regel van de versiehistorie. Datum staat als jjjj-mm-dd; Versie is leeg als +/// de regel niet bij een uitgebrachte appversie hoort. +/// +public record VersieRegel(string Datum, string? Versie, string Kop, string Tekst); + /// /// 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 diff --git a/src/MijnBesluit.Domain/Entities/VersieRegel.cs b/src/MijnBesluit.Domain/Entities/VersieRegel.cs new file mode 100644 index 0000000..55680f1 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/VersieRegel.cs @@ -0,0 +1,33 @@ +namespace MijnBesluit.Domain.Entities; + +/// +/// 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. +/// +public class VersieRegel +{ + public int Id { get; set; } + + /// De dag waarop de functie er kwam. + public DateOnly Datum { get; set; } + + /// Het versienummer van de app, bijvoorbeeld "1.1". Leeg als het alleen de website betreft. + public string? Versie { get; set; } + + /// Het vette stukje: wat kun je nu. + public string Kop { get; set; } = string.Empty; + + /// De uitleg erachter. Platte tekst. + public string Tekst { get; set; } = string.Empty; + + /// Een verborgen regel ziet alleen de beheerder; handig voor iets dat nog niet live is. + public bool Zichtbaar { get; set; } = true; + + public DateTime AangemaaktOp { get; set; } = DateTime.UtcNow; + public DateTime GewijzigdOp { get; set; } = DateTime.UtcNow; +} diff --git a/src/MijnBesluit.Domain/Enums/Enums.cs b/src/MijnBesluit.Domain/Enums/Enums.cs index f8ddfa9..9da2aae 100644 --- a/src/MijnBesluit.Domain/Enums/Enums.cs +++ b/src/MijnBesluit.Domain/Enums/Enums.cs @@ -74,7 +74,8 @@ public enum AuditAction NalatenschapToegangVerleend = 60, AdvertentieIngediend = 70, AdvertentieBetaald = 71, AdvertentieGoedgekeurd = 72, AdvertentieAfgewezen = 73, AdvertentieGestopt = 74, - KluisSoortBeheerd = 80 + KluisSoortBeheerd = 80, + VersiehistorieBeheerd = 81 } /// diff --git a/src/MijnBesluit.Infrastructure/DependencyInjection.cs b/src/MijnBesluit.Infrastructure/DependencyInjection.cs index ad84fe1..8c9a60e 100644 --- a/src/MijnBesluit.Infrastructure/DependencyInjection.cs +++ b/src/MijnBesluit.Infrastructure/DependencyInjection.cs @@ -94,6 +94,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -105,6 +106,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // De link naar de landingspagina voor iemand zonder account moet naar de echte site wijzen. services.AddScoped(sp => diff --git a/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs b/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs index 3824d4c..3134f91 100644 --- a/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs +++ b/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs @@ -20,6 +20,7 @@ public class MijnBesluitDbContext : DbContext public DbSet VaultFiles => Set(); public DbSet VaultCategories => Set(); public DbSet VaultCategoryFields => Set(); + public DbSet VersieRegels => Set(); public DbSet Toestellen => Set(); public DbSet Sessies => Set(); public DbSet ContentGrants => Set(); @@ -220,6 +221,19 @@ public class MijnBesluitDbContext : DbContext e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId); }); + b.Entity(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(e => { e.ToTable("vault_categories"); diff --git a/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs b/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs index 1ffc584..740888a 100644 --- a/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs +++ b/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs @@ -267,6 +267,26 @@ public class VaultCategoryRepository : IVaultCategoryRepository public void Remove(VaultCategoryField veld) => _db.VaultCategoryFields.Remove(veld); } +/// De versiehistorie; zie . +public class VersiehistorieRepository : IVersiehistorieRepository +{ + private readonly MijnBesluitDbContext _db; + public VersiehistorieRepository(MijnBesluitDbContext db) => _db = db; + + public async Task> 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 FindAsync(int id, CancellationToken ct = default) => + _db.VersieRegels.FirstOrDefaultAsync(r => r.Id == id, ct); + + public Task 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 { private readonly MijnBesluitDbContext _db; diff --git a/src/MijnBesluit.Protocol/Envelope.cs b/src/MijnBesluit.Protocol/Envelope.cs index 0adb28d..90e1d23 100644 --- a/src/MijnBesluit.Protocol/Envelope.cs +++ b/src/MijnBesluit.Protocol/Envelope.cs @@ -111,6 +111,12 @@ public static class SocketActions /// public const string VaultCategory = "vault.category"; + /// + /// 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. + /// + public const string Versiehistorie = "app.versiehistorie"; + /// Nalatenschappen die aan jou zijn toevertrouwd, en wat daarvan is vrijgegeven. public const string LegacyEntrusted = "legacy.entrusted"; public const string LegacyEntrustedItems = "legacy.entrusted-items"; diff --git a/src/MijnBesluit.Server/SocketDispatcher.cs b/src/MijnBesluit.Server/SocketDispatcher.cs index 603a9c6..bf0bee4 100644 --- a/src/MijnBesluit.Server/SocketDispatcher.cs +++ b/src/MijnBesluit.Server/SocketDispatcher.cs @@ -21,6 +21,9 @@ public class RequestScope /// public VaultCategoryService? Soorten { get; init; } + /// Optioneel: zonder deze dienst geeft de versiehistorie een lege lijst. + public VersiehistorieService? Versiehistorie { get; init; } + /// /// Voor het samenvoegen van dubbele profielen. Optioneel: zonder deze dienst werkt alles, /// alleen wordt een tweede uitnodiging dan geweigerd in plaats van samengevoegd. @@ -493,6 +496,14 @@ public class SocketDispatcher return SocketResponse.Ok(id, lijst); } + case SocketActions.Versiehistorie: + { + var lijst = scope.Versiehistorie is null + ? Array.Empty() + : (await scope.Versiehistorie.ZichtbaarAsync(ct)).ToArray(); + return SocketResponse.Ok(id, lijst); + } + case SocketActions.VaultOpen: { var p = request.PayloadAs(); diff --git a/src/MijnBesluit.SocketServer/SocketServerService.cs b/src/MijnBesluit.SocketServer/SocketServerService.cs index dd42654..07145c5 100644 --- a/src/MijnBesluit.SocketServer/SocketServerService.cs +++ b/src/MijnBesluit.SocketServer/SocketServerService.cs @@ -45,6 +45,7 @@ public class SocketServerService : BackgroundService Families = scope.ServiceProvider.GetRequiredService(), Vault = scope.ServiceProvider.GetRequiredService(), Soorten = scope.ServiceProvider.GetRequiredService(), + Versiehistorie = scope.ServiceProvider.GetRequiredService(), Messages = scope.ServiceProvider.GetRequiredService(), Legacy = scope.ServiceProvider.GetRequiredService(), Meldingen = scope.ServiceProvider.GetRequiredService(), diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Versiehistorie.razor b/src/MijnBesluit.Web/Components/Onderdelen/Versiehistorie.razor index 1622119..805ffd7 100644 --- a/src/MijnBesluit.Web/Components/Onderdelen/Versiehistorie.razor +++ b/src/MijnBesluit.Web/Components/Onderdelen/Versiehistorie.razor @@ -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 - het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site - delen geen project - en een zelfcontrole vergelijkt ze letterlijk. + De regels staan in een tabel op de server (version_history) en worden beheerd op de website + onder Beheer > Versiehistorie. Site en app lezen dezelfde tabel; dit bestand bevat zelf geen + 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. *@ +@inject VersiehistorieService Historie

Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.

-

20 oktober 2026 · versie 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. -
  • -
+@if (Fout) +{ +

De versiehistorie kon niet worden opgehaald. Probeer het later nog eens.

+} +else if (Regels is null) +{ +

Bezig met laden…

+} +else if (Regels.Count == 0) +{ +

Er staat nog niets in de versiehistorie.

+} +else +{ + @foreach (var groep in Groepen()) + { +

@groep.Kop

+
    + @foreach (var regel in groep.Regels) + { +
  • @Punt(regel.Kop) @regel.Tekst
  • + } +
+ } +} -

20 september 2026

-
    -
  • - 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. -
  • -
  • - 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. -
  • -
  • - Duidelijker voor de nalatenschapsbeheerder. Bij "Aan mij toevertrouwd" - zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is. -
  • -
  • - 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. -
  • -
+@code { + private IReadOnlyList? Regels; + private bool Fout; -

19 september 2026

-
    -
  • - 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. -
  • -
  • - 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. -
  • -
  • - Met de vinger naar beneden trekken ververst het scherm. Werkt op Kluis, - Mijn mensen, Momenten en Nalatenschap. -
  • -
+ protected override async Task OnInitializedAsync() + { + try { Regels = await Historie.ZichtbaarAsync(); } + catch { Fout = true; } + } + + private sealed record Groep(string Kop, bool Eerste, List Regels); + + /// + /// Opeenvolgende regels met dezelfde dag (en dezelfde versie) vormen samen één kop. De volgorde + /// komt van de server: nieuwste dag bovenaan, binnen een dag in de volgorde van invoer. + /// + private IEnumerable Groepen() + { + var uit = new List(); + 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())); + 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}"; + } + + /// De kop eindigt op een punt, zodat de tekst erachter als gewone zin leest. + private static string Punt(string kop) + { + var k = kop.Trim(); + return k.Length == 0 || ".!?".Contains(k[^1]) ? k : k + "."; + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Beheer.razor b/src/MijnBesluit.Web/Components/Pages/Beheer.razor index 3001a0f..75e21f6 100644 --- a/src/MijnBesluit.Web/Components/Pages/Beheer.razor +++ b/src/MijnBesluit.Web/Components/Pages/Beheer.razor @@ -35,6 +35,9 @@ else Ook een eigen scherm: dat is inrichten, geen terugdraaien. *@

Kluissoorten beheren ›

+ @* Versiehistorie: de lijst "wat is er nieuw" op de website en in de app. Eén tabel voor beide. *@ +

Versiehistorie beheren ›

+ @if (Melding is not null) {
@Melding
} @* ------------------------------------------------------------ statistieken *@ diff --git a/src/MijnBesluit.Web/Components/Pages/BeheerVersiehistorie.razor b/src/MijnBesluit.Web/Components/Pages/BeheerVersiehistorie.razor new file mode 100644 index 0000000..b586d94 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/BeheerVersiehistorie.razor @@ -0,0 +1,256 @@ +@page "/beheer/versiehistorie" +@attribute [Authorize] +@inject BeheerService Beheerkant +@inject VersiehistorieService Historie +@rendermode InteractiveServer + +Versiehistorie - Beheer + +@* 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) +{ +

Niet gevonden

+

Deze pagina bestaat niet, of hij is niet voor jou.

+} +else +{ +

Versiehistorie

+

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

+

‹ Terug naar beheer

+ + @if (Melding is not null) {
@Melding
} + + @* ------------------------------------------------------------ nieuwe of bestaande regel *@ +
+

@(BewerkId is null ? "Nieuwe regel" : "Regel wijzigen")

+
+
+ + +
+
+ + +
Alleen invullen als dit bij een uitgebrachte versie van de app hoort.
+
+
+
+ + +
+
+ + +
@Formulier.Tekst.Length van @VersiehistorieService.MaxTekst tekens. Gewone tekst, geen opmaak.
+
+
+ + +
+
+ + @if (BewerkId is not null) + { + + } +
+
+ + @* ------------------------------------------------------------ de lijst *@ + @if (Lijst is null) + { +

Bezig met laden…

+ } + else if (Lijst.Count == 0) + { +

Er staat nog niets in de versiehistorie.

+ } + else + { +
+ @foreach (var r in Lijst) + { +
+
+
+
+ @Datums.Datum(r.Datum) + @if (!string.IsNullOrWhiteSpace(r.Versie)) { · versie @r.Versie } +
+
@r.Kop
+
@r.Tekst
+ @if (!r.Zichtbaar) + { +
Verborgen
+ } +
+
+
+ + + @if (Weghalen == r.Id) + { + Echt verwijderen? + + + } + else + { + + } +
+
+ } +
+ } +} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId; + private bool Magdit, Bezig; + private string? Melding, MeldingSoort; + + private IReadOnlyList? 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; } + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Home.razor b/src/MijnBesluit.Web/Components/Pages/Home.razor index 2f1eae6..6e8421d 100644 --- a/src/MijnBesluit.Web/Components/Pages/Home.razor +++ b/src/MijnBesluit.Web/Components/Pages/Home.razor @@ -178,8 +178,7 @@ } - @* Zelfde tekst als in de app: Versiehistorie.razor staat twee keer op schijf en een - zelfcontrole vergelijkt ze letterlijk. *@ + @* De tekst komt uit de tabel version_history, net als in de app. *@ @if (VersiehistorieOpen) { CampagneUren = new(); public List Soorten = VaultCategorySaatgoed.Maak(); public List SoortVelden = new(); + public List VersieRegels = new(); private long _seq; public long NextId() => ++_seq; @@ -533,6 +534,27 @@ public class TestPush : IPushSender /// De startrijen uit db/21_kluissoorten.sql, zodat de tests met dezelfde soorten beginnen als de echte database. +public class VersiehistorieRepo : IVersiehistorieRepository +{ + private readonly Store _s; public VersiehistorieRepo(Store s) => _s = s; + private int _volgende = 1; + + public Task> ListAsync(bool alleenZichtbaar, CancellationToken ct = default) => + Task.FromResult>( + _s.VersieRegels.Where(r => !alleenZichtbaar || r.Zichtbaar) + .OrderByDescending(r => r.Datum).ThenBy(r => r.Id).ToList()); + + public Task 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 BestaatAsync(DateOnly datum, string kop, int? behalveId, CancellationToken ct = default) => + Task.FromResult(_s.VersieRegels.Any(r => r.Datum == datum && string.Equals(r.Kop, kop, StringComparison.OrdinalIgnoreCase) && (behalveId == null || r.Id != behalveId))); + + public void Add(VersieRegel regel) { if (regel.Id == 0) regel.Id = _volgende++; _s.VersieRegels.Add(regel); } + public void Remove(VersieRegel regel) => _s.VersieRegels.Remove(regel); +} + public static class VaultCategorySaatgoed { public static List Maak() diff --git a/tests/MijnBesluit.SelfCheck/Program.cs b/tests/MijnBesluit.SelfCheck/Program.cs index 8e05434..12e286a 100644 --- a/tests/MijnBesluit.SelfCheck/Program.cs +++ b/tests/MijnBesluit.SelfCheck/Program.cs @@ -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 // versies daarvan is het ergste geval van allemaal - het is het document waar iemand zich op // 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 app = Path.Combine(map.FullName, "src", "MijnBesluit.AppUi", "Onderdelen", naam); @@ -1943,5 +1943,6 @@ ServiceChecks.Register(runner); AdvertentieChecks.Register(runner); UitrolChecks.Register(runner); SoortenChecks.Register(runner); +VersiehistorieChecks.Register(runner); return runner.Summary(); diff --git a/tests/MijnBesluit.SelfCheck/Runner.cs b/tests/MijnBesluit.SelfCheck/Runner.cs index a76f6a8..1f6a330 100644 --- a/tests/MijnBesluit.SelfCheck/Runner.cs +++ b/tests/MijnBesluit.SelfCheck/Runner.cs @@ -26,6 +26,7 @@ public class World public AdvertentieService Adverteren { get; } public AccountOpheffenService Opheffen { get; } public VaultCategoryService Soorten { get; } + public VersiehistorieService Versiehistorie { get; } /// Maakt in één stap een account aan; scheelt in de scenario's het hele codegedoe. public async Task MaakGebruikerAsync(string email, string voornaam, string achternaam = "") @@ -108,6 +109,7 @@ public class World Families = new FamilyService(families, people, users, invitations, messages, legacy, Email, 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); Meldingen = new MeldingService(new ToestelRepo(Store), users, Push, uow, Clock); Messages = new MessageService(messages, people, users, vault, keys, files, Email, audit, uow, Clock, Meldingen); diff --git a/tests/MijnBesluit.SelfCheck/VersiehistorieChecks.cs b/tests/MijnBesluit.SelfCheck/VersiehistorieChecks.cs new file mode 100644 index 0000000..3866062 --- /dev/null +++ b/tests/MijnBesluit.SelfCheck/VersiehistorieChecks.cs @@ -0,0 +1,139 @@ +using MijnBesluit.Application.Models; +using MijnBesluit.Application.Services; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.SelfCheck; + +/// +/// 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. +/// +public static class VersiehistorieChecks +{ + private static async Task BeheerderAsync(World w, string email = "beheer@example.nl") + { + var u = await w.MaakGebruikerAsync(email, "Beheer", "Er"); + w.Store.Users.Single(x => x.Id == u.Id).IsBeheerder = true; + return u; + } + + private static async Task MaakAsync(World w, User beheerder, 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"); + }); + } +}