PluginProbe
Texty – SMS Notification for WordPress, WooCommerce, Dokan and more / trunk
Texty – SMS Notification for WordPress, WooCommerce, Dokan and more vtrunk
2.0.2 trunk 0.2 1.0 1.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 2.0.0 2.0.1
texty / includes / Api / SettingsController.php

SettingsController.php in Texty – SMS Notification for WordPress, WooCommerce, Dokan and more trunk, at includes/Api/SettingsController.php

512 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings REST Controller.
4 *
5 * Serves the Texty gateway settings via a schema-driven REST API compatible
6 * with the plugin-ui <Settings> component. Every registered gateway becomes
7 * its own page in the schema; saving a page activates that gateway and writes
8 * its credentials into the existing `texty_settings` option (BC-preserved).
9 *
10 * Storage shape (unchanged):
11 * texty_settings = [
12 * 'gateway' => 'twilio', // active gateway key (root)
13 * 'twilio' => [ 'sid' => ..., 'token' => ..., 'from' => ... ],
14 * 'vonage' => [ 'key' => ..., 'secret' => ..., 'from' => ... ],
15 * ...
16 * ]
17 *
18 * Routes:
19 * GET /texty/v1/settings/schema — schema + values + active_gateway
20 * POST /texty/v1/settings/schema — save one gateway scope (also activates it)
21 *
22 * @package Texty\Api
23 * @since 2.0.0
24 */
25
26 namespace Texty\Api;
27
28 use Texty\Gateways\GatewayInterface;
29 use Texty\Dependencies\WeDevs\WPKit\Settings\BaseSettingsRESTController;
30 use WP_Error;
31 use WP_REST_Request;
32 use WP_REST_Response;
33
34 /**
35 * SettingsController Class
36 */
37 class SettingsController extends BaseSettingsRESTController {
38
39 /**
40 * Single backing option that stores all gateway credentials + the active key.
41 */
42 const OPTION_KEY = 'texty_settings';
43
44 /**
45 * Constructor.
46 *
47 * @since 2.0.0
48 */
49 public function __construct() {
50 parent::__construct( 'texty/v1', 'settings/schema', 'texty' );
51 }
52
53 /**
54 * Build the page-per-gateway schema from the gateway registry.
55 *
56 * Every gateway returned by texty()->gateways()->all() — including third
57 * parties registered via `texty_register_gateways` — becomes a page whose
58 * label, description, logo and credential fields are pulled from the
59 * gateway class itself (name(), description(), logo(), get_settings()).
60 * get_settings() also supplies each field's saved value, so the frontend's
61 * SettingsProvider can extract initial values from the field defaults.
62 *
63 * @return array[]
64 * @since 2.0.0
65 */
66 protected function get_settings_schema(): array {
67 $schema = [];
68 $priority = 10;
69
70 foreach ( texty()->gateways()->all() as $key => $classname ) {
71 $gateway = is_object( $classname ) ? $classname : new $classname();
72
73 if ( ! $gateway instanceof GatewayInterface ) {
74 continue;
75 }
76
77 $schema = array_merge( $schema, $this->build_gateway_schema( (string) $key, $gateway, $priority ) );
78 $priority += 10;
79 }
80
81 /**
82 * Filter the gateway settings schema. Gateways registered via
83 * `texty_register_gateways` are included automatically; use this filter
84 * to adjust generated elements or add extra ones.
85 *
86 * @param array[] $schema Flat array of settings elements.
87 */
88 return apply_filters( 'texty_settings_schema', $schema );
89 }
90
91 /**
92 * Storage path override.
93 *
94 * Each credential field stores at texty_settings[<gateway_key>][<cred_key>].
95 * Schema field ids carry a `<gateway>_` prefix to stay globally unique for
96 * the frontend; strip it here so the stored option keeps the legacy shape
97 * (e.g. field `twilio_sid` → texty_settings['twilio']['sid']).
98 *
99 * @param array $element Field element.
100 *
101 * @return string[]
102 * @since 2.0.0
103 */
104 protected function get_field_path( array $element ): array {
105 if ( ! empty( $element['page_id'] ) ) {
106 $page_id = $element['page_id'];
107 $field_id = $element['id'];
108 $prefix = $page_id . '_';
109
110 if ( 0 === strpos( $field_id, $prefix ) ) {
111 $field_id = substr( $field_id, strlen( $prefix ) );
112 }
113
114 return [ $page_id, $field_id ];
115 }
116 return parent::get_field_path( $element );
117 }
118
119 /**
120 * Load values for the schema from a single backing option (`texty_settings`).
121 *
122 * BaseSettingsRESTController defaults to one option per page; we override to
123 * keep all gateway credentials in one option (BC).
124 *
125 * @param array $schema Settings schema elements.
126 *
127 * @return array<string, array>
128 * @since 2.0.0
129 */
130 protected function load_values( array $schema ): array {
131 $stored = $this->load_stored_settings();
132 $values = [];
133
134 foreach ( $schema as $element ) {
135 if ( 'page' !== $element['type'] ) {
136 continue;
137 }
138 $values[ $element['id'] ] = isset( $stored[ $element['id'] ] ) && is_array( $stored[ $element['id'] ] )
139 ? $stored[ $element['id'] ]
140 : [];
141 }
142
143 return $values;
144 }
145
146 /**
147 * GET handler — wraps the parent response with two extra fields:
148 * - active_gateway : the gateway currently set as active for sending
149 * - connected_gateways : keys of all gateways with saved (non-empty) credentials
150 *
151 * The two are independent — a gateway can be "connected" (has credentials)
152 * without being "active" (chosen for sending).
153 *
154 * @param WP_REST_Request $request Request object.
155 *
156 * @return WP_REST_Response
157 * @since 2.0.0
158 */
159 public function get_items( $request ) {
160 $response = parent::get_items( $request );
161 $data = $response->get_data();
162 $stored = $this->load_stored_settings();
163
164 $active = texty()->settings()->gateway();
165 $data['active_gateway'] = $active ? (string) $active : '';
166
167 $schema = isset( $data['schema'] ) && is_array( $data['schema'] ) ? $data['schema'] : [];
168 $connected = [];
169 $page_ids = [];
170 foreach ( $schema as $element ) {
171 if ( 'page' === $element['type'] ) {
172 $page_ids[] = $element['id'];
173 }
174 }
175
176 foreach ( $page_ids as $page_id ) {
177 $creds = isset( $stored[ $page_id ] ) && is_array( $stored[ $page_id ] ) ? $stored[ $page_id ] : [];
178 foreach ( $creds as $value ) {
179 if ( '' !== (string) $value ) {
180 $connected[] = $page_id;
181 break;
182 }
183 }
184 }
185
186 $data['connected_gateways'] = $connected;
187
188 $response->set_data( $data );
189 return $response;
190 }
191
192 /**
193 * POST handler — saves credentials for one gateway page.
194 *
195 * Saving does NOT activate the gateway. Activation is a separate action
196 * (POST /texty/v1/gateway/activate) so the UI can offer a Connect → Activate
197 * two-step flow. The active gateway field is left untouched.
198 *
199 * Steps:
200 * 1. Convert flat dot-keyed values from the frontend (keyed by dependency_key)
201 * into a nested structure keyed by storage path.
202 * 2. Validate the credentials via the gateway's own validate() method.
203 * 3. Merge into the existing `texty_settings` option (preserving root gateway).
204 *
205 * @param WP_REST_Request $request Request object.
206 *
207 * @return WP_REST_Response
208 * @since 2.0.0
209 */
210 public function create_item( $request ) {
211 $scope_id = sanitize_key( (string) ( $request->get_param( 'scopeId' ) ?? '' ) );
212 $values = $request->get_param( 'values' );
213
214 if ( ! is_array( $values ) || empty( $scope_id ) ) {
215 return new WP_REST_Response(
216 [ 'errors' => [ 'values' => __( 'Invalid values format.', 'texty' ) ] ],
217 400
218 );
219 }
220
221 $schema = $this->get_settings_schema();
222 $page_ids = $this->get_page_ids( $schema );
223
224 if ( ! in_array( $scope_id, $page_ids, true ) ) {
225 return new WP_REST_Response(
226 [ 'errors' => [ 'scopeId' => __( 'Invalid scope ID.', 'texty' ) ] ],
227 400
228 );
229 }
230
231 // Convert flat values (keyed by field id, e.g. twilio_sid) → nested ([gw][cred]).
232 $fields = $this->get_fields_for_page( $schema, $scope_id );
233 $nested = [];
234 foreach ( $fields as $field ) {
235 $dep_key = $this->build_dependency_key( $field );
236 if ( array_key_exists( $dep_key, $values ) ) {
237 $path = $this->get_field_path( $field );
238 $this->set_nested_value( $nested, $path, $values[ $dep_key ] );
239 }
240 }
241
242 $creds = isset( $nested[ $scope_id ] ) && is_array( $nested[ $scope_id ] ) ? $nested[ $scope_id ] : [];
243
244 // Validate credentials via the gateway's own validate() method.
245 $validation = $this->validate_gateway_credentials( $scope_id, $creds );
246 if ( is_wp_error( $validation ) ) {
247 return new WP_REST_Response(
248 [
249 'errors' => [
250 'gateway' => $validation->get_error_message(),
251 ],
252 ],
253 400
254 );
255 }
256
257 // Merge credentials into existing settings — preserve the active gateway.
258 $existing = $this->load_stored_settings();
259 $merged = $this->array_merge_deep( $existing, $nested );
260
261 update_option( self::OPTION_KEY, $merged );
262
263 /**
264 * Fires after Texty gateway credentials have been saved.
265 *
266 * @param array $merged Final merged settings persisted to the option.
267 * @param array $nested Nested values for this scope only.
268 * @param string $scope_id The gateway key that was saved.
269 */
270 do_action( 'texty_settings_after_save', $merged, $nested, $scope_id );
271
272 // Build the per-page values response.
273 $values_response = [];
274 foreach ( $schema as $element ) {
275 if ( 'page' !== $element['type'] ) {
276 continue;
277 }
278 $values_response[ $element['id'] ] = isset( $merged[ $element['id'] ] ) && is_array( $merged[ $element['id'] ] )
279 ? $merged[ $element['id'] ]
280 : [];
281 }
282
283 return new WP_REST_Response(
284 [
285 'success' => true,
286 'values' => $values_response,
287 'active_gateway' => isset( $merged['gateway'] ) ? (string) $merged['gateway'] : '',
288 'connected' => $scope_id,
289 ]
290 );
291 }
292
293 /**
294 * Validate gateway credentials by delegating to the gateway's validate().
295 *
296 * @param string $gateway_key Gateway registry key.
297 * @param array $creds Submitted credentials for that gateway.
298 *
299 * @return WP_Error|true
300 * @since 2.0.0
301 */
302 private function validate_gateway_credentials( string $gateway_key, array $creds ) {
303 $registered = texty()->gateways()->all();
304 if ( ! isset( $registered[ $gateway_key ] ) ) {
305 return true;
306 }
307
308 $gateway_class = $registered[ $gateway_key ];
309 $gateway = new $gateway_class();
310
311 $mock_request = new WP_REST_Request( 'POST' );
312 $mock_request->set_param( $gateway_key, $creds );
313
314 $result = $gateway->validate( $mock_request );
315 return is_wp_error( $result ) ? $result : true;
316 }
317
318 /**
319 * The key the frontend uses for a field's value.
320 *
321 * The plugin-ui SettingsProvider keys its flat values map by the raw field
322 * element `id` (see `collectKeys` in settings-context), so field ids must
323 * be globally unique — hence the `<gateway>_<field>` naming in the schema.
324 *
325 * @param array $field Field element.
326 *
327 * @return string
328 * @since 2.0.0
329 */
330 private function build_dependency_key( array $field ): string {
331 return $field['id'];
332 }
333
334 /**
335 * Read the backing option safely.
336 *
337 * @return array
338 * @since 2.0.0
339 */
340 private function load_stored_settings(): array {
341 $stored = get_option( self::OPTION_KEY, [] );
342 return is_array( $stored ) ? $stored : [];
343 }
344
345 /**
346 * Build a `validations` array marking a field as required.
347 *
348 * @param string $label Human-readable field label, used in the message.
349 *
350 * @return array
351 * @since 2.0.0
352 */
353 private function required_validation( string $label ): array {
354 return [
355 [
356 'rules' => 'required',
357 /* translators: %s: field label, e.g. "Account SID" */
358 'message' => sprintf( __( '%s is required.', 'texty' ), $label ),
359 ],
360 ];
361 }
362
363 /**
364 * Build the schema elements for one gateway from its own class.
365 *
366 * Page label/description/logo come from the gateway's name(), description()
367 * and logo() methods; credential fields come from get_settings(), which
368 * also supplies the saved value for each field. Field ids are prefixed
369 * with the gateway key to stay globally unique for the frontend (see
370 * build_dependency_key()).
371 *
372 * @param string $key Gateway registry key.
373 * @param GatewayInterface $gateway Gateway instance.
374 * @param int $priority Page priority (registry order).
375 *
376 * @return array[]
377 * @since 2.0.0
378 */
379 private function build_gateway_schema( string $key, GatewayInterface $gateway, int $priority ): array {
380 $doc_links = $this->doc_links();
381
382 $page = [
383 'type' => 'page',
384 'id' => $key,
385 'label' => $gateway->name(),
386 'description' => $gateway->description(),
387 'image_url' => $gateway->logo(),
388 'priority' => $priority,
389 ];
390
391 if ( isset( $doc_links[ $key ] ) ) {
392 $page['doc_link'] = $doc_links[ $key ];
393 $page['doc_link_text'] = __( 'Get your account', 'texty' );
394 }
395
396 $elements = [ $page ];
397 $settings = $gateway->get_settings();
398
399 if ( empty( $settings ) || ! is_array( $settings ) ) {
400 return $elements;
401 }
402
403 $section_id = $key . '_credentials';
404
405 $elements[] = [
406 'type' => 'section',
407 'id' => $section_id,
408 'page_id' => $key,
409 'priority' => 10,
410 ];
411
412 $field_priority = 10;
413
414 foreach ( $settings as $cred_key => $setting ) {
415 $label = isset( $setting['name'] ) ? $setting['name'] : $cred_key;
416 $type = isset( $setting['type'] ) ? $setting['type'] : 'text';
417
418 $field = [
419 'type' => 'field',
420 'id' => $key . '_' . $cred_key,
421 'page_id' => $key,
422 'section_id' => $section_id,
423 'variant' => $this->field_variant( (string) $cred_key, $type ),
424 'label' => $label,
425 /* translators: %s: credential field label, e.g. "Account SID" */
426 'placeholder' => sprintf( __( 'Enter %s', 'texty' ), $label ),
427 'layout' => 'full-width',
428 'priority' => $field_priority,
429 'default' => isset( $setting['value'] ) ? $setting['value'] : '',
430 'validations' => $this->required_validation( $label ),
431 ];
432
433 if ( ! empty( $setting['help'] ) ) {
434 $field['description'] = $setting['help'];
435 }
436
437 $elements[] = $field;
438 $field_priority += 10;
439 }
440
441 return $elements;
442 }
443
444 /**
445 * Map a gateway settings field to a plugin-ui field variant.
446 *
447 * @param string $cred_key Credential key within the gateway (e.g. 'from').
448 * @param string $type Field type declared by the gateway's get_settings().
449 *
450 * @return string
451 * @since 2.0.0
452 */
453 private function field_variant( string $cred_key, string $type ): string {
454 if ( 'password' === $type ) {
455 return 'show_hide';
456 }
457
458 if ( 'phone' === $type || 'from' === $cred_key ) {
459 return 'phone';
460 }
461
462 return 'text';
463 }
464
465 /**
466 * Sign-up links for the built-in gateways, shown as the page's doc link.
467 *
468 * @return array<string, string>
469 * @since 2.0.0
470 */
471 private function doc_links(): array {
472 return [
473 'twilio' => 'https://www.twilio.com/try-twilio',
474 'vonage' => 'https://dashboard.nexmo.com/sign-up',
475 'plivo' => 'https://console.plivo.com/accounts/register/',
476 'clickatell' => 'https://www.clickatell.com/sign-up/',
477 ];
478 }
479
480 /**
481 * Translatable validation messages for built-in field types.
482 *
483 * @return array<string, string>
484 * @since 2.0.0
485 */
486 protected function get_validation_messages(): array {
487 return [
488 'number' => __( 'Must be a numeric value.', 'texty' ),
489 'switch' => __( 'Must be "on" or "off".', 'texty' ),
490 'invalid_option' => __( 'Invalid option selected.', 'texty' ),
491 'must_be_array' => __( 'Must be an array.', 'texty' ),
492 'invalid_options' => __( 'Contains invalid options.', 'texty' ),
493 'color_picker' => __( 'Must be a valid hex color (e.g. #ff0000).', 'texty' ),
494 'must_be_object' => __( 'Must be an object.', 'texty' ),
495 ];
496 }
497
498 /**
499 * Permission-error message for read/write contexts.
500 *
501 * @param string $context 'read' or 'write'.
502 *
503 * @return string
504 * @since 2.0.0
505 */
506 protected function get_permission_error_message( string $context ): string {
507 return 'write' === $context
508 ? __( 'You do not have permission to update settings.', 'texty' )
509 : __( 'You do not have permission to view settings.', 'texty' );
510 }
511 }
512