PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.7
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.7
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 1 week ago class-form-captcha-handler.php 1 week ago class-form-controller.php 1 week ago class-form-email-config-check.php 1 week ago class-form-email-handler.php 1 week ago class-form-encryption.php 1 week ago class-form-exporter.php 1 week ago class-form-field-validator.php 1 week ago class-form-file-handler.php 1 week ago class-form-google-auth.php 1 week ago class-form-integration-handler.php 1 week ago class-form-math-parser.php 1 week ago class-form-permissions.php 1 week ago class-form-registry.php 1 week ago class-form-settings.php 1 week ago class-form-submission-cpt.php 1 week ago class-form-submission-handler.php 1 week ago
class-form-submission-handler.php
959 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 ),
476 );
477
478 $today_args = $base_args;
479 $today_args['date_query'] = array(
480 array(
481 'after' => $today . ' 00:00:00',
482 'inclusive' => true,
483 ),
484 );
485 $today_query = new \WP_Query($today_args);
486
487 $week_args = $base_args;
488 $week_args['date_query'] = array(
489 array(
490 'after' => $week_ago . ' 00:00:00',
491 'inclusive' => true,
492 ),
493 );
494 $week_query = new \WP_Query($week_args);
495
496 return array(
497 'total' => $count['total'],
498 'new' => $count['new'],
499 'today' => $today_query->found_posts,
500 'this_week' => $week_query->found_posts,
501 );
502 }
503
504 /**
505 * Check if recent submissions for a form all have admin email failures.
506 *
507 * Only returns true when there are at least $min_count submissions with
508 * email status tracked and ALL of them have admin email failures.
509 *
510 * @param string $form_id
511 * @param int $count Number of recent submissions to check
512 * @param int $min_count Minimum submissions required to trigger
513 * @return bool
514 */
515 public static function HasRecentEmailFailures($form_id, $count = 5, $min_count = 3)
516 {
517 $ids = get_posts(array(
518 'post_type' => FormSubmissionCPT::POST_TYPE,
519 'post_status' => 'publish',
520 'posts_per_page' => $count,
521 'orderby' => 'date',
522 'order' => 'DESC',
523 'fields' => 'ids',
524 'no_found_rows' => true,
525 'update_post_term_cache' => false,
526 // 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.
527 'meta_query' => array(
528 array(
529 'key' => '_spb_form_id',
530 'value' => sanitize_text_field($form_id),
531 ),
532 array(
533 'key' => '_spb_form_email_status',
534 'compare' => 'EXISTS',
535 ),
536 array(
537 'key' => '_spb_form_status',
538 'value' => 'spam',
539 'compare' => '!=',
540 ),
541 ),
542 ));
543
544 if (count($ids) < $min_count) {
545 return false;
546 }
547
548 foreach ($ids as $id) {
549 $status = get_post_meta($id, '_spb_form_email_status', true);
550 if (!is_array($status) || !isset($status['admin'])) {
551 continue;
552 }
553 if (!empty($status['admin']['sent'])) {
554 // At least one recent email succeeded — no systemic issue
555 return false;
556 }
557 }
558
559 return true;
560 }
561
562 // ========================================
563 // Spam Protection
564 // ========================================
565
566 /**
567 * Increment the spam counter for a form.
568 *
569 * @param string $form_id
570 * @return int New count
571 */
572 public static function IncrementSpamCount($form_id)
573 {
574 $option_key = '_spb_form_spam_count_' . sanitize_key($form_id);
575 $current = intval(get_option($option_key, 0));
576 $new_count = $current + 1;
577 update_option($option_key, $new_count, false);
578 return $new_count;
579 }
580
581 /**
582 * Get the spam counter for a form.
583 *
584 * @param string $form_id
585 * @return int
586 */
587 public static function GetSpamCount($form_id)
588 {
589 return intval(get_option('_spb_form_spam_count_' . sanitize_key($form_id), 0));
590 }
591
592 /**
593 * Store a spam submission.
594 * Text fields only -- file field values are stored as empty arrays.
595 *
596 * @param string $form_id
597 * @param array $fields
598 * @param string $spam_reason 'honeypot', 'captcha', or 'bot_detection'
599 * @return int|false Post ID on success, false on failure
600 */
601 public static function StoreSpam($form_id, $fields, $spam_reason)
602 {
603 // Strip file upload data -- store empty arrays for file fields
604 $text_fields = array();
605 foreach ($fields as $key => $value) {
606 if (is_array($value)) {
607 $text_fields[sanitize_text_field($key)] = array();
608 } else {
609 $text_fields[sanitize_text_field($key)] = $value;
610 }
611 }
612
613 $post_id = wp_insert_post(array(
614 'post_type' => FormSubmissionCPT::POST_TYPE,
615 'post_status' => 'publish',
616 'post_title' => sanitize_text_field($form_id) . ' - spam - ' . current_time('mysql'),
617 ));
618
619 if (is_wp_error($post_id)) {
620 return false;
621 }
622
623 update_post_meta($post_id, '_spb_form_id', sanitize_text_field($form_id));
624 update_post_meta($post_id, '_spb_form_fields', $text_fields);
625 update_post_meta($post_id, '_spb_form_ip', self::HashIP());
626 update_post_meta($post_id, '_spb_form_status', 'spam');
627 update_post_meta($post_id, '_spb_form_spam_reason', sanitize_text_field($spam_reason));
628
629 return $post_id;
630 }
631
632 /**
633 * Move a spam submission to regular submissions ("Not Spam" rescue).
634 * Does NOT trigger emails or integrations.
635 *
636 * @param int $post_id
637 * @return bool
638 */
639 public static function MarkNotSpam($post_id)
640 {
641 $post = get_post($post_id);
642 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
643 return false;
644 }
645 $current_status = get_post_meta($post_id, '_spb_form_status', true);
646 if ($current_status !== 'spam') {
647 return false;
648 }
649 update_post_meta($post_id, '_spb_form_status', 'new');
650 delete_post_meta($post_id, '_spb_form_spam_reason');
651 return true;
652 }
653
654 /**
655 * Get count of spam submissions for a form.
656 *
657 * @param string $form_id
658 * @return int
659 */
660 public static function GetSpamSubmissionCount($form_id)
661 {
662 $args = array(
663 'post_type' => FormSubmissionCPT::POST_TYPE,
664 'post_status' => 'publish',
665 'posts_per_page' => 1,
666 'no_found_rows' => false,
667 'update_post_meta_cache' => false,
668 'update_post_term_cache' => false,
669 // 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.
670 'meta_query' => array(
671 array(
672 'key' => '_spb_form_id',
673 'value' => sanitize_text_field($form_id),
674 ),
675 array(
676 'key' => '_spb_form_status',
677 'value' => 'spam',
678 ),
679 ),
680 );
681 $query = new \WP_Query($args);
682 return $query->found_posts;
683 }
684
685 // ========================================
686 // Spam Auto-Purge Cron
687 // ========================================
688
689 const SPAM_PURGE_HOOK = 'spb_form_spam_purge';
690
691 /**
692 * Schedule the daily spam purge cron if not already scheduled.
693 */
694 public static function ScheduleSpamPurge()
695 {
696 if (!wp_next_scheduled(self::SPAM_PURGE_HOOK)) {
697 wp_schedule_event(time(), 'daily', self::SPAM_PURGE_HOOK);
698 }
699 }
700
701 /**
702 * Unschedule the spam purge cron.
703 */
704 public static function UnscheduleSpamPurge()
705 {
706 $timestamp = wp_next_scheduled(self::SPAM_PURGE_HOOK);
707 if ($timestamp) {
708 wp_unschedule_event($timestamp, self::SPAM_PURGE_HOOK);
709 }
710 }
711
712 /**
713 * Purge spam submissions older than 30 days.
714 */
715 public static function PurgeOldSpam()
716 {
717 $cutoff = gmdate('Y-m-d H:i:s', strtotime('-30 days'));
718 $args = array(
719 'post_type' => FormSubmissionCPT::POST_TYPE,
720 'post_status' => 'publish',
721 'posts_per_page' => 100,
722 'no_found_rows' => true,
723 'fields' => 'ids',
724 // 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.
725 'meta_query' => array(
726 array(
727 'key' => '_spb_form_status',
728 'value' => 'spam',
729 ),
730 ),
731 'date_query' => array(
732 array(
733 'before' => $cutoff,
734 'inclusive' => false,
735 ),
736 ),
737 );
738
739 $ids = get_posts($args);
740 if (!empty($ids)) {
741 self::BulkDelete($ids);
742 }
743 }
744
745 // ========================================
746 // Data Retention Auto-Purge Cron
747 // ========================================
748
749 const RETENTION_PURGE_HOOK = 'spb_form_retention_purge';
750
751 /**
752 * Schedule the daily data retention purge cron if not already scheduled.
753 */
754 public static function ScheduleRetentionPurge()
755 {
756 if (!wp_next_scheduled(self::RETENTION_PURGE_HOOK)) {
757 wp_schedule_event(time(), 'daily', self::RETENTION_PURGE_HOOK);
758 }
759 }
760
761 /**
762 * Unschedule the data retention purge cron.
763 */
764 public static function UnscheduleRetentionPurge()
765 {
766 $timestamp = wp_next_scheduled(self::RETENTION_PURGE_HOOK);
767 if ($timestamp) {
768 wp_unschedule_event($timestamp, self::RETENTION_PURGE_HOOK);
769 }
770 }
771
772 /**
773 * Purge non-spam submissions older than the configured retention period.
774 */
775 public static function PurgeOldSubmissions()
776 {
777 $days = intval(get_option('superbaddons_form_data_retention', 0));
778 if ($days <= 0) {
779 return;
780 }
781
782 $cutoff = gmdate('Y-m-d H:i:s', strtotime('-' . $days . ' days'));
783 $args = array(
784 'post_type' => FormSubmissionCPT::POST_TYPE,
785 'post_status' => 'publish',
786 'posts_per_page' => 100,
787 'no_found_rows' => true,
788 'fields' => 'ids',
789 // 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.
790 'meta_query' => array(
791 array(
792 'key' => '_spb_form_status',
793 'value' => 'spam',
794 'compare' => '!=',
795 ),
796 ),
797 'date_query' => array(
798 array(
799 'before' => $cutoff,
800 'inclusive' => false,
801 ),
802 ),
803 );
804
805 $ids = get_posts($args);
806 if (!empty($ids)) {
807 self::BulkDelete($ids);
808 }
809 }
810
811 // ========================================
812 // Submission Notes
813 // ========================================
814
815 /**
816 * Add a note to a submission.
817 *
818 * @param int $post_id
819 * @param int $author_id
820 * @param string $author_name
821 * @param string $text
822 * @return array|false The new note on success, false on failure
823 */
824 public static function AddNote($post_id, $author_id, $author_name, $text)
825 {
826 $post = get_post($post_id);
827 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
828 return false;
829 }
830
831 $text = sanitize_textarea_field($text);
832 if (empty($text) || mb_strlen($text) > 1000) {
833 return false;
834 }
835
836 $notes = get_post_meta($post_id, '_spb_form_notes', true);
837 if (!is_array($notes)) {
838 $notes = array();
839 }
840
841 $note = array(
842 'author_id' => intval($author_id),
843 'author_name' => sanitize_text_field($author_name),
844 'date' => current_time('mysql', true),
845 'text' => $text,
846 );
847
848 $notes[] = $note;
849 update_post_meta($post_id, '_spb_form_notes', $notes);
850
851 return $note;
852 }
853
854 /**
855 * Delete a note from a submission.
856 *
857 * @param int $post_id
858 * @param int $index
859 * @param int $current_user_id
860 * @return bool
861 */
862 public static function DeleteNote($post_id, $index, $current_user_id)
863 {
864 $post = get_post($post_id);
865 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
866 return false;
867 }
868
869 $notes = get_post_meta($post_id, '_spb_form_notes', true);
870 if (!is_array($notes) || !isset($notes[$index])) {
871 return false;
872 }
873
874 // Only the note's author or users with manage_options can delete
875 $note = $notes[$index];
876 if (intval($note['author_id']) !== intval($current_user_id) && !current_user_can('manage_options')) {
877 return false;
878 }
879
880 array_splice($notes, $index, 1);
881 update_post_meta($post_id, '_spb_form_notes', $notes);
882
883 return true;
884 }
885
886 /**
887 * Get notes for a submission.
888 *
889 * @param int $post_id
890 * @return array
891 */
892 public static function GetNotes($post_id)
893 {
894 $notes = get_post_meta($post_id, '_spb_form_notes', true);
895 return is_array($notes) ? $notes : array();
896 }
897
898 /**
899 * Get the note count for a submission.
900 *
901 * @param int $post_id
902 * @return int
903 */
904 public static function GetNoteCount($post_id)
905 {
906 $notes = get_post_meta($post_id, '_spb_form_notes', true);
907 return is_array($notes) ? count($notes) : 0;
908 }
909
910 // ========================================
911 // Field Preferences
912 // ========================================
913
914 /**
915 * Save field preferences for a user/form combination.
916 *
917 * @param int $user_id
918 * @param string $form_id
919 * @param array $fields Array of field IDs
920 * @return bool
921 */
922 public static function SaveFieldPreference($user_id, $form_id, $fields)
923 {
924 $meta_key = '_spb_form_field_prefs_' . sanitize_key($form_id);
925 return update_user_meta(intval($user_id), $meta_key, array_map('sanitize_text_field', $fields)) !== false;
926 }
927
928 /**
929 * Get field preferences for a user/form combination.
930 *
931 * @param int $user_id
932 * @param string $form_id
933 * @return array|null Null if no preference saved
934 */
935 public static function GetFieldPreference($user_id, $form_id)
936 {
937 $meta_key = '_spb_form_field_prefs_' . sanitize_key($form_id);
938 $fields = get_user_meta(intval($user_id), $meta_key, true);
939 if (!is_array($fields) || empty($fields)) {
940 return null;
941 }
942 return $fields;
943 }
944
945 /**
946 * Hash IP for privacy-safe storage.
947 *
948 * @return string
949 */
950 private static function HashIP()
951 {
952 $ip = '';
953 if (isset($_SERVER['REMOTE_ADDR'])) {
954 $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
955 }
956 return wp_hash($ip);
957 }
958 }
959