id, '_page_pcwp-attendees')) { wp_enqueue_style('pcwp-attendees', PCWP_URL . 'assets/attendees.css', [], PCWP_VERSION); } }); add_action('admin_post_pcwp_rsvp_admin', [$this, 'adminAction']); add_action('updated_post_meta', [$this, 'changed'], 50, 4); add_action('added_post_meta', [$this, 'changed'], 50, 4); add_action('deleted_post_meta', [$this, 'changed'], 50, 4); add_action('pcwp_rsvp_notify', [$this, 'notify']); add_action('pcwp_rsvp_reconcile', [$this, 'reconcile']); add_action('pcwp_rsvp_reconcile_batch', [$this, 'reconcile']); add_action('init', static function (): void { if (! wp_next_scheduled('pcwp_rsvp_reconcile')) { wp_schedule_event(time() + 30, 'hourly', 'pcwp_rsvp_reconcile'); } }); add_filter('wp_privacy_personal_data_exporters', [$this, 'exporters']); add_filter('wp_privacy_personal_data_erasers', [$this, 'erasers']); add_action('admin_init', static function (): void { if (function_exists('wp_add_privacy_policy_content')) { wp_add_privacy_policy_content('ProEventsWP', __('RSVP registrations store your name, email, optional WordPress account ID, event, registration status and confirmation delivery status on this site. Confirmation emails contain a private cancellation link. Site administrators can manage attendees. WordPress personal data export and erasure tools cover registration records.', 'proeventswp')); } }); } public function block(): void { register_block_type(PCWP_PATH . 'blocks/event-rsvp', ['style' => 'pcwp-calendar-blocks', 'render_callback' => function (array $attrs, string $content, \WP_Block $block): string { $id = absint($block->context['postId'] ?? $attrs['eventId'] ?? get_the_ID()); if (! doing_filter('the_content') && is_singular('pcwp_event') && has_block('pro-calendar-wp/event-rsvp', get_post($id))) { return ''; } return $this->render(['event_id' => $id]); }]); } public function routes(): void { register_rest_route('pro-calendar-wp/v1', '/events/(?P\d+)/rsvps', ['methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => function (\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $check = $this->validate((int) $request['id'], (string) $request['nonce'], (string) $request['website']); if (is_wp_error($check)) { return $check; } $result = (new RsvpService())->register((int) $request['id'], (string) $request['name'], (string) $request['email']); $result = $this->publicResult((int) $request['id'], $result); // Never return attendee IDs or disclose whether this email was already registered. return is_wp_error($result) ? $result : new \WP_REST_Response(['message' => __('Your registration is confirmed. Check your email for event details.', 'proeventswp')]); }]); } /** * @param array{id:int,duplicate:bool}|\WP_Error $result * @return array{id:int,duplicate:bool}|\WP_Error */ private function publicResult(int $id, array|\WP_Error $result): array|\WP_Error { $capacity = absint(get_post_meta($id, '_pcwp_rsvp_capacity', true)); // A full event must give the same public response for existing and new emails. if (! is_wp_error($result) && $result['duplicate'] && $capacity > 0 && RsvpService::activeCount($id) >= $capacity) { return new \WP_Error('pcwp_rsvp_full', __('This event is fully booked.', 'proeventswp'), ['status' => 409]); } return $result; } private function validate(int $id, string $nonce, string $honeypot): bool|\WP_Error { if ($honeypot !== '' || ! wp_verify_nonce($nonce, 'pcwp_rsvp_' . $id)) { return new \WP_Error('pcwp_rsvp_request', __('Reload the page and try again.', 'proeventswp'), ['status' => 403]); } $key = 'pcwp_rsvp_rate_' . hash_hmac('sha256', sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')), wp_salt('nonce')); $count = (int) get_transient($key); if ($count >= 10) { return new \WP_Error('pcwp_rsvp_rate', __('Please wait a minute before trying again.', 'proeventswp'), ['status' => 429]); } set_transient($key, $count + 1, MINUTE_IN_SECONDS); return true; } /** @param array $attrs */ public function render(array $attrs = []): string { $id = absint($attrs['event_id'] ?? get_the_ID()); if (! \ProCalendarWP\Events\EventVisibility::isPublic($id) || ! get_post_meta($id, '_pcwp_rsvp_enabled', true)) { return ''; } $event = \ProCalendarWP\Plugin::instance()->events()->find($id); if ($event === null) { return ''; } wp_enqueue_style('pcwp-calendar-blocks'); $feedbackKey = sanitize_key(wp_unslash($_GET['pcwp_rsvp_result'] ?? '')); $feedback = $feedbackKey !== '' ? get_transient('pcwp_rsvp_result_' . $feedbackKey) : false; $feedback = is_array($feedback) && (int) $feedback['event_id'] === $id ? $feedback : []; $notice = isset($feedback['message']) ? '

' . esc_html($feedback['message']) . '

' : ''; $capacity = absint(get_post_meta($id, '_pcwp_rsvp_capacity', true)); $full = $capacity > 0 && RsvpService::activeCount($id) >= $capacity; if ($full || in_array($event->status, ['canceled', 'postponed'], true) || $event->endLocal->getTimestamp() < time()) { return '
' . $notice . '

' . esc_html($full ? __('Fully booked', 'proeventswp') : __('Registration closed', 'proeventswp')) . '

'; } $user = wp_get_current_user(); ob_start(); ?>

validate($id, sanitize_text_field(wp_unslash($_POST['pcwp_rsvp_nonce'] ?? '')), sanitize_text_field(wp_unslash($_POST['website'] ?? ''))); $result = is_wp_error($valid) ? $valid : (new RsvpService())->register($id, sanitize_text_field(wp_unslash($_POST['name'] ?? '')), sanitize_email(wp_unslash($_POST['email'] ?? ''))); $result = $this->publicResult($id, $result); $key = strtolower(wp_generate_password(40, false, false)); $feedback = ['event_id' => $id, 'message' => is_wp_error($result) ? $result->get_error_message() : __('Registration confirmed. Check your email for event details.', 'proeventswp')]; if (is_wp_error($result)) { $feedback['name'] = sanitize_text_field(wp_unslash($_POST['name'] ?? '')); $feedback['email'] = sanitize_email(wp_unslash($_POST['email'] ?? '')); } set_transient('pcwp_rsvp_result_' . $key, $feedback, 5 * MINUTE_IN_SECONDS); wp_safe_redirect(add_query_arg('pcwp_rsvp_result', $key, get_permalink($id) ?: home_url('/'))); exit; } public function landing(): void { if (is_singular('pcwp_event') && get_post_meta(get_queried_object_id(), '_pcwp_rsvp_enabled', true)) { if (! defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); } nocache_headers(); } if (! isset($_GET['pcwp_rsvp_cancel'])) { return; } $id = absint($_GET['pcwp_rsvp_cancel']); $token = sanitize_text_field(wp_unslash($_GET['token'] ?? '')); $row = (new RsvpService())->row($id); nocache_headers(); header('Referrer-Policy: no-referrer'); if ($row === null || ! hash_equals((string) $row['token_hash'], hash('sha256', $token))) { wp_die(esc_html__('This cancellation link is invalid.', 'proeventswp'), '', ['response' => 403]); } wp_enqueue_style('pcwp-calendar-blocks'); $this->pageStart(__('Cancel your registration?', 'proeventswp')); echo '

