# Liefdesbrieven Prototype van een dating-app op basis van brieven: profielen zijn alleen tekst, foto's horen in brieven (maximaal één per brief, en niet in een eerste brief), en je ziet alleen profielen waarbij de filters wederzijds passen. ## De flow 1. **Account** — registreren (naam, e-mail, wachtwoord, ik ben — vrouw/man/non-binair plus optioneel de toggle *trans*, weergegeven als "trans man"; je verschijnt dan alleen bij wie "ook trans personen" aanvinkt — geboortedatum, en een aparte, niet vooraf aangevinkte toestemmingscheckbox met de privacyverklaring erbij: AVG art. 9, want gender/trans/relatiestatus/zoekfilter zijn bijzondere gegevens) of inloggen. De sessie blijft staan op het toestel tot je uitlogt, of tot hij `SESSION_TTL_DAYS` (90) niet gebruikt is. Een bestaand account zonder vastgelegde toestemming (`consent_at` null) ziet eerst de toestemmingsvraag (ConsentView) — akkoord, of gegevens opvragen en account verwijderen, of uitloggen. 2. **Profiel** (stap 2 na registratie) — *Over mij*: relatiestatus, profieltekst, het vinkje *Profiel tijdelijk verbergen* (zie [Niet verschijnen](#niet-verschijnen)), optioneel locatie; *Wie ik wil zien*: gender, leeftijd, en optioneel ook mensen in een relatie en/of alleen binnen een straal. Daarna land je in Zoeken. 3. **Zoeken** — fulltext in profielteksten, alleen wederzijds passende profielen. 4. **Brief schrijven** — minimaal 300 tekens, maximaal één foto (met een verplichte beschrijving), maar pas nadat de ander jou geschreven heeft: zolang er niets terugkwam is elke brief tekst. Na versturen open je de correspondentie. 5. **Postvak** — één regel per correspondentie, ongelezen brieven gemarkeerd (badge op de tab); ververst elke 30 s en zodra de app weer in beeld komt. Openen markeert de brieven als gelezen. De correspondentie leest als een chat: oud → nieuw, maar je landt op de eerste ongelezen brief ("▼ nieuw"), of onderaan als er niets nieuws is; "Schrijf een brief terug" blijft sticky in beeld. Er worden 20 brieven per keer geladen (`THREAD_PAGE`), oudere laden bij zodra je naar boven scrolt. Helemaal aan het begin staat het profiel van de ander als eerste "brief" (in Zoeken is die persoon later misschien niet meer te vinden). Boven de brieven staat een vaste balk met de naam en de acties: **favoriet** (★) — persoonlijk en eenzijdig, favorieten staan bovenaan in je postvak —, **meld** (⚑, zie [Melden](#melden)) en **blokkeer** (⊘, met bevestiging). Blokkeren is stil en verbergt (wist niets): de correspondentie verdwijnt uit jóuw postvak, jullie zien elkaar niet meer in Zoeken en niemand kan de ander nog schrijven. De ander ziet alleen, in plaats van de antwoordknop, "Post aan X kan niet meer bezorgd worden" — geen reden, geen melding. Deblokkeren kan bij Mijn profiel (lijst *Geblokkeerd*) en brengt alles terug. Terug naar het overzicht via de tab Postvak (of de terugknop). 6. **Uitloggen** onderaan Mijn profiel. 7. **Account** (Mijn profiel, na het profiel) — naam, e-mail, geboortedatum, gender/trans en wachtwoord wijzigen (AVG art. 16), altijd met het huidige wachtwoord ter bevestiging; een nieuw wachtwoord logt de andere toestellen uit. 8. **Notificaties** (Mijn profiel, na Account) — één schakelaar, uit tot je hem aanzet, en "Postbode komt langs om:" met twee tijdstippen die je zelf kiest (standaard 08:00, het tweede uit). Daarna kijkt het toestel op die momenten zelf of er nieuwe post is en zegt het. Zie [Notificaties](#notificaties). 9. **Jouw gegevens** (AVG, helemaal onderaan Mijn profiel) — *Mijn gegevens opvragen* toont alles wat de server van je bewaart als JSON (account, profiel, alle brieven van en aan jou, favorieten, blokkades, sessietijdstippen), te kopiëren en in de browser te downloaden; *Account verwijderen* wist na bevestiging met je wachtwoord het account definitief, inclusief álle brieven — ook bij je correspondenten. Met link naar de privacyverklaring. ### Melden Blokkeren regel je voor jezelf en werkt meteen; **melden** gaat naar de beheerder, die iets kan doen wat de gebruiker niet kan. Beide bestaan naast elkaar — en beide zijn nodig: zonder meldfunctie wijst Google Play een app met gebruikerscontent af. Melden kan op drie plekken, en dat is met opzet: - **⚑ meld** in de balk boven een correspondentie — over de persoon; - **⚑ meld deze brief** onder elke ontvangen brief — over die ene brief. "Deze brief deugt niet" is iets anders dan "deze persoon deugt niet", en dat onderscheid is precies wat de beheerder nodig heeft om te kunnen beoordelen; - **⚑ meld** onder een profiel in Zoeken — een nepprofiel of iemand die duidelijk te jong is zie je aan het profiel; daarvoor eerst een brief moeten schrijven zou onzin zijn. Dit is ook waarom melden, anders dan blokkeren en favoriet, géén lopende correspondentie vereist. De dialoog vraagt een reden (`REPORT_REASONS` in `src/constants.js`, ook als check-constraint in het schema), een vrije toelichting, en biedt aan de persoon meteen te blokkeren — dat laatste alleen als er post tussen jullie ligt, want blokkeren volgt zijn eigen regel. De melding is stil: de gemelde krijgt geen bericht en ziet niet dat er gemeld is. Na versturen blijft de dialoog staan met een bevestiging — melden zonder terugkoppeling voelt als in een put roepen. Afhandelen gebeurt op de server, niet in de app: ```sh npm run reports # openstaande meldingen, oudste eerst, met de gemelde brief npm run reports -- --all # ook de afgehandelde npm run reports -- --user 3 # het dossier van één persoon: meldingen + eerdere maatregelen npm run reports -- --handle 12 # gezien, geen maatregel nodig ``` Er is bewust géén beheerdersscherm in de app: dat vraagt om een tweede soort account met meer rechten, en dat is precies het account dat je niet wilt hebben in een app waar brieven privé horen te zijn. `npm run reports` is ook de enige plek waar iemand anders dan de schrijver en de ontvanger een brief te zien krijgt; de privacyverklaring zegt dat zo, en beperkt het tot het beoordelen van díe melding. ### Maatregelen Een meldknop zonder maatregelen is een klachtenbus zonder deur. Er zijn er vier, oplopend; elk zet de melding meteen op afgehandeld en schrijft een regel in `measures`. | Maatregel | Commando | Wat de code afdwingt | | --- | --- | --- | | **Waarschuwing** | `--warn 12 "tekst"` | De gebruiker krijgt hem bij het openen van de app te zien (`WarningView`) en móet hem wegklikken; dat wegklikken is meteen het bewijs dat hij gelezen is (`acknowledged_at`) | | **Brief verwijderen** | `--remove-letter 12` | De gemelde brief en zijn foto zijn aan beide kanten weg | | **Schorsen** | `--suspend 12 "reden" [--days 30]` | Sessies worden ingetrokken, inloggen geeft 403 mét reden en einddatum, de persoon verdwijnt uit Zoeken, en post aan hem krijgt dezelfde neutrale weigering als bij een blokkade. Zonder `--days`: onbepaalde tijd | | **Account verwijderen** | `--remove-account 12 "reden" --yes` | Dezelfde `DELETE` als AVG art. 17: profiel, sessies, favorieten, blokkades en álle brieven, ook bij de correspondenten. `--yes` is verplicht | Terugdraaien kan alleen bij een schorsing: `--unsuspend `. Naast de meldingen ligt er nog één soort post op deze plank: `--afscheid` toont de brieven die vertrekkers achterlieten. Dat is geen werkvoorraad — er valt niets aan af te handelen, en antwoorden kan niet, want er staat geen afzender bij. Zie `farewells` in het [datamodel](#datamodel-dbschemasql). Eén ding om te onthouden bij het schorsen: een geschorste kan niet inloggen en dus ook de AVG-knoppen niet gebruiken. Zijn rechten blijven wel bestaan, dus een verzoek om inzage of verwijdering per mail moet je gewoon uitvoeren — de privacyverklaring zegt dat toe. In de export zitten `warnings`, `measures` en een lopende `suspension` mee. Drie keuzes die de moeite van het onthouden waard zijn: - **Een schorsing wist niets.** De brieven die er al liggen blijven staan, ook bij de mensen die ze kregen. Schorsen gaat over wat iemand nog kán doen, niet over wat er gebeurd is. - **De reden staat in de inlogmelding**, maar pas ná het wachtwoord — anders is `/api/auth/login` een manier om te achterhalen wie geschorst is. Een maatregel die je niet uitlegt is geen maatregel maar een storing. - **Een verwijderde brief laat geen grafsteen achter.** Geen "brief verwijderd door de beheerder": dat maakt de ontvanger vooral nieuwsgierig naar wat er weggehaald is. Wil je dat de schrijver het weet, geef er dan een `--warn` bij. Het logboek `measures` overleeft het verwijderen van een account: `user_id` en `report_id` vervallen naar null, zodat blijft staan **dát** er is opgetreden en waarom, zonder nog te zeggen tegen wie. Zonder dat logboek is "we kijken ernaar" niet te controleren en is een tweede overtreding niet van een eerste te onderscheiden. Het serverlog krijgt per melding één regel (`melding #: gebruiker A meldt gebruiker B (reden) — Ne melding over deze persoon`) met alléén ids: geen toelichting, geen brieftekst. Journald bewaart 30 dagen en is breder leesbaar dan de database. ### Wat één account kan Melden is gemaakt voor mens-meldt-mens. De dreiging die daar per definitie doorheen glipt is de verkoper die zich als dater voordoet — niet-vervelend zijn is zijn methode — en het script dat hetzelfde doet zonder te typen. Wat allebei nodig hebben is **massa**: veel brieven, veel profielen, en zo nodig een nieuw account. Deze grenzen maken massa duur, en vragen daarvoor geen enkel nieuw persoonsgegeven: er wordt alleen geteld wat er al ligt. Ze staan in `src/constants.js`, de regels in `server/quota.js`. | Grens | Waarde | Waarom zo | | --- | --- | --- | | Brieven per uur | `LETTERS_PER_HOUR` (8) | Eén brief per 7½ minuut, van minimaal 300 tekens. Ruim voor een mens, krap voor een script | | Brieven per etmaal | `LETTERS_PER_DAY` (24) | Schuivend venster van 24 uur, geen kalenderdag — anders staat er om middernacht een verse voorraad klaar | | De eerste dag | `NEW_ACCOUNT_LETTERS` (4) in `NEW_ACCOUNT_HOURS` (24) | Een nieuw account is het goedkoopste dat er is, dus is het ook het minst waard. Dit zet het tempo van wie net geschorst is en opnieuw begint terug naar nul | | Zoekopdrachten | `SEARCH_PER_WINDOW` (120) per `SEARCH_WINDOW_MIN` (10) minuten, per gebruiker | De app zoekt tijdens het typen (200 ms vertraging), dus dit ligt ruim boven wat handen halen | | Profielen per zoekopdracht | `SEARCH_RESULTS_MAX` (100) | Zonder dak is één lege zoekopdracht een kopie van het complete ledenbestand. De app zegt eerlijk dat er meer zijn | Twee dingen die niet vanzelf spreken: - **De brieven worden geteld uit `letters.sent_at`, niet uit een teller in het geheugen** (`letterQuota` in `server/queries.js`). Een teller in het geheugen springt bij elke herstart en elke uitrol op nul, en dat is precies het moment waarop een script weer los mag. Zoeken mag het wél in het geheugen doen: daar is het plafond op de resultaten de eigenlijke maatregel, en die staat gewoon in de query. - **Dit straft niet en meldt niets.** Wie hiertegenaan loopt krijgt 429 met een wachttijd die klopt — het moment waarop de oudste brief uit het venster valt, of waarop het account een dag oud is — en verder niets. Geen dossier, geen melding in de wachtrij: wie hier tegenaan loopt is meestal gewoon ijverig. Dit is de eerste tranche. Wat het níet doet is het aantal accounts beperken; het maakt alleen één account minder waard — zie hieronder voor de andere helft. ### Wat een plek kost De grenzen hierboven maken één account minder waard. Dat helpt niets tegen wie er honderd neemt, en dat is precies wat een verkoper doet zodra één account te krap wordt. Daarom kost een account ook echt iets: er zijn `FREE_ACCOUNTS` (1000) **gratis plekken**, en daarna kost registreren eenmalig geld via de app-store. De bak gratis plekken is geen maatregel maar startkapitaal. Een datingapp zonder leden heeft niets te bieden, dus de eerste lichting moet er gratis in kunnen. Dat die bak in principe in één keer leeg te trekken is, is een **bewust aanvaard risico**: in de koudestartperiode zijn het er weinig genoeg om met de hand te modereren, en daarna is betalen de norm. Wat het kost staat in de store (nu **€ 5,00** in Nederland, artikel `account`), en wat het opbrengt gaat naar de maker — daar hoeft niets omheen. De hoogte is een knop met twee effecten tegelijk: hoger betekent minder aanmeldingen, en tegelijk aanmeldingen van mensen die er iets voor over hadden. Wie de prijs verhoogt koopt dus deels kwaliteit. Verhogen kan in de Play Console zonder nieuwe app-versie: de app vraagt het bedrag bij de store op en noemt zelf nooit een prijs. **De teller is de knop.** Er is geen moment waarop iemand iets omzet: zolang `signup_slots.granted < FREE_ACCOUNTS` is registreren gratis, en zodra dat niet meer waar is vraagt de app om een betaling. Er is ook niets te tonen — geen "nog 312 van de 1000", geen gemiste-kans-melding achteraf. Wie te laat komt hoort alleen wat het kost. `GET /api/auth/signup` geeft daarom alleen `{ free, productId }` en nooit een aantal: een teller die af te lezen is, is ook af te lezen door wie hem wil leegtrekken. De volgorde bij een betaalde aanmelding is **nakijken → betalen → aanmaken**, en die volgorde is het hele ontwerp: 1. `POST /api/auth/check` kijkt het formulier na zonder iets aan te maken. Zonder deze stap kan iemand betalen en dán horen dat zijn e-mailadres al bezet is. 2. De app koopt het artikel in de store (`src/lib/billing.js`) en **bewaart het bonnetje in localStorage vóór de server het gezien heeft**. Valt de verbinding weg tussen betalen en registreren, dan staat de bon er bij de volgende start nog en maakt de app het af. 3. `POST /api/auth/register` met `purchase` erbij. De server trekt de bon na bij Google (`server/billing.js`) en legt hem in dezelfde transactie vast als het account zelf. Vier eigenschappen die het geheel dragen, en waar de code omheen gebouwd is: | Eigenschap | Hoe | | --- | --- | | Eén bon = één account | `unique (provider, token)` op `purchases`, geclaimd in dezelfde transactie als de gebruiker | | Wissen en opnieuw levert niets op | `purchases.user_id` vervalt naar **null**, cascadeert niet; de gratis teller loopt niet terug | | Twee mensen kunnen niet allebei de laatste gratis plek pakken | `update signup_slots set granted = granted + 1 where granted < $1` — Postgres serialiseert dat op de rij en toetst de voorwaarde opnieuw | | Betalen zonder account eindigt niet in geld kwijt | De bon is pas verbruikt als er een account op staat; tot dan bewaart de app hem en kan hij opnieuw ingediend worden | **Wat de app niet doet is de betaling zelf.** De store is de verkoper: Google incasseert, doet de btw, kent de klant en handelt terugbetaling af. Bij ons komt één ondoorzichtige tekenreeks binnen. Daarom staat er in de database geen naam, bedrag of kaartgegeven, en hoeft de privacyverklaring geen betaaldienstverlener als verwerker te noemen — alleen dat de store zelf verantwoordelijk is voor wat hij bij de betaling verwerkt. Afhandelen doet **de server**, ná het aanmaken van het account, en niet de app (`autoAcknowledgePurchases: false`). Een aankoop die binnen drie dagen niet verwerkt is draait Google automatisch terug: dat is het vangnet dat de koper zijn geld teruggeeft als het bij ons misgaat, en dat vangnet moet blijven werken tot de plek er echt is. En de server **consumeert** de bon (`:consume`), hij bevestigt hem niet alleen. Dat is geen detail: een eenmalig artikel dat wel bevestigd maar niet geconsumeerd is blijft bij Google eigendom van dat Google-account, en dan is er nooit een tweede te koop. Wie zijn account wist zou daarmee levenslang buitengesloten zijn — zijn oude bon is hier verbruikt en een nieuwe bestaat niet. Consumeren legt de plek terug in de winkel. Dat verzwakt niets: één bon blijft één account, want dát bewaakt `unique (provider, token)` en niet de store, en wie tien accounts wil betaalt tien keer. Lukt consumeren niet, dan bevestigt de server alsnog — liever een koper die geen tweede plek kan kopen dan een koper wiens geld teruggedraaid wordt terwijl zijn account bestaat. Instellen op de server (buiten git, zie [Productie](#productie)): | Variabele | Wat | | --- | --- | | `PLAY_PACKAGE` | het pakket in de Play Console (standaard `nl.bramluiken.liefdesbrieven`) | | `PLAY_SERVICE_ACCOUNT` | het sleutelbestand van het serviceaccount, als pad óf als JSON | Staat de sleutel er niet, dan weigert de server betaalde registratie met 503 in plaats van haar door te laten: een onbevestigd bonnetje is geen bonnetje. Testen kan met een licentietester in de Play Console — die krijgt een echte, gratis aankoop met een echte token (`purchaseType: 0`), en die accepteert de server bewust. Let op bij `purchaseState`: in de **server**-API van Google is `0` = betaald. De billing-library in de app telt andersom (`PURCHASED = 1`). Die verwisseling hoeft maar één keer gemaakt te worden om elke bon goed te keuren; `server/billing.js` gebruikt de serverkant. **Volgorde bij het uitrollen.** De betaalweg zit in de app, niet alleen op de server: een oudere app-versie kan geen aankoop doen en krijgt bij een lege bak alleen de 402-melding te zien. Deze versie moet dus in de store staan (en het artikel eronder werken) vóórdat de gratis plekken op raken. `npm run reports` waarschuwt vanaf 10% resterend. Nog open, bewust niet in deze tranche: - **Terugbetalingen.** Wordt een aankoop teruggedraaid (Google's *Voided Purchases API* of real-time developer notifications), dan gebeurt er nu niets — het account blijft staan. - **Apple.** Het serverdeel is leveranciers-neutraal: `purchases.provider` kent `'apple'` en `verifyPurchase()` heeft er een tak voor die nu een nette 501 geeft. Wat er moet komen is de verificatie tegen de App Store Server API (StoreKit 2, `jwsRepresentation`); de app stuurt dat veld al mee. Uit te zoeken bij die stap: de ACM-uitspraak die datingapps in Nederland ruimte geeft voor betaling buiten Apple om. ### Hoe de gemeenschap ervoor staat Ledentallen zeggen niets over de vraag die er in de koudestartperiode het meest toe doet: *heeft iemand die zich vandaag aanmeldt hier iets aan?* Een app met duizend leden waarvan er tien alle post krijgen is voor een nieuwkomer leger dan een app met honderd leden die elkaar schrijven. `npm run health` meet dat. ```sh npm run health # de laatste 30 dagen npm run health -- --days 90 # ander venster npm run health -- --probe m:v,35 # wat treft een man van 35 aan die vrouwen zoekt? npm run health -- --fast # sla de bereikbaarheid over (de enige trage meting) npm run health -- --json # dezelfde cijfers machineleesbaar npm run health -- --html > rapport.html # hetzelfde rapport als pagina ``` Bovenaan staat het antwoord in zeven regels; daaronder de onderbouwing in zeven secties (wie er zijn, de trechter van een nieuwkomer, antwoordkans, verdeling van de aandacht, bereikbaarheid, diepte, hygiëne). Er komt **geen persoon uit** — geen naam, geen adres, geen brief, alleen tellingen en medianen. `npm run reports` is en blijft de enige plek waar iemand anders dan de schrijver en de ontvanger een brief te zien krijgt. Drie keuzes die de cijfers pas betekenis geven: **Een gemiddelde liegt hier het hardst.** Het deelt de post van de drukste correspondent uit over iedereen die niets kreeg. Daarom staat overal de mediaan naast het gemiddelde, staat er hoeveel leden er *niets* kregen, en staat er welk aandeel van alle post naar de drukste 10% ging. Bij de seed: gemiddeld 4,6 ontvangen brieven per lid, mediaan 0, vijf van de zeven kregen nooit iets. Beide getallen zijn waar; alleen het tweede beschrijft wat een nieuwkomer voelt. **Een eerste brief van gisteren is niet onbeantwoord, die is jong.** De antwoordkans wordt daarom alleen berekend over eerste brieven die minstens `--settle` dagen oud zijn, én de vraag is of er *bínnen* dat venster antwoord kwam. Teller en noemer krijgen zo evenveel klok. "Ooit beantwoord" staat er ter vergelijking naast, met de waarschuwing erbij: dat cijfer loopt vanzelf op naarmate de data ouder wordt en is dus geen maatstaf. **Het proefprofiel meet de app vanaf de deur.** Bedachte nieuwkomers gaan door dezelfde `MUTUAL_MATCH` als Zoeken — geen kopie van de matchregel, want dan meet je de kopie. Wat zo'n profiel moet vastleggen is **wie je bent én wie je zoekt**. De eerste versie nam daarvoor de standaardinstellingen van `createUser()`, en die zetten `seek_genders` op alle drie de genders. Daarmee telde de proef vrouwen, mannen én non-binaire mensen bij elkaar op: een optelsom over oriëntaties die geen mens maakt. Op de proefgemeenschap gaf dat **200** passende mensen voor een man van 35 waar het er **93** zijn. Een standaardinstelling is een eigenschap van het aanmeldscherm, geen eigenschap van een persoon. Het raster is daarom een vaste lijst profielen (`probeProfiles` in `server/report.js`) — vrouw zoekt man, man zoekt vrouw, vrouw zoekt vrouw, man zoekt man, non-binair zoekt iedereen — elk × 25/35/45/55/65 jaar. Vast en niet afgeleid van wie er al lid is: een proef die zijn vragen ontleent aan de bestaande leden meet vooral dat de bestaande leden er zijn. Juist de cel die leeg blijft is het antwoord waarvoor dit bestaat. Locatie ging op dezelfde manier mis. Dat een vers account er geen heeft is een toevalligheid van het meetmoment, en het maakte iedereen met een zoekstraal onzichtbaar voor de proef — wat het rapport toen als *bevinding* presenteerde. De proef krijgt nu een woonplaats getrokken uit waar de leden werkelijk wonen, en elk getal is de mediaan over vijftien plaatsingen; de laagste en hoogste staan erbij. Zolang weinig leden een straal instellen is die spreiding smal (123–127 op een mediaan van 125), en dat is zelf het antwoord op de vraag of woonplaats ertoe doet. Wat wél op de beginwaarden blijft staan: alleen vrijgezellen, geen trans correspondenten, en de leeftijdsband. Die band heeft één formule — de afstand naar boven, die glad meegroeit van een paar jaar in je tienertijd naar een plafond van een jaar of twaalf (`FILTER_GAP_*` in `src/constants.js`) — en de ondergrens is niets anders dan **de omkering** daarvan: de jongste die jij ziet is degene die jou nog net als oudste heeft. Daarmee is de band **wederkerig**, en dat is hier de eigenschap die telt, want de matchquery is symmetrisch: twee mensen op de beginstand zien elkaar allebei of allebei niet, nooit eenzijdig. In cijfers: 25 → 20–33, 33 → 25–44, 50 → 38–62, 80 → 68–92. Een vuistregel als "helft plus zeven" staat er bewust niet, want die is een uiterste (een vijftiger krijgt dan 32–86) en heeft bovendien een leeftijd waar de band nul breed wordt — een artefact dat overal klemmen afdwingt. Deze kromme wordt nergens nul en is ook onder de 18 zinnig; dat we daar afkappen is onze grens, niet die van de formule. Dat zijn keuzes die het product bewust dichtzet (zie [de matchquery](#de-symmetrische-matchquery)) en die de meeste mensen laten staan; het rapport zegt erbij dat wie ze openzet meer vindt. Verandert `createUser()` van beginfilters, dan hoort `probeGrid()` mee te veranderen — anders meet de proef een gebruiker die niet bestaat. Wat het rapport níet kan zien, en zelf onderaan opsomt: vertrokken leden (die nemen hun brieven mee, dus elk cijfer over het verleden krimpt), of iemand in Zoeken gezíen is, en de werkelijke omvang van misbruik — meldingen en blokkades zijn altijd een ondergrens, want ze verdwijnen met het account en na `REPORT_RETENTION_DAYS`. Een gezondheidscijfer van één moment zegt weinig; de beweging erin zegt alles. Vandaar `npm run health -- --json >> health.jsonl`. **Vier uitvoervormen, één meting.** De terminal is om even te kijken, `--json` is om te bewaren en te vergelijken, `--html` geeft één zelfstandig bestand om door te sturen, en `/cijfers` op de website is de publieke maandstand (zie hieronder). Ze halen alle vier hun gegevens uit `collectReport()` in `server/report.js` — anders zegt de ene pagina iets anders dan de andere over dezelfde maand, en is geen van beide nog te vertrouwen. Het bestand van `--html` Die pagina heeft niets van buiten nodig — geen stylesheet, geen lettertype van een server — zodat hij een jaar later nog opent zoals hij bedoeld was; de opmaak leent het palet uit `src/theme.js` en dezelfde drie letters die de app gebruikt (`scripts/health-html.js`). Kleurgebruik is er karig: één blauw in drie stappen voor "hoeveelheid", en rood uitsluitend voor wat tegenvalt, zodat de kleur zelf een mededeling is. De getalopmaak staat in `scripts/health-format.js` en wordt door beide uitvoervormen gedeeld — "20 u" en "18% (702 van 3858)" horen overal hetzelfde te betekenen. Staat er een `@voorbeeld.nl`-adres in de database, dan zet het rapport daar bovenaan een waarschuwing bij: dan kijk je naar `db:setup`- of `db:generate`-data en niet naar mensen. Dat hoort boven het rapport te staan in plaats van eronder ontdekt te worden. ### De maandstand op de website (`/cijfers`) Een datingapp kan van alles beweren over hoeveel leden hij heeft. Wat iemand wil weten is of hij er antwoord krijgt. Dat staat op `/cijfers`, gelinkt vanaf de voorpagina, en het is **een ander document dan het beheerdersrapport** — met opzet: - **Kleine aantallen verraden mensen.** "2 vrouwen in een relatie, waarvan 0 actief" wijst in een kleine gemeenschap naar een persoon. Alles met een noemer onder 20 wordt daarom niet getoond, en de uitsplitsing naar gender, trans-status en relatiestatus staat er helemaal niet op: dat zijn bijzondere persoonsgegevens (AVG art. 9), en die horen niet in een publieke statistiek, hoe geaggregeerd ook. Onder de 40 leden toont de pagina alleen dát ze te klein is om iets te zeggen. - **De lezer is geen beheerder** maar iemand die overweegt mee te doen. Geen cohorttrechter, geen Gini; wel de antwoordkans, de wachttijd, en hoeveel mensen er zijn voor iemand van zijn leeftijd én oriëntatie. - **Het slechte nieuws staat er net zo goed op** als het goede: hoeveel leden er geen enkele brief kregen, welk aandeel van de post naar de drukste tien procent gaat, en hoeveel eerste brieven onbeantwoord bleven. Een pagina die alleen de gunstige helft publiceert is een reclamefolder, en dan kun je hem beter niet maken. - **Maandelijks, niet live.** De stand wordt één keer per maand berekend en daarna uit het geheugen geserveerd. Dat is geen zuinigheid: een teller die live meeloopt is een teller waaruit je met twee metingen kunt afleiden wat er tussendoor met één persoon gebeurde. Een maandstand kan dat niet. De O(n²)-bereikbaarheidsmeting blijft er bovendien buiten, zodat een publiek verzoek nooit duur is. De letters worden hier niet ingesloten maar apart geserveerd (`/lettertype/.woff2`, een jaar houdbaar): dat scheelt per paginaweergave 155 KB. Alleen namen uit `FONT_FILES` komen door — een pad uit de URL zou daar een lees-alles van maken. ### Een virtuele gemeenschap om op te meten De seed is zeven mensen en twee correspondenties: genoeg om de app te bekijken en met de hand na te rekenen, en veel te weinig om te zien of een meting doet wat ze belooft. Een mediaan over zeven leden is geen mediaan. `npm run db:generate` bouwt daarom een gemeenschap van duizenden op uit expliciete verdelingen. ```sh npm run db:generate # 2000 leden, een half jaar geschiedenis npm run db:generate -- --users 12000 --days 365 npm run db:generate -- --desire-sigma 2.0 # veel schevere aandacht npm run db:generate -- --list # alle knoppen met hun standaardwaarde npm run health ``` Het is **geen uitspraak over hoe mensen zich werkelijk gedragen** — de getallen erin zijn geen bevindingen maar knoppen. Ze staan er om te kunnen vragen: *als de gemeenschap zó in elkaar zit, ziet het rapport dat dan?* Het model geeft elk lid drie eigenschappen die het sociale gedrag sturen — begeerlijkheid (lognormaal: bepaalt wie post krijgt), antwoordbereidheid (logit-normaal, dalend met hoeveel post je al kreeg) en volhardendheid (kans dat een briefwisseling doorgaat) — plus een aanmelddatum uit een groeikromme en een levensduur uit een exponentiële verdeling. Doelwitten worden getrokken naar begeerlijkheid en dan getoetst aan de wederzijdse match; dáár komt de scheve aandacht vandaan. Alles is reproduceerbaar: dezelfde `--seed` en dezelfde knoppen geven dezelfde gemeenschap, tot op de brief. Er wordt nergens `Math.random()` gebruikt — anders is "het cijfer bewoog" niet te onderscheiden van "het toeval bewoog". Dat het werkt is af te lezen aan één knop verzetten en kijken wat er beweegt (1500 leden, zaad 3): | knop verzet | Gini | naar de drukste 10% | antwoordkans | met straal | proef-mediaan | echte corr. | | --- | --- | --- | --- | --- | --- | --- | | *standaard* | 0,69 | 40% | 18% | 172 | 44 | 4% | | `--desire-sigma 0.2` | **0,61** | **30%** | 20% | 172 | 43 | 5% | | `--desire-sigma 2.0` | **0,83** | **62%** | 17% | 172 | 44 | 4% | | `--reply-rate 0.05` | 0,72 | 45% | **4%** | 172 | 45 | 1% | | `--reply-rate 0.80` | 0,69 | 43% | **39%** | 172 | 43 | 9% | | `--radius-share 0.8` | 0,81 | 56% | 19% | **891** | **20** | 5% | | `--persist 0.9` | 0,73 | 50% | 20% | 172 | 44 | **11%** | Elke knop beweegt de meting die erbij hoort, en laat de rest staan. De laatste regel van `--radius-share` is de leerzaamste: méér leden met een zoekstraal verandert niets aan de antwoordkans, maar snijdt wat een nieuwkomer kan bereiken van 44 naar 20 — en dat is precies het soort achteruitgang dat je aan een ledental nooit zou zien. Twee dingen om te onthouden: - **De matchvoorwaarde staat hier in JS, als kopie van `MUTUAL_MATCH`.** Dat is verdedigbaar omdat het om het *maken* van gegevens gaat en niet om het meten ervan — het rapport meet straks met het origineel. Maar ze moeten wel gelijk blijven, anders schrijven de gegenereerde leden aan mensen die ze in de app nooit gezien zouden hebben. - **Het wist de hele database.** Het weigert te draaien tegen iets anders dan localhost; `--force` overrulet dat, en dan is het je eigen beslissing. Terug naar de gewone seed: `npm run db:setup`. Kosten: 2000 leden in ~3 seconden, 12 000 leden in ~14. Het rapport erover duurt bij die 12 000 (45 000 correspondenties) 7 seconden, waarvan 5 in de bereikbaarheid — de enige O(n²)-meting, en de reden dat die een steekproef en een `--fast` heeft. ### Privacy (AVG) De privacyverklaring is één tekst op drie plekken: `src/content/privacy.js` (data), in de app (`PrivacyDialog`, bij registratie/toestemming/Mijn profiel) en op de publieke website die onderdeel is van de server: **`https://liefdesbrieven.bramluiken.org/privacy`** (`server/privacy.js`; `/` is een kale voordeur met link en contact — er draait bewust géén web-versie van de app). Die URL is voor de Google Play-console (privacybeleid + de verplichte pagina "account verwijderen zonder de app"). `/api/privacy` (oude URL) → 301. Termijnen en contactgegevens komen uit `src/constants.js` (`SESSION_TTL_DAYS`, `INACTIVE_ACCOUNT_DAYS`, `PRIVACY_CONTROLLER`, `PRIVACY_CONTACT`, `PRIVACY_VERSION`), zodat tekst en gedrag niet uit elkaar lopen. Verhoog `PRIVACY_VERSION` én zet `consent_at` op null bij een wezenlijke wijziging: dan krijgt iedereen de toestemmingsvraag opnieuw. Het is een **concept** geschreven bij wat de code doet — laat het juridisch nakijken. Wat de code handhaaft: toestemming vóór registratie (`consent_at`); sessies verlopen na 90 dagen zonder gebruik; accounts die 730 dagen op geen enkele manier actief waren (geen login, geen sessiegebruik, geen profielwijziging, geen brief) worden dagelijks door de server gewist, net als meldingen ouder dan `REPORT_RETENTION_DAYS` (365) (`purgeExpired`, bij het starten en elke 24 uur, met een logregel); locaties worden afgerond op 2 decimalen (~1 km) opgeslagen; inloggen (per IP en per e-mail), registreren (per IP) en wachtwoordbevestigingen (per gebruiker) zijn rate-limited (`server/ratelimit.js`, 429 + Retry-After; achter nginx hoort `X-Real-IP` gezet te zijn, zie [Productie](#productie)), net als brieven schrijven en zoeken (zie [Wat één account kan](#wat-één-account-kan)). Bij een betaalde plek verwerken we geen betaalgegevens — de store is de verkoper — en blijft na het wissen van een account alleen het bonnetje staan, zonder `user_id` (zie [Wat een plek kost](#wat-een-plek-kost)); dat staat zo in de verklaring, want het is de enige uitzondering op "alles gaat weg" — op de brief na die iemand zelf achterlaat (`farewells`, zie hieronder). Inzage is een **bestand**, geen tekstvak: `ExportDialog` toont wat erin zit en laat de export opslaan via `src/lib/export-file.js`. In de browser is dat een gewone blob-download; in de app kan dat niet (de Capacitor-webview heeft geen downloadmanager, een `` doet daar niets), dus schrijft hij het bestand naar de privécachemap van de app en geeft hij het aan het deelblad van het toestel — "Opslaan in Bestanden", Drive, of mailen aan jezelf. Bij een volgende export worden de eerdere bestanden uit die map gewist; meteen ná het delen wissen kan niet, want de ontvangende app leest het dan nog. De JSON staat met opzet niet op het scherm: met de foto's als base64 erin is dat al gauw tientallen megabytes. Wie zijn account wist mag een **brief aan de beheerder** achterlaten (optioneel veld `farewell` bij `DELETE /api/me`). Hij gaat in dezelfde transactie mee als het wissen en komt in `farewells` terecht — zonder id, naam of e-mailadres, dus onbeantwoordbaar. Dat is de prijs voor het enige wat een vertrek in woorden achterlaat: een klacht die je alleen anoniem durft in te dienen is precies de klacht die je wilt horen. Lezen: `npm run reports -- --afscheid`. Nog niet in code, wel op papier nodig: verwerkingsregister, DPIA, verwerkersovereenkomst met Hetzner, datalekprocedure; en de logbewaartermijn van 30 dagen uit de verklaring moet op de server gezet worden (`journald`: `MaxRetentionSec=30day`). Android-terugknop: sluit eerst de brief-dialoog (met bevestiging als er al tekst staat), dan de correspondentie, dan terug naar Zoeken; daarna pas de app. ## Een mislukking is nooit stil Uitgangspunt door de hele app heen: **een handeling die mislukt, zegt wat er misging.** Niet "er ging iets mis" en niet niets — de reden, in woorden, op de plek waar je stond. De aanleiding was de meldingsschakelaar. Die vroeg toestemming, kreeg een afwijzing terug, en gooide hem weg: het vinkje sprong terug naar uit en verder gebeurde er niets. Voor de gebruiker niet te onderscheiden van een knop die stuk is, en voor ons niet te onderzoeken, want de reden was al weg voor iemand hem kon lezen. Dezelfde vorm zat in het locatieknopje: de plugin onderscheidt elf oorzaken met een eigen code, en het scherm maakte er één zin van. Drie regels, in deze volgorde: 1. **Vangen mag, wegslikken niet.** Een `catch` die niets doorgeeft is alleen goed als er niets te melden valt. Geeft de aanroep een reden, dan gaat die mee naar boven — als waarde (`{ fout: { code, message } }`), niet als uitzondering, want wie hem opvangt is meestal een scherm dat iets wil tonen en geen code die iets wil afvangen. 2. **De veelvoorkomende fouten worden herkend.** Codes en boodschappen die te verwachten zijn, staan uitgeschreven met wat ze betekenen — zie `src/lib/plugin-fouten.js`. De zin zegt wat er aan de hand is, en wat de lezer eraan kan doen als hij er iets aan kan doen. Kan hij dat niet, dan zegt de zin dát: "dit ligt aan deze versie van de app" is een beter antwoord dan een aansporing die nergens toe leidt. 3. **Wat we niet herkennen, tonen we ruw.** De kale boodschap met de code erachter. Lelijk, maar door te geven — en de eerstvolgende keer dat iemand hem doorgeeft, staat hij in de lijst van punt 2. Daarnaast gaat elke aanroep van een native plugin met invoer én antwoord het logboek van het toestel in, ook in de release; zie `src/lib/log.js`. Uit te lezen met `adb -s logcat -s Liefdesbrieven`. Nog niet overal doorgevoerd. Wat er al staat: de meldingsinstelling (`PostSection`) en het ophalen van de locatie (`ProfileEditor`). ## Starten Vereist Node en een draaiende PostgreSQL. Verbinding via `DATABASE_URL` (standaard `postgres://postgres@localhost:5432/liefdesbrieven`, zie `.env.example`). ```sh npm install npm run db:setup # maakt de database aan, voert db/schema.sql + db/seed.sql uit npm run api # API op http://localhost:3000/api (of: npm run api:watch) npm run dev # frontend op http://localhost:5173 — proxyt /api naar :3000 npm run reports # meldingen bekijken en maatregelen nemen npm run health # hoe de gemeenschap ervoor staat (en wat een nieuwkomer mag verwachten) npm run db:generate # de seed vervangen door een virtuele gemeenschap van duizenden npm run db:promotie # eenmalig: de aparte database voor /promotieteam (raakt de app niet) ``` De frontend praat via `src/api/client.js` met de API; alle data (profielen, brieven, matching, afstanden) komt uit Postgres. `VITE_API_URL` is in `.env` leeg (zelfde origin, via de proxy); alleen `.env.production` — gelezen door `vite build`, dus door de Capacitor-app — wijst naar de productie-API. Inloggen kan als elke seed-gebruiker: `@voorbeeld.nl` (daan, marit, noor, hanna, vera, jasper, sam) met wachtwoord `liefdesbrieven` — handig om een briefwisseling van twee kanten te bekijken. Daan heeft twee correspondenties (Marit ongelezen, Noor ouder maar favoriet → bovenaan). `npm run db:setup` maakt de tabellen opnieuw aan (bestaande data gaat verloren). ## Backend (Postgres + Hono) ### Datamodel (`db/schema.sql`) - `users` — account (`email` uniek, `password_hash`), profiel als tekst (`bio`), `birthdate`, `gender` + `trans` (onafhankelijke vlag; weergave "trans man"), `relationship` (`vrijgezel` | `relatie`), en het filter als `seek_genders` + `seek_relatie` (ook mensen in een relatie zien; vrijgezellen altijd) + `seek_trans` (ook trans personen zien) + `seek_min_birth`/`seek_max_birth`. Leeftijden worden aan de rand vertaald naar geboortedata (`oldest_birthdate()`, `youngest_birthdate()`, identiek aan `agesToBirthRange()` in de frontend), zodat het filter niet veroudert en een B-tree-range-scan blijft. - `users.bio_tsv` — gegenereerde `tsvector` (`dutch`) met GIN-index voor fulltext. - `users.lat`/`lon` (optioneel) en `seek_radius_km` (optioneel, vereist een locatie). Afstand via de SQL-functie `distance_km()` (haversine, geen PostGIS). - `sessions` — `token_hash` (sha256 van het token dat de client bewaart), `user_id`. - `letters` — `from_id`, `to_id`, `sent_at`, `read_at` (null = ongelezen), `body` (check ≥ 300 tekens). - `photos` — hoogstens één per brief (`letter_id` uniek, `on delete cascade`): de bytes zelf in een `bytea` (max 1 MB), plus `mime`, `width`, `height` en de verplichte beschrijving `alt`. Zie [De foto bij een brief](#de-foto-bij-een-brief). - `favorites` — `(user_id, other_id)`: `user_id` heeft `other_id` als favoriet. Eenzijdig; alleen te zetten bij een lopende correspondentie. Het postvak sorteert `favorite desc, sent_at desc`. - `blocks` — `(user_id, blocked_id)`: `user_id` heeft `blocked_id` geblokkeerd. Verbergt, wist niets. `MUTUAL_MATCH` en `canWrite` sluiten een blokkade in welke richting ook uit; `listThreads` verbergt door mij geblokkeerde correspondenties; `getThread` geeft `canReply: false`. - `reports` — meldingen van misbruik: `reporter_id`, `reported_id`, optioneel `letter_id` (null = over de persoon), `reason`, `details`, `handled_at`. De drie sleutels vervallen bewust verschillend: `reported_id` **cascade** (is de gemelde weg, dan valt er niets te beoordelen), `reporter_id` en `letter_id` **set null** — een melding mag niet verdwijnen doordat de melder zijn account opzegt, want juist bij misbruik is dat precies wat er gebeurt. Wat overblijft is de melding zonder melder. Zie [Melden](#melden). - `warnings` — waarschuwingen van de beheerder aan één gebruiker; `acknowledged_at` null = de app toont hem vóór alles en laat pas verder na het wegklikken. - `measures` — het logboek van genomen maatregelen (`waarschuwing`, `brief_verwijderd`, `schorsing`, `opheffing`, `account_verwijderd`). Beide sleutels vervallen naar null, zodat het logboek een accountverwijdering overleeft. - `users.suspended_at` / `suspended_until` / `suspend_reason` — een lopende schorsing. `suspended_until` null bij een gezette `suspended_at` betekent *onbepaalde tijd*, niet *niet geschorst*. Afgedwongen in `getSessionUser`, bij inloggen, in `MUTUAL_MATCH` en bij `POST /api/letters`. Zie [Maatregelen](#maatregelen). - `signup_slots` — één rij met `granted`: hoeveel accounts er zonder betaling zijn aangemaakt. Met opzet een teller en geen `count(*)` op `users`: een verwijderd account geeft zijn plek niet terug, anders is "aanmaken, wissen, opnieuw" de goedkoopste manier om de gratis bak leeg te halen. De bovengrens staat in `src/constants.js` (`FREE_ACCOUNTS`), niet in de database — dan is hij te verzetten zonder schemawijziging. - `purchases` — betaalde plekken: `provider` (`google` | `apple`), `token` (het bonnetje), `product_id`, `order_id` en `user_id`. `unique (provider, token)` is de hele replay-bescherming, en `user_id` vervalt naar **null** in plaats van te cascaderen: zou de rij met het account meeverdwijnen, dan levert dezelfde aankoop een gratis tweede account op. Zie [Wat een plek kost](#wat-een-plek-kost). - `departures` — wat er van een verdwenen account overblijft: `at`, `days_member`, `reason` (`zelf` | `slapend` | `maatregel`) en één bit, `wrote`. Géén vreemde sleutel naar `users`: het hele punt is dat deze rij het wissen overleeft. Zonder haar krimpt elke telling over het verleden stilletjes mee met wie er opzegt, en ziet de database eruit als een gemeenschap waar nooit iemand is weggegaan. Ze wijst naar niemand — geen id, geen naam, geen gender, geen leeftijd, geen tekst. Geschreven vanuit `deleteUser()` en `purgeExpired()`, telkens in dezelfde transactie als het wissen zelf. - `farewells` — `(at, body)`: de brief die iemand bij zijn vertrek voor de beheerder achterliet. Optioneel, en net als `departures` zonder vreemde sleutel — hier staat geen id, geen naam en geen e-mailadres, dus er is niet op te antwoorden en niet uit af te leiden wie het schreef. Geschreven vanuit `deleteUser()`, in dezelfde transactie als het wissen zelf; een te lange brief wordt afgekapt in plaats van geweigerd, want vertrekken is een recht en de brief een gunst. Lezen: `npm run reports -- --afscheid`. - `search_terms` — `(term, day, count)`: hoe vaak elk losse woord uit de zoekbalk op een dag werd gezocht. Net als `departures` wijst deze tabel naar niemand, en dat is de hele opzet: geen gebruiker, geen sessie, geen tijdstip fijner dan de dag, en niet de zoekopdracht maar de woorden eruit (`toTerms()`, dezelfde die naar Postgres gaat). "marieke van der berg" valt daarmee uiteen in vier woorden die elk op een grote hoop vallen. Er is dus niets te exporteren, niets te wissen bij een accountverwijdering en niets te vorderen. Zie [Waar op gezocht wordt](#waar-op-gezocht-wordt). ### Waar op gezocht wordt Wat mensen in Zoeken intypen is het enige signaal over wat ze hopen te vinden dat niet uit de profielen zelf komt: een woord dat vaak gezocht wordt en zelden in een profieltekst staat, is een gat in de gemeenschap. Dat is waarvoor dit er is — en de vorm is gekozen zodat het antwoord op die vraag geen zoekgeschiedenis oplevert. **Eén regel per zoekintentie, niet per toetsaanslag** (`server/search-log.js`). De app zoekt tijdens het typen; de client debounced 200 ms, maar dat plakt alleen aanslagen binnen 200 ms aan elkaar — "dostojevski" komt nog altijd binnen als `do`, `dost`, `dostoje`, `dostojevski`. De server houdt daarom de laatste zoekopdracht per gebruiker een paar seconden vast en telt hem pas als er iets komt dat géén voortzetting is (ander woord → de vorige was af), als het `IDLE_MS` stil blijft, of als het zoekveld leeg wordt gemaakt. Doortypen én backspacen gelden als dezelfde zoekopdracht; de laatste versie wint, want dat is wat er op het scherm staat als het zoeken stopt. Wat er in het geheugen ligt is hoogstens één zoekopdracht per zoekende gebruiker, seconden lang, en het overleeft geen herstart — die paar tellingen zijn de goede kant om te verliezen. **Uit de logs gehouden.** Hono's `logger()` logt het pad *inclusief* querystring; die is daarom vervangen door `pathLogger` in `server/index.js`, dat alleen `pathname` logt. Bij nginx moet hetzelfde gebeuren, zie [Productie](#productie). **Eruit komt het** in `npm run health`, sectie H: de vaakst gezochte woorden met het aantal profielen waarin ze voorkomen, plus de woorden die vaak gezocht worden en in hoogstens twee profielen staan. Alleen daar — `collectReport()` haalt de tellingen uitsluitend op met `searchTerms: true`, en de publieke pagina `/cijfers` vraagt er niet om (en gebruikt bovendien een andere renderer). ### De symmetrische matchquery Eén `WHERE` die beide kanten controleert (`MUTUAL_MATCH` in `server/queries.js`): ```sql where u.gender = any (me.seek_genders) and (u.relationship = 'vrijgezel' or me.seek_relatie) and (not u.trans or me.seek_trans) and u.birthdate between me.seek_min_birth and me.seek_max_birth and (me.seek_radius_km is null or (u.lat is not null and distance_km(me.lat, me.lon, u.lat, u.lon) <= me.seek_radius_km)) and me.gender = any (u.seek_genders) and (me.relationship = 'vrijgezel' or u.seek_relatie) and (not me.trans or u.seek_trans) and me.birthdate between u.seek_min_birth and u.seek_max_birth and (u.seek_radius_km is null or (me.lat is not null and distance_km(u.lat, u.lon, me.lat, me.lon) <= u.seek_radius_km)) and u.bio_tsv @@ to_tsquery('dutch', 'dost:* | zuurd:*') order by ts_rank_cd(...) desc, last_active desc ``` Zoektermen worden als prefix-query (`term:*`) met OR gecombineerd, zodat typen-en-zoeken zich gedraagt als in het prototype. Relatiestatus: vrijgezellen ziet iedereen; iemand die `in een relatie` heeft aangevinkt verschijnt alleen bij mensen die `ook mensen in een relatie` aan hebben staan (`seek_relatie`, standaard uit) — en andersom. Trans: net zo. Wie bij registratie *trans* aanvinkt (weergave "trans man"), verschijnt alleen bij mensen die `ook trans personen` aan hebben staan (`seek_trans`; standaard uit, aan als je zelf trans bent). Dit beschermt de trans persoon: wie hem of haar vindt, staat daar bewust voor open — geen teleurstelling achteraf. Afstandsfilter: zonder straal speelt locatie geen rol (behalve om afstanden te tonen). Mét straal zie je alleen mensen die hun locatie hebben ingevuld en binnen je straal zitten — en omgekeerd verschijn jij alleen bij mensen binnen wier straal jij valt. #### Niet verschijnen Twee voorwaarden zeggen niets over of jullie bij elkaar passen, maar of iemand überhaupt te vinden hoort te zijn. Ze staan in `MUTUAL_MATCH` en niet in `searchProfiles`, en dat is het punt: die ene `WHERE` is de enige plek waar "mag deze persoon opduiken" beantwoord wordt. Zoeken, `canWrite()` en het gezondheidsrapport hangen er alle drie aan, dus een uitzondering die je hier maakt geldt overal, en eentje die je hier vergeet lekt overal. - **`hidden`** — het vinkje *Profiel tijdelijk verbergen*, onder de profieltekst bij Mijn profiel. Je verdwijnt uit Zoeken en niemand kan je nog een eerste brief sturen. Lopende correspondenties gaan door: `canWrite()` staat schrijven toe bij een match **óf** bij bestaande post, en die tweede tak raakt dit niet. Dat is het verschil met een schorsing of een vertrek — verbergen is een pauze, en wie al met je in gesprek is merkt er niets van. Het vinkje staat daarom ook bij de profieltekst en niet bij de AVG-knoppen onderaan, waar het als weggaan zou lezen. - **`btrim(u.bio) <> ''`** — een leeg profiel verschijnt niet. Dit is een app waarin je op je woorden gevonden wordt, dus zonder woorden valt er niets te vinden; een lege kaart in Zoeken kost de kijker tijd en levert de schrijver niets op. `btrim` en niet `<> ''`, want een profiel van spaties is net zo leeg — en dat geldt ook voor rijen die er al stonden. De profieleditor zegt het erbij zodra het vak leeg is, zodat het geen stille straf is. ### API De Hono-server is drie lagen: de root-app serveert de publieke website (`GET /`, `GET /privacy` en `GET /cijfers`, HTML, geen sessie), daarin zijn de JSON-API onder `/api` en de mediaserver onder `/media` gemount. Nginx proxyt het hele domein naar de server. Auth: `POST /api/auth/register` of `/login` geeft `{ token, user }`; daarna stuurt de client `Authorization: Bearer ` mee. Wachtwoorden: scrypt (`server/auth.js`); in `sessions` staat alleen de sha256 van het token. Een 401 laat de client zijn token wissen en het inlogscherm tonen. | Methode | Pad | Doet | | ------- | ---------------------------- | ------------------------------------------------------------- | | GET | `/api/auth/signup` | kost een account nu geld? → `{ free, productId }`. Geen sessie nodig, en met opzet geen aantal — zie [Wat een plek kost](#wat-een-plek-kost) | | POST | `/api/auth/check` | hetzelfde formulier als `/register` nakijken zónder iets aan te maken → `{ ok: true }` of 400/409. Bestaat zodat niemand betaalt voor een e-mailadres dat al bezet is; rate-limited per IP | | POST | `/api/auth/register` | `{ email, password, name, gender, trans?, birthdate, consent: true, purchase? }` → sessie (18+; zonder `consent` 400); rate-limited per IP. `purchase` = `{ provider, token, productId }` uit de store, nodig zodra de gratis plekken op zijn. 402 (`code: payment_required`) = er moet (alsnog) betaald worden; 409 met `code: purchase_used` = op deze bon staat al een account (te onderscheiden van de 409 voor een bezet e-mailadres, want alleen bij het eerste mag de app de bon weggooien) | | POST | `/api/auth/login` | `{ email, password }` → sessie; rate-limited per IP en per e-mail (429). Geschorst account: 403 met reden en einddatum — pas ná het wachtwoord, anders verklapt dit endpoint wie geschorst is | | POST | `/api/auth/logout` | sessie weggooien | | GET | `/api/me` | mijn profiel (incl. e-mail, `consentAt` en `warning` = openstaande waarschuwing van de beheerder of null) | | POST | `/api/warnings/:id/ack` | waarschuwing gelezen en weggeklikt; alleen je eigen, en maar één keer (anders 404) | | POST | `/api/me/consent` | `{ consent: true }` — bestaand account geeft alsnog toestemming → me | | PUT | `/api/account` | `{ currentPassword, name?, email?, birthdate?, gender?, trans?, newPassword? }` → me; fout wachtwoord 403; nieuw wachtwoord wist de andere sessies | | POST | `/api/me/logout-others` | `{ currentPassword }` → `{ loggedOut }`: alle andere toestellen uitloggen, dit toestel blijft. Fout wachtwoord 403. Het wachtwoord is hier de hele beveiliging: zonder zou een gestolen token genoeg zijn om de eigenaar buiten te zetten | | PUT | `/api/me` | `{ text, relationship, genders, seekRelatie, seekTrans, ageMin, ageMax, location?, radiusKm?, hidden? }` — `hidden: true` haalt je uit Zoeken (zie [Niet verschijnen](#niet-verschijnen)) | | GET | `/api/me/export` | AVG-inzage: `{ exportedAt, account, profile, letters, favorites, blocks, reports, warnings, measures, sessions }` — alles van mij, brieven met naam van de ander; geen wachtwoord/tokens. `reports` = meldingen die ík maakte; meldingen óver mij staan er bewust niet in (AVG art. 15 lid 4: dat zou verraden wie mij gemeld heeft). `warnings`/`measures` = wat er tegen mij is ondernomen, zonder te zeggen wie meldde | | DELETE | `/api/me` | AVG-vergetelheid: `{ password }` ter bevestiging (fout wachtwoord → 403); wist het account en via cascade sessies, favorieten, blokkades, meldingen óver mij en álle brieven van en aan mij. Optioneel `{ farewell }`: een brief aan de beheerder, bewaard in `farewells` zonder afzender (te lang → afgekapt, nooit geweigerd) | | GET | `/api/search?q=` | wederzijds passende profielen (+ `distanceKm`), fulltext. Hoogstens `SEARCH_RESULTS_MAX` (100) per keer, met `truncated: true` als er meer waren; max 120 zoekopdrachten per 10 minuten per gebruiker (429). De losse woorden worden geteld in `search_terms` — zonder wie, zonder wanneer, zie [Waar op gezocht wordt](#waar-op-gezocht-wordt) | | GET | `/api/threads` | postvak: per gesprekspartner de laatste brief + `unread` + `favorite`; favorieten eerst | | GET | `/api/threads/:userId` | één pagina brieven (oudste eerst) + `favorite` + `canReply` + `canPhoto` (heeft de ander mij al geschreven? zo nee: geen foto) + `hasMore`; `?before=` voor oudere, `?limit=` (max 100). Eerste pagina bevat altijd alle ongelezen. Alleen als er post tussen jullie ligt óf je die persoon mag schrijven — anders 404, want anders is dit één profiel per id en zijn verbergen, blokkeren en de symmetrische match voor niets | | POST | `/api/threads/:userId/read` | brieven van die persoon aan mij als gelezen markeren | | POST | `/api/threads/:userId/favorite` | `{ favorite: true \| false }` — alleen bij een lopende correspondentie (anders 404) | | POST | `/api/threads/:userId/block` | `{ block: true \| false }` — blokkeren alleen bij een lopende correspondentie; deblokkeren altijd | | GET | `/api/blocks` | wie ik geblokkeerd heb: `[{ user, since }]` | | GET | `/api/post` | "ligt er post?" — het dagelijkse bezorgmoment van het toestel (zie [Notificaties](#notificaties)). `?since=` = de hoogste brief waarover al een melding ging → `{ senders, favorites, name, latest }`. Staat vóór de sessie-middleware en controleert het token zelf, want deze vraag mag **niet** als gebruik van de app tellen: hij werkt `last_seen_at` niet bij. Geen briefteksten, hoogstens twee namen | | POST | `/api/reports` | misbruik melden: `{ about, letterId?, reason, details?, block? }` → `{ id, blocked }`. Mag zonder lopende correspondentie; `letterId` moet wél tussen ons tweeën liggen (anders 400). `block` werkt alleen bij een lopende correspondentie. Max 20 meldingen per dag per gebruiker (429) | | POST | `/api/letters` | `{ to, text, photo? }` — ≥ 300 tekens, alleen bij match of lopende correspondentie; 403 bij blokkade ("Post aan X kan niet meer bezorgd worden"). `photo` = `{ data (base64), mime, width, height, alt }` of `null`; 400 als de ander mij nog nooit geschreven heeft (geen foto in een openingsbrief); 413 als het verzoek te groot is. Begrensd per uur, per etmaal en de eerste dag (429 met wachttijd, zie [Wat één account kan](#wat-één-account-kan)) | | GET | `/media/:id` | de bytes van één foto (`image/jpeg\|png\|webp`, `Cache-Control: private, no-store`) — alléén voor de afzender en de ontvanger van de bijbehorende brief; iedereen anders krijgt 404. Vraagt om `Authorization: Bearer` en zit **niet** onder `/api` | ### De foto bij een brief Eén foto per brief, en maar twee mensen mogen hem ooit zien: de schrijver en de ontvanger. Dat bepaalt de hele opzet. **Niet in een openingsbrief.** Er mag pas een foto mee zodra de ander míj geschreven heeft (`hasLetterFrom`). Ongevraagde post is dus altijd tekst — ook de tweede en de derde brief aan iemand die zwijgt; antwoorden mag wél meteen beeld hebben, want wie terugschrijft heeft de ander gekozen. De server weigert zo'n foto (400) en `getThread` geeft `canPhoto` mee, zodat het schrijfblad de fotokiezer niet eens toont — anders had je hem al gekozen en verkleind voor je het hoorde. **Opslag: `bytea` in Postgres**, in een aparte tabel `photos` met `letter_id ... on delete cascade`. Daarmee blijft het wissen van een account één `DELETE` — de brieven gaan mee, en de foto's met de brieven; er is geen tweede plek (schijf, bucket) die achter kan blijven lopen. Eén back-up, en de systemd-sandbox (`ProtectSystem=strict`) hoeft nergens schrijfrechten te krijgen. Wil je later naar objectopslag, dan verandert de API-vorm niet. **Onderweg naar de server** gaat de foto mee in de JSON van `POST /api/letters`, als base64. Brief en foto worden in één transactie opgeslagen (`sendLetter`), dus er bestaat nooit een foto zonder brief en nooit een halve brief. **Verkleinen gebeurt op het toestel** (`src/lib/image.js`): maximaal 1600 px op de langste zijde, opnieuw gecodeerd als JPEG (kwaliteit zakt stapsgewijs tot hij onder 1 MB blijft). Het hercoderen is niet alleen voor de bytes — het wist alle EXIF, en daar zitten normaal de gps-coördinaten in van waar de foto genomen is. Die verlaten het toestel dus nooit. De server bewerkt niets en heeft geen `sharp` nodig; hij controleert alleen de magische bytes tegen de opgegeven mime (`server/media.js`), zodat een als jpeg aangekondigd html-bestand er niet doorheen komt. **Beschrijving verplicht.** Zonder tekst in "Wat staat er op de foto?" gaat de brief niet weg. Diezelfde tekst is het bijschrift onder de foto én de `alt`. **Uitleveren: `/media/`, met sessietoken.** Een `` kan geen `Authorization`-header meesturen, dus de app haalt de foto op met `fetch` en toont hem als blob-URL (`src/hooks/usePhoto.js`, `src/components/Photo.jsx`). Zo staat er nooit een toegangsbewijs in een URL — niets om te lekken via logs, geschiedenis of doorsturen. De server controleert per verzoek in SQL of ik de afzender of de ontvanger ben (`getPhotoFor`); zo niet, dan 404 en niet 403: dát een foto bestaat is zelf al iets wat alleen die twee aangaat. ### Promotieteam (`/promotieteam`) Vrienden die vrijwillig mond-tot-mondreclame maken, met een feestje als dank. Er is geen quotum en geen controle, en dat bepaalt de vorm van deze administratie: er wordt **niets geteld en niets toegeschreven** — geen aanbrengcodes, geen verwijzing naar wie zich aanmeldde, geen score per persoon. Zou zoiets er staan, dan gaat iemand ernaar kijken, en dan is het alsnog een quotum. Wat overblijft is een ledenlijst: wie doet mee, en hoe bereik je diegene als het feest er is. Twee dingen staan met opzet apart van de app: - **Een eigen database.** Het schema van de app wordt bij elke wijziging opnieuw opgezet en zijn gegevens zijn wegwerpbaar (zie hieronder bij Productie); de namen van je vrienden zijn dat niet. Dus: `db/promotie/schema.sql`, eigen `PROMOTIE_DATABASE_URL`, en een setup (`npm run db:promotie`) die geen enkele `drop` kent en dus veilig te herhalen is. Deze database hoort wél in de back-up. - **Een eigen slot.** Eén wachtwoord uit `PROMOTIE_PASSWORD`, via HTTP Basic (de gebruikersnaam doet niet mee). Ontbreekt de variabele, dan antwoordt de pagina met 503 — dicht, niet open. Er is geen link heen vanaf de publieke pagina's en er staat `noindex` op. Vrienden vullen hier niets in; dit is een beheerpagina. De pagina zelf is server-gerenderde HTML zonder JavaScript, net als `/` en `/privacy`. Elk lid is een eigen formulier: velden bijwerken met **Opslaan**, uit het team met **Gestopt** (de rij blijft staan, `left_on` krijgt de datum, en "Doet weer mee" draait het terug), en echt verwijderen via **Wissen** — dat gaat over een bevestigingspagina, want zonder JavaScript is er geen "weet je het zeker?" en één misklik hoort geen naam te kosten. Onderaan de actieve leden staat een `mailto:`-link met alle adressen in **bcc**, zodat een uitnodiging niet iedereens adres rondstuurt. Elk lid heeft een e-mailadres of een telefoonnummer (het schema eist er minstens één): zonder contact kun je niemand uitnodigen en is de rij een naam zonder feest. Raden wordt geremd door dezelfde rate-limiter als het inloggen (`server/ratelimit.js`, tien missers per kwartier per adres); alleen missers tellen, want een browser stuurt de Basic-header bij elke paginaweergave opnieuw mee. #### De post aan het team Twee soorten, allebei via Postmark (`server/mail.js`) en allebei **één mail per persoon**: geen bcc-blok waarin iedereen elkaars adres ziet, en met `{naam}` als aanhef. De welkomstmail is kale tekst, want dit is post van een mens. De groepsmail gaat in twee versies mee — dezelfde woorden, en een sobere HTML-versie erbij die precies doet wat platte tekst ook doet, met één verschil: de afmeldlink zit achter woorden in plaats van als URL van vijf regels kaal in beeld. Inline stijlen (mailclients gooien een `