nieuwsbrieven-drupal.inc.txt 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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. BEVESTIGINGSMAIL — waarom die er voor de app NIET is
  25. -----------------------------------------------------
  26. De drie nieuwsbrieven staan op opt-in/out-methode **Double**. Simplenews
  27. omschrijft die stand zelf zo:
  28. "Double: When (un)subscribing at a subscription form, anonymous users
  29. receive an (un)subscription confirmation email. Authenticated users
  30. are (un)subscribed immediately."
  31. Bevestiging geldt dus alleen voor ANONIEME bezoekers. Een app-gebruiker is
  32. per definitie ingelogd en wordt direct (un)subscribed — net als op
  33. /user/<uid>/simplenews.
  34. ⚠️ Simplenews past die regel NIET zelf toe. simplenews_subscribe_user()
  35. bevat geen enkele check op de ingelogde gebruiker; hij doet blind wat de
  36. $confirm-parameter zegt. De formulieren bepalen die waarde, met
  37. simplenews_require_double_opt_in($tid, $account) — FALSE zodra het
  38. mailadres van de ingelogde gebruiker zelf is, anders de opt-in-methode.
  39. Deze resource roept precies diezelfde functie aan, dus de app volgt de
  40. website automatisch, ook als de opt-in-instelling ooit wijzigt. Je hoeft
  41. er geen variabele voor te zetten.
  42. Dit verklaart ook het cijfer dat eerder verdacht leek. Gemeten over de hele
  43. database (tid 1, een oude restterm, niet meegeteld):
  44. anoniem (uid 0) met account (uid > 0)
  45. status 1 0 45
  46. status 0 0 3
  47. status 2 450 0
  48. Precies de tweedeling die de Double-stand voorschrijft: anoniem gaat naar
  49. status 2 (wacht op de bevestigingslink), ingelogd gaat meteen naar 1. Geen
  50. kapotte mailroute. Wel blijft staan dat van die 450 anonieme inschrijvingen
  51. er in tien jaar nul zijn bevestigd — dat is een lage conversie op de
  52. publieke formulieren en misschien ooit een eigen kijkje waard, maar het
  53. raakt de app niet.
  54. NOODREM (normaal niet nodig): custom_nieuwsbrieven_confirm overrulet de
  55. functie hierboven. Niet gezet = automatisch, 1 = altijd bevestigingsmail,
  56. 0 = nooit.
  57. drush @uitgaanskrant.com vset custom_nieuwsbrieven_confirm 1
  58. drush @uitgaanskrant.com vdel custom_nieuwsbrieven_confirm
  59. GEDRAG IN HET KORT
  60. -------------------
  61. ABONNEREN -> direct actief (status 1), geen mail. Zie hierboven.
  62. UITSCHRIJVEN -> altijd direct, zonder mail. Ook met bevestiging aan:
  63. anders drukt de gebruiker in de app op "uit" en blijft
  64. hij abonnee tot hij een mail opent. Afmelden hoort
  65. makkelijker te zijn dan aanmelden, en "Double" schrijft
  66. voor ingelogde gebruikers sowieso direct uitschrijven voor.
  67. ⚠️ Deze instelling raakt ALLEEN de app. De website-pagina
  68. (/user/<uid>/simplenews) heeft confirm=FALSE hardcoded in simplenews zelf
  69. (includes/simplenews.subscription.inc regel 97); daar verandert niets aan.
  70. =====================================================================
  71. BLOK A — het complete bestand custom.nieuwsbrieven.inc
  72. =====================================================================
  73. ⬇ PLAK VANAF HIER ⬇
  74. <?php
  75. /**
  76. * @file
  77. * Nieuwsbrief-abonnementen (simplenews) voor de Flutter-app.
  78. *
  79. * Drie Services-resources onder het endpoint 'flutterdrup':
  80. * GET nieuwsbrieven.json - lijst + status van de ingelogde gebruiker
  81. * POST nieuwsbrieven/subscribe.json - {"tid": 36676}
  82. * POST nieuwsbrieven/unsubscribe.json - {"tid": 36676}
  83. *
  84. * Simplenews 7.x-1.1: nieuwsbrieven zijn taxonomy-termen in de vocabulary
  85. * 'newsletter'. Let op: variable_get('simplenews_vid') geeft op deze site 0,
  86. * dus leid de vocabulary daar NOOIT uit af — simplenews_category_get_visible()
  87. * is de betrouwbare ingang (en laat 'hidden' nieuwsbrieven automatisch weg).
  88. *
  89. * Statuscodes in {simplenews_subscription}.status:
  90. * 1 = geabonneerd, 0 = uitgeschreven, 2 = wacht op bevestiging.
  91. */
  92. /**
  93. * Rol die de ondernemersnieuwsbrief te zien krijgt.
  94. */
  95. define('CUSTOM_NIEUWSBRIEVEN_ROL_ONDERNEMER', 'Horeca-owner');
  96. /**
  97. * De nieuwsbrieven die deze gebruiker mag zien.
  98. *
  99. * De ondernemersnieuwsbrief is alleen zichtbaar voor de rol Horeca-owner.
  100. * Dit filter is met opzet server-side: de app heeft er geen conditie voor
  101. * nodig, en subscribe() gebruikt dezelfde lijst als whitelist, zodat een
  102. * handmatig verzoek zich er ook niet op kan abonneren.
  103. *
  104. * @param object $account
  105. * Volledig geladen user-object (user_load), want de globale $user hoeft
  106. * zijn rollen niet gevuld te hebben.
  107. *
  108. * @return array
  109. * Categorie-objecten, keyed op tid.
  110. */
  111. function _custom_nieuwsbrieven_zichtbaar($account) {
  112. $lijst = simplenews_category_get_visible();
  113. $ondernemers_tid = (int) variable_get('custom_nieuwsbrieven_ondernemers_tid', 18017);
  114. if ($ondernemers_tid && isset($lijst[$ondernemers_tid])) {
  115. $rollen = (is_object($account) && !empty($account->roles)) ? $account->roles : array();
  116. if (!in_array(CUSTOM_NIEUWSBRIEVEN_ROL_ONDERNEMER, $rollen, TRUE)) {
  117. unset($lijst[$ondernemers_tid]);
  118. }
  119. }
  120. return $lijst;
  121. }
  122. /**
  123. * Abonnementsstatus per tid, in één query.
  124. *
  125. * Bewust rechtstreeks op de database in plaats van via
  126. * simplenews_subscriber_load_by_mail(): die kent een static cache, en deze
  127. * functie wordt ook aangeroepen direct NA een wijziging.
  128. *
  129. * @return array
  130. * tid => status.
  131. */
  132. function _custom_nieuwsbrieven_statussen($mail) {
  133. if (!is_string($mail) || $mail === '') {
  134. return array();
  135. }
  136. return db_query('SELECT s.tid, s.status
  137. FROM {simplenews_subscription} s
  138. INNER JOIN {simplenews_subscriber} sub ON sub.snid = s.snid
  139. WHERE sub.mail = :mail', array(':mail' => $mail))->fetchAllKeyed();
  140. }
  141. /**
  142. * Eén rij van de respons, in dezelfde vorm voor index en subscribe.
  143. */
  144. function _custom_nieuwsbrieven_rij($tid, $category, $statussen) {
  145. $tid = (int) $tid;
  146. $status = isset($statussen[$tid]) ? (int) $statussen[$tid] : 0;
  147. $omschrijving = '';
  148. if (is_object($category) && property_exists($category, 'description')) {
  149. $omschrijving = trim(strip_tags((string) $category->description));
  150. }
  151. return array(
  152. 'tid' => (string) $tid,
  153. 'naam' => is_object($category) ? $category->name : '',
  154. 'omschrijving' => $omschrijving,
  155. // 1 = aan, 0 = uit, 2 = wacht op bevestiging. De app toont bij 2 een
  156. // regel "check je mail"; zonder dit onderscheid zou de switch bij de
  157. // volgende paginaload terugspringen naar uit en lijkt de app kapot.
  158. 'status' => $status,
  159. 'geabonneerd' => ($status === 1),
  160. );
  161. }
  162. /**
  163. * GET nieuwsbrieven.json
  164. */
  165. function _custom_nieuwsbrieven_index() {
  166. global $user;
  167. if (empty($user->uid)) {
  168. return services_error('Niet ingelogd', 401);
  169. }
  170. $account = user_load($user->uid);
  171. if (!$account || empty($account->mail)) {
  172. return services_error('Account zonder e-mailadres', 400);
  173. }
  174. $statussen = _custom_nieuwsbrieven_statussen($account->mail);
  175. $out = array();
  176. foreach (_custom_nieuwsbrieven_zichtbaar($account) as $tid => $category) {
  177. $out[] = _custom_nieuwsbrieven_rij($tid, $category, $statussen);
  178. }
  179. return $out;
  180. }
  181. /**
  182. * POST nieuwsbrieven/subscribe.json
  183. */
  184. function _custom_nieuwsbrieven_subscribe($tid) {
  185. return _custom_nieuwsbrieven_wijzig($tid, TRUE);
  186. }
  187. /**
  188. * POST nieuwsbrieven/unsubscribe.json
  189. */
  190. function _custom_nieuwsbrieven_unsubscribe($tid) {
  191. return _custom_nieuwsbrieven_wijzig($tid, FALSE);
  192. }
  193. /**
  194. * Gedeelde afhandeling van (un)subscribe.
  195. */
  196. function _custom_nieuwsbrieven_wijzig($tid, $aan) {
  197. global $user;
  198. if (empty($user->uid)) {
  199. return services_error('Niet ingelogd', 401);
  200. }
  201. $account = user_load($user->uid);
  202. if (!$account || empty($account->mail)) {
  203. return services_error('Account zonder e-mailadres', 400);
  204. }
  205. $tid = (int) $tid;
  206. $lijst = _custom_nieuwsbrieven_zichtbaar($account);
  207. // Whitelist. Zonder deze check kan een handmatig verzoek zich abonneren op
  208. // een 'hidden' nieuwsbrief of op de ondernemersnieuwsbrief zonder de rol.
  209. if (!isset($lijst[$tid])) {
  210. return services_error('Onbekende nieuwsbrief', 400);
  211. }
  212. $statussen = _custom_nieuwsbrieven_statussen($account->mail);
  213. $huidig = isset($statussen[$tid]) ? (int) $statussen[$tid] : 0;
  214. if ($aan) {
  215. // Al actief? Niets doen. Anders stuurt simplenews bij confirm=TRUE tóch
  216. // een mail ("je bent al ingeschreven") bij elke dubbele tik.
  217. if ($huidig !== 1) {
  218. // Wel of geen bevestigingsmail? Niet zelf beslissen, maar exact
  219. // dezelfde regel volgen als het websiteformulier: dat roept
  220. // simplenews_require_double_opt_in() aan. Die geeft FALSE zodra het
  221. // mailadres van de ingelogde gebruiker zelf is, en valt anders terug
  222. // op de opt-in/out-methode van de nieuwsbrief. Een app-gebruiker is
  223. // per definitie ingelogd, dus dit levert hier altijd FALSE op:
  224. // meteen actief, geen mail. Verandert de opt-in-instelling ooit, dan
  225. // volgt de app vanzelf mee.
  226. //
  227. // Let op: simplenews_subscribe_user() kijkt NIET zelf of iemand
  228. // ingelogd is -- hij doet blind wat deze parameter zegt. De
  229. // "Double"-stand wordt dus door de aanroeper toegepast, niet door
  230. // simplenews.
  231. $confirm = variable_get('custom_nieuwsbrieven_confirm', NULL);
  232. if ($confirm === NULL) {
  233. $confirm = simplenews_require_double_opt_in($tid, $account);
  234. }
  235. simplenews_subscribe_user($account->mail, $tid, (bool) $confirm, 'app');
  236. }
  237. }
  238. else {
  239. // Uitschrijven gaat altijd direct: met een bevestigingsmail zou de
  240. // gebruiker in de app op 'uit' drukken en toch abonnee blijven.
  241. if ($huidig !== 0) {
  242. simplenews_unsubscribe_user($account->mail, $tid, FALSE, 'app');
  243. }
  244. }
  245. // Verse status ophalen, zodat de app meteen de echte stand toont.
  246. $statussen = _custom_nieuwsbrieven_statussen($account->mail);
  247. return _custom_nieuwsbrieven_rij($tid, $lijst[$tid], $statussen);
  248. }
  249. ⬆ TOT HIER ⬆
  250. =====================================================================
  251. BLOK B — invoegen in custom_services_resources() in custom.module
  252. =====================================================================
  253. Vlak vóór de regel 'favorieten' => array( (regel ~3398).
  254. ⬇ PLAK VANAF HIER ⬇
  255. 'nieuwsbrieven' => array(
  256. 'operations' => array(
  257. 'index' => array(
  258. 'help' => 'Nieuwsbrieven met de abonnementsstatus van de ingelogde gebruiker.',
  259. 'callback' => '_custom_nieuwsbrieven_index',
  260. 'access callback' => 'user_is_logged_in',
  261. 'access arguments' => array(),
  262. 'access arguments append' => FALSE,
  263. 'args' => array(),
  264. ),
  265. ),
  266. 'actions' => array(
  267. 'subscribe' => array(
  268. 'help' => 'Abonneer de ingelogde gebruiker op een nieuwsbrief.',
  269. 'callback' => '_custom_nieuwsbrieven_subscribe',
  270. 'access callback' => 'user_is_logged_in',
  271. 'access arguments' => array(),
  272. 'access arguments append' => FALSE,
  273. 'args' => array(
  274. array(
  275. 'name' => 'tid',
  276. 'type' => 'int',
  277. 'description' => 'Term-id van de nieuwsbrief.',
  278. 'source' => array('data' => 'tid'),
  279. 'optional' => FALSE,
  280. ),
  281. ),
  282. ),
  283. 'unsubscribe' => array(
  284. 'help' => 'Schrijf de ingelogde gebruiker uit voor een nieuwsbrief.',
  285. 'callback' => '_custom_nieuwsbrieven_unsubscribe',
  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. ),
  300. ),
  301. ⬆ TOT HIER ⬆
  302. =====================================================================
  303. Voorbeeldrespons
  304. =====================================================================
  305. GET nieuwsbrieven.json — gewone gebruiker (geen Horeca-owner):
  306. [
  307. {"tid":"36667","naam":"Uitgaanskrant voor bezoekers van de horeca",
  308. "omschrijving":"Uitgaanskrant voor bezoekers van de horeca",
  309. "status":0,"geabonneerd":false},
  310. {"tid":"36676","naam":"Uitgaanskrant.com wekelijkse uitgaansagenda",
  311. "omschrijving":"Wekelijkse agenda op basis van je favoriete gemeenten. …",
  312. "status":1,"geabonneerd":true}
  313. ]
  314. Een Horeca-owner krijgt daar tid 18017 bij.
  315. POST subscribe/unsubscribe geven exact één zo'n rij terug, met de nieuwe
  316. status — de app kan de switch daar direct op zetten.
  317. ⚠️ Bind in FlutterFlow pas op $.status nadat "Test API Call" een gevulde
  318. respons heeft opgeleverd; anders krijgt het pad type "Anything" en plakt de
  319. builder er .toString() achter.