Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

92.9 kB Ruw bestand

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), 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) 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.
  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:

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:

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 <gebruikers-id>.

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.

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:

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 #<id>: 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:

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):

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:

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.

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) 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:

De letters worden hier niet ingesloten maar apart geserveerd (/lettertype/<naam>.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.

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:

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), net als brieven schrijven en zoeken (zie 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); 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 <a download> 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 <serial> 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).

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: <naam>@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)

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.

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):

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.

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 <token> 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
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)
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
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=<letterId> 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). ?since=<briefId> = 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)
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/<id>, met sessietoken. Een <img src> 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:

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 <style>-blok weg), geen plaatjes, geen sjabloon.

Eén regel om niet te vergeten bij die HTML: tekstkleur en achtergrondkleur zet je samen of geen van beide. Alleen color: #1F2C55 zetten gaat goed tot iemand zijn client in donkere modus zet — dan staat inktblauw op bijna-zwart. Met een eigen background-color erbij klopt het paar altijd: clients die niets doen tonen een vel papier op hun donkere ondergrond, en clients die de kleuren omkeren (Gmail's force-dark) draaien beide om en houden het contrast dus intact.

Het afzenderadres is marketing@liefdesbrieven.bramluiken.org, met een weergavenaam ervoor. Niet yesreply@ (dat is het adres voor systeempost en landt bij de klantenservice) en niet privacy@ (het AVG-loket): post over het promotieteam is de marketingkant en sorteert naar Platforms/Liefdesbrieven/Marketing. Ook niet nieuwsbrief@, dat naar dezelfde map gaat maar hoort bij de nieuwsbrief zelf en daarvoor vrij blijft. Het persoonlijke zit in de naam, de aanhef en de tekst — niet in het adres. Instelbaar met PROMOTIE_FROM.

Afmelden is van Postmark, de administratie klopt toch. Bij een broadcast-stream handelt Postmark het afmelden zelf af: hun link, hun List-Unsubscribe-headers, en desnoods een eigen regel onder je bericht. Dat is niet uit te zetten — UnsubscribeHandlingType: "None" mag niet bij dit streamtype en "Custom" moet hun support per account vrijgeven. Vandaar hun placeholder {{{ pm:unsubscribe }}} in onze eigen slotzin: dan staat er één nette regel in plaats van twee links onder elkaar.

De terugweg is streamSuppressions(): elke keer dat de pagina opent haalt de server de onderdrukkingslijst van de stream op. Wie zich afmeldde of het als spam markeerde krijgt left_on — precies wat Gestopt hier betekent — plus unsubscribed_at. Een hard bounce telt niet als vertrek maar wordt bij dat lid getoond als adres bestaat niet, want dat is geen besluit maar een tikfout.

En hij gaat twee kanten op. Postmark zet op zijn afmeldpagina meteen "did you do that by accident? click here to resubscribe", dus een misklik herstelt iemand in dezelfde seconde — en dan hoort die persoon hier weer mee te doen zonder dat jij iets rechtzet. Daar is unsubscribed_at voor: verdwijnt zo iemand weer van de lijst bij Postmark, dan vervalt zijn left_on. Wie jij met de hand op Gestopt zette staat nooit op die lijst en wordt dus met rust gelaten. Andersom haalt de knop Doet weer mee de onderdrukking bij Postmark weg (unsuppress()) — anders zou de eerstvolgende synchronisatie dat terugzetten meteen ongedaan maken, en zou er sowieso geen post aankomen. Lukt dat niet — een spamklacht mag niet ongedaan gemaakt worden — dan blijft het lid op Gestopt staan mét de reden, in plaats van een naam in de lijst die niets ontvangt. Is Postmark onbereikbaar, dan verschijnt de lijst gewoon zoals hij hier bekend is, met de melding erboven.

Productie

De API draait onder het publieke domein van de installatie: https://<domein>/api, met de publieke pagina's op / en /privacy. Dat domein staat in .env.production en op de winkelvermelding, dus dat is geen geheim. Wélke machine het draait, hoe die heet en welke paden hij gebruikt staat bewust niet in deze repository — die is publiek. Hieronder dus de vórm van de uitrol, met de eigen waarden als parameters.

