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.


BEVESTIGINGSMAIL — waarom die er voor de app NIET is
-----------------------------------------------------
De drie nieuwsbrieven staan op opt-in/out-methode **Double**. Simplenews
omschrijft die stand zelf zo:

  "Double: When (un)subscribing at a subscription form, anonymous users
   receive an (un)subscription confirmation email. Authenticated users
   are (un)subscribed immediately."

Bevestiging geldt dus alleen voor ANONIEME bezoekers. Een app-gebruiker is
per definitie ingelogd en wordt direct (un)subscribed — net als op
/user/<uid>/simplenews.

⚠️ Simplenews past die regel NIET zelf toe. simplenews_subscribe_user()
bevat geen enkele check op de ingelogde gebruiker; hij doet blind wat de
$confirm-parameter zegt. De formulieren bepalen die waarde, met
simplenews_require_double_opt_in($tid, $account) — FALSE zodra het
mailadres van de ingelogde gebruiker zelf is, anders de opt-in-methode.
Deze resource roept precies diezelfde functie aan, dus de app volgt de
website automatisch, ook als de opt-in-instelling ooit wijzigt. Je hoeft
er geen variabele voor te zetten.

Dit verklaart ook het cijfer dat eerder verdacht leek. Gemeten 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

Precies de tweedeling die de Double-stand voorschrijft: anoniem gaat naar
status 2 (wacht op de bevestigingslink), ingelogd gaat meteen naar 1. Geen
kapotte mailroute. Wel blijft staan dat van die 450 anonieme inschrijvingen
er in tien jaar nul zijn bevestigd — dat is een lage conversie op de
publieke formulieren en misschien ooit een eigen kijkje waard, maar het
raakt de app niet.

NOODREM (normaal niet nodig): custom_nieuwsbrieven_confirm overrulet de
functie hierboven. Niet gezet = automatisch, 1 = altijd bevestigingsmail,
0 = nooit.
  drush @uitgaanskrant.com vset custom_nieuwsbrieven_confirm 1
  drush @uitgaanskrant.com vdel custom_nieuwsbrieven_confirm


GEDRAG IN HET KORT
-------------------
  ABONNEREN    -> direct actief (status 1), geen mail. Zie hierboven.
  UITSCHRIJVEN -> altijd direct, zonder mail. Ook met bevestiging aan:
                  anders drukt de gebruiker in de app op "uit" en blijft
                  hij abonnee tot hij een mail opent. Afmelden hoort
                  makkelijker te zijn dan aanmelden, en "Double" schrijft
                  voor ingelogde gebruikers sowieso direct uitschrijven voor.

⚠️ Deze instelling raakt ALLEEN de app. De website-pagina
(/user/<uid>/simplenews) heeft confirm=FALSE hardcoded in simplenews zelf
(includes/simplenews.subscription.inc regel 97); daar verandert niets aan.


=====================================================================
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) {
      // Wel of geen bevestigingsmail? Niet zelf beslissen, maar exact
      // dezelfde regel volgen als het websiteformulier: dat roept
      // simplenews_require_double_opt_in() aan. Die geeft FALSE zodra het
      // mailadres van de ingelogde gebruiker zelf is, en valt anders terug
      // op de opt-in/out-methode van de nieuwsbrief. Een app-gebruiker is
      // per definitie ingelogd, dus dit levert hier altijd FALSE op:
      // meteen actief, geen mail. Verandert de opt-in-instelling ooit, dan
      // volgt de app vanzelf mee.
      //
      // Let op: simplenews_subscribe_user() kijkt NIET zelf of iemand
      // ingelogd is -- hij doet blind wat deze parameter zegt. De
      // "Double"-stand wordt dus door de aanroeper toegepast, niet door
      // simplenews.
      $confirm = variable_get('custom_nieuwsbrieven_confirm', NULL);
      if ($confirm === NULL) {
        $confirm = simplenews_require_double_opt_in($tid, $account);
      }
      simplenews_subscribe_user($account->mail, $tid, (bool) $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.
