Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
/* Minimale lengte van een brief, in tekens */
export const MIN_CHARS = 300;
/*
* De schaal van het leeftijdsfilter: waar de invoervelden op staan, wat de server toetst en waarbinnen
* het startfilter valt. Eén plek, want een grens die op drie plekken los is opgeschreven, klopt op een
* dag op twee.
*
* De bovengrens is 120 en niet 99. Mensen worden weldegelijk ouder dan honderd — de oudst bekende mens
* werd 122 — en wie 101 is hoort niet buiten zijn eigen app te vallen omdat de schuifknop op staat.
* Het kostte hier ook echt iets: met 99 als plafond liep het startfilter van een hoogbejaarde vast op
* `check (seek_min_birth <= seek_max_birth)` en eindigde registreren in "interne fout".
*/
export const AGE_MIN = 18;
export const AGE_MAX = 120;
/*
* Het beginfilter: hoe ver de zoekband bij een nieuw account boven en onder de eigen leeftijd ligt.
*
* Wat er vastligt is de **afstand naar boven**, en die is overal positief:
*
* u(a) = FILTER_GAP_MIN + (FILTER_GAP_MAX − FILTER_GAP_MIN) / (1 + e^(−(a − FILTER_GAP_MID)/FILTER_GAP_STEEP))
* oudste(a) = a + u(a)
*
* De ondergrens is geen tweede formule maar de **omkering van de eerste**: de jongste die ik zie is
* degene die mij nog net als oudste heeft. Daarmee is de band wederkerig — twee mensen op de
* beginstand zien elkaar allebei of allebei niet, nooit eenzijdig — en dat is hier de eigenschap die
* telt, want de matchquery is symmetrisch (MUTUAL_MATCH).
*
* Waarom een gladde s-vorm en niet een regel als "helft plus zeven": zulke regels zijn lijnen met
* ergens een punt waar de band nul breed wordt (bij half plus zeven is dat 14 jaar). Zo'n punt is een
* artefact — het is de plek waar de formule zichzelf opblaast — en het dwingt overal klemmen en
* knikken af om hem te verbergen. Hier is er geen: u loopt van FILTER_GAP_MIN naar FILTER_GAP_MAX en
* wordt nergens nul. De kromme is ook onder de 18 zinnig (16 → 13–19), en dat wij bij 18 afkappen is
* onze grens, niet die van de wiskunde.
*
* De vier getallen, met wat ze betekenen:
* GAP_MIN de kleinste afstand, in de vroege tienerjaren
* GAP_MAX het plafond; vanaf een jaar of 50 verandert de band niet meer (dan 24 jaar breed)
* GAP_MID waar het verbreden zit — bijna alles gebeurt tussen 15 en 35, daarna weinig
* GAP_STEEP hoe scherp die overgang is
*
* Geijkt op wat er sociaal klopt, niet op literatuur: 25 → 20–33, 33 → 25–44, 50 → 38–62, 80 → 68–92.
* Tinder publiceert deze cijfers niet en de wél gepubliceerde zijn uitgesplitst naar gender — en dat
* verschil (mannen kijken omlaag, vrouwen omhoog) modelleren we bewust niet: de app doet geen aanname
* over jou op grond van je gender. Zodra `npm run health` genoeg leden ziet om te zeggen welke banden
* mensen zélf instellen, horen deze vier getallen daarop bijgesteld te worden. Daarom staan ze hier
* en niet in een query.
*/
export const FILTER_GAP_MIN = 2;
export const FILTER_GAP_MAX = 13;
export const FILTER_GAP_MID = 24;
export const FILTER_GAP_STEEP = 5;
/* Correspondentie: zoveel brieven per keer laden (nieuwste eerst; oudere laden bij), ongelezen altijd allemaal */
export const THREAD_PAGE = 20;
/*
* Notificaties (opt-in, alleen in de Android-app). Vaste bezorgmomenten: rond deze uren — lokale
* tijd van het toestel — vraagt het toestel zelf aan de server of er nieuwe post is en zet daar een
* melding bij. Vaste momenten en niet één per brief, omdat een brief geen bericht is: wie per brief
* gepiept wordt, gaat chatten.
*
* Zoveel momenten als er waarden in staan, dus twee; `null` is er een die uit staat. De tweede
* begint uit, want één keer per dag is genoeg tot iemand zelf vindt van niet — een tweede bezorging
* hoort iets te zijn dat je aanzet, niet iets dat je moet wegklikken.
*/
export const POST_HOURS = [8, null];
/* Account: minimale wachtwoordlengte, maximale naamlengte */
export const PASSWORD_MIN = 8;
export const NAME_MAX = 40;
/*
* En een bovengrens aan het wachtwoord, want die ontbrak. Niet omdat lange wachtwoorden slecht zijn
* — het omgekeerde — maar omdat de server elke poging door scrypt haalt: 16 MB geheugen en een
* tiende seconde per keer, en bij het inloggen met opzet óók als het e-mailadres niet bestaat (dan
* verraadt de responstijd niets). Zonder grens is één verzoek met een wachtwoord van een megabyte
* dus een goedkope manier om die kosten op te drijven. 128 tekens is ruim boven wat een mens of een
* wachtwoordkluis maakt, en de grens geldt overal waar een wachtwoord gezet of gecontroleerd wordt.
*/
export const PASSWORD_MAX = 128;
/*
* De profieltekst. Hier stond geen enkele grens op — niet in de app, niet in de API, niet in het
* schema — terwijl dit de tekst is die geïndexeerd wordt (bio_tsv) en in elk zoekresultaat meekomt.
* Eén profiel van een paar megabyte zou dus in ieders Zoeken belanden.
*
* Het getal is een productkeuze en geen techniek: dit is een app waarin je op je wóórden gevonden
* wordt, dus de tekst mag lang zijn. Tweeduizend tekens is een pagina of drie alinea's — genoeg om
* iets over jezelf te zeggen, en niet genoeg om een boek in een zoekresultaat te plakken.
*/
export const BIO_MAX = 2000;
/*
* Bewaartermijnen (AVG art. 5 lid 1e) — de server handhaaft ze (dagelijkse opschoning), de
* privacyverklaring noemt ze. Sessie: verlopen na zoveel dagen zonder gebruik. Account: gewist
* na zoveel dagen zonder enige activiteit (geen sessie gebruikt, geen profiel/brief).
*/
export const SESSION_TTL_DAYS = 90;
export const INACTIVE_ACCOUNT_DAYS = 730;
/* Locatie wordt afgerond opgeslagen: 2 decimalen ≈ 1 km — genoeg voor afstanden, niet voor een adres */
export const LOCATION_DECIMALS = 2;
/*
* Verwerkingsverantwoordelijke en contact voor privacyvragen (zie src/content/privacy.js).
* Het adres hoort bij het domein van de app zelf en wordt apart bezorgd — met opzet geen
* persoonlijk adres, want dit staat in de app, op de publieke pagina en in de winkelvermelding.
*/
export const PRIVACY_CONTROLLER = "Bram Luiken";
export const PRIVACY_VERSION = "20 augustus 2026";
/*
* Waar de app voor de buitenwereld staat. Hieruit bouwt de server de herstel-link van "wachtwoord
* vergeten" (server/wachtwoord.js); PUBLIC_BASE_URL in de omgeving wint, zodat een andere
* installatie of een lokale proef zichzelf kan aanwijzen.
*
* Dit stond eerder niet vast maar kwam uit de Host-header van het verzoek — een adres dat de
* bezoeker zelf opschrijft, in een link die naar iemand ánders gemaild wordt. Wie `/auth/forgot`
* aanriep met het e-mailadres van een vreemde en een eigen Host-header, liet ons dus een geldig
* herstel-token naar dat slachtoffer sturen met een link naar zijn eigen site. Het domein is
* hetzelfde als dat van de adressen hieronder en staat al in .env.production; het hoort dus gewoon
* hier te staan en niet uit het verzoek te komen.
*/
export const PUBLIC_URL = "https://liefdesbrieven.bramluiken.org";
/*
* Vijf adressen, want het is vijf soorten post — en post die op één stapel belandt is post die
* blijft liggen. De localparts zijn niet vrij te kiezen: de mailbox sorteert
* `<rol>@liefdesbrieven.bramluiken.org` op een vaste lijst rollen naar Platforms/Liefdesbrieven/<map>,
* en wat daar niet in staat belandt in Spam. Liefdesbrieven staat als Nederlands platform
* geregistreerd, dus alleen de lijst `localparts_nl` telt hier.
* Zie bramluiken.nl-setup: ansible/roles/mail/files/sieve-rules.yml (localparts_nl).
*
* Kies op sóórt post, niet op wat er toevallig al geïmporteerd is: privacy@ is voor AVG-verzoeken
* en niet het algemene adres. Gewone post — een vraag, een suggestie, de brief van een vertrekker —
* gaat naar contact@.
*/
export const GENERAL_CONTACT = "contact@liefdesbrieven.bramluiken.org"; // → Algemeen
export const PRIVACY_CONTACT = "privacy@liefdesbrieven.bramluiken.org"; // → Privacy
export const MODERATION_CONTACT = "moderatie@liefdesbrieven.bramluiken.org"; // → Moderatie
export const COMPLAINTS_CONTACT = "klachten@liefdesbrieven.bramluiken.org"; // → Klachten
export const CHILD_SAFETY_CONTACT = "kinderveiligheid@liefdesbrieven.bramluiken.org"; // → Kinderveiligheid
/* Genderlabels zoals getoond in de UI */
export const GENDER_LABEL = { v: "vrouw", m: "man", x: "non-binair" };
/* Volledige genderweergave: 'trans' is een onafhankelijke toggle bovenop het gender → "trans man" */
export const genderLabel = ({ gender, trans }) => (trans ? "trans " : "") + GENDER_LABEL[gender];
/* Relatiestatus: heeft iemand al een romantische relatie? Eigen status + filter, wederzijds net als gender */
export const RELATIONSHIP_LABEL = { vrijgezel: "vrijgezel", relatie: "in een relatie" };
/*
* Eén foto per brief. De app verkleint de foto tot PHOTO_MAX_PX op de langste zijde en codeert hem
* opnieuw als JPEG vóór verzenden: dat wist alle EXIF-gegevens (waaronder de gps-coördinaten van waar
* de foto genomen is) nog op het toestel, en houdt hem onder PHOTO_MAX_BYTES. De server accepteert
* alleen PHOTO_MIMES en controleert dat op de magische bytes (server/media.js).
*/
export const PHOTO_MAX_PX = 1600;
export const PHOTO_MAX_BYTES = 1024 * 1024;
export const PHOTO_MIMES = ["image/jpeg", "image/png", "image/webp"];
/* Beschrijving van de foto: verplicht bij het toevoegen, en zowel het bijschrift eronder als de alt-tekst */
export const PHOTO_ALT_MAX = 200;
/*
* Melden. Blokkeren doe je voor jezelf (verbergen), melden doe je voor de beheerder: die kan iets
* aan misbruik doen, jij niet. Beide zijn nodig — Google Play eist voor apps met gebruikerscontent
* dat je een gebruiker én een stuk content kunt melden, en zonder melden komt de app niet door de
* review. De sleutels staan zo in de database (check-constraint in db/schema.sql), de labels hier.
*/
export const REPORT_REASONS = {
minderjarig: "Deze persoon lijkt minderjarig",
intimidatie: "Bedreiging, intimidatie of haat",
seksueel: "Ongewenst seksueel beeld of voorstel",
nep: "Nepprofiel, of doet zich voor als iemand anders",
oplichting: "Oplichting of vraag om geld",
anders: "Iets anders",
};
/* Toelichting bij een melding: optioneel, maar het is meestal het enige dat de beheerder verder helpt */
export const REPORT_DETAILS_MAX = 1000;
/*
* Bewaartermijn van een melding (AVG art. 5 lid 1e). Lang genoeg om een patroon te zien — één
* melding zegt weinig, drie meldingen over dezelfde persoon zeggen alles — en niet langer.
*/
export const REPORT_RETENTION_DAYS = 365;
/*
* De brief die je bij het verwijderen van je account aan de beheerder kunt achterlaten. Geen
* minimum (anders dan bij een echte brief): "de zoekfunctie deugt niet" is een complete klacht.
* Het maximum is ruim — wie vertrekt heeft soms veel te zeggen, en dit is de laatste keer.
*/
export const FAREWELL_MAX = 5000;
/*
* ---------- Wat één account kan (de eerste tranche) ----------
*
* 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. Wat hij wél nodig
* heeft is massa — veel brieven, veel profielen, en zo nodig een nieuw account. Deze grenzen maken
* die massa duur, zonder ook maar één nieuw persoonsgegeven te vragen.
*
* Ze zijn ruim voor iemand die brieven schrijft (minimaal MIN_CHARS tekens per stuk, met de hand)
* en krap voor een script. Ze straffen niemand en melden niets: wie erlangs komt hoort alleen dat
* het even wachten is.
*/
/* Brieven per uur en per dag (het etmaal = de laatste 24 uur, geen kalenderdag) */
export const LETTERS_PER_HOUR = 8;
export const LETTERS_PER_DAY = 24;
/*
* Startdrempel. Een nieuw account is het goedkoopste dat er is; dit maakt het jonge account het
* minst waard, en zet het tempo van wie net geschorst is en opnieuw begint terug naar nul. Voor een
* echte nieuwkomer is het geen hindernis: wie op dag één vier brieven van 300 tekens schrijft is al
* uitzonderlijk ijverig. NEW_ACCOUNT_HOURS moet 24 of minder blijven — de server telt de brieven van
* een jong account uit hetzelfde etmaalvenster als LETTERS_PER_DAY (zie server/queries.js).
*/
export const NEW_ACCOUNT_HOURS = 24;
export const NEW_ACCOUNT_LETTERS = 4;
/*
* Zoeken. Twee grenzen, want ze doen iets anders: SEARCH_PER_WINDOW remt hoe vaak er gezocht wordt
* (de app zoekt tijdens het typen, met 200 ms vertraging, dus dit is ruim boven wat handen halen),
* en SEARCH_RESULTS_MAX begrenst wat één zoekopdracht prijsgeeft. Dat tweede is de eigenlijke
* maatregel: zonder dak geeft elke lege zoekopdracht het complete ledenbestand terug, en dan is
* één verzoek per minuut al genoeg om alles te oogsten. De app zegt het eerlijk als er meer is.
*/
export const SEARCH_WINDOW_MIN = 10;
export const SEARCH_PER_WINDOW = 120;
export const SEARCH_RESULTS_MAX = 100;
/*
* Zoeken binnen één correspondentie is iets anders dan zoeken in Zoeken: het gaat door je eigen
* post, dus er valt niets prijs te geven en er is geen rem nodig. Alleen een dak op de trefferlijst,
* zodat "de" niet driehonderd brieven ophaalt; de app zegt het als er meer waren.
*/
export const THREAD_HITS_MAX = 50;
/*
* ---------- Wanneer is het een correspondentie? ----------
*
* Vier brieven, en van beide kanten minstens twee. Die tweede eis doet het werk: drie brieven van
* de een en één van de ander is geen briefwisseling maar een beleefd einde, en zonder die eis telt
* dat toch mee. Zo is het ook niet in je eentje te halen.
*
* Dit staat hier en niet bij de meting (scripts/health.js), omdat het een uitspraak over het
* product is en geen instelling van een rapport: zou de app ooit zeggen "jullie zijn aan het
* corresponderen", dan hoort dat dezelfde drempel te zijn als die het rapport telt.
*/
export const REAL_CORRESPONDENCE_LETTERS = 4;
export const REAL_CORRESPONDENCE_EACH = 2;
/*
* ---------- Wat een account kost (de tweede tranche) ----------
*
* De eerste tranche maakt één account minder waard. Die maakt niets uit voor wie er honderd neemt,
* en dat is precies wat een verkoper doet zodra één account te krap wordt. Dit maakt het tweede
* account duur: er zijn FREE_ACCOUNTS gratis plekken, en daarna kost registreren eenmalig geld.
*
* De bak is geen anti-sybilmaatregel maar een 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.
*
* De prijs is een drempel, en dat de opbrengst bij de maker terechtkomt hoeft niet weggemoffeld te
* worden: het kost geld, het levert geld op, en dat is gewoon hoe het werkt. Wél is de hoogte 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; dat is een
* afweging, geen bijverschijnsel.
*
* De teller is de knop: zolang er plekken zijn is registreren gratis, en zodra ze op zijn vraagt de
* app om te betalen. Er is geen moment waarop er iets omgezet moet worden, en er is niets te tonen —
* de gebruiker die te laat komt ziet geen gemiste teller, alleen de prijs.
*
* Uitgegeven plekken komen niet terug als het account verwijderd wordt (zie db/schema.sql):
* anders is "aanmaken, wissen, opnieuw" de goedkoopste manier om de bak leeg te halen.
*/
export const FREE_ACCOUNTS = 1000;
/*
* Het artikel in de store waarmee een betaalde plek gekocht wordt (Play Console → In-app products,
* en straks hetzelfde id in App Store Connect). Eenmalig, geen abonnement. De prijs staat hier
* bewust níet: die komt uit de store, per land en per valuta, en zou hier alleen verouderen.
* De app vraagt het id op bij de server (GET /api/auth/signup) en niet uit deze constante, zodat
* een prijs- of productwijziging geen nieuwe app-versie vraagt.
*/
export const SIGNUP_PRODUCT_ID = "account";