' . esc_html__('Cancel your registration?', 'proeventswp') . '

' . esc_html(get_the_title((int) $row['event_id'])) . '

'; wp_nonce_field('pcwp_cancel_' . $id); echo '' . esc_html__('Keep my registration', 'proeventswp') . '
'; $this->pageEnd(); exit; } public function cancel(): void { $id = absint($_POST['id'] ?? 0); check_admin_referer('pcwp_cancel_' . $id); if (! (new RsvpService())->cancellation($id, sanitize_text_field(wp_unslash($_POST['token'] ?? '')))) { wp_die(esc_html__('Your registration could not be cancelled. Please try again.', 'proeventswp')); } $row = (new RsvpService())->row($id); wp_enqueue_style('pcwp-calendar-blocks'); nocache_headers(); $this->pageStart(__('Registration cancelled', 'proeventswp')); echo '

' . esc_html__('Registration cancelled', 'proeventswp') . '

' . esc_html__('Your place has been released.', 'proeventswp') . '

' . esc_html__('Back to the event', 'proeventswp') . '
'; $this->pageEnd(); exit; } private function pageStart(string $title): void { header('X-Robots-Tag: noindex, nofollow'); add_filter('pre_get_document_title', static fn (): string => $title . ' · ' . get_bloginfo('name')); echo ''; wp_head(); echo ''; wp_body_open(); echo '
' . esc_html(get_bloginfo('name')) . '
'; } private function pageEnd(): void { wp_footer(); echo ''; } public function menu(): void { add_submenu_page('edit.php?post_type=pcwp_event', __('Attendees', 'proeventswp'), __('Attendees', 'proeventswp'), 'manage_pcwp_attendees', 'pcwp-attendees', [$this, 'attendees']); } public function attendees(): void { if (! current_user_can('manage_pcwp_attendees')) { return; } global $wpdb; $search = sanitize_text_field(wp_unslash($_GET['s'] ?? '')); $page = max(1, absint($_GET['paged'] ?? 1)); $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$wpdb->prefix}pcwp_rsvps WHERE name LIKE %s OR email LIKE %s ORDER BY id DESC LIMIT 50 OFFSET %d", '%' . $wpdb->esc_like($search) . '%', '%' . $wpdb->esc_like($search) . '%', ($page - 1) * 50), ARRAY_A); $totals = $wpdb->get_row($wpdb->prepare("SELECT COUNT(*) total, SUM(status='confirmed') confirmed, SUM(status='cancelled') cancelled, SUM(status='confirmed' AND mail_status='failed') failed FROM {$wpdb->prefix}pcwp_rsvps WHERE name LIKE %s OR email LIKE %s", '%' . $wpdb->esc_like($search) . '%', '%' . $wpdb->esc_like($search) . '%'), ARRAY_A); // translators: 1: confirmed registrations, 2: cancelled registrations, 3: failed confirmation emails. echo '

