Skip to content

Commit d1009c0

Browse files
authored
docs(integrations): add framework README (#4722)
1 parent fb6c06d commit d1009c0

1 file changed

Lines changed: 354 additions & 0 deletions

File tree

  • includes/reader-activation/integrations
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
# Newspack Integrations
2+
3+
## A framework for syncing reader data with third-party services.
4+
5+
A uniform contract for syncing Newspack reader data with external systems — ESPs, CRMs, donation platforms — through bidirectional contact synchronization.
6+
7+
Each integration:
8+
9+
- Pushes contact data (Newspack → external) on reader activity events.
10+
- Pulls contact data (external → Newspack) on a recurring schedule.
11+
- Declares its own settings, metadata prefix, and outgoing fields.
12+
- Exposes external fields back to Newspack for use as segmentation criteria and content gate access rules.
13+
14+
The framework is built on top of [Data Events](../../data-events/README.md) and uses WooCommerce [ActionScheduler](https://actionscheduler.org/) for retries and per-integration job grouping.
15+
16+
---
17+
18+
## File Map
19+
20+
| File | Purpose |
21+
| --- | --- |
22+
| `../class-integrations.php` | Registry and orchestrator. Owns the integrations registry, enable/disable state, data event handler map, ActionScheduler group lookup, My Account endpoint registration, and the hourly health check. |
23+
| `class-integration.php` | Abstract base class. Implements settings storage, metadata prefix, outgoing/incoming field selection, contact preparation, the health-check shell, and the data-event handler dispatcher. |
24+
| `class-esp.php` | Built-in ESP integration. Generic adapter for Newspack Newsletters (Mailchimp, ActiveCampaign, Constant Contact). |
25+
| `class-incoming-field.php` | Value object describing an external field returned by an integration. Carries display metadata plus flags for access rules and segmentation criteria. |
26+
| `class-contact-pull.php` | Pull pipeline. Per-integration synchronous loopback requests plus ActionScheduler-backed retries with exponential backoff. |
27+
| `class-contact-cron.php` | Recurring cron orchestration. Stages users for pull/push and processes both queues every 5 minutes. |
28+
29+
The registry class is `Newspack\Reader_Activation\Integrations` (parent namespace). Classes under this folder live in `Newspack\Reader_Activation\Integrations\*`.
30+
31+
---
32+
33+
## Built-in Integrations
34+
35+
### `esp`
36+
37+
Syncs contacts and metadata fields with the active Newspack Newsletters service provider. Auto-registers and is enabled by default on new installs (and on legacy upgrades unless the legacy `newspack_reader_activation_sync_esp` option was explicitly disabled). Pulls custom merge fields back into Newspack for segmentation and access rules.
38+
39+
---
40+
41+
## Registering an Integration
42+
43+
Integrations are registered during the `newspack_reader_activation_register_integrations` action, which fires on `init` priority 5:
44+
45+
```php
46+
add_action(
47+
'newspack_reader_activation_register_integrations',
48+
function () {
49+
Newspack\Reader_Activation\Integrations::register( new My_Integration() );
50+
}
51+
);
52+
```
53+
54+
`Integrations::register()` keeps the instance in a static registry. Enabled/disabled state is persisted to the `newspack_reader_activation_enabled_integrations` option.
55+
56+
After registration, the integration becomes available in the publisher-facing Integrations UI and (if enabled) starts receiving sync requests.
57+
58+
---
59+
60+
## Building an Integration
61+
62+
An integration is a class extending `\Newspack\Reader_Activation\Integration`. The base class provides settings storage, metadata prefix, outgoing/incoming field selection, and a contact preparation pipeline. You only need to provide the parts unique to your third-party.
63+
64+
### Minimum implementation
65+
66+
```php
67+
namespace Newspack\My_Plugin;
68+
69+
use Newspack\Reader_Activation\Integration;
70+
71+
class My_Integration extends Integration {
72+
public function __construct() {
73+
parent::__construct(
74+
'my_integration',
75+
__( 'My Integration', 'my-plugin' ),
76+
__( 'Syncs reader data with My Service.', 'my-plugin' )
77+
);
78+
}
79+
80+
public function register_settings_fields() {
81+
return [
82+
[
83+
'key' => 'api_key',
84+
'type' => 'password',
85+
'label' => __( 'API Key', 'my-plugin' ),
86+
'default' => '',
87+
],
88+
];
89+
}
90+
91+
public function can_sync( $return_errors = false ) {
92+
$errors = new \WP_Error();
93+
if ( ! $this->get_settings_field_value( 'api_key' ) ) {
94+
$errors->add( 'no_api_key', __( 'API key is missing.', 'my-plugin' ) );
95+
}
96+
if ( $return_errors ) {
97+
return $errors;
98+
}
99+
return ! $errors->has_errors();
100+
}
101+
102+
public function push_contact_data( $contact, $context = '', $existing_contact = null ) {
103+
$contact = $this->prepare_contact( $contact );
104+
// ... push $contact to your API.
105+
return true;
106+
}
107+
}
108+
```
109+
110+
### Required methods
111+
112+
| Method | Purpose |
113+
| --- | --- |
114+
| `register_settings_fields()` | Return static field declarations (key, type, default at minimum). Called from the constructor. No API calls, no conditional logic based on external state. |
115+
| `can_sync( $return_errors = false )` | Check whether the integration is configured and ready to sync. Return `bool` or `WP_Error` depending on `$return_errors`. Called before every push and pull. |
116+
| `push_contact_data( $contact, $context, $existing_contact )` | Send contact data to the external system. Return `true` on success or `WP_Error` on failure. Failed pushes are retried via Contact Sync's ActionScheduler-backed retry mechanism. |
117+
118+
### Optional overrides
119+
120+
| Method | Purpose |
121+
| --- | --- |
122+
| `is_set_up()` | Whether external prerequisites (provider chosen, key entered, etc.) are configured. Defaults to `true`. Used by the Integrations UI to mark cards as ready. |
123+
| `get_setup_url()` | Admin URL where the integration's prerequisites are configured. Defaults to empty string. |
124+
| `test_connection()` | Lightweight live API call to verify credentials and reachability. Called as part of `health_check()`. Defaults to `true`. |
125+
| `pull_contact_data( $user_id )` | Fetch contact data from the external system. Return `array` of `field_key => value` or `WP_Error`. Defaults to `[]`. |
126+
| `get_available_incoming_fields()` | Return an array of `Incoming_Field` objects representing the schema available to pull. Required for any integration that supports pulling. |
127+
| `configure_incoming_field( $field )` | Enrich an `Incoming_Field` with display metadata and promotion flags (access rule / segment criteria). Called by the framework on every constructed field. |
128+
| `register_handlers()` | Register data event handlers (see [Data Event Handlers](#data-event-handlers)). Called once after all integrations have been registered. |
129+
| `supports_frontend_registration()` | Return `true` to expose this integration's registration key to the page and accept it on the frontend registration endpoint. |
130+
| `get_registration_key()` / `validate_registration_request()` | Override to implement custom registration key schemes. Default is timing-safe HMAC-SHA256 of the integration ID with the site's auth salt. |
131+
| `handle_logged_in_user_registration( $user, $request )` | Called when a logged-in user attempts to register again via the frontend. Use to update user data, link the account, record a new event, etc. Default is a no-op. |
132+
| `get_my_account_menu_item()` | Return `[ 'slug' => ..., 'label' => ..., 'position' => ... ]` to add a tab to the WooCommerce My Account page. Default returns `null` (no tab). |
133+
| `render_my_account_page( $value )` | Echo markup for the integration's My Account page. Called inside the WooCommerce account template. |
134+
135+
---
136+
137+
## Settings Fields
138+
139+
Settings fields are declared statically in `register_settings_fields()` and stored individually as WordPress options keyed `newspack_integration_settings_{id}_{key}`.
140+
141+
### Field declaration
142+
143+
```php
144+
[
145+
'key' => 'master_list',
146+
'type' => 'select',
147+
'default' => '',
148+
'label' => __( 'Master List', 'my-plugin' ),
149+
'description' => __( '...', 'my-plugin' ),
150+
'options' => [ ... ], // Required for 'select'.
151+
]
152+
```
153+
154+
Supported `type` values: `text`, `password`, `textarea`, `number`, `checkbox`, `select`, `metadata`. The base class sanitizes values per type before persisting.
155+
156+
### Reading and writing
157+
158+
```php
159+
$value = $this->get_settings_field_value( 'api_key' );
160+
$this->update_settings_field_value( 'api_key', $new_value );
161+
```
162+
163+
`get_settings_field_value()` returns the field's declared `default` when no value is stored. A small legacy option map provides lazy migration for fields that were renamed from older site-wide options.
164+
165+
### Enriching at render time
166+
167+
`get_settings_fields()` is the static declaration. `get_settings_config()` is what the REST API serves to the UI — override it to add expensive data (e.g. live list options from an API) or to filter fields by the current provider/state. See `ESP::get_settings_config()` for the canonical example.
168+
169+
### Built-in metadata fields
170+
171+
Every integration automatically gets three additional fields appended to its settings:
172+
173+
| Field key | Type | Purpose |
174+
| --- | --- | --- |
175+
| `metadata_prefix` | `text` | String prepended to every outgoing metadata field name (default `NP_`). Stored at `newspack_integration_metadata_prefix_{id}`. Required so outgoing field names are unique on the external system. |
176+
| `outgoing_metadata_fields` | `metadata` | Subset of Newspack metadata fields to push. Stored at `newspack_integration_outgoing_fields_{id}`. |
177+
| `incoming_metadata_fields` | `metadata` | Subset of external fields to pull and store on the Newspack user. Stored at `newspack_integration_incoming_fields_{id}` as a `key => raw_data` map. |
178+
179+
---
180+
181+
## Push (Outgoing Sync)
182+
183+
When a contact needs to be synced, the framework calls `push_contact_data()` on every active integration. The contact array is the Newspack canonical form (email, name, metadata, etc.). Implementations should call `$this->prepare_contact( $contact )` first, which:
184+
185+
1. Filters `$contact['metadata']` to the keys enabled on this integration.
186+
2. Renames keys using the integration's metadata prefix.
187+
3. Preserves keys already in prefixed form if the underlying field is enabled.
188+
189+
`prepare_contact()` is a no-op when the site is still on the legacy metadata schema (where the metadata classes pre-filter), which keeps newly-built integrations compatible with un-migrated sites.
190+
191+
### When pushes are triggered
192+
193+
- Data event handlers registered via `register_handler()` (see below).
194+
- Recurring cron via `Contact_Cron` (every 5 minutes for logged-in users).
195+
- Direct calls from other Newspack subsystems via `Contact_Sync::sync_contact()`.
196+
197+
### Retries
198+
199+
Failed pushes are scheduled for retry by the upstream `Contact_Sync` class with exponential backoff via ActionScheduler. Each integration's retries are grouped under `newspack-integration-{id}` so they can be inspected and managed independently in the Activity Logs UI.
200+
201+
---
202+
203+
## Pull (Incoming Sync)
204+
205+
Integrations that override `pull_contact_data( $user_id )` can fetch external state back into Newspack. The returned associative array is intersected with the enabled incoming fields and persisted via `Reader_Data::update_item()` for each key.
206+
207+
### When pulls are triggered
208+
209+
The pull pipeline (`Contact_Pull` + `Contact_Cron`) runs on every logged-in pageview, throttled per user:
210+
211+
- If the user's last enqueue was more than 24 hours ago (`PULL_SYNC_THRESHOLD`), all enabled integrations are pulled synchronously via per-integration loopback `admin-ajax.php` requests. The request timeout defaults to 1 second per integration — anything that overruns falls back to the cron queue.
212+
- Otherwise, the user is staged for pull on the next 5-minute batch (`Contact_Cron::CRON_INTERVAL`).
213+
214+
### Retries
215+
216+
`Contact_Pull` schedules per-integration retries with a `30s → 2min → 8min → 30min → 2h` backoff for up to 5 attempts. Retries run under the integration's ActionScheduler group (`newspack-integration-{id}`) using the hook `newspack_contact_pull_retry`.
217+
218+
### Incoming fields
219+
220+
`get_available_incoming_fields()` returns the schema offered by the external system. Each field is an `Incoming_Field`:
221+
222+
```php
223+
$field = new Incoming_Field( 'membership_level', $raw );
224+
$field
225+
->set_name( __( 'Membership Level', 'my-plugin' ) )
226+
->set_value_type( 'string' ) // 'string' or 'boolean'.
227+
->set_matching_function( 'list__in' ) // 'default', 'list__in', 'list__not_in', 'range'.
228+
->set_options( [ [ 'value' => 'gold', 'label' => 'Gold' ], ... ] )
229+
->set_description( __( 'Member tier from the CRM.', 'my-plugin' ) )
230+
->set_is_access_rule( true ) // Register as a content gate access rule.
231+
->set_is_segment_criteria( true ) // Register as a popups segmentation criterion.
232+
->set_access_rule_callback( function ( $user_id, $args ) { /* ... */ } );
233+
```
234+
235+
Use `configure_incoming_field()` to enrich a field after construction — it's called on every field returned by `get_available_incoming_fields()` and again whenever stored fields are re-hydrated. This is where you set `is_access_rule`, `is_segment_criteria`, and any custom callback.
236+
237+
The base class also offers `get_filtered_incoming_fields()`, which hides fields whose name matches one of the integration's own outgoing prefixed keys, so publishers don't re-select fields they're already pushing.
238+
239+
---
240+
241+
## Data Event Handlers
242+
243+
Integrations subscribe to [Data Events](../../data-events/README.md) by overriding `register_handlers()` and calling `$this->register_handler()`:
244+
245+
```php
246+
public function register_handlers() {
247+
$this->register_handler( 'reader_registered', 'on_reader_registered' );
248+
$this->register_handler( 'woo_order_updated', 'on_woo_order_updated' );
249+
}
250+
251+
public function on_reader_registered( $timestamp, $data, $client_id ) {
252+
// Push or transform contact data.
253+
}
254+
```
255+
256+
Each `register_handler( $action_name, $method )` call:
257+
258+
1. Stores the (integration, method) tuple in the registry's handler map, keyed by `static::class . '::' . $action_name`.
259+
2. Registers a static callable `[ static::class, 'dispatch_data_event_handler' ]` with Data Events. This is intentionally serializable so ActionScheduler can persist it across requests.
260+
3. Filters the handler's ActionScheduler group to the integration's own group via `newspack_data_events_handler_action_group`, so failures and retries are scoped per integration.
261+
262+
When the event fires, Data Events calls the static dispatcher, which resolves the live integration instance from the registry and invokes the original instance method. Each handler is wrapped in its own retry context — a thrown exception from a handler triggers Data Events' standard ActionScheduler retry for just that handler, without affecting other integrations.
263+
264+
**Constraint:** at most one instance per concrete subclass can register a handler for a given action. Late registrations for the same `(class, action)` pair overwrite earlier ones.
265+
266+
---
267+
268+
## ActionScheduler Groups
269+
270+
Every integration owns an ActionScheduler group named `newspack-integration-{id}`, registered with a human-readable label via `newspack_action_scheduler_group_labels`. Scheduled actions (data event handler retries, pull retries, push retries) are placed in their integration's group, which makes per-integration filtering possible in the Activity Logs UI.
271+
272+
Programmatic access:
273+
274+
```php
275+
// All groups across integrations.
276+
Integrations::get_all_action_groups();
277+
278+
// Scheduled actions for one integration.
279+
$integration->get_scheduled_actions( [
280+
'status' => 'failed',
281+
'per_page' => 50,
282+
] );
283+
284+
// Scheduled actions for all integrations, filtered.
285+
Integrations::get_scheduled_actions( [
286+
'integration_id' => 'esp',
287+
'status' => 'pending',
288+
] );
289+
290+
Integrations::count_scheduled_actions( [ 'integration_id' => 'esp' ] );
291+
```
292+
293+
An empty `integration_id` queries every group registered by the framework.
294+
295+
---
296+
297+
## Health Checks
298+
299+
The framework schedules an hourly cron hook `newspack_integration_health_check` that walks every active integration and runs:
300+
301+
1. `can_sync( true )` — settings validation as a `WP_Error`.
302+
2. `test_connection()` — live API check.
303+
304+
`Integration::health_check()` is `final` and returns either `true` or a `WP_Error` aggregating failures. When a failure is detected, the framework logs the error under `NEWSPACK-INTEGRATION` and fires:
305+
306+
```php
307+
do_action(
308+
'newspack_integration_health_check_failed',
309+
[
310+
'integration_id' => $integration->get_id(),
311+
'integration_name' => $integration->get_name(),
312+
'error' => $result, // WP_Error
313+
]
314+
);
315+
```
316+
317+
This hook is consumed by Newspack Manager to surface alerts. To disable the schedule on a specific site, add `'newspack_integration_health_check'` to the `NEWSPACK_CRON_DISABLE` constant array.
318+
319+
---
320+
321+
## My Account Endpoints
322+
323+
Integrations can add their own tab to the WooCommerce My Account page by returning a menu item from `get_my_account_menu_item()`:
324+
325+
```php
326+
public function get_my_account_menu_item() {
327+
return [
328+
'slug' => 'my-rewards',
329+
'label' => __( 'My Rewards', 'my-plugin' ),
330+
'position' => 25, // Optional; appended if omitted.
331+
];
332+
}
333+
334+
public function render_my_account_page( $value ) {
335+
echo '<h2>' . esc_html__( 'My Rewards', 'my-plugin' ) . '</h2>';
336+
// ...
337+
}
338+
```
339+
340+
The registry handles rewrite endpoint registration, query var registration, menu item insertion (positional or appended, always keeping `customer-logout` last), and a one-off `flush_rewrite_rules()` whenever the set of declared slugs changes. Slug collisions resolve to first-wins; integrations registered later silently skip.
341+
342+
Endpoints are only registered for integrations that are currently enabled.
343+
344+
---
345+
346+
## Frontend Reader Registration
347+
348+
To allow an integration to drive frontend reader registration (e.g. a third-party signup form posting to Newspack):
349+
350+
1. Override `supports_frontend_registration()` to return `true`. The framework will output the integration's registration key on the page and accept it on the registration endpoint.
351+
2. Optionally override `get_registration_key()` and `validate_registration_request()` to implement a custom key scheme (asymmetric keys, time-bounded tokens, etc.). The default is HMAC-SHA256 of the integration ID with the site's auth salt, compared in constant time.
352+
3. Optionally override `handle_logged_in_user_registration( $user, $request )` to react when an already-logged-in reader submits a registration request — record a new donation, link an account, fire an analytics event, etc.
353+
354+
The built-in JS client (`newspackReaderActivation.register()`) always sends the value returned by `get_registration_key()`. Custom key schemes that diverge from this default need their own client-side code to compute and submit the key.

0 commit comments

Comments
 (0)