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