Ga naar inhoud

Backend & API

De backend bedient de Beoordeling-cyclus en de overige bounded contexts: hij neemt vragen gratis aan (met het vaste aantal beoordelingen uit de Systeeminstellingen), matcht opiniegevers, verwerkt opinies inclusief hun reactie (verplicht wanneer de maker die vroeg), kent credits toe, laat de maker elke ontvangen beoordeling waarderen (0–5), bewaakt de credits-poort op het inzien van de uitslag, controleert alle vrije tekst via de tekstmoderatie, bewaakt de retentie en levert stamdata. Deze pagina beschrijft de greenfield-doelarchitectuur: een .NET/C#-backend als modulaire monoliet, gelaagd en volgens Domain-Driven Design, met EF Core + PostgreSQL, SignalR voor realtime push en een resource-georiënteerde REST-API. Het Node/Express-prototype (bron/prototype/) dient uitsluitend als referentie, niet als basis.

De terminologie volgt het domeinmodel en de termen. De API spreekt domeintaal (Vraag, Opinie, Melding, Credits, Waardering); de bounded context die in het model Beheer heet, is leidend voor de stamdata- en systeemendpoints. Per modelversie 2026-07-05 (vastgesteld 2026-07-08b) hoort daar ook de context Beloning & Financieel bij: sterren, adviesniveaus, euro-saldo met iDeal en uitbetalinggebouwd, met een gesimuleerde (dummy) iDeal (een echte PSP volgt); zie de endpoints hieronder.

AspectKeuzeOnderbouwing vanuit de eisen van I See You
Platform.NET / C#Eén deployable, sterke typing en een rijp DDD-/EF-ecosysteem voor een betrouwbaar, lange-termijn-stabiel domein.
VormModulaire monoliet, module per bounded contextLage operationele kosten (één deployable) met heldere contextgrenzen die later splitsbaar zijn als het verkeer dat eist.
LagenDomain ← Application ← Infrastructure ← ApiHet vluchtige, anonieme domein staat centraal en blijft vrij van infrastructuur; afhankelijkheden wijzen strikt naar binnen.
Use-casesDunne, expliciete handlers (geen MediatR)Minder magie en indirectie; elke use-case is direct leesbaar en testbaar.
PersistentieEF Core + PostgreSQL (Npgsql)Eén database voor vluchtige én persistente data; check-constraints en unieke indexen dwingen domeinregels af.
RealtimeSignalR (push); REST voor alle state-mutatiesLage-latency presence en toewijzing zonder polling; state-wijzigingen blijven idempotent via REST.
SchaalStateless, scale-to-zero + autoscalingLage startkosten bij weinig verkeer, klaar voor pieken; geen socket-state die scale-out blokkeert.

De backend is gelaagd volgens DDD. De afhankelijkheden wijzen strikt naar binnen: het domein weet niets van infrastructuur of web.

flowchart LR
    Api["ISeeYou.Api (Minimal API + SignalR, compositieroot)"]
    Infra["ISeeYou.Infrastructure (EF Core/Npgsql, media, hashing, BackgroundServices)"]
    App["ISeeYou.Application (use-cases + poorten)"]
    Domain["ISeeYou.Domain (puur)"]

    Api --> Infra
    Api --> App
    Infra --> App
    App --> Domain
    Infra --> Domain
