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.
