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
- 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_atnull) ziet eerst de toestemmingsvraag (ConsentView) — akkoord, of gegevens opvragen en account verwijderen, of uitloggen. - 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.
- Zoeken — fulltext in profielteksten, alleen wederzijds passende profielen.
- 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.
- 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). - Uitloggen onderaan Mijn profiel.
- 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.
- 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.
- 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:
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:
- 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/logineen 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
--warnbij.
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:
- De brieven worden geteld uit
letters.sent_at, niet uit een teller in het geheugen (letterQuotainserver/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:
POST /api/auth/checkkijkt 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.- 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. POST /api/auth/registermetpurchaseerbij. 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:
- 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.providerkent'apple'enverifyPurchase()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.
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:
- 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/<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:
- 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;
--forceoverrulet 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), 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:
- Vangen mag, wegslikken niet. Een
catchdie 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. - 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. - 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)
users— account (emailuniek,password_hash), profiel als tekst (bio),birthdate,gender+trans(onafhankelijke vlag; weergave "trans man"),relationship(vrijgezel|relatie), en het filter alsseek_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 aanagesToBirthRange()in de frontend), zodat het filter niet veroudert en een B-tree-range-scan blijft.users.bio_tsv— gegenereerdetsvector(dutch) met GIN-index voor fulltext.users.lat/lon(optioneel) enseek_radius_km(optioneel, vereist een locatie). Afstand via de SQL-functiedistance_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_iduniek,on delete cascade): de bytes zelf in eenbytea(max 1 MB), plusmime,width,heighten de verplichte beschrijvingalt. Zie De foto bij een brief.favorites—(user_id, other_id):user_idheeftother_idals favoriet. Eenzijdig; alleen te zetten bij een lopende correspondentie. Het postvak sorteertfavorite desc, sent_at desc.blocks—(user_id, blocked_id):user_idheeftblocked_idgeblokkeerd. Verbergt, wist niets.MUTUAL_MATCHencanWritesluiten een blokkade in welke richting ook uit;listThreadsverbergt door mij geblokkeerde correspondenties;getThreadgeeftcanReply: false.reports— meldingen van misbruik:reporter_id,reported_id, optioneelletter_id(null = over de persoon),reason,details,handled_at. De drie sleutels vervallen bewust verschillend:reported_idcascade (is de gemelde weg, dan valt er niets te beoordelen),reporter_idenletter_idset 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.warnings— waarschuwingen van de beheerder aan één gebruiker;acknowledged_atnull = 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_untilnull bij een gezettesuspended_atbetekent onbepaalde tijd, niet niet geschorst. Afgedwongen ingetSessionUser, bij inloggen, inMUTUAL_MATCHen bijPOST /api/letters. Zie Maatregelen.signup_slots— één rij metgranted: hoeveel accounts er zonder betaling zijn aangemaakt. Met opzet een teller en geencount(*)opusers: 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 insrc/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_idenuser_id.unique (provider, token)is de hele replay-bescherming, enuser_idvervalt 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.departures— wat er van een verdwenen account overblijft:at,days_member,reason(zelf|slapend|maatregel) en één bit,wrote. Géén vreemde sleutel naarusers: 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 vanuitdeleteUser()enpurgeExpired(), 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 alsdepartureszonder 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 vanuitdeleteUser(), 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 alsdepartureswijst 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
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.
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.btrimen 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 <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:
- 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, eigenPROMOTIE_DATABASE_URL, en een setup (npm run db:promotie) die geen enkeledropkent 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 staatnoindexop. 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 <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.
-
De welkomstmail gaat automatisch naar wie je toevoegt, als er een e-mailadres bij staat. Zijn tekst staat in de database (tabel
welcome) en je bewerkt hem op de pagina zelf — niet in deze repository, want dit zijn jouw woorden aan je vrienden en schaven aan een zin hoort geen uitrol te kosten. Ging hij niet weg, dan zegt de pagina dat en staat er een knop Welkom sturen naast dat lid. Stream:outbound(transactioneel: post aan één persoon).Daar komt vast een blok onder: waar het over gaat, in de woorden van de winkelvermelding (
STORE_KERNin src/content/store.js). Dat staat bewust níet in de bewerkbare tekst — wát Liefdesbrieven is hoort overal hetzelfde te zijn, terwijl de zinnen erboven van jou zijn en per keer mogen verschillen. Iemand die het gaat navertellen heeft geen volledige beschrijving nodig maar de punten waarop dit van de rest verschilt; vandaar de korte versie en nietSTORE_FULL. Op de pagina staat het blok onder het tekstvak, zodat je ziet wat er meegaat. -
Groepspost typ je op de pagina en gaat over twee stappen: eerst een voorbeeld — letterlijk de mail die de eerste op de lijst krijgt, mét naam en afmeldlink — en dan pas versturen. Wat eruit ging staat in
mailings: onderwerp, tekst, aantal, en bij wie het misging. Stream:liefdesbrieven-promotieteam(Broadcasts).
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:
- verwerkt in
db/schema.sql— dat blijft de enige waarheid voor een vérse installatie (en voor de ontwikkelmachine, waarnpm run db:setupgewoon blijft werken; die registreert de migraties dan meteen als uitgevoerd); - als bestand in
db/migrations/,0001-korte-naam.sql— de weg ernaartoe voor de database die er al staat. Het nummer bepaalt de volgorde en wordt nooit hergebruikt; wat één keer op productie draaide is onaantastbaar (fout erin? de vólgende migratie herstelt hem); en elke migratie draait in één transactie — helemaal of niet.npm run db:migratedwingt deze regels af waar het kan en weigert bij volgordeconflicten of een werkkopie die achterloopt op de database.
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
capacitor.config.json:webDir: dist;android.allowMixedContentstaat uit — de app praat met de https-API. Alleen voor een lokalehttp://-API vanuit dehttps://localhost-webview moet die tijdelijk aan.- Geolocatie loopt via
@capacitor/geolocation(src/lib/location.js); de permissies staan inandroid/app/src/main/AndroidManifest.xml. In de browser valt de plugin terug opnavigator.geolocation. - De gegevensexport bewaren loopt via
@capacitor/filesystem+@capacitor/share(src/lib/export-file.js): schrijven naar de privécachemap, dan het deelblad. Geen permissies nodig — het bestand blijft in de eigen opslag van de app tot de gebruiker zelf een bestemming kiest. Nanpm installmoetnpx cap sync androidde plugins in het Android-project zetten, anders valt de knop in de app stil. - Layout: de shell is precies één schermhoogte (
100dvh); kop en tabs staan vast en alleen de inhoud (.app-main) scrolt — als een app, niet als een webpagina. De kolom is 480px breed, en 640px zodra het scherm in beide richtingen ruim is (tablet, uitgevouwen foldable — niet een telefoon in landschap, die is breed maar laag). Breder niet: dan worden de regels van een brief te lang om prettig te lezen. - De hardware-terugknop loopt via
@capacitor/app(backButtoninApp.jsx); het sessietoken staat inlocalStoragevan de webview (src/lib/session.js). - CORS: de API staat standaard
https://localhost(Android) encapacitor://localhost(iOS) toe; zieCORS_ORIGINSin.env.example. - Lettertypen zitten in de bundel (Fontsource), dus de app heeft geen netwerk nodig om goed te renderen — alleen voor de API.
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:
- Eenmalig werk dat zichzelf opnieuw inplant, geen
PeriodicWorkRequest: een periodieke taak telt 24 uur vanaf de vórige uitvoering, dus elk uitstel schuift het bezorgmoment mee en na een week is het middernacht — en met twee momenten per dag loopt dat meteen mis.Bezorging.plan()rekent elke keer opnieuw naar het eerstvolgende ingestelde uur, dus uitstel van vanochtend verschuift vanavond niet. WorkManager bewaart het werk zelf over een herstart heen — daar is geen eigenBOOT_COMPLETED-ontvanger voor nodig. - Geen herkansing binnen de dag. Geen verbinding, server plat, onbegrijpelijk antwoord: stil overslaan en morgen weer. Een melding om 03:00 omdat de wifi even weg was is erger dan geen melding.
- Het sessietoken staat in
SharedPreferences(postbode.xml) omdat de werker moet kunnen vragen of er post ligt terwijl de app dicht is. Die voorkeuren zijn uitgesloten van back-up en van overzetten naar een nieuw toestel (res/xml/backup_rules.xmlenres/xml/data_extraction_rules.xml): wie een back-up terugzet hoort niet een ingelogde app in handen te krijgen. - 401 stopt alles: sessie verlopen, uitgelogd of geschorst → token weg, schakelaar uit, geen
nieuwe afspraak. Inloggen zet het weer aan als de schakelaar aan stond (
Postbode.session(), aangeroepen vanuitApp.jsxzodra het token wisselt). - Meldingen uit in de systeeminstellingen → de schakelaar gaat uit en er wordt niet meer gekeken. Dagelijks de server bevragen voor een melding die toch nergens verschijnt is werk voor niemand.
androidx.workheeftListenableFuturenodig om te compileren. Guava zit er bij het draaien al in (via de Play Billing-plugin), maar op het compileerpad wint het lége artefact dat guava daarvoor publiceert, en die versiekeuze is niet te overrulen — AGP trekt compile- en runtimepad gelijk. VandaarcompileOnly "com.google.guava:guava"inandroid/app/build.gradle, met de uitleg erbij.
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:
- Melden en maatregelen zitten er sinds 19-08-2026 in (zie Melden en
Maatregelen): melden van een persoon, een losse brief en vanuit
Zoeken, en daarna waarschuwen, een brief weghalen, schorsen of het account
verwijderen. Dat was de laatste afwijzingsgrond in de code — Google's beleid voor apps
met gebruikerscontent eist niet alleen een meldknop maar ook dat er iets met een
melding gebeurt, en daar wordt bij closed/open testing en productie wél op
gecontroleerd. Bij het invullen van de winkelvermelding hoort de vraag hoe meldingen
worden afgehandeld; het antwoord is
npm run reportsop de server. - Een 18+-app kan leeftijdsverificatie op het Google-account vragen voordat
Play hem installeert (ID of selfie via
myaccount.google.com/age-verification). Dat is beleid op het account, niet op de app, en het gaat niet weg door de app af te maken.
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:
- Een betalingsprofiel onder het ontwikkelaarsaccount (bankgegevens, belastinggegevens). Zonder dat is er geen artikel aan te maken.
- Een in-app product onder Monetise → In-app products, met product-id
account(gelijk aanSIGNUP_PRODUCT_IDinsrc/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. - 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. - 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)