ProjectInhoudMag afhangen van
ISeeYou.DomainAggregates, value objects, enums, domeinregels. Puur — geen frameworks.(niets)
ISeeYou.ApplicationUse-case-handlers en poorten (interfaces): IRepository<T>, IMediaStore, IRealtimeNotifier, IClock, IPasswordHasher, IModeratieDienst, IUitslagNotificaties, IAiPersonaService.Domain
ISeeYou.InfrastructureEF Core/Npgsql-implementaties, mediaopslag, Argon2id-hashing, BackgroundServices.Application, Domain
ISeeYou.ApiMinimal API-endpoints, SignalR-hubs, autorisatiebeleid, compositieroot (DI).Infrastructure, Application
test/*xUnit (domein/applicatie), Testcontainers-Postgres (integratie), SignalR-testclient.alle bovenstaande

De poorten leven in Application; de adapters (concrete implementaties) in Infrastructure. Daardoor kennen de use-cases alleen abstracties en is bijvoorbeeld de mediaopslag of de klok inwisselbaar zonder het domein te raken.

Modulaire monoliet: module per bounded context

Section titled “Modulaire monoliet: module per bounded context”

Binnen één deployable is er één module per bounded context: Beoordeling, Gebruiker & Toegang, Moderatie & Retentie en Beheer. Elke module groepeert zijn eigen aggregates, use-cases en endpoints. Modules communiceren via expliciete contracten, niet door rechtstreeks in elkaars tabellen te grijpen.

Geen MediatR. Use-cases zijn dunne, expliciete handlers die rechtstreeks via DI worden aangeroepen. Dit houdt de aanroeppaden leesbaar en vermijdt verborgen pipeline-gedrag — passend bij de wens voor lange-termijn stabiliteit en eenvoud.

Eén PostgreSQL-database (via Npgsql) bewaart zowel vluchtige als persistente data. De mapping verankert domeinregels in het schema.

OnderwerpAanpakReden
Value objectsAls owned types (bijv. Profiel, SelectieCriteria, ProfielSnapshot, Beoordelingsselectie).Value objects horen bij hun aggregate en hebben geen eigen identiteit.
RegioEen vlakke Keuzelijst (type regio, geen landprefix meer sinds 2026-07-11m), optioneel op Profiel/SelectieCriteria. (Het veld Land en de afgeleide RegioM49-referentietabel zijn per 2026-07-11m vervallen — ADR-037.)I See You wordt per land als aparte site gehost (NL eerst); een Land-veld binnen de applicatie was overbodig.
ScoresAls jsonb-kolom op de Opinie.Een brede, variabele set attributen (alleen de gekozen secties) die als geheel wordt gelezen/geschreven; geen losse kolom per attribuut.
ReactieAls nullable tekstkolom op de Opinie; het slot-type als enum-kolom Opdracht.MinimumNiveau (per 2026-07-10h).Eén vrije tekstreactie per opinie — verplicht op een vrije-reactie-/betaald-advies-slot (MinimumNiveau ≥ Niveau1), niet gevraagd/opgeslagen op een gewoon slot. De verplicht-regel wordt in de use-case afgedwongen, niet als check-constraint. (De vlag VrijeTekstGevraagd in de Beoordelingsselectie is per 2026-07-10h functioneel vervallen; bestaande jsonb-data deserialiseert ongewijzigd.)
NotificatieEmailAls nullable tekstkolom op de Vraag.Optioneel adres voor de uitslagnotificatie; wordt direct na de verzendpoging op null gezet (bij het zetten van beoordeeldOp), zodat er geen adres achterblijft.
EnumsValue conversion naar string (bijv. MediaType, Vraagtype, OpdrachtStatus, OpinieBron).Leesbare, stabiele waarden in de database in plaats van magische integers.
Score-bereikDomeininvariant score 1..5 in de AttribuutScore-constructor (per 2026-07-05 gebouwd). De Scores staan als jsonb op de Opinie, dus het bereik wordt in het domein afgedwongen, niet als kolom-check-constraint.De schaal 1–5 (verplicht per gevraagd attribuut; “geen mening” vervallen) is een harde domeinregel.
WaarderingEen nullable waardering-kolom (0..5) op de Opinie met check-constraint waardering >= 0 AND waardering <= 5. De afgeleide reviewer-statistieken (aantalBeoordelingen, waarderingSom/waarderingAantal, gemiddeldeWaardering) staan als tellers op de Gebruiker.De waardering is een kwaliteitsoordeel van de maker (0–5), geen valuta; de reviewer-totalen worden bij (her)waardering exact bijgesteld.
CreditsEen credits-kolom op de Gebruiker (persistent, start 0) en op de AnoniemeGebruiker (sessie-gebonden, vervalt met de sessie). Verdienen gebeurt in dezelfde transactie als het voltooien van de opinie; betalen (de op de vraag bevroren creditkosten = aantalBeoordelingen × creditkostenPerBeoordeling) in dezelfde transactie als het ontgrendelen van de eigen uitslag (Vraag.resultaatOntgrendeld). Een check-constraint credits >= 0 borgt dat het saldo nooit negatief wordt.Credits zijn een echte teller (verdienen/besteden), geen projectie; het saldo moet exact en niet-negatief blijven.
ConcurrencyOptimistic concurrency via xmin.Voorkomt verloren updates bij gelijktijdige mutaties zonder pessimistische locks.
Migratiesdotnet ef-migrations + seed van Keuzelijst (gender, achtergrond, standaard-secties/attributen en de vlakke regio-lijst), plus de standaard Systeeminstellingen.Reproduceerbaar schema en startstamdata.

Vluchtig vs. persistent — dezelfde database

Section titled “Vluchtig vs. persistent — dezelfde database”

De anonimiteit vraagt dat het meeste snel verdwijnt. Vluchtige en persistente gegevens leven in dezelfde database, maar verschillen in retentie:

AardTabellenBehandeling
VluchtigVraag, Opinie, Opdracht, AnoniemeGebruiker, SessieZodra de beoordeling klaar is (alle opinies binnen) wordt beoordeeldOp gezet en blijven vraag + opname nog een resultaat-retentievenster (resultaatRetentieUren, standaard 168) bestaan zodat de maker het resultaat kan terugzien; daarna ruimt de retentie-sweep ze op — of eerder, wanneer de maker zelf verwijdert. Een timestamp-index voedt daarnaast de instelbare veiligheids-timeout die alleen nooit-afgeronde vragen opruimt.
PersistentGebruiker (incl. credits + de afgeleide waarderings-statistieken), Bericht, Keuzelijst, SysteemBlijft behouden (account met saldo en reviewer-statistieken; stamdata en systeemwaarden zijn beheerd).

De opschoning van de vluchtige tabellen loopt via de RetentieHostedService (een BackgroundService, altijd actief — geen uitschakelbare vlag). Na afronding blijven vraag + opname het instelbare resultaat-retentievenster (resultaatRetentieUren, standaard 168) staan; de retentie-sweep verwijdert ze zodra now − beoordeeldOp ≥ resultaatRetentieUren (of eerder, wanneer de maker zijn eigen vraag verwijdert). Daarnaast draait dezelfde service de instelbare veiligheids-timeout (veiligheidsTimeoutMin uit de Systeeminstellingen) voor vragen die nooit afgerond raken. Zie Beveiliging & privacy en Media & opname.

Realtime push loopt via SignalR. Alle state-mutaties gaan via REST; SignalR doet uitsluitend push — geen state-mutatie via een hub.

Gebouwd is één hub, met precies twee server-events:

HubPadEvents (server → client)
BeoordelingHub/hubs/beoordelingOpdrachtBeschikbaar (er is nieuw werk in de pool) en VraagBeoordeeld ({ makerId, vraagId } — de uitslag van een vraag is klaar)

Eerlijk over de huidige stand: de frontend gebruikt deze hub nog niet — de portalen pollen (o.a. “Mijn vragen”, elke ~5s); de @microsoft/signalr-client is een follow-up. Een aparte AanwezigheidHub voor heartbeat/presence (die laatsteBezoek en het online-venster zou voeden) is doelbeeld, nog niet gebouwd.

De hub is schaal-klaar: er staat geen socket-state in de weg van scale-out. Bij meerdere instances wordt een Redis-backplane geconfigureerd (config, geen herontwerp).

De API is resource-georiënteerd (zelfstandige naamwoorden, geen werkwoord-routes). Alle paden leven onder /api en spreken JSON, behalve het media-stream-endpoint. Matching is een server-side gevolg van het aanmaken van een vraag: het plaatsen van een vraag is gratis (er worden geen credits afgeschreven) en triggert intern het aanmaken van net zoveel Opdrachten (slots) als het vaste aantal beoordelingen (aantalBeoordelingen — de bij het stellen bevroren instelling aantalOpiniegevers) — die altijd Open ontstaan — er is geen los publiek matching-endpoint. De credits-poort staat niet op het insturen maar op het inzien van de eigen uitslag (de op de vraag bevroren creditkosten = aantalBeoordelingen × creditkostenPerBeoordeling worden eenmalig afgeschreven bij het ontgrendelen). De selectie filtert op de SelectieCriteria (gender, regio, achtergrond, leeftijdsrange); een regio-criterium verbreedt niet. Online gebruikers (binnen het online-venster) krijgen daarbij voorrang; online-zijn is geen filter of keuze. Levert de matching te weinig mensen op, of reageert niemand binnen de antwoordtermijn, dan vangt een AI-persona (Opinie.bron = AI) de beoordeling op.

MethodePadDoel
POST/api/sessiesInloggen (beheerder of gebruiker); start een Sessie en weigert een tweede actieve sessie (single-device). De respons bevat het actuele credit-saldo, zodat het portaal het direct kan tonen.
GET/api/profielDe profielpagina-gegevens van de ingelogde actor: het profiel (leeftijd, gender, achtergrond, optioneel regio), het credit-saldo en — voor een geregistreerde gebruiker — e-mail plus de reviewer-statistieken (aantal gegeven beoordelingen, gemiddelde waardering); voor een anonieme gebruiker de sessie-vervaltijd. Zie Aanmelden & registratie.
DELETE/api/sessies/huidigDe eigen sessie beëindigen (uitloggen).
GET/api/credits/huidigHet actuele credit-saldo van de ingelogde actor ophalen — persistent voor een Gebruiker, sessie-gebonden voor een AnoniemeGebruiker (vervalt met de sessie, anoniemeSessieMin). Voedt het zichtbare saldo en de kostencontrole bij het ontgrendelen van de eigen uitslag.
POST/api/gebruikersGeregistreerde Gebruiker aanmaken (fictieve naam, Argon2id-hash; optioneel een e-mailadres, uitsluitend voor wachtwoordherstel). Optioneel een regio, gekozen uit een Keuzelijst.
POST/api/tijdelijke-gebruikersAnoniemeGebruiker aanmaken en de anonieme sessie starten (minimaal, vluchtig profiel voor anonieme deelname; geen e-mail, geen herstel). Opent een sessie-gebonden credit-saldo dat op 0 begint en met de sessie vervalt (anoniemeSessieMin).

Geen locatie-tracking. Er is geen land- of locatieveld (per 2026-07-11mADR-037): geen GeoIP, geen permissieprompt en geen Geolocation-API/GPS. De optionele regio is gewoon een keuze uit een keuzelijst. Zie Bounded context Gebruiker & Toegang en Beveiliging & privacy.

MethodePadDoel
POST/api/media/tempLive-capture (video 10s of foto) tijdelijk uploaden; levert een tijdelijke media-key.
POST/api/vragenVraag aanmaken (promote tijdelijke media) met een optionele toelichting (tekstmoderatie), een optioneel NotificatieEmail (per 2026-07-04e; simpele formaatvalidatie — het adres voor de uitslagnotificatie) en de door de maker gekozen Beoordelingsselectie: het veld Selectie (de secties/attributen; minstens één sectie, anders HTTP 400 — per 2026-07-10h, de vlag VrijeTekstGevraagd is vervallen). Bij het aanmaken van de slots krijgen maximaal maxVrijeReactiesZonderBetaald slots (of, bij Niveaueisen, precies de eis-aantallen) een MinimumNiveau — de vrije-reactie-slots. Het aantal beoordelingen stuurt de client niet mee (het veld AantalBeoordelingen is per 2026-07-04 vervallen): de server neemt de instelling aantalOpiniegevers uit de Systeeminstellingen en bevriest die als aantalBeoordelingen op de vraag. Wordt de toelichting door de moderatie afgekeurd, dan wordt de vraag niet aangemaakt (HTTP 400 met de reden). Gratis — er worden geen credits afgeschreven; de bevroren inzage-prijs creditkosten = aantalBeoordelingen × creditkostenPerBeoordeling. De opname gaat direct de pool in (beoordeelbaar werk) en triggert server-side het aanmaken van aantalBeoordelingen slots + de matching.
GET/api/vragen/{id}/streamMedia streamen met Range-ondersteuning (HTTP 206).
GET/api/vragen/{id}/opdrachtenDe Opdrachten van één vraag ophalen.
POST/api/opdrachten/{id}/opinieOpinie opslaan (scores 1–5 op álle geselecteerde secties/attributen — verplicht per gevraagd attribuut, een onvolledige set scores wordt geweigerd (per 2026-07-05 gebouwd) — plus een vrije Reactie (string)); zet de bijbehorende opdracht op Voltooid. Is het slot een vrije-reactie-/betaald-advies-slot (MinimumNiveau ≥ Niveau1, per 2026-07-10h), dan is Reactie verplicht: een lege verplichte reactie wordt geweigerd (HTTP 400, opdracht blijft open, geen credit); op een gewoon slot wordt een meegestuurde reactie genegeerd (niveau 0 levert geen tekst). Een ingevulde reactie loopt door de tekstmoderatie: wordt ze afgekeurd, dan wordt de beoordeling niet opgeslagen, de opdracht blijft open en de reviewer krijgt geen credit (HTTP 400 met de reden, direct bij het indienen). Een voltooide, toegestane menselijke opinie levert de opiniegever creditsPerBeoordeling credits op (in dezelfde transactie bijgeschreven); de respons bevat het bijgewerkte saldo. Zodra alle opinies binnen zijn (isBeoordeeld()) wordt beoordeeldOp op de vraag gezet; is er een NotificatieEmail, dan verstuurt de handler in dezelfde flow eenmalig het uitslag-seintje via IUitslagNotificaties (fail-graceful) en wist daarna het adres. De opname wordt niet direct verwijderd maar blijft in het resultaat-retentievenster staan.
GET/api/vragen/mijn?actor=…Mijn vragen: de eigen vragen van de maker met hun status (in behandeling / beoordeeld) en, indien beoordeeld, het resultaat (SectieGemiddelden, aantal opinies, de reacties) plus de media-key. Alleen zichtbaar voor de maker zelf; voedt de maker-weergave voor resultaat terugzien.
POST/api/vragen/{id}/ontgrendel?actor=…De maker betaalt eenmalig de op de vraag bevroren creditkosten (= aantalBeoordelingen × creditkostenPerBeoordeling) om zijn eigen uitslag te mogen inzien (eigenaarscontrole op makerId); zet Vraag.resultaatOntgrendeld en schrijft de credits in dezelfde transactie af. Heeft de actor te weinig credits, dan wordt er niets afgeschreven en niet ontgrendeld (HTTP 400 — eerst verdienen door te beoordelen). Idempotent: al ontgrendeld = gratis. Zie resultaat terugzien.
GET/api/vragen/{id}/uitslagHet resultaat van één eigen vraag: opinies + afgeleide SectieGemiddelden + de per opinie gegeven reactie en de door de maker toegekende waardering. De respons bevat geen bron-veld (mens/AI) meer (per 2026-07-04h): de herkomst van een opinie wordt niet aan de maker getoond — voor de maker is elke ontvangen beoordeling gelijk (en waardeerbaar). Het datamodel houdt Opinie.bron wél bij; alleen de beheer-endpoints tonen de herkomst nog. Alleen beschikbaar wanneer Vraag.resultaatOntgrendeld (zie het ontgrendel-endpoint).
POST/api/opinies/{id}/waardering?actor=…De maker geeft elke ontvangen Opinie een waardering 0–5 (eigenaarscontrole op makerId van de vraag): zet Opinie.waardering. Bij een menselijke opinie stelt de handler in dezelfde transactie de reviewer-statistieken (aantalBeoordelingen, waarderingSom/waarderingAantal, gemiddeldeWaardering) op de Gebruiker bij; bij een AI-opinie wordt de waardering wél opgeslagen maar beïnvloedt ze geen reviewer-profiel (er is geen reviewer). Dit werkt op elke opinie (per 2026-07-04h) — de eerdere regel “AI-opinies worden niet gewaardeerd” vervalt; de maker ziet toch geen herkomst en kan zo elke ontvangen beoordeling waarderen. Idempotent/herwaardeerbaar: Waardeer(score) corrigeert de totalen met de vorige waarde. Alleen na ontgrendelen.
DELETE/api/vragen/{id}?actor=…De maker verwijdert zijn eigen vraag + opname direct (eigenaarscontrole op makerId), vóór afloop van het resultaat-retentievenster.
MethodePadDoel
POST/api/vragen/{id}/meldingenMelding plaatsen; bij het bereiken van de via Beheer instelbare meldingen-drempel (standaard 3) wordt de vraag automatisch verwijderd.

Proactieve tekstmoderatie zit in de schrijf-endpoints, niet in een apart endpoint. Alle vrije tekst — de toelichting bij POST /api/vragen en de reactie bij POST /api/opdrachten/{id}/opinie — loopt vóór opslaan door de IModeratieDienst-poort; afgekeurde tekst leidt tot een HTTP 400 en wordt niet opgeslagen. Zie de moderatie-poort hieronder.

MethodePadDoel
GET/api/keuzelijsten/{type}Publiek (read-only): de Keuzelijst-opties van één type ophalen voor de formulieren van het gebruikersportaal — gender, culturele achtergrond, regio, de beoordelings-secties (sectie) en (per 2026-07-08f) hun attributen (attribuut, sleutel "<sectie>.<attribuut>").
GET / POST / PUT / DELETE/api/beheer/keuzelijsten[/{id}]Keuzelijst-stamdata beheren (opties lezen, toevoegen, wijzigen, verwijderen — per taal, type, sleutel, omschrijving, volgorde). Beheerder-only.
GET/api/beheer/gebruikersOverzicht van geregistreerde gebruikers voor de beheerder, inclusief hun reviewer-statistieken (aantalBeoordelingen, de afgeleide gemiddeldeWaardering 0–5), sterren/adviesniveau en de aanmaakdatum.
POST/api/beheer/gebruikers/creditsHandmatig credits toekennen aan een gebruiker (beheer/test).
POST/api/beheer/gebruikers/expertHet Expert-adviesniveau handmatig toekennen of intrekken (per 2026-07-08b).
POST/api/beheer/gebruikers/sterrenHet sterrenaantal direct zetten (test-tooling): body { gebruikersnaam, aantal }; het adviesniveau volgt automatisch uit de drempel (sterrenPerNiveau).
POST/api/beheer/gebruikers/opschonenOude gebruikers opschonen (test-tooling): verwijdert geregistreerde gebruikers ouder dan { ouderDanDagen } (op aanmaakdatum; 0 = alles), inclusief hun eigen vragen (+ opnames/opinies/slots/meldingen), sessies en sterren.
POST/api/beheer/testvragenTestvragen genereren (test/QA): maakt N willekeurige vragen met opgegeven SelectieCriteria.
GET/api/beheer/vragenOverzicht van vragen voor de beheerder met hun status (aantalOpinies / aantalBeoordelingen). Anders dan de user-facing uitslag toont dit overzicht de beheerder wél de herkomst (mens/AI) van de opinies (Opinie.bron, per 2026-07-04h). De respons bevat per vraag aantalOpinies en aantalOpiniegevers, waarmee het beheerportaal filtert op vragen die nog niet alle opinies binnen hebben (aantalOpinies < aantalOpiniegevers) — zo ziet de beheerder trage/vastgelopen vragen die eventueel handmatig een AI-opinie nodig hebben. Beheerder-only (IsBeheerder).
POST/api/beheer/vragen/{id}/ai-opinieHandmatig één AI-opinie op een Open slot van de vraag genereren met body { personaId } (per 2026-07-04f). De handler kiest een Open slot, roept IAiPersonaService aan met de gekozen AIPersona, en slaat de Opinie op (bron = AI, personaId, met scores + een reactie — per 2026-07-04g levert de AI ook een reactie); zodra alle opinies binnen zijn wordt beoordeeldOp gezet (zelfde afronding als het automatische vangnet). Vult alleen een Open slot — nooit een door een mens vastgehouden Voorgesteld/Gekozen slot. Is er geen open slot (vraag al vol) → HTTP 404/400; onbekende/ inactieve personaId400. Fail-graceful: zonder OpenAI-sleutel/chat-permissie of bij een fout levert de dienst de deterministische score (met generieke reactie). Beheerder-only (IsBeheerder).
GET / POST/api/beheer/personas[/{id}/toggle]AI-persona’s beheren: inzien, toevoegen en (de)activeren (de laatste actieve persona kan niet uit). Beheerder-only.
DELETE/api/beheer/vragen/{id}Een vraag (met opname, opinies en slots) volledig verwijderen — de moderatie-/opschoonactie van de beheerder.
GET / PUT/api/beheer/instellingenSysteeminstellingen lezen / bijwerken. De GET is publiek (het gebruikersportaal heeft de niet-gevoelige prijzen/aantallen nodig); de PUT is beheerder-only. Parameters: aantalOpiniegevers (het aantal beoordelingen per vraag, standaard 10), antwoordtermijnMin, creditkostenPerBeoordeling, creditsPerBeoordeling, anoniemeSessieMin, onlineVensterMin, veiligheidsTimeoutMin, resultaatRetentieUren (standaard 168), meldingenDrempel, keuzesetGrootte, keuzetermijnMin, maxOnbeantwoordMin, antiThrashCooldownMin, maxVoorstelHerhaling, aiVangnetActief, onboardingAantalBeoordelingen; en de beloning-/financieel-parameters (vastgesteld 2026-07-08b): niveauPrijzen (per niveau), sterrenPerNiveau (30), sterDrempelTweedePlaatsen, sterDrempelDerdePlaatsen, sterCreditsMijlpaal, gewichtMakerWaardering (plus de gewichten consensus/correlatie), minBeoordelaarsVoorRanking, welkomstbonusMaxEuro (5), beoordelaarAandeelProcent (50), uitbetaalDrempelEuro (10) en minTeaserNiveauBeoordelaars.
GET / POST/api/berichtenBerichten lezen / plaatsen. Gemodelleerd, nog niet gebouwd — het Bericht-aggregate bestaat in het domein, maar er zijn nog geen berichten-endpoints of -schermen (zie Berichten).

Beloning & Financieel (gebouwd — iDeal gesimuleerd)

Section titled “Beloning & Financieel (gebouwd — iDeal gesimuleerd)”
MethodePadDoel
GET/api/adviesniveausDe adviesniveaus met hun actuele prijzen per beoordeling (uit niveauPrijzen), voor het instellen van de niveaueisen bij een vraag en het tonen van de totaalkosten.
GET/api/eurosaldo/huidigHet actuele euro-saldo (“inleg”) van de ingelogde actor, naast het (gescheiden) credits-saldo.
POST/api/betalingen/idealStart een iDeal-Betaling (nu: dummy-PSP): een inleg (euro-saldo opladen; bij de eerste inleg past de server eenmalig de welkomstbonus toe) of een teaser-ontgrendeling. Respons: de redirect-URL.
POST/api/betalingen/webhookDe PSP-webhook: bevestigt (of faalt) een betaling en voert het gevolg idempotent uit — saldo bijschrijven (+ bonus) of de gekoppelde opinie ontgrendelen; slaat alleen de minimaal noodzakelijke transactiedata op (referentie + status).
POST/api/opinies/{id}/ontgrendel-teaserOntgrendelt één geblurde teaser (eigenaarscontrole op de maker van de vraag; hoogstens één per vraag — vastgesteld 2026-07-08b): schrijft de bevroren prijsSnapshot van het euro-saldo af, zet Opinie.ontgrendeld = true en schrijft het beoordelaars-aandeel bij (beoordelaarAandeelProcent, 50/50 — betaling volgt de ontgrendeling). Idempotent.
POST/api/uitbetalingenEen uitbetaling aanvragen vanaf het profielscherm; alleen mogelijk bij een euro-saldo ≥ uitbetaalDrempelEuro (standaard €10).
GET/api/beheer/sterrenBeheer-inzicht in de toegekende sterren en de tellers per bron (beste van vraag, 2e/3e plaatsen, credits-mijlpalen) per gebruiker. Beheerder-only.

Ster-/rankingberekening is geen endpoint maar een server-side gevolg. Zodra een vraag volledig beoordeeld is (dezelfde flow die beoordeeldOp zet), berekent de backend de consensus-ranking over de menselijke opinies (consensus + correlatie-controle + de gewogen maker-waardering; pas vanaf minBeoordelaarsVoorRanking), kent zo nodig sterren toe (nr. 1, en de tellers/mijlpalen) en werkt het adviesniveau van de betrokken reviewers bij. Herwaardering door de maker kan de ranking-invoer wijzigen; de exacte herberekeningsstrategie is een implementatiekeuze.

MethodePadDoel
GET/api/healthLiveness-/readiness-check voor het hostingplatform.

REST blijft het kanaal voor élke state-mutatie. De volgende push-signalen bestaan server-side (zie Realtime); zolang de frontend nog geen SignalR-client heeft, vervangt polling ze tijdelijk:

SignaalHubIn plaats van
Nieuw werk in de pool (OpdrachtBeschikbaar)BeoordelingHubpollen op beschikbaar werk
Uitslag klaar (VraagBeoordeeld, { makerId, vraagId })BeoordelingHubpollen op de status van “Mijn vragen”

Presence (online/offline via een AanwezigheidHub) is doelbeeld; laatsteBezoek wordt nu bij gewone API-aanroepen bijgewerkt.

Alle door gebruikers ingetypte vrije tekst wordt proactief gecontroleerd vóór opslaan. De use-case-laag kent daarvoor de applicatie-poort IModeratieDienst met resultaat ModeratieUitslag(Toegestaan, Reden?); de infrastructuur levert de adapter.

public interface IModeratieDienst
{
Task<ModeratieUitslag> ControleerAsync(string tekst, CancellationToken ct);
}
public sealed record ModeratieUitslag(bool Toegestaan, string? Reden);
  • Wat wordt gecontroleerd. De toelichting van de maker (bij POST /api/vragen) en de reactie van de reviewer (bij POST /api/opdrachten/{id}/opinie). Afgekeurde tekst wordt geweigerd (niet opgeslagen, HTTP 400 met de reden), zodat de indiener direct kan aanpassen. Bij een afgekeurde beoordeling krijgt de reviewer geen credit. De reactie van een AI-opinie wordt niet gemodereerd: die tekst komt van het model (IAiPersonaService), niet van een gebruiker.
  • Implementatie OpenAiModeratieDienst. De infra-adapter roept de OpenAI Moderation API aan (POST https://api.openai.com/v1/moderations, model omni-moderation-latest). Die API is gratis.
  • Configuratie & fail-open. De sleutel is de app-setting OpenAI__ApiKey. Ontbreekt die, dan draait de moderatie fail-open (alles toegestaan) zodat de app blijft werken; de poort blijft dan een no-op die Toegestaan = true teruggeeft.

Eén sleutel, twee OpenAI-permissies. Dezelfde app-setting OpenAI__ApiKey bedient sinds 2026-07-04f twee OpenAI-diensten: de Moderations-permissie voor deze tekstmoderatie én de chat/completions-permissie (“Model capabilities: write”) voor de vision-gebaseerde AI-persona-dienst. Ontbreekt de chat-permissie, dan blijft de tekstmoderatie werken en valt alleen de AI-persona terug op deterministische scores.

Deze proactieve tekstmoderatie staat los van het reactieve melden van opnames (Moderatie & Retentie).

Zodra een vraag beoordeeld is (beoordeeldOp gezet) en er een notificatieEmail op de vraag staat, stuurt de backend de maker één kort mailtje met alleen een link naar het gebruikersportaal (“je uitslag staat klaar”). De use-case-laag kent daarvoor de applicatie-poort IUitslagNotificaties; de infrastructuur levert de adapter.

public interface IUitslagNotificaties
{
Task UitslagKlaarAsync(string email, CancellationToken ct);
}
  • Bewust geen inhoud. De mail bevat geen beoordelingsinhoud (geen scores, gemiddelden of reacties) — alleen een link. Dit is robuust tegen aflever-/spamproblemen: de mail is enkel een trigger, de site is de bron (de uitslag zit achter de credits-poort).
  • Implementatie op Azure Communication Services. De infra-adapter verstuurt via Azure Communication Services (ACS) Email, met afzender DoNotReply@…azurecomm.net.
  • Configuratie & fail-graceful. De adapter leest de app-settings Email__ConnectionString (ACS-connectionstring), Email__Afzender (het afzenderadres) en App__PortaalUrl (de basis-URL voor de link in de mail). Ontbreekt de configuratie of faalt de verzending, dan is de poort fail-graceful: er wordt niets geblokkeerd (het afronden van de vraag gaat door, de uitslag is altijd in de app te zien) en de mail is best-effort.
  • Privacy: adres eenmalig. Het aanroepen van de poort gebeurt in de flow die beoordeeldOp zet; direct na de verzendpoging wordt Vraag.notificatieEmail op null gezet (of de verzending nu slaagde of niet), zodat er geen adres achterblijft. Zie Beveiliging & privacy.

Deze uitgaande notificatie staat los van de interne Berichten en van het (aparte) account-e-mailadres voor wachtwoordherstel.

De AI-persona die een Open slot afvult (automatisch via het vangnet of handmatig via POST /api/beheer/vragen/{id}/ai-opinie) genereert zijn scores via de applicatie-poort IAiPersonaService; de infrastructuur levert de adapter.

public interface IAiPersonaService
{
Task<AiBeoordeling> GenereerOordeelAsync(
AIPersona persona, Vraag vraag, Beoordelingsselectie selectie,
Stream media, MediaType mediaType, CancellationToken ct);
}
public sealed record AiBeoordeling(Scores Scores, string? Reactie);
  • Vision-gebaseerd op gpt-4o-mini. Sinds 2026-07-04f is de standaard-adapter een echte, vision-gebaseerde dienst op OpenAI gpt-4o-mini (een goedkoop vision-model). De persona-instructie stuurt de “persoonlijkheid”; het model krijgt de Vraag.toelichting (per 2026-07-04g) en de te scoren secties/attributen mee en scoort alleen die — allemaal, verplicht — op de schaal 1–5 (per 2026-07-05 gebouwd; verplicht per gevraagd attribuut, ontbrekende → neutraal 3).
  • Scores + reactie (per 2026-07-04g). De poort geeft voortaan een AiBeoordeling terug: de scores én een korte vrije reactie (constructieve tekst), zodat een AI-opinie niet meer “geen reactie” oplevert en de AI ook een alleen-vrije-tekst-vraag kan beantwoorden. De prompt is herkaderd om binnen het contentbeleid bruikbare, constructieve feedback over echte mensen te geven: expliciet dat de persoon zélf, vrijwillig (opt-in), om opbouwende stijl-/uiterlijkfeedback vroeg, en om constructieve feedback in plaats van een “aantrekkelijkheidsoordeel”. (Dit verhoogt de bereidheid van het model, maar is geen 100%-garantie; een ander model is de mogelijke vervolgstap.) De AI-reactie hoeft niet door de tekstmoderatie — ze komt van het model, niet van een gebruiker.
  • Foto direct, video via FFmpeg-frames. Bij een foto (MediaType.Foto) gaat het beeld rechtstreeks mee. Bij een video (MediaType.Video, max. 10s) haalt de backend met FFmpeg ~4 frames uit de opname en stuurt die als beelden mee. FFmpeg zit in het API-Docker-image (zie Media & opname en Deployment).
  • Configuratie & fail-graceful. De adapter gebruikt dezelfde app-setting OpenAI__ApiKey als de tekstmoderatie, maar heeft daarnaast de chat/completions-permissie (“Model capabilities: write”) nodig. Ontbreekt de sleutel of die permissie, of faalt de aanroep, dan valt de dienst fail-graceful terug op de bestaande deterministische (regelgebaseerde) scores met een generieke reactie, zodat er altijd een opinie ontstaat en de app blijft werken. Kosten: ordegrootte < $0,01 per AI-opinie (foto 1 beeld, video ~4 frames).
  • Alleen Open slots. Zowel het automatische vangnet als de handmatige beheer-actie vullen uitsluitend Open slots; een door een mens vastgehouden Voorgesteld/Gekozen slot wordt nooit overgenomen. Een AI-opinie levert geen credits op. De maker kan ze sinds 2026-07-04h wél waarderen (hij ziet de herkomst immers niet), maar zo’n waardering beïnvloedt geen reviewer-profiel — er is geen reviewer.

Zie ADR-024, ADR-025 en het domeinmodel — AI-persona’s.

Er zijn twee frontends (beheerportaal en gebruikersportaal) op één deployable API. De scheiding loopt via autorisatiebeleid, niet via een 1-op-1 contextgrens. De gebouwde invulling is in-app auth met ondertekende bearer-tokens (ADR-031) — gekozen omdat de portalen (Static Web Apps) cross-origin draaien t.o.v. de API, waar SameSite-cookies niet meegestuurd worden:

PortaalGuardAuthenticatieDekt grofweg
Beheerportaaladmin-guard (endpoint-filter op /api/beheer/*)POST /api/beheer/login verifieert de admin-credentials uit het Systeem-aggregate (constant-time) en geeft een HMAC-SHA256-ondertekend admin-token, dat het portaal in de Authorization-header meestuurt; zonder geldig token → 401.context Beheer + admin-kant Moderatie
Gebruikersportaalactor-guard (endpoint-filter op de gebruikers-endpoints)Login (POST /api/sessies) of een anonieme sessie (POST /api/anoniem/sessie) geeft een ondertekend actor-token; alle gebruikers-endpoints leiden de actor server-side uit het token af (geen als tekst meegegeven actor meer). Eigen Gebruiker/Sessie-aggregate, fictieve naam, optioneel e-mail — uitsluitend voor wachtwoordherstel; single-device sessie.Beoordeling (incl. waarderen en credits), melden, betalingen, gebruikerskant Gebruiker & Toegang

Publiek blijven alleen: registreren, login/anonieme sessie, media-streaming, de read-only stamdata (GET /api/keuzelijsten/{type}, GET /api/beheer/instellingen), het slot-detail en de PSP-webhook. Doelbeeld blijft ASP.NET Core Identity (beheer, MFA mogelijk) + Argon2id met cookie-sessies (ADR-007); de bearer-token-invulling is daar de pragmatische, cross-origin-bestendige tussenstap van. De API is single-tenant. Details over hashing, rate limiting, CORS, CSP en retentie staan op Beveiliging & privacy.

flowchart LR
    BeheerFE["Beheerportaal (React)"] -->|REST + SignalR| Api
    GebruikerFE["Gebruikersportaal (React)"] -->|REST + SignalR| Api
    GebruikerFE -->|live camera| Capture["Capture (video 10s / foto)"]
    Capture -->|POST /api/media/temp| Api

    subgraph Backend["ISeeYou.Api (modulaire monoliet)"]
        Api["Minimal API + SignalR-hubs"]
        UseCases["Use-case-handlers (per module)"]
        Api --> UseCases
    end

    UseCases -->|EF Core/Npgsql| DB[("PostgreSQL (vluchtig + persistent)")]
    UseCases -->|IMediaStore| Blob[("Object-/blob-storage (S3-compatibel)")]
    Api -. push .-> Hubs["AanwezigheidHub / BeoordelingHub"]
    Retentie["RetentieHostedService (altijd aan)"] -.->|"verwijdert na resultaat-retentievenster, bij maker-actie of veiligheids-timeout"| DB
    Retentie -.-> Blob

Mediabytes staan in object-/blob-storage (niet in PostgreSQL); de database bewaart alleen de media-key en metadata. De IMediaStore-poort wordt geïmplementeerd door één S3-compatibele adapter. Zie Media & opname en Deployment.

De huidige greenfield-keuzes wijken bewust af van de prototype-referentie:

OnderwerpPrototype (referentie)Doelarchitectuur
PlatformNode/Express, JavaScript.NET / C#
OpslagJSON-bestanden + in-memory cachePostgreSQL (EF Core/Npgsql)
StructuurVlakke server.jsGelaagd + DDD, modulaire monoliet
RealtimeGeen / pollingSignalR-hubs
WachtwoordenKlare tekstArgon2id-hashing
RetentieAchter ENABLE_CLEANUP-vlagRetentieHostedService, altijd aan
TekstmoderatieGeenProactief via IModeratieDienst (OpenAI Moderation, fail-open)
MediaLokaal bestandssysteemObject-storage via IMediaStore

Zie de tech-stack op Repo-indeling & tech stack, de Beslissingen (ADR’s) en het domeinmodel voor de onderbouwing.