Na een uitrol: npm run doctor. Dat is de technische zelftest — database bereikbaar, alle tabellen uit db/schema.sql aanwezig, en de betaalketen (sleutel → token → rechten bij Google) nagelopen. Afsluitcode 1 als er iets kapot is, dus hij is ook zonder lezen te gebruiken. Hij staat los van npm run health, want dat rapport gaat over de gemeenschap en niet over de machine.

De twee storingen waarvoor hij bestaat zijn allebei stil. Nieuwe code op een oude database valt pas om bij de eerste gebruiker die de nieuwe kolom raakt, en dan met een 500 waar niemand bij staat. En een haperende betaalketen doet helemaal niets zichtbaars: zijn de gratis plekken op, dan komt er simpelweg geen enkel nieuw lid meer binnen terwijl de app er kerngezond uitziet. Daarom zegt het script ook of de gratis bak leeg is — dat bepaalt of een stukke kassa een dichte deur is of iets dat je nog rustig kunt repareren.

Uitrollen is een push naar een bare repo op de server:

git remote add production ssh://<gebruiker>@<host>/<pad>/liefdesbrieven.git
git push production main

De post-receive-hook van die bare repo zet de werkkopie op de nieuwe commit, draait npm ci --omit=dev en herstart de systemd-unit liefdesbrieven-server (eigen service-user, poort uit PORT, achter nginx + Let's Encrypt). Configuratie staat in een .env naast de werkkopie (DATABASE_URL, PORT, CORS_ORIGINS, en voor betaalde plekken PLAY_PACKAGE + PLAY_SERVICE_ACCOUNT; niet in git). Het sleutelbestand van het serviceaccount hoort daar ook niet in: zet het naast .env met chmod 600 en verwijs ernaar, of plak de JSON in de variabele zelf. Voor de ledenlijst van het promotieteam horen daar PROMOTIE_PASSWORD en PROMOTIE_DATABASE_URL bij; zonder die eerste blijft /promotieteam dicht. Die database maak je één keer aan met npm run db:promotie en laat je daarna staan. Voor de post aan dat team ook POSTMARK_SERVER_TOKEN; zonder dat token gaat er niets weg, komen er geen afmeldingen binnen, en zegt de pagina dat. Database: Postgres 17, database en rol liefdesbrieven. Logs: journalctl -u liefdesbrieven-server -f.

Schemawijzigingen: migreren, niet opnieuw opzetten. Sinds de productie-release (24 augustus 2026) staan er echte mensen in de productiedatabase en is de inhoud niet meer wegwerpbaar. Tot die dag was opnieuw opzetten het lichtste dat werkte; die tijd is voorbij, en db:setup hoort productie dus nooit meer te zien.

Een schemawijziging bestaat voortaan twee keer:

Uitrollen gaat dan zo, in deze volgorde — eerst de code (de hook draait npm ci en herstart), dan de database, en dan pas kijken of het goed is:

git push production main
ssh <server>
cd /opt/liefdesbrieven && set -a && . ./.env && set +a
npm run db:migrate                     # voert uit wat openstaat, elk in een eigen transactie
npm run doctor                         # die rekent openstaande migraties tot "kapot"

npm run db:migrate -- --status kijkt alleen (afsluitcode 1 als er iets openstaat), en npm run doctor controleert behalve de tabellen uit db/schema.sql ook of alle migraties gedraaid hebben — nieuwe code op een oude database is de gevaarlijkste uitrolfout die er is, en een ontbrekende kolom is daarvan de stilste vorm.

Dit geldt alleen voor de database van de app. De promotie-database staat er los van en wordt door dit alles niet aangeraakt; dát is de reden dat hij apart staat.

Eén nginx-regel hoort bij de foto's: een foto van 1 MB gaat als base64 over de lijn (~1,35x), dus client_max_body_size moet in de nginx-site van deze app van 1M naar 2M — anders weigert nginx een brief met foto vóór de server hem ziet.

Eén nginx-regel hoort bij de rate-limiting, en die is dragender dan hij eruitziet. Achter een proxy kent de app het adres van de bezoeker alleen uit een header, en op dat adres hangt élke rem die per adres telt: inloggen, registreren, herstel-links en het slot op /promotieteam. X-Forwarded-For is daar niet geschikt voor — met $proxy_add_x_forwarded_for plakt nginx het echte adres achter wat de bezoeker zelf meestuurde, dus wat er vooraan staat is door de bezoeker gekozen. Zet daarom X-Real-IP, waar nginx alleen zijn eigen $remote_addr in schrijft:

# in het location-block dat naar de app proxyt
proxy_set_header X-Real-IP $remote_addr;

clientIp() in server/ratelimit.js leest die als eerste, valt anders terug op het láátste element van X-Forwarded-For, en vertrouwt beide alleen als het verzoek van de eigen machine of het eigen netwerk komt — rechtstreeks verkeer van buiten heeft immers geen proxy en zijn headers zijn dus verzonnen. Staat de proxy op een andere machine, dan zet je TRUST_PROXY=1 in .env.

En één nginx-regel hoort bij het zoeken. De app-server logt met opzet alleen het pad (zie pathLogger in server/index.js), maar nginx logt standaard de héle requestregel — inclusief ?q= en met het IP ernaast. Dat is een zoekgeschiedenis in een dating-app, en die hoort er niet te zijn:

# in de server-block van deze site: het pad wel, de querystring niet
log_format zonder_query '$remote_addr - - [$time_local] "$request_method $uri $server_protocol" '
                        '$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/liefdesbrieven.access.log zonder_query;

Stond dat er nog niet, dan staan er in de bestaande logs zoektermen. Die lopen vanzelf uit de rotatie, of eerder weg met truncate -s 0 op het access-logbestand; de journald-kant (journalctl --vacuum-time=1d -u liefdesbrieven-server) hoort daar dan bij.

Native app (Capacitor)

De web-build (dist/) draait ongewijzigd in een Capacitor-webview; Ionic Framework is niet nodig. Het Android-project staat in android/ (iOS vereist macOS/Xcode: npx cap add ios).

# De app-build leest .env.production: VITE_API_URL=https://liefdesbrieven.bramluiken.org.
# Tegen je lokale API testen? Zet in .env.production.local (gitignored) VITE_API_URL op
#   emulator http://10.0.2.2:3000 of fysiek toestel http://<ip-van-je-pc>:3000
#   (en android.allowMixedContent op true in capacitor.config.json)
npm run cap:sync           # vite build + cap sync (web-assets + plugins naar android/)
npm run cap:android        # idem + Android Studio openen → run op emulator/toestel
npm run cap:run:android    # idem, maar direct via de CLI op een aangesloten toestel/emulator

Notificaties

Eén schakelaar bij Mijn profiel, uit tot je hem aanzet, met daaronder "Postbode komt langs om:" en de tijdstippen. Staat hij aan, dan kijkt het toestel op die momenten — POST_HOURS, lokale tijd — of er nieuwe post ligt en zet daar een melding bij. De melding zelf is Android-werk; in de browser staat het scherm er wel, maar valt er niets te schakelen (src/lib/postbode.js geeft dan overal null terug). Dat is geen halfslachtigheid: de browser is de ontwikkelomgeving, er draait geen webversie van de app, en een scherm dat je niet kunt zien is een scherm waar niemand aan werkt.

De melding komt van het toestel zelf, niet van een server. Dat is de hele opzet, en het is de enige beslissing hier die niet meer terug te draaien is zonder alles opnieuw te bouwen. De gewone manier — Firebase Cloud Messaging — betekent een Google-project, een google-services.json, een device-token per toestel in onze database, en Google dat van elke melding weet dát er post voor deze persoon is en wanneer. Voor een dating-app is dat een verkeersregister van iemands correspondentie bij een derde. Alles in deze app is zo gebouwd dat er geen derde tussen zit (server/media.js voor foto's, de betaling die alleen een bonnetje oplevert); een melding hoort daar geen uitzondering op te zijn.

De prijs staat er eerlijk bij, in de app en in de privacyverklaring: Android mag het moment uitstellen. WorkManager plant het werk in met een netwerkvoorwaarde, Doze kan het naar een onderhoudsvenster schuiven, en fabrikanten knijpen achtergrondwerk af. Voor post die een dag oud mag zijn is dat te dragen. Voor iets wat meteen moet aankomen zou dit niet kunnen — en dat is precies waarom deze app geen chat is.

Vaste momenten, niet één per brief. Een melding per binnenkomende brief maakt van correspondentie een gesprek met vertraging, en dan werkt de rest van het ontwerp tegen je in: 300 tekens minimum, geen foto in een eerste brief, brieven die je in één keer leest. Wie per brief gepiept wordt gaat chatten. Een bezorgmoment is de postbode — en die kwam ook twee keer langs, 's ochtends en aan het eind van de middag.

De momenten zet de gebruiker zelf, want wanneer post welkom is verschilt per leven — wie 's nachts werkt heeft niets aan acht uur 's ochtends. Er zijn er twee in te stellen; de tweede begint op uit, want één keer per dag is genoeg tot iemand zelf vindt van niet, en een tweede bezorging hoort iets te zijn dat je aanzet in plaats van iets dat je moet wegklikken.

Het zijn hele uren: minuten zouden een precisie beloven die er niet is, want Android bepaalt zelf wanneer een app op de achtergrond aan de beurt is. Verzetten mag ook terwijl de melding uit staat — wie eerst wil zien wánneer het zou gebeuren voordat hij ja zegt, hoort dat te kunnen — en de keuze blijft bewaard als je hem tussendoor uitzet. De momenten zijn een verzameling tijdstippen: Bezorging.uren() sorteert en ontdubbelt, dus twee keer hetzelfde uur is één bezorging. Het laatste moment dat nog aan staat kan niet op uit; anders zou de schakelaar aan kunnen staan zonder dat er ooit iets langskomt, en dát zeg je met de schakelaar.

Wat er in de melding staat, en waarom niet meer. Nooit een brief — de tekst blijft achter het inlogscherm. Wel: hoogstens twee namen en verder een aantal. De namen zijn die van je favorieten (★), want dat is de enige lijst waarvan de app zeker weet dat jij hem zelf hebt gemaakt. Daarachter houdt het tellen op bij VEEL (3): "en nog 3 anderen" kun je overzien, "en nog negen anderen" leest als achterstallig onderhoud, en dat is geen mededeling maar een takenlijst. Staat er niemand met een ster tussen, dan is het aantal wél het enige wat er te melden valt en blijft het staan — bij precies één afzender met diens naam erbij. De zin wordt gemaakt in PostCheck.zin(); de gevallen staan daar op een rij.

Twee namen is het dak, niet drie: een melding ligt op een vergrendelscherm dat de halve kamer kan zien. Wie helemaal geen namen wil, zet dat per app in Android zelf.

Wat er niet gebeurt. De server bewaart hier niets van — dat je meldingen aan hebt staat alleen op het toestel, net als de id van de laatste brief waarover een melding ging (since). En de dagelijkse vraag telt met opzet niet als gebruik van de app: GET /api/post staat vóór de sessie-middleware en gebruikt getSessionUserQuiet(), dat last_seen_at níet bijwerkt. Zou het dat wel doen, dan verliep er nooit meer een sessie (SESSION_TTL_DAYS) en telde het rapport elk slapend toestel als actief lid — dan meet je de wekker in plaats van de mens.

De brieven zelf worden niet opgehaald: het antwoord is { senders, favorites, name, latest } en verder niets. since zorgt dat een brief die je bewust laat liggen niet elke dag opnieuw wordt aangekondigd, en dat wat je op een ander toestel al las hier niet alsnog langskomt.

De stukken:

Waar Wat
server/queries.js getSessionUserQuiet() en unreadSince() — de stille sessiecontrole en de telling
server/index.js GET /api/post, vóór de sessie-middleware en met een eigen tokencontrole
Bezorging.java voorkeuren op het toestel + het inplannen (WorkManager, eenmalig werk dat zichzelf opnieuw inplant)
PostCheck.java het bezorgmoment: vragen, de zin maken, de melding zetten
Postbode.java de Capacitor-plugin: aan/uit, toestemming, sessie volgen, tik op de melding
src/lib/postbode.js de JS-kant; in de browser een lege huls
src/features/profile/PostSection.jsx de schakelaar bij Mijn profiel

Aandachtspunten bij het bouwen:

Icoon en splash-screen

Eén bron: de envelop in src/components/Envelope.jsx (ook het in-app laadscherm). npm run assets (assets/build.mjs) rendert daaruit assets/src/*.svg|html, rastert de PNG's (icon-only, adaptive foreground/background, splash licht/donker, favicon, apple-touch-icon) en laat @capacitor/assets alle Android-mipmaps en splash-drawables genereren. Daarna zet het script twee dingen recht die @capacitor/assets verkeerd doet: de adaptive-icon-lagen op 108 dp (i.p.v. 48 dp, anders wazig) en de adaptive-icon-XML zonder insets (effen inkt-achtergrond, envelop op volle grootte). De systeem-splash van Android 12+ (styles.xml) is effen papierkleur zonder icoon; de Capacitor-splash met de envelop komt er direct overheen. Het script gebruikt ~/.claude/tools/imgconv en webshot; assets/src/preview.html toont alles op één blad.

Google Play (interne test)

De app staat sinds 18-08-2026 op Play als interne test — nl.bramluiken.liefdesbrieven, uitgegeven onder het organisatie-account Bram Luiken (Vectasus). Interne testing gaat niet langs Google's review en is niet publiek vindbaar: alleen de accounts in de testerslijst zien de app, via de opt-in-link. Zolang de app niet beoordeeld is, tonen testers de tijdelijke naam nl.bramluiken.liefdesbrieven (unreviewed) in de Play Store.

Uitgeven is een ondertekende App Bundle bouwen en die uploaden:

npm run cap:sync                                   # vite build + cap sync
cd android && ./gradlew.bat bundleRelease          # JAVA_HOME naar de JBR van Android Studio
# → android/app/build/outputs/bundle/release/app-release.aab

De ondertekening leest android/keystore.properties (niet in git; zie android/.gitignore). Dat bestand wijst naar de upload-keystore en de wachtwoorden staan er in; keystore én wachtwoorden liggen buiten deze repository, in de eigen secrets-opslag. Ontbreekt het bestand, dan bouwt bundleRelease nog steeds, maar ongesigneerd en dus niet te uploaden. Die keystore is niet te vervangen zonder Google om een upload-key-reset te vragen; maak er een back-up van. De release zelf wordt door Google ondertekend (Play App Signing), de upload-keystore bewijst alleen dat de upload van ons komt.

Elke volgende upload heeft een hogere versionCode nodig in android/app/build.gradle — Play weigert een bundle met een versiecode die al bestaat.

Wat er nog moet gebeuren

De leeftijdsclassificatie is ingevuld (IARC, 18-08-2026): categorie Social, dating-app ja, blokkeren ja, rapporteren nee, precieze locatie delen nee — de app slaat locaties afgerond op ~1 km op en toont alleen een afstand. Resultaat: PEGI 18, USK 18+, ESRB Mature 17+, ClassInd 18+, Zuid-Korea 19+, overal "Users Interact". Tinder staat op dezelfde classificatie.

Die classificatie wordt pas actief als de app ter review kan, en daarvoor moet de hele opzet af — 1 van 11 in het app-dashboard. Wat er nog ligt: privacybeleid instellen (de URL bestaat al), Sign in details (reviewers hebben inloggegevens nodig, want alles zit achter de login), advertenties (nee), doelgroep (18+), Gegevensveiligheid (e-mail, naam, geboortedatum, gender, locatie op ~1 km, brieven en foto's), government apps / financial features / health (alle drie nee), app-categorie met contactgegevens. Dat zijn verklaringen, geen werk.

De winkelvermelding is er wél (stand 21-08-2026, nagekeken via de Play Developer API): titel, korte en volledige beschrijving in nl-NL, plus icoon, feature graphic en vijf telefoonschermafdrukken. Die teksten staan woord voor woord in src/content/store.js — één beschrijving van de app, zodat de winkel, de website en de post aan het promotieteam niet uit elkaar gaan lopen. Verandert er iets in de console, werk dat bestand dan bij (of andersom).

De privacyverklaring-URL die Play vraagt is https://liefdesbrieven.bramluiken.org/privacy, en die pagina beschrijft ook het verwijderen van je account — de aparte eis die Play daaraan stelt.

Twee dingen om rekening mee te houden:

In-app aankoop: de betaalde plek

Voor Wat een plek kost moet er in de Play Console het volgende staan, en dit is buiten de code om — het is niet met een release te regelen:

  1. Een betalingsprofiel onder het ontwikkelaarsaccount (bankgegevens, belastinggegevens). Zonder dat is er geen artikel aan te maken.
  2. Een in-app product onder Monetise → In-app products, met product-id account (gelijk aan SIGNUP_PRODUCT_ID in src/constants.js), eenmalig, met een prijs per land. De app vraagt de prijs bij de store op en toont hem zoals die er staat; er staat nergens een bedrag in de code.
  3. Een serviceaccount met de rol View financial data / toegang tot de Play Developer API, gekoppeld in Users and permissions. De JSON-sleutel gaat naar de server als PLAY_SERVICE_ACCOUNT (zie Productie) — niet in git.
  4. Licentietesters onder Setup → License testing: die kunnen echt kopen zonder te betalen, en krijgen een echte token. Dat is de enige manier om de hele weg te testen.

In het formulier Gegevensveiligheid verandert er niets wezenlijks: we verwerken geen betaalgegevens (dat doet Google), alleen een aankoop-id. De financial features-verklaring gaat over leningen, bankieren en crypto en blijft dus "nee"; wel komt er het IAP-label bij. Kosten: sinds 30-06-2026 10% service fee over de eerste $1M in de EER, plus ~5% billing fee bij Play's eigen betaalsysteem.

Nog een valkuil bij het installeren: staat er nog een lokaal gebouwde APK op het toestel, dan weigert Play met "incompatibele versie" — andere handtekening. Ook als je hem verwijderd lijkt te hebben, want een tweede gebruikersprofiel (werkprofiel, Vault Profile) houdt hem vast. Nakijken en opruimen:

adb -s <serial> shell pm list users
adb -s <serial> shell "dumpsys package nl.bramluiken.liefdesbrieven | grep 'User .*installed'"
adb -s <serial> uninstall nl.bramluiken.liefdesbrieven    # over alle gebruikers heen

Structuur

android/                Capacitor Android-project (npx cap add android)
  app/src/main/java/nl/bramluiken/liefdesbrieven/
    MainActivity.java   BridgeActivity + registratie van de eigen plugin
    Postbode.java       Capacitor-plugin: bericht als er post ligt (aan/uit, toestemming, tik op de melding)
    Bezorging.java      voorkeuren op het toestel + het dagelijkse moment inplannen (WorkManager)
    PostCheck.java      het bezorgmoment zelf: vragen of er post ligt en de melding zetten
assets/
  build.mjs             genereert icoon/splash/favicon uit Envelope.jsx (npm run assets)
  src/                  gegenereerde SVG/HTML-bronnen + preview.html
  *.png                 invoer voor @capacitor/assets
public/                 favicon.svg, apple-touch-icon.png
capacitor.config.json   appId, webDir, plugin-instellingen
db/
  schema.sql            tabellen, indexen, leeftijd→geboortedatum-functies
  seed.sql              voorbeeldgebruikers en -brief (zonder foto's)
  setup.js              maakt de database aan en voert schema + seed uit (--no-seed: alleen schema)
  migrate.js            voert db/migrations/ uit op een bestaande database (npm run db:migrate)
  migrations/           schemawijzigingen sinds de productie-release, 0001-korte-naam.sql
  generate.js           een virtuele gemeenschap van duizenden, uit verdelingen (npm run db:generate)
  promotie/
    schema.sql          promotieteam: ledenlijst, welkomsttekst, verzendlog — eigen database, geen drop
    setup.js            maakt die database aan en voert het schema uit (npm run db:promotie)
scripts/
  reports.js            meldingen bekijken en maatregelen nemen (npm run reports)
  health.js             hoe de gemeenschap ervoor staat, en wat een nieuwkomer mag verwachten (npm run health)
server/
  index.js              Hono-app: routes, validatie, sessie-middleware, mediaserver
  auth.js               wachtwoord-hashing (scrypt) en sessietokens
  promotie.js           /promotieteam: ledenlijst bijhouden en het team mailen, achter één wachtwoord
  mail.js               post versturen via Postmark (één mail per persoon, transactioneel of broadcast)
  media.js              foto uit een verzoek nakijken (magische bytes, grootte, beschrijving)
  search-log.js         zoekwoorden tellen zonder wie: prefix-collapsing tegen het typen-en-zoeken
  ratelimit.js          schuivend venster in het geheugen, clientIp(), 429 + Retry-After
  quota.js              hoeveel brieven één account nu nog mag (uur, etmaal, startdrempel)
  billing.js            het bonnetje van de store natrekken (Play Developer API) en bevestigen
  report.js             welke metingen het gemeenschapsrapport doet, en met welke vensters
  report-html.js        het rapport als pagina: het bestand (--html) én de maandstand (/cijfers)
  report-format.js      getallen → Nederlands; gedeeld door alle uitvoervormen
  queries.js            SQL: accounts/sessies, matchquery, postvak, brieven, foto's, profiel,
                        en onderaan de cijfers achter npm run health
  db.js                 pg-pool + transaction()
src/
  main.jsx              entry point
  App.jsx               sessiepoort, state, tabs, routering, terugknop, postvak-verversing
                        (layout: kop + tabs staan vast, elk scherm scrolt in zijn eigen .app-main)
  theme.js              kleurenpalet en lettertypen
  constants.js          MIN_CHARS, genderlabels, fotogrenzen, brief- en zoekgrenzen, FREE_ACCOUNTS,
                        POST_HOURS, REAL_CORRESPONDENCE_* (gedeeld met server/)
  styles/global.css     globale basisstijlen
  content/
    privacy.js          de privacyverklaring als data (app, /privacy en de export lezen dezelfde tekst)
    child-safety.js     de normen tegen kindermisbruik, voor /kinderveiligheid
    store.js            de winkelvermelding zoals hij bij Play staat, plus de korte versie voor het promotieteam
  api/client.js         fetch-wrapper + revivers (datums → Date); VITE_API_URL, Bearer-token
  lib/
    session.js          sessietoken in localStorage + useSession()
    postbode.js         bericht als er post ligt: aan/uit, toestemming, sessie volgen (Android; leeg in de browser)
    billing.js          een plek kopen in de store; bewaart het bonnetje tot er een account op staat
    dates.js            datumweergave en leeftijd→geboortedatum (ter weergave)
    geo.js              afstandsweergave
    location.js         huidige positie via @capacitor/geolocation
    highlight.jsx       zoektermen markeren
    image.js            gekozen foto verkleinen en hercoderen (weg EXIF) vóór verzenden
    export-file.js      de AVG-export als bestand bewaren: deelblad in de app, download in de browser
  hooks/
    useAsync.js         data laden met annulering, debounce en reload
    usePhoto.js         foto-id → blob-URL (fetch mét sessietoken, opgeruimd bij unmount)
  components/           herbruikbare bouwstenen (AirmailEdge, Postmark, Photo, Envelope, LoadingScreen, DateFields, ...)
  features/
    auth/               inloggen / account aanmaken
    search/             zoekpagina + profielkaart
    inbox/              postvak, correspondentie, brief
    profile/            profiel bewerken
    compose/            brief schrijven (dialoog)
    report/             misbruik melden (dialoog, voor een persoon of één brief)