- 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
432 lines
17 KiB
C#
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;
|
|
}
|