Files
MijnBesluit/src/MijnBesluit.Client/Modellen.cs
T
jfandClaude Sonnet 5.5 af13d0e533 Flexibele kluissoorten: soorten in de database, beheer op de website
- Tabellen vault_categories en vault_category_fields (db/21), startrijen handmatig (db/handmatig/21b)
- VaultCategoryService met regels voor zichtbaarheid, gebruik en eigen velden
- Beheer > Kluissoorten (/beheer/soorten) met gebruikstellers; soort in gebruik niet te verwijderen
- Kluis en Nalatenschap (web en app) tonen soorten en eigen velden uit de database
- Socketprotocol: vault.categories; GRANTs voor mijnbesluit_app in db/02
- db/20 herhaalbaar gemaakt
- App versie 1.1 (versiecode 3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
2026-10-20 08:15:40 +00:00

432 lines
17 KiB
C#

using System.Text.Json.Serialization;
namespace MijnBesluit.Client;
// De vorm van wat de SocketServer terugstuurt. De namen komen overeen met wat de dispatcher
// verstuurt; het protocol gebruikt kleine beginletters, dat regelt de JSON-instelling.
public record SessieGegevens(
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("persoonId")] long PersoonId,
[property: JsonPropertyName("verlooptOp")] DateTime? VerlooptOp);
public record CodeGestuurd(
[property: JsonPropertyName("verstuurdNaar")] string VerstuurdNaar,
[property: JsonPropertyName("geldigTot")] DateTime GeldigTot);
public record FamilieOverzicht(long Id, string Name, bool Eigenaar);
/// <summary>Eén regel op het beginscherm: iets wat speelt of eraan komt.</summary>
public record Overzichtsregel(string Soort, string Tekst, DateTime? Datum, long? Id)
{
public bool IsWaarschuwing => Soort == "overlijden";
public bool IsMoment => Soort == "moment";
}
/// <summary>Alles wat het beginscherm laat zien.</summary>
public record Overzicht(
int AantalKluis,
int AantalFamilie,
int AantalVoorMij,
int AantalVanMij,
int AantalNieuweBerichten,
int AantalBeheerders,
int VooruitblikDagen,
IReadOnlyList<Overzichtsregel> Regels);
/// <summary>
/// De sociale media die je bij een kluisitem kunt kiezen.
///
/// Dit is een kopie van SocialeMedia in MijnBesluit.Domain: de app ziet het domein niet, want
/// die praat alleen over de socket. Een controle in de zelftest bewaakt dat de twee lijsten
/// gelijk blijven, zodat ze niet stilletjes uit elkaar lopen.
/// </summary>
public static class SocialeMedia
{
public const string Anders = "Anders";
public static readonly string[] Namen =
{
"Facebook", "Instagram", "WhatsApp", "LinkedIn", "X (voorheen Twitter)",
"TikTok", "YouTube", "Snapchat", "Pinterest", "Telegram", "Signal",
"Discord", "Reddit", "Strava", "Marktplaats", Anders
};
}
/// <summary>
/// Datums zoals ze op het scherm horen te staan, in het Nederlands.
///
/// Dit is een kopie van Datums in MijnBesluit.Domain: de app ziet het domein niet. Een controle
/// in de zelftest bewaakt dat de twee gelijk blijven.
///
/// Waarom dit met de hand gaat en niet met ToString("d MMMM yyyy"): dat volgt de taalinstelling
/// van het toestel, en dan leest een telefoon die op Engels staat "9 June 2031" terwijl de rest
/// van het scherm Nederlands is. Een app die maar in één taal bestaat, hoort ook maar in één taal
/// te rekenen.
/// </summary>
public static class Datums
{
public static readonly string[] Maanden =
{
"januari", "februari", "maart", "april", "mei", "juni",
"juli", "augustus", "september", "oktober", "november", "december"
};
/// <summary>Afkortingen voor waar het krap is. "Mei" heeft geen punt: dat is geen afkorting.</summary>
public static readonly string[] KorteMaanden =
{
"Jan.", "Feb.", "Mrt.", "Apr.", "Mei", "Jun.",
"Jul.", "Aug.", "Sep.", "Okt.", "Nov.", "Dec."
};
/// <summary>Zondag eerst, want zo telt DayOfWeek.</summary>
public static readonly string[] Dagen =
{
"zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"
};
}
/// <summary>
/// Wat iemand van je is: de lijst die je te kiezen krijgt, en hoe elke relatie heet.
///
/// Dit is een kopie van Relaties in MijnBesluit.Domain: de app ziet het domein niet, want die
/// praat alleen over de socket. Daarom staan hier nummers en geen enum - dat is wat er over de
/// lijn gaat. Een controle in de zelftest bewaakt dat de twee lijsten gelijk blijven.
///
/// <see cref="Keuzes"/> is wat je kunt kiezen, <see cref="Naam"/> vertaalt élke waarde die ooit
/// heeft bestaan. Kind, Bonuskind, Ouder, Bonusouder, Kleinkind en Grootouder kun je niet meer
/// kiezen, maar staan wel bij bestaande mensen in de database; zonder vertaling zou daar ineens
/// niets meer staan.
/// </summary>
public static class Relaties
{
public static readonly (int Waarde, string Naam)[] Keuzes =
{
(1, "Partner"),
(11, "Zoon"),
(12, "Dochter"),
(28, "Bonuszoon"),
(29, "Bonusdochter"),
(16, "Schoonzoon"),
(15, "Schoondochter"),
(14, "Kleinzoon"),
(13, "Kleindochter"),
(33, "Achterkleinzoon"),
(32, "Achterkleindochter"),
(19, "Vader"),
(20, "Moeder"),
(22, "Bonusvader"),
(21, "Bonusmoeder"),
(24, "Schoonvader"),
(25, "Schoonmoeder"),
(17, "Opa"),
(18, "Oma"),
(6, "Broer"),
(7, "Zus"),
(30, "Bonusbroer"),
(31, "Bonuszus"),
(26, "Zwager"),
(27, "Schoonzus"),
(34, "Oom"),
(35, "Tante"),
(10, "Vriend"),
(23, "Vriendin"),
(99, "Anders")
};
/// <summary>Hoe een relatie op het scherm heet - ook de relaties die je niet meer kunt kiezen.</summary>
public static string Naam(int relatie) => relatie switch
{
2 => "Kind",
3 => "Bonuskind",
4 => "Ouder",
5 => "Bonusouder",
8 => "Kleinkind",
9 => "Grootouder",
0 => "Familie",
_ => Keuzes.FirstOrDefault(k => k.Waarde == relatie).Naam ?? "Familie"
};
}
/// <summary>
/// Een profiel dat iemand anders voor je heeft aangemaakt op jouw e-mailadres, en waarvan alleen
/// jij kunt zeggen of het over jou gaat. Zie docs/Identiteit.md.
/// </summary>
public record Aantekening(long PersoonId, string Van, string? Relatie, DateTime AangemaaktOp);
/// <summary>Eigendomsstatus van een profiel: 0 beheerd, 1 uitgenodigd, 2 geclaimd.</summary>
public enum ProfielStand { Beheerd = 0, Uitgenodigd = 1, Geclaimd = 2 }
public record Familielid(
long PersonId,
string DisplayName,
string FirstName,
string LastName,
string? Email,
int Relation,
string? RelationLabel,
ProfielStand State,
bool CanEdit,
DateOnly? BirthDate,
DateOnly? DateOfDeath,
// De geschiedenis van het profiel. Komt rechtstreeks van de server; alles in UTC.
DateTime CreatedAt,
DateTime? InvitedAt,
DateTime? InvitationExpiresAt,
int InvitationResends,
DateTime? ClaimedAt,
// Twee dingen die pas ter sprake komen als je iemand weghaalt, maar die meekomen met de
// lijst zodat het scherm ze meteen heeft. Zie FamilyService.VerwijderLidAsync.
bool IsBeheerder,
int MomentenKlaar,
// De profielfoto. Het nummer om hem groot op te halen, en de kleine versie als base64 om
// hem meteen te kunnen tonen zonder tweede ronde langs de server.
long? ProfielfotoId = null,
string? Profielfoto = null)
{
public bool IsGeclaimd => State == ProfielStand.Geclaimd;
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
/// <summary>
/// Wat er verdwijnt als je je account opheft. Staat in het scherm vóórdat iemand ja zegt:
/// "weet je het zeker?" is een lege vraag, "je kluis met 23 items" is er een waar iemand echt
/// over nadenkt.
/// </summary>
public record Opheffing(int Kluisitems, int Bestanden, int BerichtenKlaar, int BerichtenBezorgd,
int Mensen, int Nalatenschappen);
/// <summary>Je eigen gegevens, zoals het profielscherm ze toont.</summary>
public record EigenProfiel(long PersoonId, string Voornaam, string Achternaam, string Email,
DateOnly? Geboortedatum, DateTime LidSinds,
// De kleine versie van je eigen profielfoto, als base64. Die gaat mee
// in dit antwoord omdat de app hem op meer schermen laat zien dan
// alleen je profiel - in de kluis staat hij bij "Alleen voor mijzelf".
// Een eigen ronde langs de server voor een plaatje van veertig pixels
// zou duurder zijn dan het plaatje zelf.
string? Profielfoto = null)
{
public string Naam => string.Join(" ",
new[] { Voornaam, Achternaam }.Where(d => !string.IsNullOrWhiteSpace(d)));
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
/// <summary>
/// Een nalatenschap die jij beheert. <see cref="Afgerond"/> is false zolang de persoon er
/// gewoon is: dan weet je alleen dat je bent aangewezen en valt er niets in te zien.
/// </summary>
public record Toevertrouwd(long PersonId, string DisplayName, DateOnly? DateOfDeath,
DateTime? AccessGrantedAt, int AantalItems,
bool Afgerond = false, int Volgorde = 1, string? Profielfoto = null,
int RequiredDeclarations = 3, int ObjectionDays = 14)
{
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
public record KluisItem(
long Id,
string Title,
int Category,
int ReleasePolicy,
DateTime? ReleaseAt,
DateTime UpdatedAt,
long? SharedManagerPersonId = null,
string? CategoryName = null)
{
/// <summary>
/// Hoe de soort heet. De server stuurt de naam mee, zodat een soort die de beheerder zelf
/// maakte ook in een oudere lijst een naam heeft; ontbreekt hij, dan geldt de ingebouwde naam
/// van het nummer.
/// </summary>
public string SoortNaam(Func<int, string> ingebouwd) =>
string.IsNullOrWhiteSpace(CategoryName) ? ingebouwd(Category) : CategoryName;
}
/// <summary>
/// Een soort kluisitem zoals de server hem aanbiedt. Een ingebouwde soort (nummer 1 tot en met 11
/// en 99) heeft zijn invulscherm in de app en geen <see cref="Velden"/>; een soort die de
/// beheerder maakte heeft een nummer vanaf 100 en beschrijft zijn formulier hier.
/// </summary>
public record KluisSoort(int Id, string Naam, string? Uitleg, bool BesprekenMet, bool Ingebouwd,
IReadOnlyList<KluisVeld> Velden);
/// <summary>Eén veld van het formulier van een soort die de beheerder maakte.</summary>
public record KluisVeld(string Sleutel, string Label, string Soort, IReadOnlyList<string> Opties,
string? Hulptekst, bool Verplicht, bool Actief)
{
public const string Tekst = "tekst";
public const string Lang = "lang";
public const string Datum = "datum";
public const string Keuze = "keuze";
public const string Uitleg = "uitleg";
}
/// <summary>
/// De ingebouwde soorten kluisitems, voor als de server er geen lijst van geeft: een oudere
/// server, of geen verbinding op het moment dat het scherm opent. Dezelfde nummers, namen en
/// volgorde als de startrijen in db/21_kluissoorten.sql; een controle in de zelftest bewaakt dat
/// ze gelijk blijven.
/// </summary>
public static class IngebouwdeSoorten
{
private static KluisSoort S(int id, string naam, bool bespreken = false) =>
new(id, naam, null, bespreken, true, Array.Empty<KluisVeld>());
public static readonly IReadOnlyList<KluisSoort> Lijst = new[]
{
S(3, "Wachtwoord"), S(8, "Sociale media"), S(4, "Apparaat"), S(5, "Pincode"), S(2, "Polis"),
S(1, "Uitvaartwens", true), S(9, "Donorcodicil", true), S(10, "Rijbewijs"), S(11, "Paspoort"),
S(6, "Document"), S(7, "Notitie"), S(99, "Overig")
};
/// <summary>De naam bij een nummer; "Overig" voor wat we niet kennen.</summary>
public static string Naam(int id) => Lijst.FirstOrDefault(s => s.Id == id)?.Naam ?? "Overig";
}
/// <summary>De ontsleutelde inhoud van een kluisitem. Per soort is maar een deel gevuld.</summary>
public class KluisInhoud
{
public string? Notes { get; set; }
public string? Website { get; set; }
public string? Username { get; set; }
public string? Secret { get; set; }
public string? Pincode { get; set; }
public string? DeviceName { get; set; }
public string? RecoveryCodes { get; set; }
/// <summary>Welk sociaal medium; een van SocialeMedia.Namen, of "Anders".</summary>
public string? Platform { get; set; }
/// <summary>Alleen gevuld als Platform "Anders" is.</summary>
public string? PlatformAnders { get; set; }
public string? Insurer { get; set; }
public string? PolicyNumber { get; set; }
public string? ContactPhone { get; set; }
public decimal? Amount { get; set; }
public string? BurialOrCremation { get; set; }
public string? Location { get; set; }
public string? Music { get; set; }
public string? Speakers { get; set; }
public string? Wishes { get; set; }
/// <summary>
/// De velden van een soort die de beheerder zelf maakte, op de vaste sleutel van het veld.
/// Bij de ingebouwde soorten blijft dit leeg.
/// </summary>
public Dictionary<string, string> Velden { get; set; } = new();
/// <summary>
/// De ingevulde velden van een soort die de beheerder maakte, met hun label, in de volgorde van
/// het formulier. Alleen wat is ingevuld; datums staan er leesbaar, zoals "12 oktober 2026".
/// Een veld dat niet meer in de soort staat, wordt niet getoond.
/// </summary>
public IEnumerable<(string Naam, string Waarde)> EigenRegels(KluisSoort? soort)
{
if (soort is null) yield break;
foreach (var veld in soort.Velden.Where(v => v.Soort != KluisVeld.Uitleg))
{
if (!Velden.TryGetValue(veld.Sleutel, out var waarde) || string.IsNullOrWhiteSpace(waarde)) continue;
if (veld.Soort == KluisVeld.Datum &&
DateOnly.TryParseExact(waarde, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dag))
waarde = $"{dag.Day} {Datums.Maanden[dag.Month - 1]} {dag.Year}";
yield return (veld.Label, waarde);
}
}
}
/// <summary>Een bijlage bij een kluisitem, zonder de inhoud zelf.</summary>
public record Bijlage(long Id, string FileName, string ContentType, long SizeBytes, DateTime CreatedAt,
bool IsProfilePhoto = false)
{
/// <summary>De grootte zoals je hem aan iemand laat zien: "1,4 MB".</summary>
public string Grootte => SizeBytes >= 1024 * 1024
? $"{SizeBytes / 1024d / 1024d:0.#} MB"
: $"{Math.Max(1, SizeBytes / 1024)} kB";
public bool IsAfbeelding => ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
public bool IsVideo => ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Een opgehaald bestand, met de inhoud erbij.</summary>
public record BestandInhoud(string Naam, string Soort, byte[] Inhoud);
public record Bericht(
long Id,
string Subject,
int Kind,
long FromPersonId,
long ToPersonId,
DateTime? DeliveredAt,
DateTime? ReadAt,
DateTime? DeliverAt,
bool DeliverAfterDeath,
int Status,
long? VaultFileId)
{
public bool IsNieuw => Status == 2; // 2 = bezorgd, 3 = gelezen
/// <summary>0 tekst, 1 video, 2 audio, 3 document.</summary>
public bool IsVideo => Kind == 1;
/// <summary>Zit er een opname of bijlage bij dit bericht?</summary>
public bool HeeftBestand => VaultFileId is > 0;
}
/// <summary>De uitkomst van een proefmelding: is hij verstuurd, en zo niet waarom niet.</summary>
public record Proefmelding(bool Gelukt, string Uitleg);
/// <summary>Iemand die jouw nalatenschap mag beheren. Volgorde 1 is de eerste keus.</summary>
/// <summary>
/// De advertentie die nu op het overzicht mag staan. De adressen zijn volledig: de app weet niet
/// waar de site staat en hoort dat ook niet zelf in elkaar te zetten.
/// </summary>
/// <param name="Link">
/// Gaat naar de site en niet rechtstreeks naar de adverteerder. Daar wordt de klik geteld en
/// vandaar wordt doorgestuurd; zo is er één plek waar dat gebeurt.
/// </param>
public record Advertentie(long Id, string Tekst, string? Afbeelding, string Link);
public record Beheerder(long PersonId, string DisplayName, int Priority, DateTime? AccessGrantedAt,
string? Profielfoto = null)
{
public bool HeeftToegang => AccessGrantedAt is not null;
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
public record NalatenschapStand(
int State,
int ActiveDeclarations,
int RequiredDeclarations,
DateTime? ObjectionDeadline,
int Missing,
int ObjectionDays = 14,
bool BeheerderHeeftGemeld = false,
bool WachtOpBeheerder = false)
{
public bool LooptEenProcedure => State == 1;
public bool IsBevestigd => State == 3;
}