upskill-event-manager/includes/class-hvac-custom-event-edit.php
Ben 3d1fbaa770 fix: Resolve trainer event edit permissions and initial styling
- Fixed permission check in canUserEditEvent() method to properly check user roles
- Changed from checking non-existent 'hvac_trainer' capability to in_array('hvac_trainer', $user->roles)
- Trainers can now create new events and edit their own events
- Security maintained: trainers cannot edit others' events
- Added initial CSS file to fix narrow width and navigation z-index issues
- Page now displays at proper 1200px max width matching other trainer pages
- Navigation menu no longer hidden under site header (z-index: 100)

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-18 20:19:50 -03:00

628 lines
No EOL
21 KiB
PHP

<?php
/**
* HVAC Custom Event Edit Form Handler
*
* Memory-efficient, type-safe event editing using modern PHP patterns
* Replaces JavaScript-dependent TEC shortcode with server-side solution
*
* @package HVAC_Community_Events
* @since 2.1.0
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
/**
* Custom event edit form handler with generator-based data loading
*/
final class HVAC_Custom_Event_Edit {
use HVAC_Singleton_Trait;
private const NONCE_ACTION = 'hvac_edit_event';
private const NONCE_FIELD = 'hvac_event_nonce';
private const CACHE_GROUP = 'hvac_events';
private const CACHE_TTL = 300; // 5 minutes
/**
* @var SplObjectStorage<WP_Post, ArrayObject> Event data cache
*/
private SplObjectStorage $eventCache;
/**
* @var ?int Current event ID being edited
*/
private ?int $currentEventId = null;
/**
* Constructor with property promotion
*/
private function __construct() {
$this->eventCache = new SplObjectStorage();
$this->initHooks();
}
/**
* Initialize WordPress hooks
*/
private function initHooks(): void {
add_action('init', [$this, 'registerRewriteRules']);
add_action('template_redirect', [$this, 'handleFormSubmission']);
add_filter('query_vars', [$this, 'addQueryVars']);
// Single template_include hook at appropriate priority
add_filter('template_include', [$this, 'loadTemplate'], 1000);
add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
}
/**
* Register rewrite rules for edit URL
*/
public function registerRewriteRules(): void {
add_rewrite_rule(
'^trainer/event/edit/?$',
'index.php?hvac_event_edit=1',
'top'
);
}
/**
* Add custom query vars
*/
public function addQueryVars(array $vars): array {
$vars[] = 'hvac_event_edit';
return $vars;
}
/**
* Load custom template for event edit page
*/
public function loadTemplate(string $template): string {
// Check if we're on the custom edit page
$is_edit_page = false;
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
// Method 1: Check by page ID (configuration-based approach)
if (defined('HVAC_EVENT_EDIT_PAGE_ID') && is_page(HVAC_EVENT_EDIT_PAGE_ID)) {
$is_edit_page = true;
}
// Method 2: Check URL path
elseif (strpos($request_uri, '/trainer/event/edit') !== false) {
$is_edit_page = true;
}
// Method 3: Check query var
elseif (get_query_var('hvac_event_edit') === '1') {
$is_edit_page = true;
}
if ($is_edit_page) {
$custom_template = HVAC_PLUGIN_DIR . 'templates/page-edit-event-custom.php';
if (file_exists($custom_template)) {
return $custom_template;
}
}
return $template;
}
/**
* Enqueue minimal required assets (no JavaScript!)
*/
public function enqueueAssets(): void {
if (!$this->isEditPage()) {
return;
}
wp_enqueue_style(
'hvac-event-edit-custom',
HVAC_PLUGIN_URL . 'assets/css/hvac-event-edit-custom.css',
[],
HVAC_PLUGIN_VERSION
);
}
/**
* Check if current page is event edit page
*/
private function isEditPage(): bool {
return get_query_var('hvac_event_edit') === '1'
|| (is_page() && get_page_template_slug() === 'templates/page-edit-event-custom.php');
}
/**
* Get event data using generator for memory efficiency
*
* @return Generator<string, mixed>
*/
public function getEventData(int $eventId): Generator {
// Check cache first
$cacheKey = "event_data_{$eventId}";
$cached = wp_cache_get($cacheKey, self::CACHE_GROUP);
if ($cached !== false && is_array($cached)) {
foreach ($cached as $key => $value) {
yield $key => $value;
}
return;
}
$event = get_post($eventId);
if (!$event || $event->post_type !== 'tribe_events') {
throw new InvalidArgumentException("Invalid event ID: {$eventId}");
}
// Core data
yield 'id' => $eventId;
yield 'title' => $event->post_title;
yield 'content' => $event->post_content;
yield 'excerpt' => $event->post_excerpt;
yield 'status' => $event->post_status;
yield 'author' => $event->post_author;
// Meta data - lazy load as needed
$metaKeys = $this->getEventMetaKeys();
foreach ($metaKeys as $key) {
yield $key => get_post_meta($eventId, $key, true);
}
// Venue data
$venueId = get_post_meta($eventId, '_EventVenueID', true);
if ($venueId) {
yield 'venue' => $this->getVenueData((int) $venueId);
}
// Organizer data
$organizerId = get_post_meta($eventId, '_EventOrganizerID', true);
if ($organizerId) {
yield 'organizer' => $this->getOrganizerData((int) $organizerId);
}
// Taxonomies
yield 'categories' => wp_get_post_terms($eventId, 'tribe_events_cat', ['fields' => 'ids']);
yield 'tags' => wp_get_post_terms($eventId, 'post_tag', ['fields' => 'ids']);
// Cache the data for future use (convert generator to array)
// Note: This is done after yielding to maintain generator efficiency
// The next call will use the cached version
$dataToCache = [];
$dataToCache['id'] = $eventId;
$dataToCache['title'] = $event->post_title;
$dataToCache['content'] = $event->post_content;
$dataToCache['excerpt'] = $event->post_excerpt;
$dataToCache['status'] = $event->post_status;
$dataToCache['author'] = $event->post_author;
foreach ($this->getEventMetaKeys() as $key) {
$dataToCache[$key] = get_post_meta($eventId, $key, true);
}
if ($venueId) {
$dataToCache['venue'] = $this->getVenueData((int) $venueId);
}
if ($organizerId) {
$dataToCache['organizer'] = $this->getOrganizerData((int) $organizerId);
}
$dataToCache['categories'] = wp_get_post_terms($eventId, 'tribe_events_cat', ['fields' => 'ids']);
$dataToCache['tags'] = wp_get_post_terms($eventId, 'post_tag', ['fields' => 'ids']);
wp_cache_set($cacheKey, $dataToCache, self::CACHE_GROUP, self::CACHE_TTL);
}
/**
* Get TEC event meta keys
*/
private function getEventMetaKeys(): array {
return [
'_EventStartDate',
'_EventEndDate',
'_EventStartDateUTC',
'_EventEndDateUTC',
'_EventTimezone',
'_EventTimezoneAbbr',
'_EventAllDay',
'_EventDuration',
'_EventCost',
'_EventCurrencySymbol',
'_EventCurrencyCode',
'_EventURL',
'_EventShowMap',
'_EventShowMapLink',
];
}
/**
* Get venue data efficiently
*/
private function getVenueData(int $venueId): ArrayObject {
$venue = get_post($venueId);
if (!$venue || $venue->post_type !== 'tribe_venue') {
return new ArrayObject();
}
return new ArrayObject([
'id' => $venueId,
'title' => $venue->post_title,
'address' => get_post_meta($venueId, '_VenueAddress', true),
'city' => get_post_meta($venueId, '_VenueCity', true),
'state' => get_post_meta($venueId, '_VenueStateProvince', true),
'zip' => get_post_meta($venueId, '_VenueZip', true),
'country' => get_post_meta($venueId, '_VenueCountry', true),
'phone' => get_post_meta($venueId, '_VenuePhone', true),
'url' => get_post_meta($venueId, '_VenueURL', true),
], ArrayObject::ARRAY_AS_PROPS);
}
/**
* Get organizer data efficiently
*/
private function getOrganizerData(int $organizerId): ArrayObject {
$organizer = get_post($organizerId);
if (!$organizer || $organizer->post_type !== 'tribe_organizer') {
return new ArrayObject();
}
return new ArrayObject([
'id' => $organizerId,
'title' => $organizer->post_title,
'phone' => get_post_meta($organizerId, '_OrganizerPhone', true),
'website' => get_post_meta($organizerId, '_OrganizerWebsite', true),
'email' => get_post_meta($organizerId, '_OrganizerEmail', true),
], ArrayObject::ARRAY_AS_PROPS);
}
/**
* Handle form submission with comprehensive validation
*/
public function handleFormSubmission(): void {
if (!$this->isEditPage() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
// Verify nonce
if (!isset($_POST[self::NONCE_FIELD]) ||
!wp_verify_nonce($_POST[self::NONCE_FIELD], self::NONCE_ACTION)) {
wp_die('Security check failed');
}
// Check capabilities
$eventId = isset($_POST['event_id']) ? (int) $_POST['event_id'] : 0;
if (!$this->canUserEditEvent($eventId)) {
wp_die('Insufficient permissions');
}
try {
$eventId = $this->saveEvent($_POST);
// Clear cache
wp_cache_delete("event_data_{$eventId}", self::CACHE_GROUP);
// Redirect with success message
wp_safe_redirect(add_query_arg([
'event_id' => $eventId,
'updated' => 'true'
], home_url('/trainer/event/edit/')));
exit;
} catch (Exception $e) {
// Log error and show message
error_log('Event save error: ' . $e->getMessage());
wp_die('Error saving event: ' . esc_html($e->getMessage()));
}
}
/**
* Save event with validation and sanitization
*/
private function saveEvent(array $data): int {
$eventId = isset($data['event_id']) ? (int) $data['event_id'] : 0;
// Prepare post data
$postData = [
'ID' => $eventId,
'post_type' => 'tribe_events',
'post_title' => sanitize_text_field($data['post_title'] ?? ''),
'post_content' => wp_kses_post($data['post_content'] ?? ''),
'post_excerpt' => sanitize_textarea_field($data['post_excerpt'] ?? ''),
'post_status' => in_array($data['post_status'] ?? '', ['publish', 'draft'])
? $data['post_status']
: 'draft',
];
// Validate required fields
if (empty($postData['post_title'])) {
throw new InvalidArgumentException('Event title is required');
}
// Insert or update
if ($eventId > 0) {
$result = wp_update_post($postData, true);
} else {
$postData['post_author'] = get_current_user_id();
$result = wp_insert_post($postData, true);
}
if (is_wp_error($result)) {
throw new RuntimeException($result->get_error_message());
}
$eventId = (int) $result;
// Update meta fields
$this->updateEventMeta($eventId, $data);
// Update venue
$this->updateVenue($eventId, $data);
// Update organizer
$this->updateOrganizer($eventId, $data);
// Update taxonomies
if (isset($data['event_categories'])) {
wp_set_post_terms($eventId, array_map('intval', $data['event_categories']), 'tribe_events_cat');
}
return $eventId;
}
/**
* Update event meta fields with validation
*/
private function updateEventMeta(int $eventId, array $data): void {
// Date/time fields
$startDate = $this->parseDateTime($data['EventStartDate'] ?? '', $data['EventStartTime'] ?? '');
$endDate = $this->parseDateTime($data['EventEndDate'] ?? '', $data['EventEndTime'] ?? '');
if ($startDate && $endDate) {
if ($endDate < $startDate) {
throw new InvalidArgumentException('End date must be after start date');
}
update_post_meta($eventId, '_EventStartDate', $startDate->format('Y-m-d H:i:s'));
update_post_meta($eventId, '_EventEndDate', $endDate->format('Y-m-d H:i:s'));
update_post_meta($eventId, '_EventStartDateUTC', get_gmt_from_date($startDate->format('Y-m-d H:i:s')));
update_post_meta($eventId, '_EventEndDateUTC', get_gmt_from_date($endDate->format('Y-m-d H:i:s')));
update_post_meta($eventId, '_EventDuration', $endDate->getTimestamp() - $startDate->getTimestamp());
}
// Other meta fields
$metaFields = [
'_EventAllDay' => isset($data['EventAllDay']) ? '1' : '0',
'_EventCost' => sanitize_text_field($data['EventCost'] ?? ''),
'_EventCurrencySymbol' => sanitize_text_field($data['EventCurrencySymbol'] ?? '$'),
'_EventURL' => esc_url_raw($data['EventURL'] ?? ''),
'_EventShowMap' => isset($data['EventShowMap']) ? '1' : '0',
'_EventShowMapLink' => isset($data['EventShowMapLink']) ? '1' : '0',
'_EventTimezone' => sanitize_text_field($data['EventTimezone'] ?? get_option('timezone_string')),
];
foreach ($metaFields as $key => $value) {
update_post_meta($eventId, $key, $value);
}
}
/**
* Parse date and time into DateTime object
*/
private function parseDateTime(string $date, string $time): ?DateTime {
if (empty($date)) {
return null;
}
$dateTimeStr = $date;
if (!empty($time)) {
$dateTimeStr .= ' ' . $time;
} else {
$dateTimeStr .= ' 00:00:00';
}
try {
return new DateTime($dateTimeStr, wp_timezone());
} catch (Exception $e) {
return null;
}
}
/**
* Update or create venue
*/
private function updateVenue(int $eventId, array $data): void {
$venueId = isset($data['venue_id']) ? (int) $data['venue_id'] : 0;
// Use existing venue
if ($venueId > 0 && isset($data['use_existing_venue'])) {
update_post_meta($eventId, '_EventVenueID', $venueId);
return;
}
// Create new venue if data provided
if (!empty($data['venue_name'])) {
$venueData = [
'post_type' => 'tribe_venue',
'post_title' => sanitize_text_field($data['venue_name']),
'post_status' => 'publish',
];
if ($venueId > 0) {
$venueData['ID'] = $venueId;
$venueId = wp_update_post($venueData);
} else {
$venueId = wp_insert_post($venueData);
}
if ($venueId && !is_wp_error($venueId)) {
// Update venue meta
update_post_meta($venueId, '_VenueAddress', sanitize_text_field($data['venue_address'] ?? ''));
update_post_meta($venueId, '_VenueCity', sanitize_text_field($data['venue_city'] ?? ''));
update_post_meta($venueId, '_VenueStateProvince', sanitize_text_field($data['venue_state'] ?? ''));
update_post_meta($venueId, '_VenueZip', sanitize_text_field($data['venue_zip'] ?? ''));
update_post_meta($venueId, '_VenueCountry', sanitize_text_field($data['venue_country'] ?? ''));
update_post_meta($venueId, '_VenuePhone', sanitize_text_field($data['venue_phone'] ?? ''));
update_post_meta($venueId, '_VenueURL', esc_url_raw($data['venue_url'] ?? ''));
update_post_meta($eventId, '_EventVenueID', $venueId);
}
}
}
/**
* Update or create organizer
*/
private function updateOrganizer(int $eventId, array $data): void {
$organizerId = isset($data['organizer_id']) ? (int) $data['organizer_id'] : 0;
// Use existing organizer
if ($organizerId > 0 && isset($data['use_existing_organizer'])) {
update_post_meta($eventId, '_EventOrganizerID', $organizerId);
return;
}
// Create new organizer if data provided
if (!empty($data['organizer_name'])) {
$organizerData = [
'post_type' => 'tribe_organizer',
'post_title' => sanitize_text_field($data['organizer_name']),
'post_status' => 'publish',
];
if ($organizerId > 0) {
$organizerData['ID'] = $organizerId;
$organizerId = wp_update_post($organizerData);
} else {
$organizerId = wp_insert_post($organizerData);
}
if ($organizerId && !is_wp_error($organizerId)) {
// Update organizer meta
update_post_meta($organizerId, '_OrganizerPhone', sanitize_text_field($data['organizer_phone'] ?? ''));
update_post_meta($organizerId, '_OrganizerWebsite', esc_url_raw($data['organizer_website'] ?? ''));
update_post_meta($organizerId, '_OrganizerEmail', sanitize_email($data['organizer_email'] ?? ''));
update_post_meta($eventId, '_EventOrganizerID', $organizerId);
}
}
}
/**
* Check if user can edit event
*/
public function canUserEditEvent(int $eventId): bool {
if (!is_user_logged_in()) {
return false;
}
$user = wp_get_current_user();
$userId = $user->ID;
// New event - check if user has the capability to create events
if ($eventId === 0) {
// Check for The Events Calendar capabilities or trainer role
return current_user_can('edit_tribe_events') ||
current_user_can('publish_tribe_events') ||
in_array('hvac_trainer', $user->roles) ||
in_array('hvac_master_trainer', $user->roles) ||
in_array('administrator', $user->roles);
}
// Existing event - validate ownership and permissions
$event = get_post($eventId);
if (!$event || $event->post_type !== 'tribe_events') {
return false;
}
// Check ownership FIRST - owners can always edit their own events
if ($event->post_author == $userId) {
return true;
}
// Administrators can edit any event
if (in_array('administrator', $user->roles)) {
return true;
}
// Master trainers can edit any event
if (in_array('hvac_master_trainer', $user->roles)) {
return true;
}
// Non-owners need special permissions to edit others' events
return current_user_can('edit_others_tribe_events') ||
current_user_can('edit_others_posts');
}
/**
* Get all venues for dropdown (using generator)
*
* @return Generator<int, string>
*/
public function getVenuesForDropdown(): Generator {
$venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => 100, // Limit to prevent memory issues
'orderby' => 'title',
'order' => 'ASC',
'author' => get_current_user_id(),
]);
foreach ($venues as $venue) {
yield $venue->ID => $venue->post_title;
}
}
/**
* Get all organizers for dropdown (using generator)
*
* @return Generator<int, string>
*/
public function getOrganizersForDropdown(): Generator {
$organizers = get_posts([
'post_type' => 'tribe_organizer',
'posts_per_page' => 100, // Limit to prevent memory issues
'orderby' => 'title',
'order' => 'ASC',
'author' => get_current_user_id(),
]);
foreach ($organizers as $organizer) {
yield $organizer->ID => $organizer->post_title;
}
}
/**
* Get event categories for checkboxes
*
* @return Generator<int, string>
*/
public function getCategoriesForCheckboxes(): Generator {
$categories = get_terms([
'taxonomy' => 'tribe_events_cat',
'hide_empty' => false,
]);
if (!is_wp_error($categories)) {
foreach ($categories as $category) {
yield $category->term_id => $category->name;
}
}
}
}
/**
* Singleton trait for memory efficiency
*/
trait HVAC_Singleton_Trait {
private static ?self $instance = null;
public static function instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
private function __clone() {}
public function __wakeup() {
throw new Exception("Cannot unserialize singleton");
}
}