Преглед изворни кода

Taak 128-A: Drupal-code voor nieuwsbrief-abonnementen

Volledige resource in snippets/nieuwsbrieven-drupal.inc.txt: het nieuwe
custom.nieuwsbrieven.inc (index/subscribe/unsubscribe), het blok voor
custom_services_resources(), deploystappen en voorbeeldrespons.

Verwerkt Bob's keuzes van 2026-09-21:
- bevestigingsmail AAN bij abonneren (confirm=TRUE, status 2), maar
  uitschrijven altijd direct — anders blijft iemand die in de app op
  'uit' drukt abonnee tot hij een mail opent. Wisselen kan zonder
  codewijziging via de variabele custom_nieuwsbrieven_confirm.
- de ondernemersnieuwsbrief wordt server-side gefilterd op de ROL
  Horeca-owner (rid 5), niet op bezit van een horecagelegenheid.

Getest: PHP-syntax op de server, en de index-logica read-only
drooggedraaid op productie voor drie soorten accounts.

Nieuw open risico, gemeten en vastgelegd: van de 450 anonieme
inschrijvingen is er in tien jaar geen enkele ooit bevestigd, terwijl
alle 45 account-inschrijvingen (die via confirm=FALSE lopen) meteen
goed staan. Testrecept voor de bevestigingsmail staat in het snippet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bob пре 19 часа
родитељ
комит
e3236fd32f
3 измењених фајлова са 456 додато и 11 уклоњено
  1. 39 11
      TASKS.md
  2. 18 0
      snippets/LEESMIJ.md
  3. 399 0
      snippets/nieuwsbrieven-drupal.inc.txt

+ 39 - 11
TASKS.md

@@ -130,15 +130,45 @@ en geen van de 6 Rules op de site luistert ernaar (alle zes hangen aan
 `node_insert`/`user_insert`/`node_presave`). "Drupal doet dat standaard al"
 klopt dus niet voor de ingelogde route.
 
-**Daarom geeft de resource de STATUS terug, niet een kale bool** — dan werkt de
-app bij beide keuzes en is wisselen later één regel in Drupal, zonder
-FlutterFlow-wijziging. Zonder status krijg je bij `confirm = TRUE` een echte
-bug: de switch springt bij de volgende paginaload terug naar uit, want het
-abonnement is nog onbevestigd. Advies blijft `FALSE` (gebruiker is ingelogd,
-mailadres is bewezen, en het is exact wat de site zelf doet).
+**Bob 2026-09-21: bevestiging moet er komen.** De resource doet daarom
+`subscribe` met `$confirm = TRUE` (status 2 + bevestigingsmail) en `unsubscribe`
+altijd met `FALSE` — met bevestiging zou de gebruiker in de app op "uit" drukken
+en toch abonnee blijven, en afmelden hoort makkelijker te zijn dan aanmelden.
+Om te wisselen zonder codewijziging: `drush vset custom_nieuwsbrieven_confirm 0`.
+
+**Daarom geeft de resource de STATUS terug, niet een kale bool** (1 aan / 2 wacht
+op bevestiging / 0 uit). Zonder dat onderscheid springt de switch bij de volgende
+paginaload terug naar uit en lijkt de app kapot.
+
+### ⚠️ Open risico: komt die bevestigingsmail wel aan?
+
+Gemeten over de hele database (tid 1 niet meegeteld):
+
+| | anoniem (uid 0) | met account |
+|---|---|---|
+| status 1 (actief) | 0 | 45 |
+| status 0 (uit) | 0 | 3 |
+| status 2 (onbevestigd) | **450** | 0 |
+
+Van de 450 anonieme inschrijvingen via de publieke formulieren is er in **tien
+jaar** (2015-12 t/m 2025-12, gestaag verspreid — geen eenmalige import) **geen
+enkele** ooit bevestigd. Bij een werkende mail verwacht je 30-70 %, niet 0 %.
+De inschrijvingen mét account staan allemaal meteen goed, en dat is logisch:
+die liepen via `/user/<uid>/simplenews`, en dat pad gebruikt `confirm = FALSE`
+en stuurt dus nooit een mail. Het enige pad dat wél een bevestigingsmail stuurt,
+is precies het pad waar nooit iemand doorheen komt.
+
+Mailconfiguratie oogt in orde (`smtp_on = 1`, mailsystem → mimemail, geen
+reroute, afzender `contact@uitgaanskrant.com`); de watchdog bevat geen
+mailfouten maar is grotendeels leeg, dus dat bewijst niets. **Testrecept staat
+in `snippets/nieuwsbrieven-drupal.inc.txt`** (schrijft en mailt echt, dus voor
+Bob zelf). Komt de mail niet aan, dan is `custom_nieuwsbrieven_confirm = 0` de
+enige variant die voor de gebruiker werkt totdat dat opgelost is.
 
 ### 128-A · Drupal: resource `nieuwsbrieven` · Eigenaar: Bob
 