' . esc_html__('Event attendees', 'proeventswp') . '

' . esc_html__('Manage registrations, cancellations and confirmation delivery.', 'proeventswp') . '

' . esc_html(sprintf(__('Confirmed: %1$d · Cancelled: %2$d · Email retries needed: %3$d', 'proeventswp'), (int) $totals['confirmed'], (int) $totals['cancelled'], (int) $totals['failed'])) . '

'; if (isset($_GET['pcwp_attendee_notice'])) { echo '

' . esc_html($_GET['pcwp_attendee_notice'] === 'sent' ? __('Confirmation sent.', 'proeventswp') : ($_GET['pcwp_attendee_notice'] === 'cancelled' ? __('Registration cancelled.', 'proeventswp') : __('Email delivery failed. The registration is preserved; use Resend to retry.', 'proeventswp'))) . '

'; } echo '
'; foreach ($rows as $row) { if (! current_user_can('edit_post', (int) $row['event_id'])) { continue; } echo ''; } if ($rows === []) { echo ''; } echo '
' . esc_html__('Name', 'proeventswp') . '' . esc_html__('Email', 'proeventswp') . '' . esc_html__('Event', 'proeventswp') . '' . esc_html__('Status', 'proeventswp') . '' . esc_html__('Confirmation', 'proeventswp') . '' . esc_html__('Actions', 'proeventswp') . '
' . esc_html($row['name']) . '' . esc_html($row['email']) . '' . esc_html(get_the_title((int) $row['event_id'])) . '' . esc_html($row['status']) . '' . esc_html($row['mail_status']) . '
'; wp_nonce_field('pcwp_attendee_' . $row['id']); if ($row['status'] === 'confirmed') { echo ' '; } echo '
' . esc_html__('No registrations found.', 'proeventswp') . '

'; if ($page > 1) { echo '' . esc_html__('Previous', 'proeventswp') . ' '; } if ($page * 50 < (int) $totals['total']) { echo '' . esc_html__('Next', 'proeventswp') . ''; } echo '

