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 modelversie2026-07-05(vastgesteld2026-07-08b) hoort daar ook de context Beloning & Financieel bij: sterren, adviesniveaus, euro-saldo met iDeal en uitbetaling — gebouwd, met een gesimuleerde (dummy) iDeal (een echte PSP volgt); zie de endpoints hieronder.
Keuzes in het kort
Section titled “Keuzes in het kort”| Aspect | Keuze | Onderbouwing 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. |
| Vorm | Modulaire monoliet, module per bounded context | Lage operationele kosten (één deployable) met heldere contextgrenzen die later splitsbaar zijn als het verkeer dat eist. |
| Lagen | Domain ← Application ← Infrastructure ← Api | Het vluchtige, anonieme domein staat centraal en blijft vrij van infrastructuur; afhankelijkheden wijzen strikt naar binnen. |
| Use-cases | Dunne, expliciete handlers (geen MediatR) | Minder magie en indirectie; elke use-case is direct leesbaar en testbaar. |
| Persistentie | EF Core + PostgreSQL (Npgsql) | Eén database voor vluchtige én persistente data; check-constraints en unieke indexen dwingen domeinregels af. |
| Realtime | SignalR (push); REST voor alle state-mutaties | Lage-latency presence en toewijzing zonder polling; state-wijzigingen blijven idempotent via REST. |
| Schaal | Stateless, scale-to-zero + autoscaling | Lage startkosten bij weinig verkeer, klaar voor pieken; geen socket-state die scale-out blokkeert. |
Lagen & afhankelijkheidsregels
Section titled “Lagen & afhankelijkheidsregels”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
| Project | Inhoud | Mag afhangen van |
|---|---|---|
ISeeYou.Domain | Aggregates, value objects, enums, domeinregels. Puur — geen frameworks. | (niets) |
ISeeYou.Application | Use-case-handlers en poorten (interfaces): IRepository<T>, IMediaStore, IRealtimeNotifier, IClock, IPasswordHasher, IModeratieDienst, IUitslagNotificaties, IAiPersonaService. | Domain |
ISeeYou.Infrastructure | EF Core/Npgsql-implementaties, mediaopslag, Argon2id-hashing, BackgroundServices. | Application, Domain |
ISeeYou.Api | Minimal 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.
Persistentie (EF Core + PostgreSQL)
Section titled “Persistentie (EF Core + PostgreSQL)”Eén PostgreSQL-database (via Npgsql) bewaart zowel vluchtige als persistente data. De mapping verankert domeinregels in het schema.
| Onderwerp | Aanpak | Reden |
|---|---|---|
| Value objects | Als owned types (bijv. Profiel, SelectieCriteria, ProfielSnapshot, Beoordelingsselectie). | Value objects horen bij hun aggregate en hebben geen eigen identiteit. |
| Regio | Een 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. |
Scores | Als 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. |
Reactie | Als 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.) |
NotificatieEmail | Als 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. |
| Enums | Value conversion naar string (bijv. MediaType, Vraagtype, OpdrachtStatus, OpinieBron). | Leesbare, stabiele waarden in de database in plaats van magische integers. |
| Score-bereik | Domeininvariant 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. |
| Waardering | Een 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. |
| Credits | Een 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. |
| Concurrency | Optimistic concurrency via xmin. | Voorkomt verloren updates bij gelijktijdige mutaties zonder pessimistische locks. |
| Migraties | dotnet 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:
| Aard | Tabellen | Behandeling |
|---|---|---|
| Vluchtig | Vraag, Opinie, Opdracht, AnoniemeGebruiker, Sessie | Zodra 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. |
| Persistent | Gebruiker (incl. credits + de afgeleide waarderings-statistieken), Bericht, Keuzelijst, Systeem | Blijft 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 (SignalR)
Section titled “Realtime (SignalR)”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:
| Hub | Pad | Events (server → client) |
|---|---|---|
BeoordelingHub | /hubs/beoordeling | OpdrachtBeschikbaar (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).
REST-API per bounded context
Section titled “REST-API per bounded context”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.
Gebruiker & Toegang
Section titled “Gebruiker & Toegang”| Methode | Pad | Doel |
|---|---|---|
| POST | /api/sessies | Inloggen (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/profiel | De 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/huidig | De eigen sessie beëindigen (uitloggen). |
| GET | /api/credits/huidig | Het 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/gebruikers | Geregistreerde Gebruiker aanmaken (fictieve naam, Argon2id-hash; optioneel een e-mailadres, uitsluitend voor wachtwoordherstel). Optioneel een regio, gekozen uit een Keuzelijst. |
| POST | /api/tijdelijke-gebruikers | AnoniemeGebruiker 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-11m— ADR-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.
Beoordeling (media, opdrachten, opinies)
Section titled “Beoordeling (media, opdrachten, opinies)”| Methode | Pad | Doel |
|---|---|---|
| POST | /api/media/temp | Live-capture (video 10s of foto) tijdelijk uploaden; levert een tijdelijke media-key. |
| POST | /api/vragen | Vraag 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}/stream | Media streamen met Range-ondersteuning (HTTP 206). |
| GET | /api/vragen/{id}/opdrachten | De Opdrachten van één vraag ophalen. |
| POST | /api/opdrachten/{id}/opinie | Opinie 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}/uitslag | Het 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. |
Moderatie & Retentie
Section titled “Moderatie & Retentie”| Methode | Pad | Doel |
|---|---|---|
| POST | /api/vragen/{id}/meldingen | Melding 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
toelichtingbijPOST /api/vragenen dereactiebijPOST /api/opdrachten/{id}/opinie— loopt vóór opslaan door deIModeratieDienst-poort; afgekeurde tekst leidt tot een HTTP 400 en wordt niet opgeslagen. Zie de moderatie-poort hieronder.
Beheer
Section titled “Beheer”| Methode | Pad | Doel |
|---|---|---|
| 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/gebruikers | Overzicht 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/credits | Handmatig credits toekennen aan een gebruiker (beheer/test). |
| POST | /api/beheer/gebruikers/expert | Het Expert-adviesniveau handmatig toekennen of intrekken (per 2026-07-08b). |
| POST | /api/beheer/gebruikers/sterren | Het sterrenaantal direct zetten (test-tooling): body { gebruikersnaam, aantal }; het adviesniveau volgt automatisch uit de drempel (sterrenPerNiveau). |
| POST | /api/beheer/gebruikers/opschonen | Oude 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/testvragen | Testvragen genereren (test/QA): maakt N willekeurige vragen met opgegeven SelectieCriteria. |
| GET | /api/beheer/vragen | Overzicht 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-opinie | Handmatig éé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 personaId → 400. 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/instellingen | Systeeminstellingen 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/berichten | Berichten 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)”| Methode | Pad | Doel |
|---|---|---|
| GET | /api/adviesniveaus | De 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/huidig | Het actuele euro-saldo (“inleg”) van de ingelogde actor, naast het (gescheiden) credits-saldo. |
| POST | /api/betalingen/ideal | Start 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/webhook | De 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-teaser | Ontgrendelt éé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/uitbetalingen | Een uitbetaling aanvragen vanaf het profielscherm; alleen mogelijk bij een euro-saldo ≥ uitbetaalDrempelEuro (standaard €10). |
| GET | /api/beheer/sterren | Beheer-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
beoordeeldOpzet), berekent de backend de consensus-ranking over de menselijke opinies (consensus + correlatie-controle + de gewogen maker-waardering; pas vanafminBeoordelaarsVoorRanking), 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.
Algemeen
Section titled “Algemeen”| Methode | Pad | Doel |
|---|---|---|
| GET | /api/health | Liveness-/readiness-check voor het hostingplatform. |
Wat via SignalR loopt in plaats van REST
Section titled “Wat via SignalR loopt in plaats van REST”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:
| Signaal | Hub | In plaats van |
|---|---|---|
Nieuw werk in de pool (OpdrachtBeschikbaar) | BeoordelingHub | pollen op beschikbaar werk |
Uitslag klaar (VraagBeoordeeld, { makerId, vraagId }) | BeoordelingHub | pollen op de status van “Mijn vragen” |
Presence (online/offline via een AanwezigheidHub) is doelbeeld; laatsteBezoek wordt nu bij gewone API-aanroepen bijgewerkt.
Tekstmoderatie (IModeratieDienst)
Section titled “Tekstmoderatie (IModeratieDienst)”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
toelichtingvan de maker (bijPOST /api/vragen) en dereactievan de reviewer (bijPOST /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, modelomni-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 dieToegestaan = trueteruggeeft.
Eén sleutel, twee OpenAI-permissies. Dezelfde app-setting
OpenAI__ApiKeybedient sinds2026-07-04ftwee 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).
Uitslagnotificatie (IUitslagNotificaties)
Section titled “Uitslagnotificatie (IUitslagNotificaties)”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) enApp__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
beoordeeldOpzet; direct na de verzendpoging wordtVraag.notificatieEmailopnullgezet (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.
AI-persona-vangnet (IAiPersonaService)
Section titled “AI-persona-vangnet (IAiPersonaService)”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. Sinds2026-07-04fis de standaard-adapter een echte, vision-gebaseerde dienst op OpenAIgpt-4o-mini(een goedkoop vision-model). De persona-instructiestuurt de “persoonlijkheid”; het model krijgt deVraag.toelichting(per2026-07-04g) en de te scoren secties/attributen mee en scoort alleen die — allemaal, verplicht — op de schaal 1–5 (per2026-07-05gebouwd; verplicht per gevraagd attribuut, ontbrekende → neutraal 3). - Scores + reactie (per
2026-07-04g). De poort geeft voortaan eenAiBeoordelingterug: de scores én een korte vrijereactie(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__ApiKeyals 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
Openslots. Zowel het automatische vangnet als de handmatige beheer-actie vullen uitsluitendOpenslots; een door een mens vastgehoudenVoorgesteld/Gekozenslot wordt nooit overgenomen. Een AI-opinie levert geen credits op. De maker kan ze sinds2026-07-04hwé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.
Autorisatiebeleid per portaal
Section titled “Autorisatiebeleid per portaal”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:
| Portaal | Guard | Authenticatie | Dekt grofweg |
|---|---|---|---|
| Beheerportaal | admin-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 |
| Gebruikersportaal | actor-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.
Architectuuroverzicht
Section titled “Architectuuroverzicht”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.
Verschillen t.o.v. het prototype
Section titled “Verschillen t.o.v. het prototype”De huidige greenfield-keuzes wijken bewust af van de prototype-referentie:
| Onderwerp | Prototype (referentie) | Doelarchitectuur |
|---|---|---|
| Platform | Node/Express, JavaScript | .NET / C# |
| Opslag | JSON-bestanden + in-memory cache | PostgreSQL (EF Core/Npgsql) |
| Structuur | Vlakke server.js | Gelaagd + DDD, modulaire monoliet |
| Realtime | Geen / polling | SignalR-hubs |
| Wachtwoorden | Klare tekst | Argon2id-hashing |
| Retentie | Achter ENABLE_CLEANUP-vlag | RetentieHostedService, altijd aan |
| Tekstmoderatie | Geen | Proactief via IModeratieDienst (OpenAI Moderation, fail-open) |
| Media | Lokaal bestandssysteem | Object-storage via IMediaStore |
Zie de tech-stack op Repo-indeling & tech stack, de Beslissingen (ADR’s) en het domeinmodel voor de onderbouwing.