PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.2.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.2.0
4.2.0 4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-submission-handler.php
superb-blocks / src / gutenberg / form Last commit date
class-form-access-control.php 3 weeks ago class-form-captcha-handler.php 2 weeks ago class-form-controller.php 2 days ago class-form-email-config-check.php 3 weeks ago class-form-email-handler.php 2 days ago class-form-encryption.php 3 weeks ago class-form-exporter.php 2 days ago class-form-field-validator.php 3 weeks ago class-form-file-handler.php 2 days ago class-form-google-auth.php 3 weeks ago class-form-integration-handler.php 3 weeks ago class-form-math-parser.php 3 weeks ago class-form-permissions.php 3 weeks ago class-form-registry.php 3 weeks ago class-form-settings.php 3 weeks ago class-form-submission-cpt.php 3 weeks ago class-form-submission-handler.php 2 days ago class-form-zip-exporter.php 2 days ago
class-form-submission-handler.php
1011 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\Form;
4
5 defined('ABSPATH') || exit();
6
7 class FormSubmissionHandler
8 {
9 /**
10 * Store a form submission.
11 *
12 * @param string $form_id
13 * @param array $fields
14 * @return int|false Post ID on success, false on failure
15 */
16 public static function Store($form_id, $fields)
17 {
18 $post_id = wp_insert_post(array(
19 'post_type' => FormSubmissionCPT::POST_TYPE,
20 'post_status' => 'publish',
21 'post_title' => sanitize_text_field($form_id) . ' - ' . current_time('mysql'),
22 ));
23
24 if (is_wp_error($post_id)) {
25 return false;
26 }
27
28 update_post_meta($post_id, '_spb_form_id', sanitize_text_field($form_id));
29 update_post_meta($post_id, '_spb_form_fields', $fields);
30 update_post_meta($post_id, '_spb_form_ip', self::HashIP());
31 update_post_meta($post_id, '_spb_form_status', 'new');
32
33 return $post_id;
34 }
35
36 /**
37 * Reorder submitted fields to match the form's field configuration.
38 *
39 * Files are merged into the field array after the text fields, and
40 * calculated fields may be appended, so stored order does not always
41 * match the form. Fields missing from the config (removed fields, or a
42 * form whose config is gone) keep their stored order at the end.
43 *
44 * @param array $fields field_id => value
45 * @param array $form_fields_config Field definitions from form config.
46 * @return array
47 */
48 public static function OrderFieldsByConfig($fields, $form_fields_config)
49 {
50 if (!is_array($fields) || empty($form_fields_config) || !is_array($form_fields_config)) {
51 return $fields;
52 }
53 $ordered = array();
54 foreach ($form_fields_config as $field_def) {
55 if (!isset($field_def['fieldId'])) {
56 continue;
57 }
58 $fid = $field_def['fieldId'];
59 if (array_key_exists($fid, $fields)) {
60 $ordered[$fid] = $fields[$fid];
61 }
62 }
63 foreach ($fields as $fid => $value) {
64 if (!array_key_exists($fid, $ordered)) {
65 $ordered[$fid] = $value;
66 }
67 }
68 return $ordered;
69 }
70
71 /**
72 * Get submissions for a form.
73 *
74 * @param string $form_id
75 * @param int $page
76 * @param int $per_page
77 * @param string $status
78 * @param string $starred '1' to filter starred only, '' for all
79 * @param string $search Search term to match against field data
80 * @param string $date_after ISO date string for date range start
81 * @param string $date_before ISO date string for date range end
82 * @param array|null $ids Restrict to these submission IDs; null applies the filters only.
83 * @return array
84 */
85 public static function GetSubmissions($form_id, $page = 1, $per_page = 20, $status = '', $starred = '', $search = '', $date_after = '', $date_before = '', $ids = null)
86 {
87 $args = array(
88 'post_type' => FormSubmissionCPT::POST_TYPE,
89 'post_status' => 'publish',
90 'posts_per_page' => $per_page,
91 'paged' => $page,
92 'orderby' => 'date',
93 'order' => 'DESC',
94 );
95
96 // Explicit selection (bulk actions, single-submission downloads). The
97 // form ID meta filter below still applies, so an ID that belongs to
98 // another form is dropped rather than exported.
99 if (is_array($ids)) {
100 $ids = array_values(array_filter(array_map('intval', $ids)));
101 if (empty($ids)) {
102 return array('submissions' => array(), 'total' => 0, 'total_pages' => 0);
103 }
104 $args['post__in'] = $ids;
105 }
106
107 $meta_query = array();
108
109 if (!empty($form_id)) {
110 $meta_query[] = array(
111 'key' => '_spb_form_id',
112 'value' => sanitize_text_field($form_id),
113 );
114 }
115
116 if (!empty($status) && in_array($status, array('new', 'read', 'spam'), true)) {
117 $meta_query[] = array(
118 'key' => '_spb_form_status',
119 'value' => $status,
120 );
121 }
122
123 if ($starred === '1') {
124 $meta_query[] = array(
125 'key' => '_spb_form_starred',
126 'value' => '1',
127 );
128 }
129
130 // Server-side search: LIKE query on serialized field data
131 if (!empty($search)) {
132 $meta_query[] = array(
133 'key' => '_spb_form_fields',
134 'value' => sanitize_text_field($search),
135 'compare' => 'LIKE',
136 );
137 }
138
139 if (!empty($meta_query)) {
140 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_id/_spb_form_status are the primary filter keys for the submission CPT; meta_query is the only way to query by them.
141 $args['meta_query'] = $meta_query;
142 }
143
144 // Date range filtering
145 if (!empty($date_after) || !empty($date_before)) {
146 $date_query = array();
147 if (!empty($date_after)) {
148 $date_query['after'] = sanitize_text_field($date_after);
149 $date_query['inclusive'] = true;
150 }
151 if (!empty($date_before)) {
152 $date_query['before'] = sanitize_text_field($date_before);
153 $date_query['inclusive'] = true;
154 }
155 $args['date_query'] = array($date_query);
156 }
157
158 $query = new \WP_Query($args);
159 $submissions = array();
160
161 foreach ($query->posts as $post) {
162 $fields = get_post_meta($post->ID, '_spb_form_fields', true);
163 $post_status = get_post_meta($post->ID, '_spb_form_status', true);
164 $is_starred = get_post_meta($post->ID, '_spb_form_starred', true);
165 $note_count = self::GetNoteCount($post->ID);
166 $sub = array(
167 'id' => $post->ID,
168 'form_id' => get_post_meta($post->ID, '_spb_form_id', true),
169 'fields' => is_array($fields) ? $fields : array(),
170 'date' => $post->post_date_gmt . 'Z',
171 'status' => !empty($post_status) ? $post_status : 'new',
172 'starred' => $is_starred === '1',
173 'note_count' => $note_count,
174 );
175 if ($post_status === 'spam') {
176 $sub['spam_reason'] = get_post_meta($post->ID, '_spb_form_spam_reason', true);
177 }
178 // Include email and integration status meta if present
179 $email_status = get_post_meta($post->ID, '_spb_form_email_status', true);
180 if (!empty($email_status) && is_array($email_status)) {
181 $sub['email_status'] = $email_status;
182 }
183 $integration_status = get_post_meta($post->ID, '_spb_form_integration_status', true);
184 if (!empty($integration_status) && is_array($integration_status)) {
185 $sub['integration_status'] = $integration_status;
186 }
187 $submissions[] = $sub;
188 }
189
190 return array(
191 'submissions' => $submissions,
192 'total' => $query->found_posts,
193 'total_pages' => $query->max_num_pages,
194 );
195 }
196
197 /**
198 * Get submission count for a form.
199 *
200 * @param string $form_id
201 * @return array
202 */
203 public static function GetCount($form_id)
204 {
205 $base_args = array(
206 'post_type' => FormSubmissionCPT::POST_TYPE,
207 'post_status' => 'publish',
208 'posts_per_page' => 1,
209 'no_found_rows' => false,
210 'update_post_meta_cache' => false,
211 'update_post_term_cache' => false,
212 );
213
214 $meta_query = array();
215
216 if (!empty($form_id)) {
217 $meta_query[] = array(
218 'key' => '_spb_form_id',
219 'value' => sanitize_text_field($form_id),
220 );
221 }
222
223 // Exclude spam from regular counts
224 $meta_query[] = array(
225 'key' => '_spb_form_status',
226 'value' => 'spam',
227 'compare' => '!=',
228 );
229
230 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_id/_spb_form_status are the primary filter keys for the submission CPT; meta_query is the only way to query by them.
231 $base_args['meta_query'] = $meta_query;
232
233 $total_query = new \WP_Query($base_args);
234 $total = $total_query->found_posts;
235
236 $new_args = $base_args;
237 $new_args['meta_query'][] = array(
238 'key' => '_spb_form_status',
239 'value' => 'new',
240 );
241
242 $new_query = new \WP_Query($new_args);
243
244 return array(
245 'total' => $total,
246 'new' => $new_query->found_posts,
247 );
248 }
249
250 /**
251 * Delete a submission.
252 *
253 * @param int $post_id
254 * @return bool
255 */
256 public static function Delete($post_id)
257 {
258 $post = get_post($post_id);
259 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
260 return false;
261 }
262
263 // File cleanup is handled by OnDeletePost() on the before_delete_post
264 // hook, which wp_delete_post() fires below — and which also covers
265 // deletions that bypass this method.
266 return wp_delete_post($post_id, true) !== false;
267 }
268
269 /**
270 * Hook: before_delete_post — delete a submission's uploaded files.
271 *
272 * Single source of truth for stored-submission file cleanup. Fires for
273 * every permanent deletion of a submission, including those that bypass
274 * Delete() (WP admin "Delete Permanently", WP-CLI, trash auto-empty, other
275 * plugins). Meta is still readable at this point; wp_delete_post() removes
276 * it afterward.
277 *
278 * @param int $post_id
279 * @param \WP_Post $post
280 */
281 public static function OnDeletePost($post_id, $post)
282 {
283 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
284 return;
285 }
286 $fields = get_post_meta($post_id, '_spb_form_fields', true);
287 if (is_array($fields)) {
288 FormFileHandler::DeleteSubmissionFiles($fields);
289 }
290 }
291
292 /**
293 * Bulk delete submissions.
294 *
295 * @param array $ids
296 * @return int Number of deleted submissions
297 */
298 public static function BulkDelete($ids)
299 {
300 $deleted = 0;
301 foreach ($ids as $id) {
302 if (self::Delete(intval($id))) {
303 $deleted++;
304 }
305 }
306 return $deleted;
307 }
308
309 /**
310 * Delete all submissions for a specific form.
311 *
312 * @param string $form_id
313 * @return int Number of deleted submissions
314 */
315 public static function DeleteAllByFormId($form_id)
316 {
317 $ids = get_posts(array(
318 'post_type' => FormSubmissionCPT::POST_TYPE,
319 'post_status' => 'any',
320 'posts_per_page' => -1,
321 'fields' => 'ids',
322 'no_found_rows' => true,
323 'update_post_meta_cache' => false,
324 'update_post_term_cache' => false,
325 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_id is the primary filter for the submission CPT; meta_query is the only way to query by it.
326 'meta_query' => array(
327 array(
328 'key' => '_spb_form_id',
329 'value' => sanitize_text_field($form_id),
330 ),
331 ),
332 ));
333
334 return self::BulkDelete($ids);
335 }
336
337 /**
338 * Star a submission.
339 *
340 * @param int $post_id
341 * @return bool
342 */
343 public static function Star($post_id)
344 {
345 $post = get_post($post_id);
346 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
347 return false;
348 }
349 update_post_meta($post_id, '_spb_form_starred', '1');
350 return true;
351 }
352
353 /**
354 * Unstar a submission.
355 *
356 * @param int $post_id
357 * @return bool
358 */
359 public static function Unstar($post_id)
360 {
361 $post = get_post($post_id);
362 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
363 return false;
364 }
365 delete_post_meta($post_id, '_spb_form_starred');
366 return true;
367 }
368
369 /**
370 * Bulk star/unstar submissions.
371 *
372 * @param array $ids
373 * @param bool $star true to star, false to unstar
374 * @return int Number of updated submissions
375 */
376 public static function BulkStar($ids, $star)
377 {
378 $updated = 0;
379 foreach ($ids as $id) {
380 $result = $star ? self::Star(intval($id)) : self::Unstar(intval($id));
381 if ($result) {
382 $updated++;
383 }
384 }
385 return $updated;
386 }
387
388 /**
389 * Mark a submission as read.
390 *
391 * @param int $post_id
392 * @return bool
393 */
394 public static function MarkAsRead($post_id)
395 {
396 $post = get_post($post_id);
397 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
398 return false;
399 }
400 update_post_meta($post_id, '_spb_form_status', 'read');
401 return true;
402 }
403
404 /**
405 * Mark a submission as unread (new).
406 *
407 * @param int $post_id
408 * @return bool
409 */
410 public static function MarkAsUnread($post_id)
411 {
412 $post = get_post($post_id);
413 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
414 return false;
415 }
416 update_post_meta($post_id, '_spb_form_status', 'new');
417 return true;
418 }
419
420 /**
421 * Bulk update status for multiple submissions.
422 *
423 * @param array $ids
424 * @param string $status 'read' or 'new'
425 * @return int Number of updated submissions
426 */
427 public static function BulkUpdateStatus($ids, $status)
428 {
429 $updated = 0;
430 $method = $status === 'new' ? 'MarkAsUnread' : 'MarkAsRead';
431 foreach ($ids as $id) {
432 if (self::$method(intval($id))) {
433 $updated++;
434 }
435 }
436 return $updated;
437 }
438
439 /**
440 * Get all distinct form IDs that have submissions.
441 *
442 * @return array Array of form_id strings
443 */
444 public static function GetDistinctFormIds()
445 {
446 global $wpdb;
447 $post_type = FormSubmissionCPT::POST_TYPE;
448
449 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
450 $results = $wpdb->get_col(
451 $wpdb->prepare(
452 "SELECT DISTINCT pm.meta_value
453 FROM {$wpdb->postmeta} pm
454 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
455 WHERE pm.meta_key = '_spb_form_id'
456 AND p.post_type = %s
457 AND p.post_status = 'publish'
458 ORDER BY pm.meta_value ASC",
459 $post_type
460 )
461 );
462
463 return is_array($results) ? $results : array();
464 }
465
466 /**
467 * Get the date of the last submission for a form.
468 *
469 * @param string $form_id
470 * @return string Date string or empty
471 */
472 public static function GetLastSubmissionDate($form_id)
473 {
474 $args = array(
475 'post_type' => FormSubmissionCPT::POST_TYPE,
476 'post_status' => 'publish',
477 'posts_per_page' => 1,
478 'orderby' => 'date',
479 'order' => 'DESC',
480 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_id is the primary filter for the submission CPT; meta_query is the only way to query by it.
481 'meta_query' => array(
482 array(
483 'key' => '_spb_form_id',
484 'value' => sanitize_text_field($form_id),
485 ),
486 ),
487 );
488
489 $query = new \WP_Query($args);
490 if ($query->have_posts()) {
491 return $query->posts[0]->post_date;
492 }
493
494 return '';
495 }
496
497 /**
498 * Get detailed stats for a form (total, new, today, this week).
499 *
500 * @param string $form_id
501 * @return array
502 */
503 public static function GetFormStats($form_id)
504 {
505 $count = self::GetCount($form_id);
506 $today = current_time('Y-m-d');
507 $week_ago = gmdate('Y-m-d', strtotime('-7 days', strtotime($today)));
508
509 $base_args = array(
510 'post_type' => FormSubmissionCPT::POST_TYPE,
511 'post_status' => 'publish',
512 'posts_per_page' => 1,
513 'no_found_rows' => false,
514 'update_post_meta_cache' => false,
515 'update_post_term_cache' => false,
516 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_id is the primary filter for the submission CPT; meta_query is the only way to query by it.
517 'meta_query' => array(
518 array(
519 'key' => '_spb_form_id',
520 'value' => sanitize_text_field($form_id),
521 ),
522 array(
523 'key' => '_spb_form_status',
524 'value' => 'spam',
525 'compare' => '!=',
526 ),
527 ),
528 );
529
530 $today_args = $base_args;
531 $today_args['date_query'] = array(
532 array(
533 'after' => $today . ' 00:00:00',
534 'inclusive' => true,
535 ),
536 );
537 $today_query = new \WP_Query($today_args);
538
539 $week_args = $base_args;
540 $week_args['date_query'] = array(
541 array(
542 'after' => $week_ago . ' 00:00:00',
543 'inclusive' => true,
544 ),
545 );
546 $week_query = new \WP_Query($week_args);
547
548 return array(
549 'total' => $count['total'],
550 'new' => $count['new'],
551 'today' => $today_query->found_posts,
552 'this_week' => $week_query->found_posts,
553 );
554 }
555
556 /**
557 * Check if recent submissions for a form all have admin email failures.
558 *
559 * Only returns true when there are at least $min_count submissions with
560 * email status tracked and ALL of them have admin email failures.
561 *
562 * @param string $form_id
563 * @param int $count Number of recent submissions to check
564 * @param int $min_count Minimum submissions required to trigger
565 * @return bool
566 */
567 public static function HasRecentEmailFailures($form_id, $count = 5, $min_count = 3)
568 {
569 $ids = get_posts(array(
570 'post_type' => FormSubmissionCPT::POST_TYPE,
571 'post_status' => 'publish',
572 'posts_per_page' => $count,
573 'orderby' => 'date',
574 'order' => 'DESC',
575 'fields' => 'ids',
576 'no_found_rows' => true,
577 'update_post_term_cache' => false,
578 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_id is the primary filter for the submission CPT; meta_query is the only way to query by it.
579 'meta_query' => array(
580 array(
581 'key' => '_spb_form_id',
582 'value' => sanitize_text_field($form_id),
583 ),
584 array(
585 'key' => '_spb_form_email_status',
586 'compare' => 'EXISTS',
587 ),
588 array(
589 'key' => '_spb_form_status',
590 'value' => 'spam',
591 'compare' => '!=',
592 ),
593 ),
594 ));
595
596 if (count($ids) < $min_count) {
597 return false;
598 }
599
600 foreach ($ids as $id) {
601 $status = get_post_meta($id, '_spb_form_email_status', true);
602 if (!is_array($status) || !isset($status['admin'])) {
603 continue;
604 }
605 if (!empty($status['admin']['sent'])) {
606 // At least one recent email succeeded — no systemic issue
607 return false;
608 }
609 }
610
611 return true;
612 }
613
614 // ========================================
615 // Spam Protection
616 // ========================================
617
618 /**
619 * Increment the spam counter for a form.
620 *
621 * @param string $form_id
622 * @return int New count
623 */
624 public static function IncrementSpamCount($form_id)
625 {
626 $option_key = '_spb_form_spam_count_' . sanitize_key($form_id);
627 $current = intval(get_option($option_key, 0));
628 $new_count = $current + 1;
629 update_option($option_key, $new_count, false);
630 return $new_count;
631 }
632
633 /**
634 * Get the spam counter for a form.
635 *
636 * @param string $form_id
637 * @return int
638 */
639 public static function GetSpamCount($form_id)
640 {
641 return intval(get_option('_spb_form_spam_count_' . sanitize_key($form_id), 0));
642 }
643
644 /**
645 * Store a spam submission.
646 * Text fields only -- file field values are stored as empty arrays.
647 *
648 * @param string $form_id
649 * @param array $fields
650 * @param string $spam_reason 'honeypot', 'captcha', or 'bot_detection'
651 * @return int|false Post ID on success, false on failure
652 */
653 public static function StoreSpam($form_id, $fields, $spam_reason)
654 {
655 // Strip file upload data -- store empty arrays for file fields
656 $text_fields = array();
657 foreach ($fields as $key => $value) {
658 if (is_array($value)) {
659 $text_fields[sanitize_text_field($key)] = array();
660 } else {
661 $text_fields[sanitize_text_field($key)] = $value;
662 }
663 }
664
665 $post_id = wp_insert_post(array(
666 'post_type' => FormSubmissionCPT::POST_TYPE,
667 'post_status' => 'publish',
668 'post_title' => sanitize_text_field($form_id) . ' - spam - ' . current_time('mysql'),
669 ));
670
671 if (is_wp_error($post_id)) {
672 return false;
673 }
674
675 update_post_meta($post_id, '_spb_form_id', sanitize_text_field($form_id));
676 update_post_meta($post_id, '_spb_form_fields', $text_fields);
677 update_post_meta($post_id, '_spb_form_ip', self::HashIP());
678 update_post_meta($post_id, '_spb_form_status', 'spam');
679 update_post_meta($post_id, '_spb_form_spam_reason', sanitize_text_field($spam_reason));
680
681 return $post_id;
682 }
683
684 /**
685 * Move a spam submission to regular submissions ("Not Spam" rescue).
686 * Does NOT trigger emails or integrations.
687 *
688 * @param int $post_id
689 * @return bool
690 */
691 public static function MarkNotSpam($post_id)
692 {
693 $post = get_post($post_id);
694 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
695 return false;
696 }
697 $current_status = get_post_meta($post_id, '_spb_form_status', true);
698 if ($current_status !== 'spam') {
699 return false;
700 }
701 update_post_meta($post_id, '_spb_form_status', 'new');
702 delete_post_meta($post_id, '_spb_form_spam_reason');
703 return true;
704 }
705
706 /**
707 * Get count of spam submissions for a form.
708 *
709 * @param string $form_id
710 * @return int
711 */
712 public static function GetSpamSubmissionCount($form_id)
713 {
714 $args = array(
715 'post_type' => FormSubmissionCPT::POST_TYPE,
716 'post_status' => 'publish',
717 'posts_per_page' => 1,
718 'no_found_rows' => false,
719 'update_post_meta_cache' => false,
720 'update_post_term_cache' => false,
721 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_id/_spb_form_status are the primary filter keys for the submission CPT; meta_query is the only way to query by them.
722 'meta_query' => array(
723 array(
724 'key' => '_spb_form_id',
725 'value' => sanitize_text_field($form_id),
726 ),
727 array(
728 'key' => '_spb_form_status',
729 'value' => 'spam',
730 ),
731 ),
732 );
733 $query = new \WP_Query($args);
734 return $query->found_posts;
735 }
736
737 // ========================================
738 // Spam Auto-Purge Cron
739 // ========================================
740
741 const SPAM_PURGE_HOOK = 'spb_form_spam_purge';
742
743 /**
744 * Schedule the daily spam purge cron if not already scheduled.
745 */
746 public static function ScheduleSpamPurge()
747 {
748 if (!wp_next_scheduled(self::SPAM_PURGE_HOOK)) {
749 wp_schedule_event(time(), 'daily', self::SPAM_PURGE_HOOK);
750 }
751 }
752
753 /**
754 * Unschedule the spam purge cron.
755 */
756 public static function UnscheduleSpamPurge()
757 {
758 $timestamp = wp_next_scheduled(self::SPAM_PURGE_HOOK);
759 if ($timestamp) {
760 wp_unschedule_event($timestamp, self::SPAM_PURGE_HOOK);
761 }
762 }
763
764 /**
765 * Purge spam submissions older than 30 days.
766 */
767 public static function PurgeOldSpam()
768 {
769 $cutoff = gmdate('Y-m-d H:i:s', strtotime('-30 days'));
770 $args = array(
771 'post_type' => FormSubmissionCPT::POST_TYPE,
772 'post_status' => 'publish',
773 'posts_per_page' => 100,
774 'no_found_rows' => true,
775 'fields' => 'ids',
776 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_status is the primary filter for the submission CPT; meta_query is the only way to query by it.
777 'meta_query' => array(
778 array(
779 'key' => '_spb_form_status',
780 'value' => 'spam',
781 ),
782 ),
783 'date_query' => array(
784 array(
785 'before' => $cutoff,
786 'inclusive' => false,
787 ),
788 ),
789 );
790
791 $ids = get_posts($args);
792 if (!empty($ids)) {
793 self::BulkDelete($ids);
794 }
795 }
796
797 // ========================================
798 // Data Retention Auto-Purge Cron
799 // ========================================
800
801 const RETENTION_PURGE_HOOK = 'spb_form_retention_purge';
802
803 /**
804 * Schedule the daily data retention purge cron if not already scheduled.
805 */
806 public static function ScheduleRetentionPurge()
807 {
808 if (!wp_next_scheduled(self::RETENTION_PURGE_HOOK)) {
809 wp_schedule_event(time(), 'daily', self::RETENTION_PURGE_HOOK);
810 }
811 }
812
813 /**
814 * Unschedule the data retention purge cron.
815 */
816 public static function UnscheduleRetentionPurge()
817 {
818 $timestamp = wp_next_scheduled(self::RETENTION_PURGE_HOOK);
819 if ($timestamp) {
820 wp_unschedule_event($timestamp, self::RETENTION_PURGE_HOOK);
821 }
822 }
823
824 /**
825 * Purge non-spam submissions older than the configured retention period.
826 */
827 public static function PurgeOldSubmissions()
828 {
829 $days = intval(get_option('superbaddons_form_data_retention', 0));
830 if ($days <= 0) {
831 return;
832 }
833
834 $cutoff = gmdate('Y-m-d H:i:s', strtotime('-' . $days . ' days'));
835 $args = array(
836 'post_type' => FormSubmissionCPT::POST_TYPE,
837 'post_status' => 'publish',
838 'posts_per_page' => 100,
839 'no_found_rows' => true,
840 'fields' => 'ids',
841 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- _spb_form_status is the primary filter for the submission CPT; meta_query is the only way to query by it.
842 'meta_query' => array(
843 array(
844 'key' => '_spb_form_status',
845 'value' => 'spam',
846 'compare' => '!=',
847 ),
848 ),
849 'date_query' => array(
850 array(
851 'before' => $cutoff,
852 'inclusive' => false,
853 ),
854 ),
855 );
856
857 $ids = get_posts($args);
858 if (!empty($ids)) {
859 self::BulkDelete($ids);
860 }
861 }
862
863 // ========================================
864 // Submission Notes
865 // ========================================
866
867 /**
868 * Add a note to a submission.
869 *
870 * @param int $post_id
871 * @param int $author_id
872 * @param string $author_name
873 * @param string $text
874 * @return array|false The new note on success, false on failure
875 */
876 public static function AddNote($post_id, $author_id, $author_name, $text)
877 {
878 $post = get_post($post_id);
879 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
880 return false;
881 }
882
883 $text = sanitize_textarea_field($text);
884 if (empty($text) || mb_strlen($text) > 1000) {
885 return false;
886 }
887
888 $notes = get_post_meta($post_id, '_spb_form_notes', true);
889 if (!is_array($notes)) {
890 $notes = array();
891 }
892
893 $note = array(
894 'author_id' => intval($author_id),
895 'author_name' => sanitize_text_field($author_name),
896 'date' => current_time('mysql', true),
897 'text' => $text,
898 );
899
900 $notes[] = $note;
901 update_post_meta($post_id, '_spb_form_notes', $notes);
902
903 return $note;
904 }
905
906 /**
907 * Delete a note from a submission.
908 *
909 * @param int $post_id
910 * @param int $index
911 * @param int $current_user_id
912 * @return bool
913 */
914 public static function DeleteNote($post_id, $index, $current_user_id)
915 {
916 $post = get_post($post_id);
917 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
918 return false;
919 }
920
921 $notes = get_post_meta($post_id, '_spb_form_notes', true);
922 if (!is_array($notes) || !isset($notes[$index])) {
923 return false;
924 }
925
926 // Only the note's author or users with manage_options can delete
927 $note = $notes[$index];
928 if (intval($note['author_id']) !== intval($current_user_id) && !current_user_can('manage_options')) {
929 return false;
930 }
931
932 array_splice($notes, $index, 1);
933 update_post_meta($post_id, '_spb_form_notes', $notes);
934
935 return true;
936 }
937
938 /**
939 * Get notes for a submission.
940 *
941 * @param int $post_id
942 * @return array
943 */
944 public static function GetNotes($post_id)
945 {
946 $notes = get_post_meta($post_id, '_spb_form_notes', true);
947 return is_array($notes) ? $notes : array();
948 }
949
950 /**
951 * Get the note count for a submission.
952 *
953 * @param int $post_id
954 * @return int
955 */
956 public static function GetNoteCount($post_id)
957 {
958 $notes = get_post_meta($post_id, '_spb_form_notes', true);
959 return is_array($notes) ? count($notes) : 0;
960 }
961
962 // ========================================
963 // Field Preferences
964 // ========================================
965
966 /**
967 * Save field preferences for a user/form combination.
968 *
969 * @param int $user_id
970 * @param string $form_id
971 * @param array $fields Array of field IDs
972 * @return bool
973 */
974 public static function SaveFieldPreference($user_id, $form_id, $fields)
975 {
976 $meta_key = '_spb_form_field_prefs_' . sanitize_key($form_id);
977 return update_user_meta(intval($user_id), $meta_key, array_map('sanitize_text_field', $fields)) !== false;
978 }
979
980 /**
981 * Get field preferences for a user/form combination.
982 *
983 * @param int $user_id
984 * @param string $form_id
985 * @return array|null Null if no preference saved
986 */
987 public static function GetFieldPreference($user_id, $form_id)
988 {
989 $meta_key = '_spb_form_field_prefs_' . sanitize_key($form_id);
990 $fields = get_user_meta(intval($user_id), $meta_key, true);
991 if (!is_array($fields) || empty($fields)) {
992 return null;
993 }
994 return $fields;
995 }
996
997 /**
998 * Hash IP for privacy-safe storage.
999 *
1000 * @return string
1001 */
1002 private static function HashIP()
1003 {
1004 $ip = '';
1005 if (isset($_SERVER['REMOTE_ADDR'])) {
1006 $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
1007 }
1008 return wp_hash($ip);
1009 }
1010 }
1011