nieuwsbrieven-drupal.inc.txt 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. Taak 128-A — Drupal: nieuwsbrief-abonnementen voor de app
  2. =========================================================
  3. DRIE INGREPEN op de server, daarna `drush @uitgaanskrant.com cc all`:
  4. 1) NIEUW BESTAND sites/all/modules/custom/custom.nieuwsbrieven.inc
  5. (zie blok A hieronder — het hele bestand)
  6. 2) custom.module regel 15, ONDER de bestaande module_load_include-regels:
  7. module_load_include('inc', 'custom', 'custom.nieuwsbrieven');
  8. 3) custom_services_resources(), vlak vóór 'favorieten' => array(
  9. (regel ~3398): het blok B hieronder invoegen.
  10. DAARNA — en dit is de stap die het vaakst vergeten wordt:
  11. /admin/structure/services/list/flutterdrup/resources
  12. → 'nieuwsbrieven' aanvinken ÉN de drie operaties eronder
  13. (index, subscribe, unsubscribe) → Save.
  14. Zonder die vinkjes wordt er geen route geregistreerd en krijg je 404,
  15. precies zoals mijn_aanmeldingen maandenlang deed.
  16. CONTROLE NA DEPLOY (anoniem, dus zonder sessie):
  17. curl -s -o /dev/null -w '%{http_code}\n' \
  18. 'https://uitgaanskrant.com/nl/flutterdrup/nieuwsbrieven.json'
  19. → moet 403 geven ("route bestaat, sessie ontbreekt"), NIET 404.
  20. ⚠️ NOOIT EDGE-CACHEN. Deze respons is per gebruiker verschillend. Het pad
  21. valt buiten de bestaande Cloudflare Cache Rules (die matchen op
  22. /flutterdrup/views/… en plaatsen), dus dat gaat vanzelf goed — voeg het
  23. er alleen nooit aan toe.
  24. ⚠️⚠️ TEST EERST OF DIE BEVESTIGINGSMAIL AANKOMT — er is reden tot twijfel
  25. ------------------------------------------------------------------------
  26. Gemeten 2026-09-21 over de hele database (tid 1, een oude restterm, niet
  27. meegeteld):
  28. anoniem (uid 0) met account (uid > 0)
  29. status 1 0 45
  30. status 0 0 3
  31. status 2 450 0
  32. Dus: van de 450 anonieme inschrijvingen via de publieke formulieren is er
  33. in TIEN JAAR (2015-12 t/m 2025-12, gestaag verspreid — geen eenmalige
  34. import) GEEN ENKELE ooit bevestigd. Bij een werkende bevestigingsmail zou
  35. je 30-70% verwachten, niet 0%.
  36. De inschrijvingen mét account staan allemaal meteen goed, en dat is ook
  37. logisch: die liepen via /user/<uid>/simplenews, en dat pad gebruikt
  38. confirm=FALSE en stuurt dus nooit een mail. Met andere woorden: het enige
  39. pad dat wél een bevestigingsmail stuurt, is ook precies het pad waar nooit
  40. iemand doorheen komt.
  41. Dat kan twee dingen betekenen: de mail wordt niet bezorgd, of de
  42. bevestigingslink werkt niet. De mailconfiguratie zelf oogt in orde
  43. (smtp_on = 1, mailsystem delegeert naar mimemail, geen reroute_email,
  44. afzender contact@uitgaanskrant.com) en de watchdog bevat geen mailfouten —
  45. maar die is grotendeels leeg, dus dat bewijst niets.
  46. WAAROM DIT ERTOE DOET: zet je bevestiging aan voor de app zonder dit te
  47. testen, dan zet de gebruiker de switch aan, gebeurt er verder niets, en
  48. blijft het abonnement voor eeuwig op status 2 staan. Precies wat die 450
  49. nu doen.
  50. TEST (schrijft wel, en verstuurt een echte mail — daarom niet door Claude
  51. gedraaid):
  52. # 1. abonneer jezelf MET bevestiging op de bezoekersnieuwsbrief
  53. drush @uitgaanskrant.com php-eval \
  54. "simplenews_subscribe_user('JOUW@MAIL.NL', 36667, TRUE, 'test');"
  55. # 2. check de status — hoort 2 te zijn
  56. drush @uitgaanskrant.com sql-query \
  57. "SELECT s.tid, s.status FROM simplenews_subscription s
  58. INNER JOIN simplenews_subscriber sub ON sub.snid=s.snid
  59. WHERE sub.mail='JOUW@MAIL.NL';"
  60. # 3. komt de mail aan? klik de link. Daarna moet stap 2 status 1 geven.
  61. # 4. opruimen
  62. drush @uitgaanskrant.com php-eval \
  63. "simplenews_unsubscribe_user('JOUW@MAIL.NL', 36667, FALSE, 'test');"
  64. Komt de mail NIET aan, dan is dat eerst een mailprobleem om op te lossen —
  65. en zolang dat niet opgelost is, is `custom_nieuwsbrieven_confirm` op 0
  66. (direct abonneren, geen mail) de enige variant die voor de gebruiker
  67. werkt. De code hieronder ondersteunt beide zonder wijziging.
  68. BEVESTIGINGSMAIL — hoe je dit aanpast
  69. --------------------------------------
  70. Staat AAN (Bob 2026-09-21). Concreet gedrag:
  71. ABONNEREN -> simplenews zet het abonnement op status 2 (onbevestigd)
  72. en mailt een link. Pas na het klikken wordt het status 1
  73. en ontvangt de gebruiker de nieuwsbrief.
  74. UITSCHRIJVEN-> gaat ALTIJD direct, zonder mail. Bewust: met bevestiging
  75. zou de gebruiker in de app op 'uit' drukken en tóch
  76. abonnee blijven tot hij een mail opent. Dat is slechte UX
  77. en juridisch precies de verkeerde kant op — afmelden moet
  78. makkelijker zijn dan aanmelden.
  79. Omzetten naar 'direct abonneren, geen mail' kan zonder codewijziging:
  80. drush @uitgaanskrant.com vset custom_nieuwsbrieven_confirm 0
  81. Terug naar bevestiging:
  82. drush @uitgaanskrant.com vdel custom_nieuwsbrieven_confirm
  83. De TEKST van die bevestigingsmail is al Nederlands en staat in variabelen,
  84. te bewerken op /admin/config/services/simplenews (tab Subscription):
  85. simplenews_confirm_subscribe_subject
  86. "Bevestiging voor [simplenews-category:name] van [site:name]"
  87. simplenews_confirm_subscribe_unsubscribed
  88. "Wij hebben een verzoek ontvangen om [simplenews-subscriber:mail] in
  89. te schrijven op de [simplenews-category:name] nieuwsbrief …
  90. [simplenews-subscriber:subscribe-url]"
  91. De tekst noemt nu "de website op [site:url]" — dat klopt straks niet meer
  92. voor een aanmelding vanuit de app. Overweeg een neutralere formulering.
  93. ⚠️ Deze instelling raakt ALLEEN de app. De website-pagina uit je screenshot
  94. (/user/<uid>/simplenews) heeft confirm=FALSE HARDCODED in simplenews zelf
  95. (includes/simplenews.subscription.inc regel 97) en stuurt dus nog steeds
  96. geen mail. Wil je dat gelijktrekken, dan is dat een aparte ingreep
  97. (hook_form_alter die dat submit-handler vervangt) — niet in deze patch.
  98. =====================================================================
  99. BLOK A — het complete bestand custom.nieuwsbrieven.inc
  100. =====================================================================
  101. ⬇ PLAK VANAF HIER ⬇
  102. <?php
  103. /**
  104. * @file
  105. * Nieuwsbrief-abonnementen (simplenews) voor de Flutter-app.
  106. *
  107. * Drie Services-resources onder het endpoint 'flutterdrup':
  108. * GET nieuwsbrieven.json - lijst + status van de ingelogde gebruiker
  109. * POST nieuwsbrieven/subscribe.json - {"tid": 36676}
  110. * POST nieuwsbrieven/unsubscribe.json - {"tid": 36676}
  111. *
  112. * Simplenews 7.x-1.1: nieuwsbrieven zijn taxonomy-termen in de vocabulary
  113. * 'newsletter'. Let op: variable_get('simplenews_vid') geeft op deze site 0,
  114. * dus leid de vocabulary daar NOOIT uit af — simplenews_category_get_visible()
  115. * is de betrouwbare ingang (en laat 'hidden' nieuwsbrieven automatisch weg).
  116. *
  117. * Statuscodes in {simplenews_subscription}.status:
  118. * 1 = geabonneerd, 0 = uitgeschreven, 2 = wacht op bevestiging.
  119. */
  120. /**
  121. * Rol die de ondernemersnieuwsbrief te zien krijgt.
  122. */
  123. define('CUSTOM_NIEUWSBRIEVEN_ROL_ONDERNEMER', 'Horeca-owner');
  124. /**
  125. * De nieuwsbrieven die deze gebruiker mag zien.
  126. *
  127. * De ondernemersnieuwsbrief is alleen zichtbaar voor de rol Horeca-owner.
  128. * Dit filter is met opzet server-side: de app heeft er geen conditie voor
  129. * nodig, en subscribe() gebruikt dezelfde lijst als whitelist, zodat een
  130. * handmatig verzoek zich er ook niet op kan abonneren.
  131. *
  132. * @param object $account
  133. * Volledig geladen user-object (user_load), want de globale $user hoeft
  134. * zijn rollen niet gevuld te hebben.
  135. *
  136. * @return array
  137. * Categorie-objecten, keyed op tid.
  138. */
  139. function _custom_nieuwsbrieven_zichtbaar($account) {
  140. $lijst = simplenews_category_get_visible();
  141. $ondernemers_tid = (int) variable_get('custom_nieuwsbrieven_ondernemers_tid', 18017);
  142. if ($ondernemers_tid && isset($lijst[$ondernemers_tid])) {
  143. $rollen = (is_object($account) && !empty($account->roles)) ? $account->roles : array();
  144. if (!in_array(CUSTOM_NIEUWSBRIEVEN_ROL_ONDERNEMER, $rollen, TRUE)) {
  145. unset($lijst[$ondernemers_tid]);
  146. }
  147. }
  148. return $lijst;
  149. }
  150. /**
  151. * Abonnementsstatus per tid, in één query.
  152. *
  153. * Bewust rechtstreeks op de database in plaats van via
  154. * simplenews_subscriber_load_by_mail(): die kent een static cache, en deze
  155. * functie wordt ook aangeroepen direct NA een wijziging.
  156. *
  157. * @return array
  158. * tid => status.
  159. */
  160. function _custom_nieuwsbrieven_statussen($mail) {
  161. if (!is_string($mail) || $mail === '') {
  162. return array();
  163. }
  164. return db_query('SELECT s.tid, s.status
  165. FROM {simplenews_subscription} s
  166. INNER JOIN {simplenews_subscriber} sub ON sub.snid = s.snid
  167. WHERE sub.mail = :mail', array(':mail' => $mail))->fetchAllKeyed();
  168. }
  169. /**
  170. * Eén rij van de respons, in dezelfde vorm voor index en subscribe.
  171. */
  172. function _custom_nieuwsbrieven_rij($tid, $category, $statussen) {
  173. $tid = (int) $tid;
  174. $status = isset($statussen[$tid]) ? (int) $statussen[$tid] : 0;
  175. $omschrijving = '';
  176. if (is_object($category) && property_exists($category, 'description')) {
  177. $omschrijving = trim(strip_tags((string) $category->description));
  178. }
  179. return array(
  180. 'tid' => (string) $tid,
  181. 'naam' => is_object($category) ? $category->name : '',
  182. 'omschrijving' => $omschrijving,
  183. // 1 = aan, 0 = uit, 2 = wacht op bevestiging. De app toont bij 2 een
  184. // regel "check je mail"; zonder dit onderscheid zou de switch bij de
  185. // volgende paginaload terugspringen naar uit en lijkt de app kapot.
  186. 'status' => $status,
  187. 'geabonneerd' => ($status === 1),
  188. );
  189. }
  190. /**
  191. * GET nieuwsbrieven.json
  192. */
  193. function _custom_nieuwsbrieven_index() {
  194. global $user;
  195. if (empty($user->uid)) {
  196. return services_error('Niet ingelogd', 401);
  197. }
  198. $account = user_load($user->uid);
  199. if (!$account || empty($account->mail)) {
  200. return services_error('Account zonder e-mailadres', 400);
  201. }
  202. $statussen = _custom_nieuwsbrieven_statussen($account->mail);
  203. $out = array();
  204. foreach (_custom_nieuwsbrieven_zichtbaar($account) as $tid => $category) {
  205. $out[] = _custom_nieuwsbrieven_rij($tid, $category, $statussen);
  206. }
  207. return $out;
  208. }
  209. /**
  210. * POST nieuwsbrieven/subscribe.json
  211. */
  212. function _custom_nieuwsbrieven_subscribe($tid) {
  213. return _custom_nieuwsbrieven_wijzig($tid, TRUE);
  214. }
  215. /**
  216. * POST nieuwsbrieven/unsubscribe.json
  217. */
  218. function _custom_nieuwsbrieven_unsubscribe($tid) {
  219. return _custom_nieuwsbrieven_wijzig($tid, FALSE);
  220. }
  221. /**
  222. * Gedeelde afhandeling van (un)subscribe.
  223. */
  224. function _custom_nieuwsbrieven_wijzig($tid, $aan) {
  225. global $user;
  226. if (empty($user->uid)) {
  227. return services_error('Niet ingelogd', 401);
  228. }
  229. $account = user_load($user->uid);
  230. if (!$account || empty($account->mail)) {
  231. return services_error('Account zonder e-mailadres', 400);
  232. }
  233. $tid = (int) $tid;
  234. $lijst = _custom_nieuwsbrieven_zichtbaar($account);
  235. // Whitelist. Zonder deze check kan een handmatig verzoek zich abonneren op
  236. // een 'hidden' nieuwsbrief of op de ondernemersnieuwsbrief zonder de rol.
  237. if (!isset($lijst[$tid])) {
  238. return services_error('Onbekende nieuwsbrief', 400);
  239. }
  240. $statussen = _custom_nieuwsbrieven_statussen($account->mail);
  241. $huidig = isset($statussen[$tid]) ? (int) $statussen[$tid] : 0;
  242. if ($aan) {
  243. // Al actief? Niets doen. Anders stuurt simplenews bij confirm=TRUE tóch
  244. // een mail ("je bent al ingeschreven") bij elke dubbele tik.
  245. if ($huidig !== 1) {
  246. // TRUE = bevestigingsmail, abonnement blijft op status 2 tot de
  247. // gebruiker op de link klikt.
  248. // FALSE = meteen actief, geen mail (wat de website zelf doet voor
  249. // ingelogde gebruikers).
  250. $confirm = (bool) variable_get('custom_nieuwsbrieven_confirm', TRUE);
  251. simplenews_subscribe_user($account->mail, $tid, $confirm, 'app');
  252. }
  253. }
  254. else {
  255. // Uitschrijven gaat altijd direct: met een bevestigingsmail zou de
  256. // gebruiker in de app op 'uit' drukken en toch abonnee blijven.
  257. if ($huidig !== 0) {
  258. simplenews_unsubscribe_user($account->mail, $tid, FALSE, 'app');
  259. }
  260. }
  261. // Verse status ophalen, zodat de app meteen de echte stand toont.
  262. $statussen = _custom_nieuwsbrieven_statussen($account->mail);
  263. return _custom_nieuwsbrieven_rij($tid, $lijst[$tid], $statussen);
  264. }
  265. ⬆ TOT HIER ⬆
  266. =====================================================================
  267. BLOK B — invoegen in custom_services_resources() in custom.module
  268. =====================================================================
  269. Vlak vóór de regel 'favorieten' => array( (regel ~3398).
  270. ⬇ PLAK VANAF HIER ⬇
  271. 'nieuwsbrieven' => array(
  272. 'operations' => array(
  273. 'index' => array(
  274. 'help' => 'Nieuwsbrieven met de abonnementsstatus van de ingelogde gebruiker.',
  275. 'callback' => '_custom_nieuwsbrieven_index',
  276. 'access callback' => 'user_is_logged_in',
  277. 'access arguments' => array(),
  278. 'access arguments append' => FALSE,
  279. 'args' => array(),
  280. ),
  281. ),
  282. 'actions' => array(
  283. 'subscribe' => array(
  284. 'help' => 'Abonneer de ingelogde gebruiker op een nieuwsbrief.',
  285. 'callback' => '_custom_nieuwsbrieven_subscribe',
  286. 'access callback' => 'user_is_logged_in',
  287. 'access arguments' => array(),
  288. 'access arguments append' => FALSE,
  289. 'args' => array(
  290. array(
  291. 'name' => 'tid',
  292. 'type' => 'int',
  293. 'description' => 'Term-id van de nieuwsbrief.',
  294. 'source' => array('data' => 'tid'),
  295. 'optional' => FALSE,
  296. ),
  297. ),
  298. ),
  299. 'unsubscribe' => array(
  300. 'help' => 'Schrijf de ingelogde gebruiker uit voor een nieuwsbrief.',
  301. 'callback' => '_custom_nieuwsbrieven_unsubscribe',
  302. 'access callback' => 'user_is_logged_in',
  303. 'access arguments' => array(),
  304. 'access arguments append' => FALSE,
  305. 'args' => array(
  306. array(
  307. 'name' => 'tid',
  308. 'type' => 'int',
  309. 'description' => 'Term-id van de nieuwsbrief.',
  310. 'source' => array('data' => 'tid'),
  311. 'optional' => FALSE,
  312. ),
  313. ),
  314. ),
  315. ),
  316. ),
  317. ⬆ TOT HIER ⬆
  318. =====================================================================
  319. Voorbeeldrespons
  320. =====================================================================
  321. GET nieuwsbrieven.json — gewone gebruiker (geen Horeca-owner):
  322. [
  323. {"tid":"36667","naam":"Uitgaanskrant voor bezoekers van de horeca",
  324. "omschrijving":"Uitgaanskrant voor bezoekers van de horeca",
  325. "status":0,"geabonneerd":false},
  326. {"tid":"36676","naam":"Uitgaanskrant.com wekelijkse uitgaansagenda",
  327. "omschrijving":"Wekelijkse agenda op basis van je favoriete gemeenten. …",
  328. "status":1,"geabonneerd":true}
  329. ]
  330. Een Horeca-owner krijgt daar tid 18017 bij.
  331. POST subscribe/unsubscribe geven exact één zo'n rij terug, met de nieuwe
  332. status — de app kan de switch daar direct op zetten.
  333. ⚠️ Bind in FlutterFlow pas op $.status nadat "Test API Call" een gevulde
  334. respons heeft opgeleverd; anders krijgt het pad type "Anything" en plakt de
  335. builder er .toString() achter.