'; } public function adminAction(): void { $id = absint($_POST['id'] ?? 0); check_admin_referer('pcwp_attendee_' . $id); $service = new RsvpService(); $row = $service->row($id); if ($row === null || ! current_user_can('manage_pcwp_attendees') || ! current_user_can('edit_post', (int) $row['event_id'])) { wp_die(esc_html__('You cannot manage this registration.', 'proeventswp')); } $notice = 'failed'; if (($_POST['operation'] ?? '') === 'cancel') { $service->cancellation($id, '', true); $notice = 'cancelled'; } elseif (($_POST['operation'] ?? '') === 'resend') { $notice = $service->confirmation($id) ? 'sent' : 'failed'; } wp_safe_redirect(add_query_arg('pcwp_attendee_notice', $notice, admin_url('edit.php?post_type=pcwp_event&page=pcwp-attendees'))); exit; } /** @param int|array $metaId */ public function changed(int|array $metaId, int $eventId, string $key, mixed $value): void { unset($metaId, $value); if (get_post_type($eventId) !== 'pcwp_event' || ! in_array($key, ['_pcwp_start', '_pcwp_end', '_pcwp_status', '_pcwp_timezone', '_pcwp_schedule', '_pcwp_recurrence_time'], true)) { return; } if (RsvpService::activeCount($eventId) > 0 && ! wp_next_scheduled('pcwp_rsvp_notify', [$eventId])) { wp_schedule_single_event(time() + 30, 'pcwp_rsvp_notify', [$eventId]); } } public function reconcile(int $after = 0): void { global $wpdb; $ids = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT event_id FROM {$wpdb->prefix}pcwp_rsvps WHERE event_id>%d AND status='confirmed' AND email<>'' ORDER BY event_id LIMIT 50", $after)); foreach ($ids as $id) { $id = (int) $id; $dirty = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$wpdb->prefix}pcwp_rsvps WHERE event_id=%d AND status='confirmed' AND email<>'' AND schedule_hash<>%s LIMIT 1", $id, RsvpService::scheduleHash($id))); if ($dirty && ! wp_next_scheduled('pcwp_rsvp_notify', [$id])) { wp_schedule_single_event(time() + 1, 'pcwp_rsvp_notify', [$id]); } } if (count($ids) === 50) { wp_schedule_single_event(time() + 10, 'pcwp_rsvp_reconcile_batch', [(int) end($ids)]); } } public function notify(int $eventId): void { global $wpdb; $hash = RsvpService::scheduleHash($eventId); $ids = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$wpdb->prefix}pcwp_rsvps WHERE event_id=%d AND status='confirmed' AND email<>'' AND schedule_hash<>%s LIMIT 50", $eventId, $hash)); foreach ($ids as $id) { $wpdb->update($wpdb->prefix . 'pcwp_rsvps', ['schedule_hash' => $hash], ['id' => (int) $id]); (new RsvpService())->confirmation((int) $id, '', true); } if (count($ids) === 50 && ! wp_next_scheduled('pcwp_rsvp_notify', [$eventId])) { wp_schedule_single_event(time() + 60, 'pcwp_rsvp_notify', [$eventId]); } } /** * @param array $items * @return array */ public function exporters(array $items): array { $items['proeventswp-rsvps'] = ['exporter_friendly_name' => 'ProEventsWP registrations', 'callback' => [$this, 'export']]; return $items; } /** @return array */ public function export(string $email, int $page = 1): array { global $wpdb; $rows = $wpdb->get_results($wpdb->prepare("SELECT id,event_id,name,email,status,created_at FROM {$wpdb->prefix}pcwp_rsvps WHERE email=%s ORDER BY id LIMIT 100 OFFSET %d", strtolower($email), (max(1, $page) - 1) * 100), ARRAY_A); $data = []; foreach ($rows as $row) { $fields = []; foreach ($row as $name => $value) { $fields[] = ['name' => $name, 'value' => (string) $value]; } $data[] = ['group_id' => 'proeventswp-rsvps', 'group_label' => __('Event registrations', 'proeventswp'), 'item_id' => 'rsvp-' . $row['id'], 'data' => $fields]; } return ['data' => $data, 'done' => count($rows) < 100]; } /** * @param array $items * @return array */ public function erasers(array $items): array { $items['proeventswp-rsvps'] = ['eraser_friendly_name' => 'ProEventsWP registrations', 'callback' => [$this, 'erase']]; return $items; } /** @return array */ public function erase(string $email, int $page = 1): array { unset($page); global $wpdb; $removed = $wpdb->update($wpdb->prefix . 'pcwp_rsvps', ['name' => '', 'email' => '', 'email_hash' => null, 'user_id' => 0, 'token_hash' => ''], ['email' => strtolower($email)]); return ['items_removed' => (bool) $removed, 'items_retained' => false, 'messages' => [], 'done' => true]; } }