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
This commit is contained in:
jf
2026-10-20 11:47:06 +00:00
co-authored by Claude Sonnet 5.5
parent 90c58fc42e
commit 5446dd4170
28 changed files with 996 additions and 108 deletions
@@ -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
<p class="zacht" style="margin-top:0">
Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
</p>
<h2 style="margin-top:0">20 oktober 2026 &middot; versie 1.1</h2>
<ul style="padding-left:1.2rem">
<li>
<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
daarvoor opnieuw hoeft te updaten. Je bestaande kluisitems blijven zoals ze waren.
</li>
</ul>
@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">
@foreach (var regel in groep.Regels)
{
<li style="margin-bottom:.5rem"><strong>@Punt(regel.Kop)</strong> @regel.Tekst</li>
}
</ul>
}
}
<h2>20 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<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>
@code {
private IReadOnlyList<VersieRegel>? Regels;
private bool Fout;
<h2>19 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je
dat voortaan ook op je telefoon, niet meer alleen op de website.
</li>
<li style="margin-bottom:.5rem">
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij
horen.
</li>
<li>
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis,
Mijn mensen, Momenten en Nalatenschap.
</li>
</ul>
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<VersieRegel> Regels);
/// <summary>
/// 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.
/// </summary>
private IEnumerable<Groep> Groepen()
{
var uit = new List<Groep>();
string? vorige = null;
foreach (var r in Regels!)
{
var kop = Koptekst(r);
if (kop != vorige)
{
uit.Add(new Groep(kop, uit.Count == 0, new List<VersieRegel>()));
vorige = kop;
}
uit[^1].Regels.Add(r);
}
return uit;
}
private static string Koptekst(VersieRegel r)
{
var dag = DateOnly.TryParseExact(r.Datum, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var d)
? $"{d.Day} {Datums.Maanden[d.Month - 1]} {d.Year}"
: r.Datum;
return string.IsNullOrWhiteSpace(r.Versie) ? dag : $"{dag} \u00b7 versie {r.Versie}";
}
/// <summary>De kop eindigt op een punt, zodat de tekst erachter als gewone zin leest.</summary>
private static string Punt(string kop)
{
var k = kop.Trim();
return k.Length == 0 || ".!?".Contains(k[^1]) ? k : k + ".";
}
}
+1 -2
View File
@@ -174,8 +174,7 @@
</Popup>
}
@* 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)
{
<Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true"
@@ -287,6 +287,21 @@ public interface IVaultCategoryRepository
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>
/// 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) =>
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) =>
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";
}
/// <summary>
/// Eén regel van de versiehistorie. <c>Datum</c> staat als jjjj-mm-dd; <c>Versie</c> is leeg als
/// de regel niet bij een uitgebrachte appversie hoort.
/// </summary>
public record VersieRegel(string Datum, string? Versie, string Kop, string Tekst);
/// <summary>
/// De ingebouwde soorten kluisitems, voor als de server er geen lijst van geeft: een oudere
/// server, of geen verbinding op het moment dat het scherm opent. Dezelfde nummers, namen en
@@ -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,
AdvertentieIngediend = 70, AdvertentieBetaald = 71, AdvertentieGoedgekeurd = 72,
AdvertentieAfgewezen = 73, AdvertentieGestopt = 74,
KluisSoortBeheerd = 80
KluisSoortBeheerd = 80,
VersiehistorieBeheerd = 81
}
/// <summary>
@@ -94,6 +94,7 @@ public static class DependencyInjection
services.AddScoped<IInvitationRepository, InvitationRepository>();
services.AddScoped<IVaultRepository, VaultRepository>();
services.AddScoped<IVaultCategoryRepository, VaultCategoryRepository>();
services.AddScoped<IVersiehistorieRepository, VersiehistorieRepository>();
services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<ILegacyRepository, LegacyRepository>();
services.AddScoped<IToestelRepository, ToestelRepository>();
@@ -105,6 +106,7 @@ public static class DependencyInjection
services.AddScoped<AccountService>();
services.AddScoped<VaultCategoryService>();
services.AddScoped<VersiehistorieService>();
services.AddScoped<VaultService>();
// De link naar de landingspagina voor iemand zonder account moet naar de echte site wijzen.
services.AddScoped(sp =>
@@ -20,6 +20,7 @@ public class MijnBesluitDbContext : DbContext
public DbSet<VaultFile> VaultFiles => Set<VaultFile>();
public DbSet<VaultCategoryDefinition> VaultCategories => Set<VaultCategoryDefinition>();
public DbSet<VaultCategoryField> VaultCategoryFields => Set<VaultCategoryField>();
public DbSet<VersieRegel> VersieRegels => Set<VersieRegel>();
public DbSet<Toestel> Toestellen => Set<Toestel>();
public DbSet<Sessie> Sessies => Set<Sessie>();
public DbSet<ContentGrant> ContentGrants => Set<ContentGrant>();
@@ -220,6 +221,19 @@ public class MijnBesluitDbContext : DbContext
e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId);
});
b.Entity<VersieRegel>(e =>
{
e.ToTable("version_history");
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Datum).HasColumnName("datum");
e.Property(x => x.Versie).HasColumnName("versie").HasMaxLength(20);
e.Property(x => x.Kop).HasColumnName("kop").HasMaxLength(120).IsRequired();
e.Property(x => x.Tekst).HasColumnName("tekst").HasMaxLength(1000).IsRequired();
e.Property(x => x.Zichtbaar).HasColumnName("zichtbaar");
e.Property(x => x.AangemaaktOp).HasColumnName("aangemaakt_op");
e.Property(x => x.GewijzigdOp).HasColumnName("gewijzigd_op");
});
b.Entity<VaultCategoryDefinition>(e =>
{
e.ToTable("vault_categories");
@@ -267,6 +267,26 @@ public class VaultCategoryRepository : IVaultCategoryRepository
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
{
private readonly MijnBesluitDbContext _db;
+6
View File
@@ -111,6 +111,12 @@ public static class SocketActions
/// </summary>
public const string VaultCategory = "vault.category";
/// <summary>
/// De versiehistorie: de zichtbare regels, nieuwste dag bovenaan. Staat in een tabel op de
/// server, zodat een nieuwe regel zonder nieuwe app te zien is. Zie docs/Versiehistorie.md.
/// </summary>
public const string Versiehistorie = "app.versiehistorie";
/// <summary>Nalatenschappen die aan jou zijn toevertrouwd, en wat daarvan is vrijgegeven.</summary>
public const string LegacyEntrusted = "legacy.entrusted";
public const string LegacyEntrustedItems = "legacy.entrusted-items";
@@ -21,6 +21,9 @@ public class RequestScope
/// </summary>
public VaultCategoryService? Soorten { get; init; }
/// <summary>Optioneel: zonder deze dienst geeft de versiehistorie een lege lijst.</summary>
public VersiehistorieService? Versiehistorie { get; init; }
/// <summary>
/// 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<VersieRegelWeergave>()
: (await scope.Versiehistorie.ZichtbaarAsync(ct)).ToArray();
return SocketResponse.Ok(id, lijst);
}
case SocketActions.VaultOpen:
{
var p = request.PayloadAs<IdPayload>();
@@ -45,6 +45,7 @@ public class SocketServerService : BackgroundService
Families = scope.ServiceProvider.GetRequiredService<FamilyService>(),
Vault = scope.ServiceProvider.GetRequiredService<VaultService>(),
Soorten = scope.ServiceProvider.GetRequiredService<VaultCategoryService>(),
Versiehistorie = scope.ServiceProvider.GetRequiredService<VersiehistorieService>(),
Messages = scope.ServiceProvider.GetRequiredService<MessageService>(),
Legacy = scope.ServiceProvider.GetRequiredService<LegacyService>(),
Meldingen = scope.ServiceProvider.GetRequiredService<MeldingService>(),
@@ -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
<p class="zacht" style="margin-top:0">
Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
</p>
<h2 style="margin-top:0">20 oktober 2026 &middot; versie 1.1</h2>
<ul style="padding-left:1.2rem">
<li>
<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
daarvoor opnieuw hoeft te updaten. Je bestaande kluisitems blijven zoals ze waren.
</li>
</ul>
@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">
@foreach (var regel in groep.Regels)
{
<li style="margin-bottom:.5rem"><strong>@Punt(regel.Kop)</strong> @regel.Tekst</li>
}
</ul>
}
}
<h2>20 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<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>
@code {
private IReadOnlyList<VersieRegelWeergave>? Regels;
private bool Fout;
<h2>19 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je
dat voortaan ook op je telefoon, niet meer alleen op de website.
</li>
<li style="margin-bottom:.5rem">
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij
horen.
</li>
<li>
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis,
Mijn mensen, Momenten en Nalatenschap.
</li>
</ul>
protected override async Task OnInitializedAsync()
{
try { Regels = await Historie.ZichtbaarAsync(); }
catch { Fout = true; }
}
private sealed record Groep(string Kop, bool Eerste, List<VersieRegelWeergave> Regels);
/// <summary>
/// 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.
/// </summary>
private IEnumerable<Groep> Groepen()
{
var uit = new List<Groep>();
string? vorige = null;
foreach (var r in Regels!)
{
var kop = Koptekst(r);
if (kop != vorige)
{
uit.Add(new Groep(kop, uit.Count == 0, new List<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. *@
<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> }
@* ------------------------------------------------------------ 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>
}
@* 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)
{
<Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true"