+**✅ Code is af en staat klaar in `snippets/nieuwsbrieven-drupal.inc.txt`** (compleet bestand `custom.nieuwsbrieven.inc` + het blok voor `custom_services_resources()` + de deploy- en controlestappen). PHP-syntax gecontroleerd op de server en de index-logica read-only drooggedraaid op productie voor een Horeca-owner, een gewone gebruiker met abonnement en een zonder: rolfilter en statussen kloppen.
+
 Zelfde vorm als het bestaande `favorieten` (flag/unflag/is_flagged):
 
 ```
@@ -178,11 +208,9 @@ function _custom_nieuwsbrieven_index() {
 **`_custom_nieuwsbrieven_zichtbaar()` is de spil** en wordt door alle drie de
 callbacks gebruikt: hij geeft `simplenews_category_get_visible()` terug (dat
 laat `hidden`-nieuwsbrieven automatisch weg), **minus tid 18017 wanneer de
-gebruiker geen horecagelegenheid heeft** (Bob's keuze 2026-09-21: de
-ondernemersnieuwsbrief alleen voor horeca-eigenaren). Eigenaarschap = dezelfde
-query als `_custom_mijn_horecagelegenheden_service()`
-(`custom.evenementen_aanmaken.inc:261`): `node` waar
-`type = CUSTOM_ESTABLISHMENT_NODE_TYPE` en `uid = $user->uid`.
+gebruiker de rol `Horeca-owner` (rid 5) niet heeft** — Bob 2026-09-21: selecteer
+op rol, niet op het bezit van een horecagelegenheid. Er zijn 11 accounts met die
+rol, waarvan er nu 1 op de ondernemersnieuwsbrief zit.
 
 Door die filtering **server-side** te doen heeft de app géén conditie nodig, en
 kan een gemanipuleerd verzoek zich er ook niet op abonneren — mits

+ 18 - 0
snippets/LEESMIJ.md

@@ -62,3 +62,21 @@ export op de string `einddatum ligt vóór`.
 ⚠️ Het `keten`-formaat (`"provincie|gemeente|plaats"`) is met opzet identiek aan
 dat van taak 123, zodat de custom functions `ketenProvincie`/`ketenGemeente`/
 `ketenPlaats` uit taak 124 hergebruikt kunnen worden. Bouw ze niet twee keer.
+
+## Nieuwsbrieven (taak 128)
+
+- `nieuwsbrieven-drupal.inc.txt` — **geen FlutterFlow-code**, maar de volledige
+  Drupal-kant van taak 128-A: het nieuwe bestand `custom.nieuwsbrieven.inc`, het
+  blok voor `custom_services_resources()`, de deploystappen en een
+  voorbeeldrespons. Geeft de app drie endpoints om nieuwsbrieven aan en uit te
+  zetten (simplenews).
+
+⚠️ Lees het blok **"TEST EERST OF DIE BEVESTIGINGSMAIL AANKOMT"** vóór je
+deployt. Van 450 anonieme inschrijvingen is er in tien jaar geen enkele
+bevestigd; zolang dat niet verklaard is, kan de bevestigingsroute in de app
+stilvallen. De code ondersteunt beide varianten via één variabele
+(`custom_nieuwsbrieven_confirm`), dus wisselen kost geen codewijziging.
+
+De rolcheck (`Horeca-owner`) bepaalt server-side wie de ondernemersnieuwsbrief
+ziet — de app heeft daar dus géén conditie voor nodig, en subscribe gebruikt
+dezelfde lijst als whitelist.

+ 399 - 0
snippets/nieuwsbrieven-drupal.inc.txt

@@ -0,0 +1,399 @@
+Taak 128-A — Drupal: nieuwsbrief-abonnementen voor de app
+=========================================================
+
+DRIE INGREPEN op de server, daarna `drush @uitgaanskrant.com cc all`:
+
+  1) NIEUW BESTAND  sites/all/modules/custom/custom.nieuwsbrieven.inc
+                    (zie blok A hieronder — het hele bestand)
+  2) custom.module regel 15, ONDER de bestaande module_load_include-regels:
+         module_load_include('inc', 'custom', 'custom.nieuwsbrieven');
+  3) custom_services_resources(), vlak vóór  'favorieten' => array(
+     (regel ~3398): het blok B hieronder invoegen.
+
+DAARNA — en dit is de stap die het vaakst vergeten wordt:
+  /admin/structure/services/list/flutterdrup/resources
+  → 'nieuwsbrieven' aanvinken ÉN de drie operaties eronder
+    (index, subscribe, unsubscribe) → Save.
+  Zonder die vinkjes wordt er geen route geregistreerd en krijg je 404,
+  precies zoals mijn_aanmeldingen maandenlang deed.
+
+CONTROLE NA DEPLOY (anoniem, dus zonder sessie):
+  curl -s -o /dev/null -w '%{http_code}\n' \
+    'https://uitgaanskrant.com/nl/flutterdrup/nieuwsbrieven.json'
+  → moet 403 geven ("route bestaat, sessie ontbreekt"), NIET 404.
+
+⚠️ NOOIT EDGE-CACHEN. Deze respons is per gebruiker verschillend. Het pad
+valt buiten de bestaande Cloudflare Cache Rules (die matchen op
+/flutterdrup/views/… en plaatsen), dus dat gaat vanzelf goed — voeg het
+er alleen nooit aan toe.
+
+
+⚠️⚠️ TEST EERST OF DIE BEVESTIGINGSMAIL AANKOMT — er is reden tot twijfel
+------------------------------------------------------------------------
+Gemeten 2026-09-21 over de hele database (tid 1, een oude restterm, niet
+meegeteld):
+
+              anoniem (uid 0)   met account (uid > 0)
+  status 1              0                 45
+  status 0              0                  3
+  status 2            450                  0
+
+Dus: van de 450 anonieme inschrijvingen via de publieke formulieren is er
+in TIEN JAAR (2015-12 t/m 2025-12, gestaag verspreid — geen eenmalige
+import) GEEN ENKELE ooit bevestigd. Bij een werkende bevestigingsmail zou
+je 30-70% verwachten, niet 0%.
+
+De inschrijvingen mét account staan allemaal meteen goed, en dat is ook
+logisch: die liepen via /user/<uid>/simplenews, en dat pad gebruikt
+confirm=FALSE en stuurt dus nooit een mail. Met andere woorden: het enige
+pad dat wél een bevestigingsmail stuurt, is ook precies het pad waar nooit
+iemand doorheen komt.
+
+Dat kan twee dingen betekenen: de mail wordt niet bezorgd, of de
+bevestigingslink werkt niet. De mailconfiguratie zelf oogt in orde
+(smtp_on = 1, mailsystem delegeert naar mimemail, geen reroute_email,
+afzender contact@uitgaanskrant.com) en de watchdog bevat geen mailfouten —
+maar die is grotendeels leeg, dus dat bewijst niets.
+
+WAAROM DIT ERTOE DOET: zet je bevestiging aan voor de app zonder dit te
+testen, dan zet de gebruiker de switch aan, gebeurt er verder niets, en
+blijft het abonnement voor eeuwig op status 2 staan. Precies wat die 450
+nu doen.
+
+TEST (schrijft wel, en verstuurt een echte mail — daarom niet door Claude
+gedraaid):
+
+  # 1. abonneer jezelf MET bevestiging op de bezoekersnieuwsbrief
+  drush @uitgaanskrant.com php-eval \
+    "simplenews_subscribe_user('JOUW@MAIL.NL', 36667, TRUE, 'test');"
+
+  # 2. check de status — hoort 2 te zijn
+  drush @uitgaanskrant.com sql-query \
+    "SELECT s.tid, s.status FROM simplenews_subscription s
+     INNER JOIN simplenews_subscriber sub ON sub.snid=s.snid
+     WHERE sub.mail='JOUW@MAIL.NL';"
+
+  # 3. komt de mail aan? klik de link. Daarna moet stap 2 status 1 geven.
+
+  # 4. opruimen
+  drush @uitgaanskrant.com php-eval \
+    "simplenews_unsubscribe_user('JOUW@MAIL.NL', 36667, FALSE, 'test');"
+
+Komt de mail NIET aan, dan is dat eerst een mailprobleem om op te lossen —
+en zolang dat niet opgelost is, is `custom_nieuwsbrieven_confirm` op 0
+(direct abonneren, geen mail) de enige variant die voor de gebruiker
+werkt. De code hieronder ondersteunt beide zonder wijziging.
+
+
+BEVESTIGINGSMAIL — hoe je dit aanpast
+--------------------------------------
+Staat AAN (Bob 2026-09-21). Concreet gedrag:
+
+  ABONNEREN   -> simplenews zet het abonnement op status 2 (onbevestigd)
+                 en mailt een link. Pas na het klikken wordt het status 1
+                 en ontvangt de gebruiker de nieuwsbrief.
+  UITSCHRIJVEN-> gaat ALTIJD direct, zonder mail. Bewust: met bevestiging
+                 zou de gebruiker in de app op 'uit' drukken en tóch
+                 abonnee blijven tot hij een mail opent. Dat is slechte UX
+                 en juridisch precies de verkeerde kant op — afmelden moet
+                 makkelijker zijn dan aanmelden.
+
+Omzetten naar 'direct abonneren, geen mail' kan zonder codewijziging:
+  drush @uitgaanskrant.com vset custom_nieuwsbrieven_confirm 0
+Terug naar bevestiging:
+  drush @uitgaanskrant.com vdel custom_nieuwsbrieven_confirm
+
+De TEKST van die bevestigingsmail is al Nederlands en staat in variabelen,
+te bewerken op  /admin/config/services/simplenews  (tab Subscription):
+  simplenews_confirm_subscribe_subject
+      "Bevestiging voor [simplenews-category:name] van [site:name]"
+  simplenews_confirm_subscribe_unsubscribed
+      "Wij hebben een verzoek ontvangen om [simplenews-subscriber:mail] in
+       te schrijven op de [simplenews-category:name] nieuwsbrief …
+       [simplenews-subscriber:subscribe-url]"
+De tekst noemt nu "de website op [site:url]" — dat klopt straks niet meer
+voor een aanmelding vanuit de app. Overweeg een neutralere formulering.
+
+⚠️ Deze instelling raakt ALLEEN de app. De website-pagina uit je screenshot
+(/user/<uid>/simplenews) heeft confirm=FALSE HARDCODED in simplenews zelf
+(includes/simplenews.subscription.inc regel 97) en stuurt dus nog steeds
+geen mail. Wil je dat gelijktrekken, dan is dat een aparte ingreep
+(hook_form_alter die dat submit-handler vervangt) — niet in deze patch.
+
+
+=====================================================================
+BLOK A — het complete bestand custom.nieuwsbrieven.inc
+=====================================================================
+⬇ PLAK VANAF HIER ⬇
+
+<?php
+
+/**
+ * @file
+ * Nieuwsbrief-abonnementen (simplenews) voor de Flutter-app.
+ *
+ * Drie Services-resources onder het endpoint 'flutterdrup':
+ *   GET  nieuwsbrieven.json             - lijst + status van de ingelogde gebruiker
+ *   POST nieuwsbrieven/subscribe.json   - {"tid": 36676}
+ *   POST nieuwsbrieven/unsubscribe.json - {"tid": 36676}
+ *
+ * Simplenews 7.x-1.1: nieuwsbrieven zijn taxonomy-termen in de vocabulary
+ * 'newsletter'. Let op: variable_get('simplenews_vid') geeft op deze site 0,
+ * dus leid de vocabulary daar NOOIT uit af — simplenews_category_get_visible()
+ * is de betrouwbare ingang (en laat 'hidden' nieuwsbrieven automatisch weg).
+ *
+ * Statuscodes in {simplenews_subscription}.status:
+ *   1 = geabonneerd, 0 = uitgeschreven, 2 = wacht op bevestiging.
+ */
+
+/**
+ * Rol die de ondernemersnieuwsbrief te zien krijgt.
+ */
+define('CUSTOM_NIEUWSBRIEVEN_ROL_ONDERNEMER', 'Horeca-owner');
+
+/**
+ * De nieuwsbrieven die deze gebruiker mag zien.
+ *
+ * De ondernemersnieuwsbrief is alleen zichtbaar voor de rol Horeca-owner.
+ * Dit filter is met opzet server-side: de app heeft er geen conditie voor
+ * nodig, en subscribe() gebruikt dezelfde lijst als whitelist, zodat een
+ * handmatig verzoek zich er ook niet op kan abonneren.
+ *
+ * @param object $account
+ *   Volledig geladen user-object (user_load), want de globale $user hoeft
+ *   zijn rollen niet gevuld te hebben.
+ *
+ * @return array
+ *   Categorie-objecten, keyed op tid.
+ */
+function _custom_nieuwsbrieven_zichtbaar($account) {
+  $lijst = simplenews_category_get_visible();
+
+  $ondernemers_tid = (int) variable_get('custom_nieuwsbrieven_ondernemers_tid', 18017);
+  if ($ondernemers_tid && isset($lijst[$ondernemers_tid])) {
+    $rollen = (is_object($account) && !empty($account->roles)) ? $account->roles : array();
+    if (!in_array(CUSTOM_NIEUWSBRIEVEN_ROL_ONDERNEMER, $rollen, TRUE)) {
+      unset($lijst[$ondernemers_tid]);
+    }
+  }
+
+  return $lijst;
+}
+
+/**
+ * Abonnementsstatus per tid, in één query.
+ *
+ * Bewust rechtstreeks op de database in plaats van via
+ * simplenews_subscriber_load_by_mail(): die kent een static cache, en deze
+ * functie wordt ook aangeroepen direct NA een wijziging.
+ *
+ * @return array
+ *   tid => status.
+ */
+function _custom_nieuwsbrieven_statussen($mail) {
+  if (!is_string($mail) || $mail === '') {
+    return array();
+  }
+
+  return db_query('SELECT s.tid, s.status
+      FROM {simplenews_subscription} s
+      INNER JOIN {simplenews_subscriber} sub ON sub.snid = s.snid
+      WHERE sub.mail = :mail', array(':mail' => $mail))->fetchAllKeyed();
+}
+
+/**
+ * Eén rij van de respons, in dezelfde vorm voor index en subscribe.
+ */
+function _custom_nieuwsbrieven_rij($tid, $category, $statussen) {
+  $tid = (int) $tid;
+  $status = isset($statussen[$tid]) ? (int) $statussen[$tid] : 0;
+
+  $omschrijving = '';
+  if (is_object($category) && property_exists($category, 'description')) {
+    $omschrijving = trim(strip_tags((string) $category->description));
+  }
+
+  return array(
+    'tid'          => (string) $tid,
+    'naam'         => is_object($category) ? $category->name : '',
+    'omschrijving' => $omschrijving,
+    // 1 = aan, 0 = uit, 2 = wacht op bevestiging. De app toont bij 2 een
+    // regel "check je mail"; zonder dit onderscheid zou de switch bij de
+    // volgende paginaload terugspringen naar uit en lijkt de app kapot.
+    'status'       => $status,
+    'geabonneerd'  => ($status === 1),
+  );
+}
+
+/**
+ * GET nieuwsbrieven.json
+ */
+function _custom_nieuwsbrieven_index() {
+  global $user;
+
+  if (empty($user->uid)) {
+    return services_error('Niet ingelogd', 401);
+  }
+
+  $account = user_load($user->uid);
+  if (!$account || empty($account->mail)) {
+    return services_error('Account zonder e-mailadres', 400);
+  }
+
+  $statussen = _custom_nieuwsbrieven_statussen($account->mail);
+
+  $out = array();
+  foreach (_custom_nieuwsbrieven_zichtbaar($account) as $tid => $category) {
+    $out[] = _custom_nieuwsbrieven_rij($tid, $category, $statussen);
+  }
+
+  return $out;
+}
+
+/**
+ * POST nieuwsbrieven/subscribe.json
+ */
+function _custom_nieuwsbrieven_subscribe($tid) {
+  return _custom_nieuwsbrieven_wijzig($tid, TRUE);
+}
+
+/**
+ * POST nieuwsbrieven/unsubscribe.json
+ */
+function _custom_nieuwsbrieven_unsubscribe($tid) {
+  return _custom_nieuwsbrieven_wijzig($tid, FALSE);
+}
+
+/**
+ * Gedeelde afhandeling van (un)subscribe.
+ */
+function _custom_nieuwsbrieven_wijzig($tid, $aan) {
+  global $user;
+
+  if (empty($user->uid)) {
+    return services_error('Niet ingelogd', 401);
+  }
+
+  $account = user_load($user->uid);
+  if (!$account || empty($account->mail)) {
+    return services_error('Account zonder e-mailadres', 400);
+  }
+
+  $tid = (int) $tid;
+  $lijst = _custom_nieuwsbrieven_zichtbaar($account);
+
+  // Whitelist. Zonder deze check kan een handmatig verzoek zich abonneren op
+  // een 'hidden' nieuwsbrief of op de ondernemersnieuwsbrief zonder de rol.
+  if (!isset($lijst[$tid])) {
+    return services_error('Onbekende nieuwsbrief', 400);
+  }
+
+  $statussen = _custom_nieuwsbrieven_statussen($account->mail);
+  $huidig = isset($statussen[$tid]) ? (int) $statussen[$tid] : 0;
+
+  if ($aan) {
+    // Al actief? Niets doen. Anders stuurt simplenews bij confirm=TRUE tóch
+    // een mail ("je bent al ingeschreven") bij elke dubbele tik.
+    if ($huidig !== 1) {
+      // TRUE  = bevestigingsmail, abonnement blijft op status 2 tot de
+      //         gebruiker op de link klikt.
+      // FALSE = meteen actief, geen mail (wat de website zelf doet voor
+      //         ingelogde gebruikers).
+      $confirm = (bool) variable_get('custom_nieuwsbrieven_confirm', TRUE);
+      simplenews_subscribe_user($account->mail, $tid, $confirm, 'app');
+    }
+  }
+  else {
+    // Uitschrijven gaat altijd direct: met een bevestigingsmail zou de
+    // gebruiker in de app op 'uit' drukken en toch abonnee blijven.
+    if ($huidig !== 0) {
+      simplenews_unsubscribe_user($account->mail, $tid, FALSE, 'app');
+    }
+  }
+
+  // Verse status ophalen, zodat de app meteen de echte stand toont.
+  $statussen = _custom_nieuwsbrieven_statussen($account->mail);
+
+  return _custom_nieuwsbrieven_rij($tid, $lijst[$tid], $statussen);
+}
+
+⬆ TOT HIER ⬆
+
+
+=====================================================================
+BLOK B — invoegen in custom_services_resources() in custom.module
+=====================================================================
+Vlak vóór de regel   'favorieten' => array(   (regel ~3398).
+⬇ PLAK VANAF HIER ⬇
+
+    'nieuwsbrieven' => array(
+      'operations' => array(
+        'index' => array(
+          'help' => 'Nieuwsbrieven met de abonnementsstatus van de ingelogde gebruiker.',
+          'callback' => '_custom_nieuwsbrieven_index',
+          'access callback' => 'user_is_logged_in',
+          'access arguments' => array(),
+          'access arguments append' => FALSE,
+          'args' => array(),
+        ),
+      ),
+      'actions' => array(
+        'subscribe' => array(
+          'help' => 'Abonneer de ingelogde gebruiker op een nieuwsbrief.',
+          'callback' => '_custom_nieuwsbrieven_subscribe',
+          'access callback' => 'user_is_logged_in',
+          'access arguments' => array(),
+          'access arguments append' => FALSE,
+          'args' => array(
+            array(
+              'name' => 'tid',
+              'type' => 'int',
+              'description' => 'Term-id van de nieuwsbrief.',
+              'source' => array('data' => 'tid'),
+              'optional' => FALSE,
+            ),
+          ),
+        ),
+        'unsubscribe' => array(
+          'help' => 'Schrijf de ingelogde gebruiker uit voor een nieuwsbrief.',
+          'callback' => '_custom_nieuwsbrieven_unsubscribe',
+          'access callback' => 'user_is_logged_in',
+          'access arguments' => array(),
+          'access arguments append' => FALSE,
+          'args' => array(
+            array(
+              'name' => 'tid',
+              'type' => 'int',
+              'description' => 'Term-id van de nieuwsbrief.',
+              'source' => array('data' => 'tid'),
+              'optional' => FALSE,
+            ),
+          ),
+        ),
+      ),
+    ),
+
+⬆ TOT HIER ⬆
+
+
+=====================================================================
+Voorbeeldrespons
+=====================================================================
+GET nieuwsbrieven.json — gewone gebruiker (geen Horeca-owner):
+
+[
+  {"tid":"36667","naam":"Uitgaanskrant voor bezoekers van de horeca",
+   "omschrijving":"Uitgaanskrant voor bezoekers van de horeca",
+   "status":0,"geabonneerd":false},
+  {"tid":"36676","naam":"Uitgaanskrant.com wekelijkse uitgaansagenda",
+   "omschrijving":"Wekelijkse agenda op basis van je favoriete gemeenten. …",
+   "status":1,"geabonneerd":true}
+]
+
+Een Horeca-owner krijgt daar tid 18017 bij.
+POST subscribe/unsubscribe geven exact één zo'n rij terug, met de nieuwe
+status — de app kan de switch daar direct op zetten.
+
+⚠️ Bind in FlutterFlow pas op $.status nadat "Test API Call" een gevulde
+respons heeft opgeleverd; anders krijgt het pad type "Anything" en plakt de
+builder er .toString() achter.