PluginProbe
Texty – SMS Notification for WordPress, WooCommerce, Dokan and more / 2.0.1
Texty – SMS Notification for WordPress, WooCommerce, Dokan and more v2.0.1
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 / Logs.php

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

434 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SMS Logs REST controller.
4 *
5 * Routes:
6 * GET /texty/v1/logs — paged listing for the SMS Logs UI.
7 * GET /texty/v1/logs/export — CSV download of the (filtered) log set.
8 * GET /texty/v1/logs/{id} — single row drill-in for the "View Log" panel.
9 *
10 * Storage: reads from `wp_texty_sms_stat` via the SmsStatStore data layer.
11 *
12 * @package Texty\Api
13 * @since 2.0.0
14 */
15
16 namespace Texty\Api;
17
18 use Texty\Models\SmsStat;
19 use WeDevs\WPKit\DataLayer\DataLayerFactory;
20 use WP_Error;
21 use WP_REST_Request;
22 use WP_REST_Response;
23 use WP_REST_Server;
24
25 /**
26 * Logs Class
27 */
28 class Logs extends Base {
29
30 const DEFAULT_PER_PAGE = 10;
31 const MAX_PER_PAGE = 100;
32
33 /**
34 * Constructor.
35 *
36 * @since 2.0.0
37 */
38 public function __construct() {
39 $this->namespace = 'texty/v1';
40 $this->rest_base = 'logs';
41 }
42
43 /**
44 * Register routes.
45 *
46 * @return void
47 * @since 2.0.0
48 */
49 public function register_routes() {
50 register_rest_route(
51 $this->namespace,
52 '/' . $this->rest_base,
53 [
54 [
55 'methods' => WP_REST_Server::READABLE,
56 'callback' => [ $this, 'get_items' ],
57 'permission_callback' => [ $this, 'admin_permissions_check' ],
58 'args' => [
59 'page' => [
60 'description' => __( 'Current page number.', 'texty' ),
61 'type' => 'integer',
62 'default' => 1,
63 ],
64 'per_page' => [
65 'description' => __( 'Items per page.', 'texty' ),
66 'type' => 'integer',
67 'default' => self::DEFAULT_PER_PAGE,
68 ],
69 'status' => [
70 'description' => __( 'Filter by status.', 'texty' ),
71 'type' => 'string',
72 'enum' => [ '', 'sent', 'failed', 'pending' ],
73 'default' => '',
74 ],
75 'type' => [
76 'description' => __( 'Filter by notification id.', 'texty' ),
77 'type' => 'string',
78 'default' => '',
79 ],
80 'search' => [
81 'description' => __( 'Free-text search.', 'texty' ),
82 'type' => 'string',
83 'default' => '',
84 ],
85 'orderby' => [
86 'description' => __( 'Sort field.', 'texty' ),
87 'type' => 'string',
88 'default' => 'created_at',
89 ],
90 'order' => [
91 'description' => __( 'Sort direction.', 'texty' ),
92 'type' => 'string',
93 'enum' => [ 'asc', 'desc' ],
94 'default' => 'desc',
95 ],
96 ],
97 ],
98 ]
99 );
100
101 register_rest_route(
102 $this->namespace,
103 '/' . $this->rest_base . '/export',
104 [
105 [
106 'methods' => WP_REST_Server::READABLE,
107 'callback' => [ $this, 'export' ],
108 'permission_callback' => [ $this, 'admin_permissions_check' ],
109 'args' => [
110 'status' => [
111 'description' => __( 'Filter by status.', 'texty' ),
112 'type' => 'string',
113 'enum' => [ '', 'sent', 'failed', 'pending' ],
114 'default' => '',
115 ],
116 'type' => [
117 'description' => __( 'Filter by notification id.', 'texty' ),
118 'type' => 'string',
119 'default' => '',
120 ],
121 'search' => [
122 'description' => __( 'Free-text search.', 'texty' ),
123 'type' => 'string',
124 'default' => '',
125 ],
126 ],
127 ],
128 ]
129 );
130
131 register_rest_route(
132 $this->namespace,
133 '/' . $this->rest_base . '/(?P<id>\d+)',
134 [
135 [
136 'methods' => WP_REST_Server::READABLE,
137 'callback' => [ $this, 'get_item' ],
138 'permission_callback' => [ $this, 'admin_permissions_check' ],
139 'args' => [
140 'id' => [
141 'description' => __( 'Log entry ID.', 'texty' ),
142 'type' => 'integer',
143 'required' => true,
144 ],
145 ],
146 ],
147 ]
148 );
149 }
150
151 /**
152 * GET /logs — paged listing.
153 *
154 * @param WP_REST_Request $request Request.
155 *
156 * @return WP_REST_Response
157 * @since 2.0.0
158 */
159 public function get_items( $request ) {
160 $store = DataLayerFactory::make_store( SmsStat::class );
161 if ( ! $store ) {
162 return rest_ensure_response(
163 [
164 'items' => [],
165 'total' => 0,
166 'per_page' => self::DEFAULT_PER_PAGE,
167 'current_page' => 1,
168 'total_pages' => 0,
169 ]
170 );
171 }
172
173 $per_page = max( 1, min( (int) $request->get_param( 'per_page' ), self::MAX_PER_PAGE ) );
174 $page = max( 1, (int) $request->get_param( 'page' ) );
175 $status = sanitize_key( (string) $request->get_param( 'status' ) );
176 $type = sanitize_key( (string) $request->get_param( 'type' ) );
177 $search = sanitize_text_field( (string) $request->get_param( 'search' ) );
178 $orderby = sanitize_key( (string) $request->get_param( 'orderby' ) );
179 $order = strtoupper( (string) $request->get_param( 'order' ) ) === 'ASC' ? 'ASC' : 'DESC';
180
181 // BaseDataStore::query falls back to the id column when orderby
182 // isn't a known field, so unknown values silently degrade to the
183 // closest analogue (id DESC ≈ created_at DESC) instead of erroring.
184 $args = [
185 'per_page' => $per_page,
186 'page' => $page,
187 'orderby' => '' !== $orderby ? $orderby : 'created_at',
188 'order' => $order,
189 ];
190 if ( '' !== $status ) {
191 $args['status'] = $status;
192 }
193 if ( '' !== $type ) {
194 $args['notification_id'] = $type;
195 }
196 if ( '' !== $search ) {
197 $args['search'] = $search;
198 }
199
200 $result = $store->query( $args );
201 $items = is_array( $result['items'] ?? null ) ? $result['items'] : [];
202
203 $payload = [
204 'items' => array_map( [ $this, 'present_row' ], $items ),
205 'total' => (int) ( $result['total'] ?? 0 ),
206 'per_page' => (int) ( $result['per_page'] ?? $per_page ),
207 'current_page' => (int) ( $result['current_page'] ?? $page ),
208 'total_pages' => (int) ( $result['total_pages'] ?? 0 ),
209 ];
210
211 return rest_ensure_response( $payload );
212 }
213
214 /**
215 * GET /logs/{id} — single row.
216 *
217 * @param WP_REST_Request $request Request.
218 *
219 * @return WP_REST_Response|WP_Error
220 * @since 2.0.0
221 */
222 public function get_item( $request ) {
223 $id = (int) $request->get_param( 'id' );
224 $store = DataLayerFactory::make_store( SmsStat::class );
225 if ( ! $store ) {
226 return new WP_Error( 'texty_no_store', __( 'Logs store unavailable.', 'texty' ), [ 'status' => 500 ] );
227 }
228
229 // BaseDataStore::read() takes a hydrated model by reference and throws
230 // when the row is missing, so it can't be fed a bare id. Query by the
231 // id column instead — it returns the same raw row shape present_row()
232 // already consumes for the listing, and an empty set on not-found.
233 $result = $store->query(
234 [
235 'id' => $id,
236 'per_page' => 1,
237 'count_total' => false,
238 ]
239 );
240 $items = is_array( $result['items'] ?? null ) ? $result['items'] : [];
241
242 if ( empty( $items ) ) {
243 return new WP_Error( 'texty_log_not_found', __( 'Log entry not found.', 'texty' ), [ 'status' => 404 ] );
244 }
245
246 return rest_ensure_response( $this->present_row( $items[0] ) );
247 }
248
249 /**
250 * GET /logs/export — stream the (optionally filtered) log set as a CSV
251 * download. Honours the same `status` / `type` / `search` filters as the
252 * listing so the export matches what the user sees.
253 *
254 * Opened directly in a browser tab (not via apiFetch), so the frontend must
255 * pass `_wpnonce` on the query string for REST cookie auth to succeed.
256 *
257 * @param WP_REST_Request $request Request.
258 *
259 * @return WP_Error|void Streams CSV and exits on success.
260 * @since 2.0.0
261 */
262 public function export( $request ) {
263 $store = DataLayerFactory::make_store( SmsStat::class );
264 if ( ! $store ) {
265 return new WP_Error( 'texty_no_store', __( 'Logs store unavailable.', 'texty' ), [ 'status' => 500 ] );
266 }
267
268 $status = sanitize_key( (string) $request->get_param( 'status' ) );
269 $type = sanitize_key( (string) $request->get_param( 'type' ) );
270 $search = sanitize_text_field( (string) $request->get_param( 'search' ) );
271
272 $args = [
273 'per_page' => -1,
274 'orderby' => 'created_at',
275 'order' => 'DESC',
276 ];
277 if ( '' !== $status ) {
278 $args['status'] = $status;
279 }
280 if ( '' !== $type ) {
281 $args['notification_id'] = $type;
282 }
283 if ( '' !== $search ) {
284 $args['search'] = $search;
285 }
286
287 $result = $store->query( $args );
288 $items = is_array( $result['items'] ?? null ) ? $result['items'] : [];
289
290 $filename = 'texty-sms-logs-' . gmdate( 'Y-m-d-His' ) . '.csv';
291
292 nocache_headers();
293 header( 'Content-Type: text/csv; charset=utf-8' );
294 header( 'Content-Disposition: attachment; filename=' . $filename );
295
296 $output = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
297
298 // UTF-8 BOM so Excel renders multibyte (e.g. Arabic) message bodies.
299 fwrite( $output, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
300
301 fputcsv(
302 $output,
303 [
304 __( 'ID', 'texty' ),
305 __( 'Date', 'texty' ),
306 __( 'Type', 'texty' ),
307 __( 'Gateway', 'texty' ),
308 __( 'Recipient', 'texty' ),
309 __( 'Message', 'texty' ),
310 __( 'Status', 'texty' ),
311 __( 'Reference ID', 'texty' ),
312 __( 'Response', 'texty' ),
313 ]
314 );
315
316 foreach ( $items as $row ) {
317 $data = $this->present_row( $row );
318 fputcsv(
319 $output,
320 [
321 $data['id'],
322 $data['created_at'],
323 $data['type_label'],
324 $data['gateway'],
325 $data['receiver'],
326 $data['message'],
327 $data['status'],
328 $data['reference_id'],
329 $data['response'],
330 ]
331 );
332 }
333
334 fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
335 exit;
336 }
337
338 /**
339 * Shape a raw DB row for the API. Adds derived `type_label` so the frontend
340 * doesn't need to look up the notification registry.
341 *
342 * @param mixed $row Raw row (object or array).
343 *
344 * @return array
345 * @since 2.0.0
346 */
347 private function present_row( $row ): array {
348 $row = is_object( $row ) ? (array) $row : (array) $row;
349
350 $notification_id = (string) ( $row['notification_id'] ?? '' );
351 $notification_group = (string) ( $row['notification_group'] ?? '' );
352
353 return [
354 'id' => (int) ( $row['id'] ?? 0 ),
355 'receiver' => (string) ( $row['receiver'] ?? '' ),
356 'gateway' => (string) ( $row['gateway'] ?? '' ),
357 'status' => (string) ( $row['status'] ?? '' ),
358 'notification_id' => $notification_id,
359 'notification_group' => $notification_group,
360 'type_label' => $this->build_type_label( $notification_id, $notification_group ),
361 'message' => (string) ( $row['message'] ?? '' ),
362 'response' => (string) ( $row['response'] ?? '' ),
363 'reference_id' => (string) ( $row['reference_id'] ?? '' ),
364 'created_at' => (string) ( $row['created_at'] ?? '' ),
365 'created_at_formatted' => $this->format_datetime( (string) ( $row['created_at'] ?? '' ) ),
366 'updated_at' => (string) ( $row['updated_at'] ?? '' ),
367 ];
368 }
369
370 /**
371 * Format a stored MySQL datetime using the site's date & time settings
372 * (Settings → General).
373 *
374 * Rows are written with current_time( 'mysql' ) — already site-local —
375 * so mysql2date is the right tool: it localizes month/day names without
376 * applying a second timezone shift.
377 *
378 * @param string $datetime MySQL datetime string (site-local).
379 *
380 * @return string
381 * @since 2.0.0
382 */
383 private function format_datetime( string $datetime ): string {
384 if ( '' === $datetime ) {
385 return '';
386 }
387
388 $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
389 $formatted = mysql2date( $format, $datetime );
390
391 return $formatted ? $formatted : $datetime;
392 }
393
394 /**
395 * Build the human "Type" string shown in the table — e.g. "Dokan -
396 * Vendor User Registration" or just the notification's title for the
397 * `wp` group. Falls back to the raw notification id when the
398 * notification class isn't registered (legacy rows).
399 *
400 * @param string $notification_id Stored notification id.
401 * @param string $notification_group Stored group key.
402 *
403 * @return string
404 * @since 2.0.0
405 */
406 private function build_type_label( string $notification_id, string $notification_group ): string {
407 if ( '' === $notification_id ) {
408 return '';
409 }
410
411 $notifications = texty()->notifications()->all();
412 $title = '';
413
414 if ( isset( $notifications[ $notification_id ] ) ) {
415 $class = $notifications[ $notification_id ];
416 $obj = new $class();
417 if ( method_exists( $obj, 'get_title' ) ) {
418 $title = (string) $obj->get_title();
419 }
420 }
421
422 if ( '' === $title ) {
423 $title = $notification_id;
424 }
425
426 $groups = texty()->notifications()->get_groups();
427 if ( 'wp' !== $notification_group && isset( $groups[ $notification_group ]['title'] ) ) {
428 return sprintf( '%s - %s', (string) $groups[ $notification_group ]['title'], $title );
429 }
430
431 return $title;
432 }
433 }
434