Eerste commit

This commit is contained in:
jf
2026-09-27 14:21:31 +00:00
commit de4ff128cb
295 changed files with 47374 additions and 0 deletions
@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using MijnBesluit.Application.Abstractions;
namespace MijnBesluit.Server;
/// <summary>
/// Waar een bijlage bij hoort. Hoogstens één van de drie is gevuld; alle drie leeg mag ook,
/// dan staat het bestand op zichzelf - zoals een video-opname die pas bij het opslaan aan een
/// bericht wordt gehangen.
/// </summary>
public record Bijlagedoel(long? KluisItemId = null, long? PersoonId = null, long? BerichtId = null);
/// <summary>
/// Houdt bestanden bij die in stukken binnenkomen of weggaan.
///
/// Waarom in stukken: één socketbericht is maximaal 16 MB, en de inhoud gaat als tekst
/// (base64) mee, wat er nog een derde bij optelt. Een foto van de telefoon past daar vaak net
/// in, een video nooit. Dus knippen we het op, met een volgnummer erbij zodat de ontvanger
/// merkt als er een stuk mist.
///
/// Onvoltooide overdrachten worden na een half uur opgeruimd. Zonder dat zou een app die
/// halverwege wegvalt geheugen laten staan tot de service herstart.
/// </summary>
public class Bestandsoverdracht
{
/// <summary>Hoeveel bytes er per stuk over de lijn gaan. Base64 maakt er ruim 680 kB van.</summary>
public const int StukGrootte = 512 * 1024;
/// <summary>Zo lang blijft een halve overdracht bewaard voordat hij wordt weggegooid.</summary>
public static readonly TimeSpan Verlooptijd = TimeSpan.FromMinutes(30);
/// <summary>Zoveel overdrachten tegelijk per gebruiker; meer is geen gebruik maar misbruik.</summary>
public const int MaxPerGebruiker = 3;
private readonly ConcurrentDictionary<string, Overdracht> _lopend = new();
private readonly IClock _clock;
public Bestandsoverdracht(IClock clock) => _clock = clock;
private sealed class Overdracht
{
public required Bijlagedoel? Doel { get; init; }
public required string Id { get; init; }
public required long GebruikerId { get; init; }
public required string Bestandsnaam { get; init; }
public required string Soort { get; init; }
public required long TotaalBytes { get; init; }
public required MemoryStream Inhoud { get; init; }
public int VolgendStuk { get; set; }
public DateTime Begonnen { get; set; }
}
/// <summary>Begint een nieuwe overdracht en geeft het kenmerk terug waarmee de stukken komen.</summary>
public string Begin(long gebruikerId, string bestandsnaam, string soort, Bijlagedoel? doel, long totaalBytes)
{
RuimOp();
if (_lopend.Values.Count(o => o.GebruikerId == gebruikerId) >= MaxPerGebruiker)
throw new InvalidOperationException("Er lopen al te veel uploads tegelijk. Maak er eerst een af.");
var id = Guid.NewGuid().ToString("N");
_lopend[id] = new Overdracht
{
Id = id,
GebruikerId = gebruikerId,
Bestandsnaam = bestandsnaam,
Soort = soort,
Doel = doel,
TotaalBytes = totaalBytes,
Inhoud = new MemoryStream(capacity: (int)Math.Min(totaalBytes, int.MaxValue)),
Begonnen = _clock.UtcNow
};
return id;
}
/// <summary>Neemt één stuk aan. Het volgnummer moet kloppen, anders is er iets kwijtgeraakt.</summary>
public void Stuk(string id, long gebruikerId, int volgnummer, byte[] inhoud)
{
var overdracht = Zoek(id, gebruikerId);
if (volgnummer != overdracht.VolgendStuk)
throw new InvalidOperationException(
$"Stuk {volgnummer} kwam binnen terwijl {overdracht.VolgendStuk} aan de beurt was. Begin opnieuw.");
if (overdracht.Inhoud.Length + inhoud.Length > overdracht.TotaalBytes)
throw new InvalidOperationException("Er komt meer binnen dan aangekondigd.");
overdracht.Inhoud.Write(inhoud, 0, inhoud.Length);
overdracht.VolgendStuk++;
}
/// <summary>Sluit de overdracht af en geeft de volledige inhoud terug.</summary>
public (string Bestandsnaam, string Soort, Bijlagedoel? Doel, byte[] Inhoud) Klaar(string id, long gebruikerId)
{
var overdracht = Zoek(id, gebruikerId);
if (overdracht.Inhoud.Length != overdracht.TotaalBytes)
throw new InvalidOperationException(
$"Er kwam {overdracht.Inhoud.Length} byte binnen van de aangekondigde {overdracht.TotaalBytes}.");
_lopend.TryRemove(id, out _);
return (overdracht.Bestandsnaam, overdracht.Soort, overdracht.Doel, overdracht.Inhoud.ToArray());
}
/// <summary>Breekt een overdracht af, bijvoorbeeld als de gebruiker annuleert.</summary>
public void Afbreken(string id, long gebruikerId)
{
if (_lopend.TryGetValue(id, out var overdracht) && overdracht.GebruikerId == gebruikerId)
{
_lopend.TryRemove(id, out _);
overdracht.Inhoud.Dispose();
}
}
/// <summary>Aantal overdrachten dat nu loopt. Alleen om te kunnen controleren.</summary>
public int Lopend => _lopend.Count;
private Overdracht Zoek(string id, long gebruikerId)
{
if (!_lopend.TryGetValue(id, out var overdracht))
throw new InvalidOperationException("Deze upload is niet (meer) bekend. Begin opnieuw.");
// Van iemand anders? Dan bestaat hij voor jou niet.
if (overdracht.GebruikerId != gebruikerId)
throw new InvalidOperationException("Deze upload is niet (meer) bekend. Begin opnieuw.");
if (_clock.UtcNow - overdracht.Begonnen > Verlooptijd)
{
_lopend.TryRemove(id, out _);
throw new InvalidOperationException("Deze upload heeft te lang stilgelegen. Begin opnieuw.");
}
return overdracht;
}
private void RuimOp()
{
var nu = _clock.UtcNow;
foreach (var (id, overdracht) in _lopend)
{
if (nu - overdracht.Begonnen <= Verlooptijd) continue;
if (_lopend.TryRemove(id, out var weg)) weg.Inhoud.Dispose();
}
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MijnBesluit.Application\MijnBesluit.Application.csproj" />
<ProjectReference Include="..\MijnBesluit.Protocol\MijnBesluit.Protocol.csproj" />
</ItemGroup>
</Project>
+225
View File
@@ -0,0 +1,225 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Security;
namespace MijnBesluit.Server;
/// <summary>Een ingelogde sessie van de app.</summary>
public sealed class Session
{
public required string Token { get; init; }
public required long UserId { get; init; }
public required long PersonId { get; init; }
public string? Device { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime ExpiresAt { get; set; }
/// <summary>
/// Tot wanneer de database denkt dat deze sessie loopt. Zie <see cref="SessionStore"/>: de
/// vervaltijd schuift bij elk verzoek op, maar wordt niet bij elk verzoek weggeschreven.
/// </summary>
internal DateTime OpgeslagenTot { get; set; }
}
/// <summary>Wat er van een bewaarde sessie terugkomt. Het token staat er niet in - dat kent alleen het toestel.</summary>
public sealed record Sessiegegevens(long UserId, long PersonId, string? Toestel,
DateTime AangemaaktOp, DateTime VerlooptOp);
/// <summary>
/// Waar sessies blijven staan als de service opnieuw start.
///
/// Deze laag krijgt nooit een token te zien, alleen de hash ervan: ze hoeft het token niet te
/// kennen en hoort het dus ook niet te krijgen.
/// </summary>
public interface ISessiebewaring
{
Task BewaarAsync(byte[] hash, Sessiegegevens sessie, DateTime nu, CancellationToken ct = default);
Task<Sessiegegevens?> ZoekAsync(byte[] hash, CancellationToken ct = default);
Task SchuifOpAsync(byte[] hash, DateTime verlooptOp, DateTime nu, CancellationToken ct = default);
Task VerwijderAsync(byte[] hash, CancellationToken ct = default);
Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default);
Task<int> RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default);
}
/// <summary>
/// De sessies van de SocketServer: in het geheugen om snel te zijn, en in de database om een
/// herstart te overleven.
///
/// Waarom dat tweede erbij is gekomen: de app blijft aangemeld en gaat daarna open met een
/// vingerafdruk of de code van de telefoon. Stonden de sessies alleen in het geheugen, dan was
/// elke herstart van de service genoeg om iedereen weer een code per e-mail te laten opvragen -
/// een uitrol, een Windows-update 's nachts, een reboot van de server. Het toestel wist dan nog
/// wie je was, de server niet meer, en juist dat zou de vingerafdruk moeten voorkomen.
///
/// Het geheugen blijft ervoor zorgen dat een gewoon verzoek geen database aanraakt. De database
/// wordt pas geraadpleegd bij een token dat we niet in het geheugen hebben - dus feitelijk bij
/// het eerste verzoek van elk toestel na een herstart.
///
/// Wat er bewaard wordt is de SHA-256 van het token, nooit het token zelf. Wie de database in
/// handen krijgt, krijgt dus geen werkende sessies mee.
///
/// Zonder bewaring werkt alles gewoon, alleen zijn de sessies dan weg na een herstart. Zo hoeven
/// de zelftests geen database te hebben.
/// </summary>
public class SessionStore
{
private readonly ConcurrentDictionary<string, Session> _sessions = new();
private readonly IClock _clock;
private readonly TimeSpan _lifetime;
private readonly ISessiebewaring? _bewaring;
/// <summary>
/// Hoeveel de vervaltijd mag zijn opgeschoven voordat we dat ook echt wegschrijven.
///
/// Elk verzoek schuift de termijn op. Dat ook elke keer opslaan zou een schrijfactie per
/// aanroep betekenen, voor iets waar bij een termijn van veertien dagen een uur speling
/// niets uitmaakt. Het ergste geval is dat een sessie na een herstart een uur eerder
/// verloopt dan hij had gemogen.
/// </summary>
public static readonly TimeSpan SchuifopDrempel = TimeSpan.FromHours(1);
public SessionStore(IClock clock, TimeSpan? lifetime = null, ISessiebewaring? bewaring = null)
{
_clock = clock;
_lifetime = lifetime ?? TimeSpan.FromDays(14);
_bewaring = bewaring;
}
/// <summary>De hash waaronder een token in de database staat.</summary>
public static byte[] Hash(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token));
public async Task<Session> CreateAsync(long userId, long personId, string? device,
CancellationToken ct = default)
{
var now = _clock.UtcNow;
var session = new Session
{
Token = TokenGenerator.Create(48),
UserId = userId,
PersonId = personId,
Device = device,
CreatedAt = now,
ExpiresAt = now.Add(_lifetime),
OpgeslagenTot = now.Add(_lifetime)
};
_sessions[session.Token] = session;
if (_bewaring is not null)
{
await _bewaring.BewaarAsync(Hash(session.Token),
new Sessiegegevens(userId, personId, device, now, session.ExpiresAt), now, ct);
}
return session;
}
/// <summary>
/// Zoekt de sessie bij een token en schuift de vervaltijd op. Null als het token niet (meer)
/// geldig is.
///
/// Kennen we het token niet uit het geheugen, dan kijken we in de database. Dat is het geval
/// waar deze hele tabel voor bestaat: het eerste verzoek van een toestel nadat de service
/// opnieuw is gestart.
///
/// Gaat de database niet, dan komt die fout hier naar buiten en wordt hij niet stilzwijgend
/// een "je sessie is verlopen". Dat verschil is belangrijk: bij dat laatste gooit de app het
/// token weg en moet iemand een code uit zijn mail halen terwijl er niets mis was met zijn
/// sessie.
/// </summary>
public async Task<Session?> ValidateAsync(string? token, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(token)) return null;
var now = _clock.UtcNow;
if (!_sessions.TryGetValue(token, out var session))
{
session = await OphalenAsync(token, now, ct);
if (session is null) return null;
}
if (session.ExpiresAt <= now)
{
_sessions.TryRemove(token, out _);
if (_bewaring is not null) await _bewaring.VerwijderAsync(Hash(token), ct);
return null;
}
session.ExpiresAt = now.Add(_lifetime);
// Niet bij elk verzoek naar de database; zie SchuifopDrempel.
if (_bewaring is not null && session.ExpiresAt - session.OpgeslagenTot > SchuifopDrempel)
{
session.OpgeslagenTot = session.ExpiresAt;
await _bewaring.SchuifOpAsync(Hash(token), session.ExpiresAt, now, ct);
}
return session;
}
/// <summary>Haalt een sessie uit de database terug in het geheugen.</summary>
private async Task<Session?> OphalenAsync(string token, DateTime now, CancellationToken ct)
{
if (_bewaring is null) return null;
var bewaard = await _bewaring.ZoekAsync(Hash(token), ct);
if (bewaard is null) return null;
if (bewaard.VerlooptOp <= now)
{
await _bewaring.VerwijderAsync(Hash(token), ct);
return null;
}
var session = new Session
{
Token = token,
UserId = bewaard.UserId,
PersonId = bewaard.PersonId,
Device = bewaard.Toestel,
CreatedAt = bewaard.AangemaaktOp,
ExpiresAt = bewaard.VerlooptOp,
OpgeslagenTot = bewaard.VerlooptOp
};
_sessions[token] = session;
return session;
}
public async Task RemoveAsync(string? token, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(token)) return;
_sessions.TryRemove(token, out _);
if (_bewaring is not null) await _bewaring.VerwijderAsync(Hash(token), ct);
}
/// <summary>Beëindigt alle sessies van één gebruiker, bijvoorbeeld bij het opheffen van een account.</summary>
public async Task RemoveAllForAsync(long userId, CancellationToken ct = default)
{
foreach (var kv in _sessions.Where(kv => kv.Value.UserId == userId))
_sessions.TryRemove(kv.Key, out _);
if (_bewaring is not null) await _bewaring.VerwijderAllesVanAsync(userId, ct);
}
/// <summary>Ruimt verlopen sessies op; draaien als achtergrondtaak.</summary>
public async Task<int> SweepAsync(CancellationToken ct = default)
{
var now = _clock.UtcNow;
var removed = 0;
foreach (var kv in _sessions.Where(kv => kv.Value.ExpiresAt <= now))
if (_sessions.TryRemove(kv.Key, out _)) removed++;
var uitDeDatabase = _bewaring is not null ? await _bewaring.RuimVerlopenOpAsync(now, ct) : 0;
// Niet optellen: dezelfde sessie staat meestal op allebei de plekken. Het getal is
// alleen voor het logboek, en dan is het grootste van de twee het eerlijkste.
return Math.Max(removed, uitDeDatabase);
}
/// <summary>Hoeveel sessies er op dit moment in het geheugen staan. Alleen om te tonen.</summary>
public int Count => _sessions.Count;
}
+925
View File
@@ -0,0 +1,925 @@
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Services;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Protocol;
namespace MijnBesluit.Server;
/// <summary>Alles wat één verzoek nodig heeft. De host levert dit per verzoek aan, met een verse databasecontext.</summary>
public class RequestScope
{
public required AccountService Accounts { get; init; }
public required FamilyService Families { get; init; }
public required VaultService Vault { get; init; }
public required MessageService Messages { get; init; }
public required LegacyService Legacy { get; init; }
public required OverzichtService Overzicht { 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.
/// </summary>
public SamenvoegService? Samenvoegen { get; init; }
/// <summary>Optioneel: zonder meldingen werkt de rest gewoon.</summary>
public MeldingService? Meldingen { get; init; }
/// <summary>Optioneel: zonder deze dienst komt er simpelweg geen advertentie op het overzicht.</summary>
public AdvertentieService? Adverteren { get; init; }
/// <summary>
/// Het opheffen van een account. Optioneel, zodat een server zonder deze dienst gewoon draait;
/// de knop in de app krijgt dan een nette melding in plaats van een foutpagina.
/// </summary>
public AccountOpheffenService? Opheffen { get; init; }
}
/// <summary>
/// Vertaalt een binnengekomen socketverzoek naar een aanroep van de Application-laag.
/// Alle regels zitten in die laag, niet hier: de website en de app dwingen dus precies
/// dezelfde dingen af, en er is maar één plek waar iets fout kan gaan.
/// </summary>
public class SocketDispatcher
{
private readonly SessionStore _sessions;
private readonly Func<Func<RequestScope, Task<SocketResponse>>, Task<SocketResponse>> _withScope;
private readonly Bestandsoverdracht _overdracht;
private readonly Action<string, long>? _traagVerzoek;
/// <summary>Vanaf hoeveel milliseconden we een verzoek traag noemen en dat melden.</summary>
public const int TraagVanafMs = 2000;
/// <param name="traagVerzoek">
/// Wordt aangeroepen met de actie en de duur zodra een verzoek langer duurt dan
/// <see cref="TraagVanafMs"/>. Hiermee komt in het logboek te staan wélke actie traag was;
/// zonder dat staar je naar een gebruiker die wacht en weet je niet waar het zit.
/// Optioneel, zodat de zelftest hem niet hoeft mee te geven.
/// </param>
public SocketDispatcher(SessionStore sessions,
Func<Func<RequestScope, Task<SocketResponse>>, Task<SocketResponse>> withScope,
Bestandsoverdracht? overdracht = null,
Action<string, long>? traagVerzoek = null)
{
_sessions = sessions;
_withScope = withScope;
_overdracht = overdracht ?? new Bestandsoverdracht(new SysteemKlok());
_traagVerzoek = traagVerzoek;
}
/// <summary>
/// Hoe een bijlage over de lijn gaat. Eén plek, zodat de lijst bij een kluisitem, bij een
/// persoon en bij een bericht er precies hetzelfde uitziet en de app er één model voor heeft.
/// </summary>
private static object Bijlagen(IEnumerable<MijnBesluit.Domain.Entities.VaultFile> bestanden) =>
bestanden.Select(f => new
{
f.Id, f.FileName, f.ContentType, f.SizeBytes, f.CreatedAt, f.IsProfilePhoto
});
/// <summary>Voor als er geen klok wordt meegegeven; de overdracht heeft er wel een nodig.</summary>
private sealed class SysteemKlok : MijnBesluit.Application.Abstractions.IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
/// <summary>Acties die zonder geldige sessie mogen.</summary>
private static readonly HashSet<string> Anonymous = new()
{
SocketActions.Ping, SocketActions.RequestLoginCode, SocketActions.SignIn,
SocketActions.RequestRegisterCode, SocketActions.Register, SocketActions.AcceptInvitation
};
/// <summary>
/// Alle acties die deze versie van de server kent, rechtstreeks uit <see cref="SocketActions"/>.
/// Zo kan er geen actie bijkomen die hier vergeten wordt.
/// </summary>
private static readonly HashSet<string> Bekend = typeof(SocketActions)
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToHashSet(StringComparer.Ordinal);
public async Task<SocketResponse> HandleAsync(SocketRequest request, SocketConnection connection, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Action))
return SocketResponse.Error(request.RequestId, "Geen actie opgegeven.");
// Eerst kijken of we de actie überhaupt kennen, en pas daarna naar de sessie. Andersom
// krijgt een app die nieuwer is dan de server "log opnieuw in" te zien, en gaat iemand
// uren zoeken in de verkeerde hoek. Nu staat er meteen dat de server de actie niet kent.
if (!Bekend.Contains(request.Action))
return SocketResponse.Error(request.RequestId, $"Onbekende actie '{request.Action}'.");
Session? session = null;
if (!Anonymous.Contains(request.Action))
{
session = await _sessions.ValidateAsync(request.Token, ct);
if (session is null)
return SocketResponse.Error(request.RequestId, "Je sessie is verlopen. Log opnieuw in.",
SocketFoutcodes.SessieVerlopen);
connection.UserId = session.UserId;
}
var klok = System.Diagnostics.Stopwatch.StartNew();
var antwoord = await _withScope(scope => DispatchAsync(request, session, connection, scope, ct));
klok.Stop();
if (klok.ElapsedMilliseconds > TraagVanafMs)
_traagVerzoek?.Invoke(request.Action, klok.ElapsedMilliseconds);
return antwoord;
}
private async Task<SocketResponse> DispatchAsync(SocketRequest request, Session? session,
SocketConnection connection, RequestScope scope,
CancellationToken ct)
{
var id = request.RequestId;
switch (request.Action)
{
case SocketActions.Ping:
return SocketResponse.Ok(id, new { tijd = DateTime.UtcNow, versleuteld = connection.IsEncrypted });
case SocketActions.RequestLoginCode:
{
var p = request.PayloadAs<EmailPayload>();
if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres in.");
var r = await scope.Accounts.VraagInlogcodeAanAsync(p.Email, connection.RemoteAddress, ct);
return r.Succeeded
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.SignIn:
{
var p = request.PayloadAs<SignInPayload>();
if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres en de code in.");
var result = await scope.Accounts.LogInMetCodeAsync(p.Email, p.Code, ct);
if (!result.Succeeded) return SocketResponse.Error(id, result.Error!);
var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct);
connection.UserId = s.UserId;
return SocketResponse.Ok(id, new { token = s.Token, verlooptOp = s.ExpiresAt, persoonId = s.PersonId });
}
case SocketActions.SignOut:
await _sessions.RemoveAsync(request.Token, ct);
connection.UserId = null;
return SocketResponse.Ok(id);
case SocketActions.RequestRegisterCode:
{
var p = request.PayloadAs<RegisterPayload>();
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
var r = await scope.Accounts.VraagRegistratiecodeAanAsync(p.Email, p.FirstName, p.LastName,
connection.RemoteAddress, ct);
return r.Succeeded
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.Register:
{
var p = request.PayloadAs<SignInPayload>();
if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres en de code in.");
var result = await scope.Accounts.RegistreerMetCodeAsync(p.Email, p.Code, ct);
if (!result.Succeeded) return SocketResponse.Error(id, result.Error!);
var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct);
connection.UserId = s.UserId;
return SocketResponse.Ok(id, new { token = s.Token, persoonId = s.PersonId });
}
case SocketActions.AcceptInvitation:
{
var p = request.PayloadAs<AcceptInvitationPayload>();
if (p is null) return SocketResponse.Error(id, "Geen uitnodiging meegegeven.");
// Is er een geldige sessie meegestuurd, dan neemt iemand die hier al is een
// tweede uitnodiging aan. Dan voegen we samen in plaats van een tweede account
// te maken. Deze actie mag ook zonder sessie, dus dit mag niet hard mislukken.
var ingelogd = (await _sessions.ValidateAsync(request.Token, ct))?.UserId;
var result = await scope.Families.AcceptInvitationAsync(
p.Token, scope.Accounts, ct, ingelogd, scope.Samenvoegen);
if (!result.Succeeded) return SocketResponse.Error(id, result.Error!);
var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct);
return SocketResponse.Ok(id, new { token = s.Token, persoonId = s.PersonId });
}
case SocketActions.OwnProfile:
{
var r = await scope.Accounts.EigenProfielAsync(session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
// Je eigen kleine profielfoto gaat mee. De app toont hem op meer schermen dan
// alleen je profiel - in de kluis staat hij als tekentje bij "Alleen voor
// mijzelf" - en dit antwoord komt er bij het opstarten toch al. Een eigen ronde
// langs de server voor een plaatje van veertig pixels zou meer kosten dan het
// plaatje zelf.
var fotos = await KleineFotosAsync(scope, new[] { r.Value!.PersoonId }, session.UserId, ct);
fotos.TryGetValue(r.Value.PersoonId, out var klein);
return SocketResponse.Ok(id, new
{
r.Value.PersoonId,
r.Value.Voornaam,
r.Value.Achternaam,
r.Value.Email,
r.Value.Geboortedatum,
r.Value.LidSinds,
profielfoto = klein
});
}
case SocketActions.SaveOwnProfile:
{
var p = request.PayloadAs<OwnProfilePayload>();
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
var r = await scope.Accounts.WijzigEigenProfielAsync(
session!.UserId, p.FirstName, p.LastName, p.BirthDate, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { gelukt = true })
: SocketResponse.Error(id, r.Error!);
}
// ------------------------------------------------ aantekeningen over mij
//
// "Heeft iemand een profiel voor mij aangemaakt?" Het adres komt uit de sessie en
// niet uit het verzoek: je vraagt dit alleen over jezelf, en dan hoort er ook geen
// adres meegestuurd te worden dat iemand kan veranderen. Zie docs/Identiteit.md.
case SocketActions.NotesAboutMe:
{
var lijst = await scope.Accounts.AantekeningenOverMijAsync(session!.UserId, ct);
return SocketResponse.Ok(id, lijst.Select(a => new
{
persoonId = a.PersoonId, van = a.Van, relatie = a.Relatie, aangemaaktOp = a.AangemaaktOp
}));
}
case SocketActions.AcceptNote:
{
if (scope.Samenvoegen is null) return SocketResponse.Error(id, "Dit kan hier niet.");
var p = request.PayloadAs<PersonIdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen profiel meegegeven.");
var r = await scope.Accounts.NeemAantekeningOverAsync(p.PersonId, session!.UserId,
scope.Samenvoegen, ct);
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.DeclineNote:
{
var p = request.PayloadAs<PersonIdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen profiel meegegeven.");
var r = await scope.Accounts.WijsAantekeningAfAsync(p.PersonId, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.DeleteAccountSummary:
{
if (scope.Opheffen is null) return SocketResponse.Error(id, "Dit kan hier niet.");
var r = await scope.Opheffen.TelOpAsync(session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.RequestDeleteAccount:
{
// Het adres komt uit de sessie en niet uit het verzoek: je kunt alleen je eigen
// account opheffen, en dan hoort er ook geen adres meegestuurd te worden dat
// iemand kan veranderen.
var ik = await scope.Accounts.EigenProfielAsync(session!.UserId, ct);
if (!ik.Succeeded) return SocketResponse.Error(id, ik.Error!);
var r = await scope.Accounts.VraagOpheffencodeAanAsync(ik.Value!.Email, null, ct);
return r.Succeeded
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.ConfirmDeleteAccount:
{
if (scope.Opheffen is null) return SocketResponse.Error(id, "Dit kan hier niet.");
var p = request.PayloadAs<CodePayload>();
if (p is null) return SocketResponse.Error(id, "Geen code meegegeven.");
var ik = await scope.Accounts.EigenProfielAsync(session!.UserId, ct);
if (!ik.Succeeded) return SocketResponse.Error(id, ik.Error!);
var controle = await scope.Accounts.ControleerOpheffencodeAsync(ik.Value!.Email, p.Code, ct);
if (!controle.Succeeded) return SocketResponse.Error(id, controle.Error!);
if (controle.Value!.Id != session.UserId)
return SocketResponse.Error(id, "Deze code hoort niet bij dit account.");
var weg = await scope.Opheffen.VerwijderAsync(session.UserId, ct);
if (!weg.Succeeded) return SocketResponse.Error(id, weg.Error!);
// Alle sessies van dit account eraf - ook die op andere toestellen. Er is niets
// meer om mee te werken, en een openstaande sessie op een account dat niet meer
// bestaat geeft bij elk volgend verzoek een fout waar niemand iets van begrijpt.
await _sessions.RemoveAllForAsync(session.UserId, ct);
return SocketResponse.Ok(id, new { gelukt = true });
}
case SocketActions.RequestEmailChange:
{
var p = request.PayloadAs<EmailPayload>();
if (p is null) return SocketResponse.Error(id, "Geen e-mailadres meegegeven.");
var r = await scope.Accounts.VraagAdreswijzigingAanAsync(session!.UserId, p.Email, null, ct);
return r.Succeeded
? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.ConfirmEmailChange:
{
var p = request.PayloadAs<SignInPayload>();
if (p is null) return SocketResponse.Error(id, "Geen code meegegeven.");
var r = await scope.Accounts.BevestigAdreswijzigingAsync(session!.UserId, p.Email, p.Code, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { gelukt = true })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.Overview:
{
var r = await scope.Overzicht.VoorAsync(session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.SetLookahead:
{
var p = request.PayloadAs<LookaheadPayload>();
if (p is null) return SocketResponse.Error(id, "Geen aantal dagen meegegeven.");
var r = await scope.Overzicht.VooruitblikAsync(session!.UserId, p.Days, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { dagen = p.Days })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.ListFamilies:
{
// De app weet bij het opstarten nog niet welke familie van wie is; hiermee
// haalt hij dat op, inclusief de families waar hij alleen lid van is.
var eigen = await scope.Families.ListOwnedAsync(session!.PersonId, ct);
var lid = await scope.Families.ListMemberOfAsync(session.PersonId, ct);
return SocketResponse.Ok(id, eigen
.Select(f => new { f.Id, f.Name, eigenaar = true })
.Concat(lid.Where(f => eigen.All(e => e.Id != f.Id))
.Select(f => new { f.Id, f.Name, eigenaar = false })));
}
case SocketActions.ListMembers:
{
var p = request.PayloadAs<FamilyIdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen familie opgegeven.");
var leden = await scope.Families.ListMembersAsync(p.FamilyId, session!.UserId, ct);
// De profielfoto's gaan als kleine versie mee in hetzelfde antwoord; zie
// KleineFotosAsync. Het nummer gaat ook mee, voor als de app de grote wil.
var bestanden = (await scope.Vault.ProfielfotosAsync(leden.Select(l => l.PersonId),
session.UserId, ct))
.ToDictionary(f => f.PersoonId);
return SocketResponse.Ok(id, leden.Select(l =>
{
bestanden.TryGetValue(l.PersonId, out var foto);
return new
{
l.PersonId, l.DisplayName, l.FirstName, l.LastName, l.Email,
l.Relation, l.RelationLabel, l.State, l.CanEdit, l.BirthDate, l.DateOfDeath,
l.CreatedAt, l.InvitedAt, l.InvitationExpiresAt, l.InvitationResends, l.ClaimedAt,
l.IsBeheerder, l.MomentenKlaar,
profielfotoId = foto?.BestandId,
profielfoto = foto?.Klein is { Length: > 0 } klein ? Convert.ToBase64String(klein) : null
};
}));
}
case SocketActions.AddMember:
{
var p = request.PayloadAs<AddMemberPayload>();
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
var r = await scope.Families.AddMemberAsync(p.FamilyId, session!.UserId, p.FirstName, p.LastName,
p.Email, p.Relation, p.RelationLabel, p.BirthDate, ct);
return r.Succeeded
? SocketResponse.Ok(id, new { persoonId = r.Value!.Id, naam = r.Value.DisplayName })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.UpdatePerson:
{
var p = request.PayloadAs<UpdateMemberPayload>();
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
var r = await scope.Families.WijzigLidAsync(p.FamilyId, p.PersonId, session!.UserId,
p.FirstName, p.LastName, p.Email,
p.Relation, p.RelationLabel, p.BirthDate, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.RemoveMember:
{
var p = request.PayloadAs<InvitePayload>();
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
var r = await scope.Families.VerwijderLidAsync(p.FamilyId, p.PersonId, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.Invite:
{
var p = request.PayloadAs<InvitePayload>();
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
var r = await scope.Families.InviteAsync(p.FamilyId, p.PersonId, session!.UserId, ct);
// Het token gaat bewust alleen naar de e-mail, niet terug naar de app.
return r.Succeeded ? SocketResponse.Ok(id, new { verstuurd = true }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.VaultList:
{
var p = request.PayloadAs<VaultListPayload>();
var items = await scope.Vault.ListAsync(session!.PersonId, p?.Category, ct);
return SocketResponse.Ok(id, items.Select(i => new
{
i.Id, i.Title, i.Category, i.ReleasePolicy, i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId
}));
}
case SocketActions.VaultOpen:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen item opgegeven.");
var r = await scope.Vault.OpenAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.VaultSave:
{
var p = request.PayloadAs<VaultSavePayload>();
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
var r = await scope.Vault.CreateAsync(session!.UserId, p.Category, p.Title,
p.Payload ?? new VaultPayload(),
p.ReleasePolicy, p.ReleaseAt, p.SharedManagerPersonId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { itemId = r.Value!.Id }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.VaultUpdate:
{
var p = request.PayloadAs<VaultUpdatePayload>();
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
var r = await scope.Vault.UpdateAsync(p.Id, session!.UserId, p.Category, p.Title,
p.Payload ?? new VaultPayload(),
p.ReleasePolicy, p.ReleaseAt, p.SharedManagerPersonId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.VaultDelete:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen item opgegeven.");
var r = await scope.Vault.DeleteAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
}
// ---------------------------------------------------------------- bijlagen
case SocketActions.FileBegin:
{
var p = request.PayloadAs<FileBeginPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bestandsgegevens meegegeven.");
if (p.TotalBytes <= 0) return SocketResponse.Error(id, "Het bestand is leeg.");
if (p.TotalBytes > VaultService.MaxBestandsgrootte)
return SocketResponse.Error(id,
$"Dit bestand is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB.");
try
{
var doel = new Bijlagedoel(p.VaultItemId, p.SubjectPersonId, p.MessageId);
var uploadId = _overdracht.Begin(session!.UserId, p.FileName, p.ContentType ?? "application/octet-stream",
doel, p.TotalBytes);
return SocketResponse.Ok(id, new { uploadId, stukGrootte = Bestandsoverdracht.StukGrootte });
}
catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); }
}
case SocketActions.FileChunk:
{
var p = request.PayloadAs<FileChunkPayload>();
if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven.");
try
{
_overdracht.Stuk(p.UploadId, session!.UserId, p.Index, Convert.FromBase64String(p.Data ?? ""));
return SocketResponse.Ok(id, new { volgende = p.Index + 1 });
}
catch (FormatException) { return SocketResponse.Error(id, "Dit stuk is onleesbaar aangekomen."); }
catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); }
}
case SocketActions.FileEnd:
{
var p = request.PayloadAs<UploadIdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen upload opgegeven.");
byte[] inhoud;
string naam, soort;
Bijlagedoel? doel;
try
{
(naam, soort, doel, inhoud) = _overdracht.Klaar(p.UploadId, session!.UserId);
}
catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); }
var r = await scope.Vault.StoreFileAsync(session.UserId, naam, soort, inhoud,
doel?.KluisItemId, doel?.PersoonId, doel?.BerichtId, ct);
return r.Succeeded
? SocketResponse.Ok(id, new { bestandId = r.Value!.Id, naam = r.Value.FileName, grootte = r.Value.SizeBytes })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.FileCancel:
{
var p = request.PayloadAs<UploadIdPayload>();
if (p is not null) _overdracht.Afbreken(p.UploadId, session!.UserId);
return SocketResponse.Ok(id);
}
case SocketActions.FileList:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen item opgegeven.");
var r = await scope.Vault.ListFilesAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.FileRead:
{
var p = request.PayloadAs<FileReadPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bestand opgegeven.");
var r = await scope.Vault.OpenFileAsync(p.Id, session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
var (bestand, inhoud) = r.Value;
var stukken = (int)Math.Ceiling(inhoud.LongLength / (double)Bestandsoverdracht.StukGrootte);
var index = Math.Max(0, p.Index);
if (index >= stukken)
return SocketResponse.Error(id, "Dat stuk bestaat niet.");
var begin = (long)index * Bestandsoverdracht.StukGrootte;
var lengte = (int)Math.Min(Bestandsoverdracht.StukGrootte, inhoud.LongLength - begin);
return SocketResponse.Ok(id, new
{
naam = bestand.FileName,
soort = bestand.ContentType,
grootte = bestand.SizeBytes,
stukken,
index,
data = Convert.ToBase64String(inhoud, (int)begin, lengte)
});
}
case SocketActions.FileDelete:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bestand opgegeven.");
var r = await scope.Vault.DeleteFileAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.Inbox:
{
var berichten = await scope.Messages.InboxAsync(session!.PersonId, ct);
return SocketResponse.Ok(id, berichten.Select(m => new
{
m.Id, m.Subject, m.Kind, m.FromPersonId, m.DeliveredAt, m.ReadAt, m.Status, m.VaultFileId
}));
}
case SocketActions.Outbox:
{
var berichten = await scope.Messages.OutboxAsync(session!.PersonId, ct);
return SocketResponse.Ok(id, berichten.Select(m => new
{
m.Id, m.Subject, m.Kind, m.ToPersonId, m.DeliverAt, m.DeliverAfterDeath, m.Status, m.VaultFileId
}));
}
case SocketActions.MessageRead:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
var r = await scope.Messages.ReadAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.MessageReadSent:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
var r = await scope.Messages.ReadSentAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.MessageCompose:
{
var p = request.PayloadAs<ComposePayload>();
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
var r = await scope.Messages.ComposeAsync(session!.UserId, p.ToPersonId, p.Subject, p.Text,
p.DeliverAt, p.AfterDeath, p.Kind, p.VaultFileId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { berichtId = r.Value!.Id }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.MessageDraft:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
var r = await scope.Messages.ConceptAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.MessageUpdate:
{
var p = request.PayloadAs<MessageUpdatePayload>();
if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld.");
var r = await scope.Messages.UpdateAsync(p.Id, session!.UserId, p.Subject, p.Text,
p.DeliverAt, p.AfterDeath, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.MessageWithdraw:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
var r = await scope.Messages.WithdrawAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { ingetrokken = true }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.MessageDelete:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
var r = await scope.Messages.DeleteForMeAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.DeclareDeath:
{
var p = request.PayloadAs<DeclareDeathPayload>();
if (p is null) return SocketResponse.Error(id, "Geen profiel opgegeven.");
var r = await scope.Legacy.DeclareDeathAsync(p.PersonId, session!.UserId, p.DateOfDeath, p.Note, ct);
return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.ObjectDeath:
{
var r = await scope.Legacy.ObjectAsync(session!.PersonId, session.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.Advertentie:
{
// De keuze en het tellen horen bij elkaar: wat hier wordt uitgekozen gaat ook
// echt mee terug, dus dat is het moment om het mee te tellen.
if (scope.Adverteren is null) return SocketResponse.Ok(id, null);
var campagne = await scope.Adverteren.KiesAsync(ct);
if (campagne is null) return SocketResponse.Ok(id, null);
await scope.Adverteren.TelVertoningAsync(campagne.Id, ct);
// Volledige adressen, want de app weet niet waar de site staat en hoort dat ook
// niet zelf in elkaar te zetten. De afbeelding en de doorverwijzing zijn openbaar;
// zie Advertenties.cs in het webproject.
var basis = scope.Adverteren.SiteUrl.TrimEnd('/');
return SocketResponse.Ok(id, new
{
campagne.Id,
campagne.Tekst,
Afbeelding = campagne.HeeftAfbeelding ? $"{basis}/advertentie/{campagne.Id}/afbeelding" : null,
Link = $"{basis}/advertentie/{campagne.Id}/uit"
});
}
case SocketActions.LegacyStatus:
{
var p = request.PayloadAs<PersonIdPayload>();
var status = await scope.Legacy.StatusAsync(p?.PersonId ?? session!.PersonId, ct);
return SocketResponse.Ok(id, status);
}
case SocketActions.RegisterDevice:
{
var p = request.PayloadAs<DevicePayload>();
if (p is null) return SocketResponse.Error(id, "Geen kenmerk meegegeven.");
if (scope.Meldingen is null) return SocketResponse.Ok(id, new { meldingen = false });
var r = await scope.Meldingen.AanmeldenAsync(session!.UserId, p.Token,
p.Platform ?? "android", p.Name, ct);
return r.Succeeded ? SocketResponse.Ok(id, new { meldingen = true })
: SocketResponse.Error(id, r.Error!);
}
case SocketActions.TestDevice:
{
// Alleen naar je eigen toestellen; er gaat geen persoonsnummer in de vraag mee.
if (scope.Meldingen is null)
return SocketResponse.Ok(id, new { gelukt = false, uitleg = "Meldingen zijn hier niet ingericht." });
var proef = await scope.Meldingen.ProefAsync(session!.UserId, ct);
return SocketResponse.Ok(id, new { gelukt = proef.Gelukt, uitleg = proef.Uitleg });
}
case SocketActions.UnregisterDevice:
{
var p = request.PayloadAs<DevicePayload>();
if (p is null || scope.Meldingen is null) return SocketResponse.Ok(id);
await scope.Meldingen.AfmeldenAsync(session!.UserId, p.Token, ct);
return SocketResponse.Ok(id);
}
case SocketActions.AppointManager:
{
var p = request.PayloadAs<AppointManagerPayload>();
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
var r = await scope.Legacy.AppointManagerAsync(session!.UserId, p.ManagerPersonId, p.Priority, ct);
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.FileListPerson:
{
var p = request.PayloadAs<PersonIdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
var r = await scope.Vault.ListPersonFilesAsync(p.PersonId, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.FileListMessage:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven.");
var r = await scope.Vault.ListMessageFilesAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.FileProfilePhoto:
{
var p = request.PayloadAs<IdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen foto opgegeven.");
var r = await scope.Vault.SetProfilePhotoAsync(p.Id, session!.UserId, ct);
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.LegacyEntrusted:
{
var r = await scope.Legacy.ToevertrouwdAsync(session!.UserId, ct);
var fotos = await KleineFotosAsync(scope, r.Select(t => t.PersonId), session.UserId, ct);
return SocketResponse.Ok(id, r.Select(t => new
{
t.PersonId, t.DisplayName, t.DateOfDeath, t.AccessGrantedAt, t.AantalItems,
t.Afgerond, t.Volgorde, t.RequiredDeclarations, t.ObjectionDays,
profielfoto = fotos.GetValueOrDefault(t.PersonId)
}));
}
case SocketActions.LegacyEntrustedItems:
{
var p = request.PayloadAs<PersonIdPayload>();
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
var r = await scope.Legacy.NalatenschapItemsAsync(p.PersonId, session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
return SocketResponse.Ok(id, r.Value!.Select(i => new
{
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy,
i.ReleaseAt, i.CreatedAt
}));
}
case SocketActions.RemoveManager:
{
var p = request.PayloadAs<ManagerPayload>();
if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven.");
var r = await scope.Legacy.RemoveManagerAsync(session!.UserId, p.ManagerPersonId, ct);
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
}
case SocketActions.VaultSharedWithMe:
{
var r = await scope.Vault.ListSharedWithMeAsync(session!.UserId, ct);
if (!r.Succeeded) return SocketResponse.Error(id, r.Error!);
return SocketResponse.Ok(id, r.Value!.Select(i => new
{
i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy,
i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId
}));
}
case SocketActions.LegacyManagers:
{
var lijst = await scope.Legacy.ListManagersAsync(session!.PersonId, ct);
var fotos = await KleineFotosAsync(scope, lijst.Select(b => b.PersonId), session.UserId, ct);
return SocketResponse.Ok(id, lijst.Select(b => new
{
b.PersonId, b.DisplayName, b.Priority, b.AccessGrantedAt,
profielfoto = fotos.GetValueOrDefault(b.PersonId)
}));
}
case SocketActions.LegacySettings:
{
var p = request.PayloadAs<ProcedurePayload>();
if (p is null) return SocketResponse.Error(id, "Geen instellingen meegegeven.");
var r = await scope.Legacy.WijzigProcedureAsync(session!.UserId, p.Declarations, p.ObjectionDays, ct);
return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!);
}
default:
return SocketResponse.Error(id, $"Onbekende actie '{request.Action}'.");
}
}
/// <summary>
/// De kleine profielfoto's van een stel mensen, als base64 en per persoon.
///
/// De app krijgt ze mee in het antwoord waar de mensen zelf in staan. Dat scheelt een
/// tweede ronde per persoon, en het zijn een paar kilobyte per stuk: de foto zelf ophalen
/// voor twintig mensen zou megabytes kosten voor plaatjes van veertig pixels.
///
/// Wie je mag zien bepaalt de kluis zelf; hier staat geen tweede oordeel.
/// </summary>
private static async Task<Dictionary<long, string?>> KleineFotosAsync(
RequestScope scope, IEnumerable<long> persoonIds, long gebruikerId, CancellationToken ct)
{
var uitkomst = new Dictionary<long, string?>();
foreach (var foto in await scope.Vault.ProfielfotosAsync(persoonIds, gebruikerId, ct))
{
if (foto.Klein is { Length: > 0 } klein)
uitkomst[foto.PersoonId] = Convert.ToBase64String(klein);
}
return uitkomst;
}
}
// De vorm van wat de app meestuurt. Bewust kleine, expliciete typen: zo is in één oogopslag
// duidelijk welke gegevens een actie nodig heeft.
public record EmailPayload(string Email);
public record SignInPayload(string Email, string Code);
/// <summary>Alleen een code. Bij het opheffen van een account komt het adres uit de sessie.</summary>
public record CodePayload(string Code);
public record RegisterPayload(string Email, string FirstName, string LastName);
public record AcceptInvitationPayload(string Token);
public record OwnProfilePayload(string FirstName, string LastName, DateOnly? BirthDate);
public record FamilyIdPayload(long FamilyId);
public record IdPayload(long Id);
public record PersonIdPayload(long PersonId);
public record AddMemberPayload(long FamilyId, string FirstName, string LastName, string? Email,
RelationType Relation, string? RelationLabel, DateOnly? BirthDate);
public record UpdateMemberPayload(long FamilyId, long PersonId, string FirstName, string LastName,
string? Email, RelationType Relation, string? RelationLabel,
DateOnly? BirthDate);
public record InvitePayload(long FamilyId, long PersonId);
public record ProcedurePayload(int Declarations, int ObjectionDays);
public record VaultListPayload(VaultCategory? Category);
public record VaultUpdatePayload(long Id, VaultCategory Category, string Title, VaultPayload? Payload,
ReleasePolicy ReleasePolicy, DateTime? ReleaseAt, long? SharedManagerPersonId = null);
public record MessageUpdatePayload(long Id, string Subject, string Text, DateTime? DeliverAt, bool AfterDeath);
public record FileBeginPayload(string FileName, string? ContentType, long? VaultItemId, long TotalBytes,
long? SubjectPersonId = null, long? MessageId = null);
public record FileIdPayload(long Id);
public record FileChunkPayload(string UploadId, int Index, string? Data);
public record UploadIdPayload(string UploadId);
public record FileReadPayload(long Id, int Index);
public record DevicePayload(string Token, string? Platform, string? Name);
public record LookaheadPayload(int Days);
public record VaultSavePayload(VaultCategory Category, string Title, VaultPayload? Payload,
ReleasePolicy ReleasePolicy, DateTime? ReleaseAt, long? SharedManagerPersonId = null);
public record ComposePayload(long ToPersonId, string Subject, string Text, DateTime? DeliverAt,
bool AfterDeath, MessageKind Kind, long? VaultFileId);
public record DeclareDeathPayload(long PersonId, DateOnly? DateOfDeath, string? Note);
public record AppointManagerPayload(long ManagerPersonId, int Priority);
public record ManagerPayload(long ManagerPersonId);