PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.6
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.6
2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / database / tables / entries.php
sureforms / inc / database / tables Last commit date
entries.php 4 days ago payments.php 2 months ago
entries.php
610 lines
1 <?php
2 /**
3 * SureForms Database Entires Table Class.
4 *
5 * @link https://sureforms.com
6 * @since 0.0.10
7 * @package SureForms
8 * @author SureForms <https://sureforms.com/>
9 */
10
11 namespace SRFM\Inc\Database\Tables;
12
13 use SRFM\Inc\Database\Base;
14 use SRFM\Inc\Helper;
15 use SRFM\Inc\Traits\Get_Instance;
16
17 // Exit if accessed directly.
18 defined( 'ABSPATH' ) || exit;
19
20 /**
21 * SureForms Database Entires Table Class.
22 *
23 * @since 0.0.10
24 */
25 class Entries extends Base {
26 use Get_Instance;
27
28 /**
29 * {@inheritDoc}
30 *
31 * @var string
32 */
33 protected $table_suffix = 'entries';
34
35 /**
36 * {@inheritDoc}
37 *
38 * @var int
39 */
40 protected $table_version = 2;
41
42 /**
43 * Current logs.
44 *
45 * @var array<array<string,mixed>> $logs
46 * The structure of each log entry is:
47 * [
48 * 'title' => string,
49 * 'messages' => array<string>,
50 * 'timestamp' => int
51 * ]
52 *
53 * @since 0.0.10
54 */
55 private $logs = [];
56
57 /**
58 * {@inheritDoc}
59 */
60 public function get_schema() {
61 return [
62 // Entry ID.
63 'ID' => [
64 'type' => 'number',
65 ],
66 // Submitted form ID.
67 'form_id' => [
68 'type' => 'number',
69 ],
70 // User ID.
71 'user_id' => [
72 'type' => 'number',
73 'default' => 0,
74 ],
75 // Current entry status: 'read', 'unread' and 'trash'.
76 'status' => [
77 'type' => 'string',
78 'default' => 'unread',
79 ],
80 // Entry's form type eg quiz, standard etc. Default empty or null means standard.
81 'type' => [
82 'type' => 'string',
83 ],
84 // Submitted form data by user.
85 'form_data' => [
86 'type' => 'array',
87 'default' => [],
88 ],
89 // Additional information about the current submitted data: i.e: Browser type, device etc.
90 'submission_info' => [
91 'type' => 'array',
92 'default' => [],
93 ],
94 // Any additional notes that is added by the admin.
95 'notes' => [
96 'type' => 'array',
97 'default' => [],
98 ],
99 // Entry activities logs.
100 'logs' => [
101 'type' => 'array',
102 'default' => [],
103 ],
104 // Entry submitted date and time.
105 'created_at' => [
106 'type' => 'datetime',
107 ],
108 // Any misc extra data that needs to be saved.
109 'extras' => [
110 'type' => 'array',
111 'default' => [],
112 ],
113 ];
114 }
115
116 /**
117 * {@inheritDoc}
118 */
119 public function get_columns_definition() {
120 return [
121 'ID BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY',
122 'form_id BIGINT(20) UNSIGNED',
123 'user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
124 'form_data LONGTEXT', // Note: @since 0.0.13 -- We have renamed `user_data` column to `form_data`.
125 'logs LONGTEXT',
126 'notes LONGTEXT',
127 'submission_info LONGTEXT',
128 'status VARCHAR(10)',
129 'type VARCHAR(20)', // Note: @since 0.0.13 -- We have added type column, it will have entry's form type eg quiz, standard etc.
130 'extras LONGTEXT',
131 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
132 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
133 'INDEX idx_form_id (form_id)', // Indexing for the performance improvements.
134 'INDEX idx_user_id (user_id)',
135 'INDEX idx_form_id_created_at_status (form_id, created_at, status)', // Composite index for performance improvements.
136 ];
137 }
138
139 /**
140 * {@inheritDoc}
141 */
142 public function get_new_columns_definition() {
143 return [
144 // Note: @since 0.0.13 -- We have added new columns `type`, `extras` and `user_id`.
145 'type VARCHAR(20) AFTER status',
146 'extras LONGTEXT AFTER status',
147 'user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER form_id',
148 'INDEX idx_user_id (user_id)',
149 ];
150 }
151
152 /**
153 * {@inheritDoc}
154 */
155 public function get_columns_to_rename() {
156 return [
157 // Note: @since 0.0.13 -- We have renamed `user_data` column to `form_data`.
158 [
159 'from' => 'user_data',
160 'to' => 'form_data',
161 ],
162 ];
163 }
164
165 /**
166 * Retrieve the key of the last log entry.
167 *
168 * @since 0.0.10
169 * @return int|null The key of the last log entry if logs exist, or null if no logs are present.
170 */
171 public function get_last_log_key() {
172 $key = array_key_last( $this->logs );
173 return is_int( $key ) ? $key : null;
174 }
175
176 /**
177 * Add a new log entry.
178 *
179 * @param string $title The title of the log entry.
180 * @param array<string> $messages Optional. An array of messages to include in the log entry. Default is an empty array.
181 * @since 0.0.10
182 * @return int|null The key of the newly added log entry, or null if the log could not be added.
183 */
184 public function add_log( $title, $messages = [] ) {
185 $log = [
186 'title' => Helper::get_string_value( trim( $title ) ),
187 'messages' => Helper::get_array_value( $messages ),
188 'timestamp' => current_time( 'mysql' ), //phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp -- Using current_time() to match the WordPress timezone.
189 ];
190
191 $this->logs = array_merge( [ $log ], $this->logs );
192
193 return $this->get_last_log_key();
194 }
195
196 /**
197 * Update an existing log entry.
198 *
199 * @param int|null $log_key The key of the log entry to update.
200 * @param string|null $title Optional. The new title for the log entry. If null, the title will not be changed.
201 * @param array<string> $messages Optional. An array of new messages to add to the log entry.
202 * @since 0.0.10
203 * @return int|null The key of the updated log entry, or null if the log entry does not exist.
204 */
205 public function update_log( $log_key, $title = null, $messages = [] ) {
206 if ( empty( $this->logs[ $log_key ] ) ) {
207 return null;
208 }
209
210 $logs = $this->logs;
211
212 $logs[ $log_key ]['title'] = ! is_null( $title ) ? Helper::get_string_value( trim( $title ) ) : $logs[ $log_key ]['title'];
213 $logs[ $log_key ]['messages'] = array_merge( Helper::get_array_value( $logs[ $log_key ]['messages'] ), Helper::get_array_value( $messages ) );
214
215 $this->logs = $logs;
216 return $log_key;
217 }
218
219 /**
220 * Resets logs to zero.
221 *
222 * @since 1.3.0
223 * @return void
224 */
225 public function reset_logs() {
226 $this->logs = [];
227 }
228
229 /**
230 * Retrieve all log entries.
231 *
232 * @since 0.0.10
233 * @return array<array<string,mixed>>
234 */
235 public function get_logs() {
236 return $this->logs;
237 }
238
239 /**
240 * Add a new entry to the database.
241 *
242 * @param array<mixed> $data An associative array of data for the new entry. Must include 'form_id'.
243 * If 'ID' is set, it will be removed before inserting.
244 * @since 0.0.10
245 * @return int|false The number of rows inserted, or false if the insertion fails.
246 */
247 public static function add( $data ) {
248 if ( empty( $data['form_id'] ) ) {
249 return false;
250 }
251
252 if ( isset( $data['ID'] ) ) {
253 // Unset ID if exists because we are creating a new entry, not updating.
254 unset( $data['ID'] );
255 }
256
257 $instance = self::get_instance();
258
259 if ( ! isset( $data['logs'] ) ) {
260 // Add default logs if no logs provided.
261 $data['logs'] = $instance->get_logs();
262 }
263
264 $result = $instance->use_insert( $data );
265
266 if ( ! $result ) {
267 // A failed entries write is the live-drop signal issue #3084 describes: the
268 // table can have been dropped after being cached as present. Drop that cache
269 // so the next admin load re-checks instead of trusting a stale answer.
270 \SRFM\Inc\Database\Register::flush_entries_table_cache();
271 }
272
273 return $result;
274 }
275
276 /**
277 * Update an entry by entry id.
278 *
279 * @param int $entry_id Entry ID.
280 * @param array<string,mixed> $data Data to update.
281 * @since 0.0.13
282 * @return int|false The number of rows updated, or false on error.
283 */
284 public static function update( $entry_id, $data = [] ) {
285 if ( empty( $entry_id ) ) {
286 return false;
287 }
288
289 if ( isset( $data['logs'] ) ) {
290 // Add logs from the current cache at the very last moment so that we don't tax the performance.
291 $data['logs'] = array_merge( Helper::get_array_value( $data['logs'] ), Helper::get_array_value( self::get( $entry_id )['logs'] ) );
292 }
293
294 return self::get_instance()->use_update( $data, [ 'ID' => absint( $entry_id ) ] );
295 }
296
297 /**
298 * Delete an entry by entry id.
299 *
300 * @param int $entry_id Entry ID to delete.
301 * @since 0.0.13
302 * @return int|false The number of rows deleted, or false on error.
303 */
304 public static function delete( $entry_id ) {
305 // Add action before deleting the entry.
306 do_action( 'srfm_before_delete_entry', $entry_id );
307
308 // Delete the entry.
309 return self::get_instance()->use_delete( [ 'ID' => absint( $entry_id ) ], [ '%d' ] );
310 }
311
312 /**
313 * Retrieve a specific entry from the database.
314 *
315 * @param int $entry_id The ID of the entry to retrieve.
316 * @since 0.0.10
317 * @return array<mixed> An associative array representing the entry, or an empty array if no entry is found.
318 */
319 public static function get( $entry_id ) {
320 $results = self::get_instance()->get_results(
321 [
322 'ID' => $entry_id,
323 ]
324 );
325
326 return isset( $results[0] ) ? Helper::get_array_value( $results[0] ) : [];
327 }
328
329 /**
330 * Retrieves a list of records based on the provided arguments.
331 *
332 * This method fetches results from the database, allowing for various
333 * customization options such as filtering, pagination, and sorting.
334 *
335 * @param array<string,mixed> $args {
336 * Optional. An array of arguments to customize the query.
337 *
338 * @type array $where An associative array of conditions to filter the results.
339 * @type int $limit The maximum number of results to return. Default is 10.
340 * @type int $offset The number of records to skip before starting to collect results. Default is 0.
341 * @type string $orderby The column by which to order the results. Default is 'created_at'.
342 * @type string $order The direction of the order (ASC or DESC). Default is 'DESC'.
343 * }
344 * @param bool $set_limit Whether to set the limit on the query. Default is true.
345 *
346 * @since 0.0.13
347 * @return array<mixed> The results of the query, typically an array of objects or associative arrays.
348 */
349 public static function get_all( $args = [], $set_limit = true ) {
350 /**
351 * Refactored the get_all method to use the get_records_by_args method from the Base class.
352 * Moved the common logic inside the Base class so it can be reused by other tables as well.
353 *
354 * @since 1.13.0
355 */
356 return self::get_instance()->get_records_by_args( $args, $set_limit );
357 }
358
359 /**
360 * Get the total count of entries by status.
361 *
362 * @param string $status The status of the entries to count.
363 * @param int|null $form_id The ID of the form to count entries for.
364 * @param array<mixed> $where_clause Additional where clause to add to the query.
365 * @since 0.0.13
366 * @return int The total number of entries with the specified status.
367 */
368 public static function get_total_entries_by_status( $status = 'all', $form_id = 0, $where_clause = [] ) {
369 switch ( $status ) {
370 case 'all':
371 $where_clause[] =
372 [
373 [
374 'key' => 'status',
375 'compare' => '!=',
376 'value' => 'trash',
377 ],
378 ];
379 if ( 0 < $form_id ) {
380 $where_clause[] = [
381 [
382 'key' => 'form_id',
383 'compare' => '=',
384 'value' => $form_id,
385 ],
386 ];
387 }
388 return self::get_instance()->get_total_count( $where_clause );
389 case 'unread':
390 case 'trash':
391 $where_clause[] = [
392 [
393 'key' => 'status',
394 'compare' => '=',
395 'value' => $status,
396 ],
397 ];
398 return self::get_instance()->get_total_count( $where_clause );
399 default:
400 return self::get_instance()->get_total_count();
401 }
402 }
403
404 /**
405 * Get the total number of entries created after the given timestamp.
406 *
407 * @param int $timestamp Timestamp in seconds.
408 * @param int $form_id Optional. The ID of the form to count entries for. Default 0 for all forms.
409 * @since 1.7.3
410 * @return int Total number of entries created after the timestamp.
411 */
412 public static function get_entries_count_after( $timestamp, $form_id = 0 ) {
413 $timestamp = absint( $timestamp );
414
415 if ( ! $timestamp ) {
416 return self::get_total_entries_by_status( 'all', $form_id );
417 }
418
419 global $wpdb;
420
421 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct DB access is required here to get the most accurate server time for menu badge logic. Caching is not suitable as this is used for real-time admin notifications.
422 $mysql_time = $wpdb->get_var( 'SELECT NOW()' );
423
424 // Convert to timestamps.
425 $mysql_timestamp = strtotime( $mysql_time );
426 $php_timestamp = time();
427
428 // Offset between MySQL and PHP.
429 $offset_seconds = $mysql_timestamp - $php_timestamp;
430
431 $adjusted_timestamp = $timestamp + $offset_seconds;
432
433 $where_clause = [
434 [
435 [
436 'key' => 'created_at',
437 'compare' => '>',
438 'value' => gmdate( 'Y-m-d H:i:s', $adjusted_timestamp ),
439 ],
440 ],
441 ];
442
443 return self::get_total_entries_by_status( 'all', $form_id, $where_clause );
444 }
445
446 /**
447 * Get the available months for entries.
448 *
449 * @param array<string,mixed> $where_clause Additional where clause to add to the query.
450 * @since 0.0.13
451 * @return array<int|string, mixed>
452 */
453 public static function get_available_months( $where_clause = [] ) {
454 $results = self::get_instance()->get_results(
455 $where_clause,
456 'DISTINCT DATE_FORMAT(created_at, "%Y%m") as month_value, DATE_FORMAT(created_at, "%M %Y") as month_label',
457 [
458 'ORDER BY month_value ASC',
459 ],
460 false
461 );
462
463 $months = [];
464 foreach ( $results as $result ) {
465 if ( is_array( $result ) && isset( $result['month_value'], $result['month_label'] ) ) {
466 $months[ $result['month_value'] ] = $result['month_label'];
467 }
468 }
469 return $months;
470 }
471
472 /**
473 * Get all the entry ID's for a form.
474 * The data is used for checking unique field validation.
475 *
476 * @param int $form_id The ID of the form to fetch entry IDs for.
477 * @since 1.0.0
478 * @return array<mixed> An array of entry IDs.
479 */
480 public static function get_all_entry_ids_for_form( $form_id ) {
481 return self::get_instance()->get_results(
482 [ 'form_id' => $form_id ],
483 'ID',
484 [
485 'ORDER BY ID DESC',
486 ]
487 );
488 }
489
490 /**
491 * Check if any non-trashed entry for a given form contains a specific field value.
492 *
493 * Uses a single SQL query with JSON_EXTRACT on the form_data column
494 * instead of loading all entries into PHP. Stops at the first match.
495 *
496 * SECURITY INVARIANT — do not relax the key matching. The only caller is the
497 * unauthenticated uniqueness check (Form_Submit::field_unique_validation()), which
498 * allows a probe only for fields the form marks unique. That restriction holds
499 * because the lookup is anchored to the EXACT submitted key: a stored form_data key
500 * always embeds its own block ID, so a key that resolves to field X can only carry
501 * X's block ID. Matching on block ID instead, or switching to LIKE / JSON_SEARCH,
502 * would break that anchoring and widen the probe beyond the allowlisted field.
503 *
504 * @param int $form_id The form ID to search within.
505 * @param string $field_key The form_data JSON key to match against.
506 * @param string $field_value The value to check for uniqueness.
507 * @since 2.7.0
508 * @return bool True if a duplicate exists, false otherwise.
509 */
510 public static function has_duplicate_field_value( $form_id, $field_key, $field_value ) {
511 if ( empty( $form_id ) || empty( $field_key ) || '' === $field_value ) {
512 return false;
513 }
514
515 global $wpdb;
516 $table_name = self::get_instance()->get_tablename();
517
518 // $wpdb->prepare() escapes this for SQL, but the key is interpolated into a JSON
519 // path string, where a quote or backslash would change the path's meaning rather
520 // than break the query. Neutralise both so the path can only ever be a single
521 // quoted member access.
522 $json_path = '$."' . str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $field_key ) . '"';
523
524 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- One-off existence check; table name from get_tablename() (not user input); caching not beneficial for uniqueness validation.
525 $exists = $wpdb->get_var(
526 $wpdb->prepare(
527 "SELECT 1 FROM {$table_name} WHERE form_id = %d AND status != 'trash' AND JSON_UNQUOTE(JSON_EXTRACT(form_data, %s)) = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is internally generated.
528 $form_id,
529 $json_path,
530 $field_value
531 )
532 );
533
534 return null !== $exists;
535 }
536
537 /**
538 * Get form IDs associated with a list of entry IDs.
539 * This method retrieves the distinct form IDs that are linked to the provided entry IDs.
540 *
541 * @param array<int> $entry_ids An array of entry IDs to fetch associated form IDs for.
542 * @since 1.1.1
543 * @return array<int> An array of form IDs.
544 */
545 public static function get_form_ids_by_entries( $entry_ids ) {
546 if ( empty( $entry_ids ) && ! is_array( $entry_ids ) ) {
547 return [];
548 }
549
550 $results = self::get_instance()->get_results(
551 [
552 [
553 [
554 'key' => 'ID',
555 'compare' => 'IN',
556 'value' => $entry_ids,
557 ],
558 ],
559 ],
560 'DISTINCT form_id'
561 );
562
563 return array_map( 'absint', array_column( $results, 'form_id' ) ); // Flatten the array.
564 }
565
566 /**
567 * Get the form data for a specific entry.
568 *
569 * @param int $entry_id The ID of the entry to get the form data for.
570 * @since 1.0.0
571 * @return array<string,mixed> An associative array representing the entry's form data.
572 */
573 public static function get_form_data( $entry_id ) {
574 $result = self::get_instance()->get_results(
575 [ 'ID' => $entry_id ],
576 'form_data'
577 );
578 return isset( $result[0] ) && is_array( $result[0] ) ? Helper::get_array_value( $result[0]['form_data'] ) : [];
579 }
580
581 /**
582 * Get the entry data for a specific entry.
583 *
584 * @param int $entry_id The ID of the entry to get the entry data for.
585 * @since 1.8.0
586 * @return array<string,mixed> An associative array representing the entry's data.
587 */
588 public static function get_entry_data( $entry_id ) {
589 $result = self::get_instance()->get_results(
590 [ 'ID' => $entry_id ],
591 'form_data, extras'
592 );
593 return isset( $result[0] ) && is_array( $result[0] ) ? $result[0] : [];
594 }
595
596 /**
597 * {@inheritDoc}
598 *
599 * Restricts orderable columns to indexed, semantically meaningful fields.
600 * Excludes LONGTEXT blob columns (form_data, submission_info, notes, logs, extras)
601 * to prevent full-table sorts on un-indexed columns.
602 *
603 * @since 2.6.0
604 * @return array<string>
605 */
606 protected function get_allowed_orderby_columns() {
607 return [ 'ID', 'id', 'form_id', 'user_id', 'status', 'type', 'created_at', 'updated_at' ];
608 }
609 }
610