PluginProbe ʕ •ᴥ•ʔ
Event Tickets with Ticket Scanner / 3.1.2
Event Tickets with Ticket Scanner v3.1.2
3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.4 trunk 2.6.0 2.7.0 2.7.1 2.7.10 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.10 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 2.9.8 2.9.9 3.0.0 3.0.1 3.0.2 3.0.3
event-tickets-with-ticket-scanner / includes / seating / class-seating-block.php
event-tickets-with-ticket-scanner / includes / seating Last commit date
class-seating-admin.php 1 week ago class-seating-base.php 1 week ago class-seating-block.php 1 week ago class-seating-frontend.php 1 week ago class-seating-plan.php 1 week ago class-seating-seat.php 1 week ago
class-seating-block.php
921 lines
1 <?php
2 /**
3 * Seating Block (Semaphore) Class
4 *
5 * Handles seat blocking/reservation logic with database transactions.
6 *
7 * @package Event_Tickets_With_Ticket_Scanner
8 * @subpackage Seating
9 * @since 2.8.0
10 */
11
12 // Exit if accessed directly
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 require_once __DIR__ . '/class-seating-base.php';
18
19 /**
20 * Seating Block Manager
21 *
22 * Provides seat blocking/semaphore functionality using database transactions.
23 *
24 * @since 2.8.0
25 */
26 class sasoEventtickets_Seating_Block extends sasoEventtickets_Seating_Base {
27
28 /**
29 * Table name
30 *
31 * @var string
32 */
33 private string $table = 'seat_blocks';
34
35 /**
36 * Get meta object structure for seat blocks
37 *
38 * @return array Meta object structure with all defaults
39 */
40 public function getMetaObject(): array {
41 $metaObj = [
42 'block_type' => self::BLOCK_TYPE_SESSION,
43 'user_id' => 0,
44 'order_item_id' => null,
45 'confirmed_at' => null
46 ];
47
48 // Premium hook - can add fields without basic plugin update
49 if ($this->MAIN->isPremium() && method_exists($this->MAIN->getPremiumFunctions(), 'getSeatingBlockMetaObject')) {
50 $metaObj = $this->MAIN->getPremiumFunctions()->getSeatingBlockMetaObject($metaObj);
51 }
52
53 return $metaObj;
54 }
55
56 /**
57 * Block a seat (temporary reservation)
58 *
59 * Uses database transaction with SELECT FOR UPDATE to prevent race conditions.
60 *
61 * @param int $seatId Seat ID
62 * @param int $productId WooCommerce product ID
63 * @param string $sessionId WC session ID
64 * @param string|null $eventDate Event date (Y-m-d) or null for general
65 * @return array Result: ['success' => bool, 'block_id' => int, 'error' => string, 'extended' => bool]
66 */
67 public function blockSeat(int $seatId, int $productId, string $sessionId, ?string $eventDate = null): array {
68 global $wpdb;
69
70 // Validate IDs
71 if ($seatId <= 0) {
72 $this->MAIN->getDB()->logError('blockSeat: Invalid seat ID: ' . $seatId);
73 return ['success' => false, 'error' => 'invalid_seat'];
74 }
75 if ($productId <= 0) {
76 $this->MAIN->getDB()->logError('blockSeat: Invalid product ID: ' . $productId);
77 return ['success' => false, 'error' => 'invalid_product'];
78 }
79 if (empty($sessionId)) {
80 $this->MAIN->getDB()->logError('blockSeat: Empty session ID');
81 return ['success' => false, 'error' => 'invalid_session'];
82 }
83
84 // Get seat info
85 $seat = $wpdb->get_row(
86 $wpdb->prepare(
87 "SELECT s.*, sp.id as plan_id FROM {$this->getTable('seats')} s
88 JOIN {$this->getTable('seatingplans')} sp ON s.seatingplan_id = sp.id
89 WHERE s.id = %d AND s.aktiv = 1",
90 $seatId
91 ),
92 ARRAY_A
93 );
94
95 if (!$seat) {
96 return ['success' => false, 'error' => 'seat_not_found'];
97 }
98
99 $planId = (int) $seat['seatingplan_id'];
100 $timeoutMinutes = $this->getBlockTimeout();
101 // Use WordPress timezone (current_time) to match DB comparisons
102 $expiresAt = date('Y-m-d H:i:s', current_time('timestamp') + ($timeoutMinutes * 60));
103
104 // Start transaction
105 $wpdb->query('START TRANSACTION');
106
107 try {
108 // Lock the rows we're checking (prevent race conditions)
109 // Check if seat is already blocked/confirmed for this product/date
110 $existingBlock = $wpdb->get_row(
111 $wpdb->prepare(
112 "SELECT * FROM {$this->getTable($this->table)}
113 WHERE seat_id = %d
114 AND product_id = %d
115 AND (event_date = %s OR event_date IS NULL)
116 AND status IN (%s, %s)
117 FOR UPDATE",
118 $seatId,
119 $productId,
120 $eventDate,
121 self::STATUS_BLOCKED,
122 self::STATUS_CONFIRMED
123 ),
124 ARRAY_A
125 );
126
127 // If block exists for same session
128 if ($existingBlock && $existingBlock['session_id'] === $sessionId && $existingBlock['status'] === self::STATUS_BLOCKED) {
129 // Check if expired - if so, delete and create new block with full time
130 if (strtotime($existingBlock['expires_at']) < current_time('timestamp')) {
131 $wpdb->delete(
132 $this->getTable($this->table),
133 ['id' => $existingBlock['id']],
134 ['%d']
135 );
136 $existingBlock = null; // Clear so we fall through to create new block
137 } else {
138 // Not expired - return existing block (don't extend time - that would be unfair)
139 $wpdb->query('COMMIT');
140 return [
141 'success' => true,
142 'block_id' => (int) $existingBlock['id'],
143 'existing' => true,
144 'expires_at' => $existingBlock['expires_at']
145 ];
146 }
147 }
148
149 // If block exists for different session or is confirmed, seat unavailable
150 if ($existingBlock) {
151 // Check if expired (cleanup might not have run yet)
152 if ($existingBlock['status'] === self::STATUS_BLOCKED && strtotime($existingBlock['expires_at']) < time()) {
153 // Expired block, delete it and continue
154 $wpdb->delete(
155 $this->getTable($this->table),
156 ['id' => $existingBlock['id']],
157 ['%d']
158 );
159 } else {
160 $wpdb->query('ROLLBACK');
161 return [
162 'success' => false,
163 'error' => 'seat_unavailable',
164 'blocked_by' => $existingBlock['status'] === self::STATUS_CONFIRMED ? 'order' : 'session'
165 ];
166 }
167 }
168
169 // Create new block
170 $result = $wpdb->insert(
171 $this->getTable($this->table),
172 [
173 'time' => current_time('mysql'),
174 'timezone' => wp_timezone_string(),
175 'seat_id' => $seatId,
176 'seatingplan_id' => $planId,
177 'product_id' => $productId,
178 'event_date' => $eventDate,
179 'session_id' => $sessionId,
180 'order_id' => null,
181 'code_id' => null,
182 'expires_at' => $expiresAt,
183 'last_seen' => current_time('mysql'), // Initialize last_seen on creation
184 'status' => self::STATUS_BLOCKED,
185 'meta' => $this->MAIN->getCore()->json_encode_with_error_handling([
186 'block_type' => self::BLOCK_TYPE_SESSION,
187 'user_id' => get_current_user_id()
188 ])
189 ],
190 ['%s', '%s', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s']
191 );
192
193 if ($result === false) {
194 $wpdb->query('ROLLBACK');
195 return ['success' => false, 'error' => 'db_error', 'message' => $wpdb->last_error];
196 }
197
198 $blockId = $wpdb->insert_id;
199 $wpdb->query('COMMIT');
200
201 return [
202 'success' => true,
203 'block_id' => $blockId,
204 'extended' => false,
205 'expires_at' => $expiresAt
206 ];
207
208 } catch (Exception $e) {
209 $wpdb->query('ROLLBACK');
210 return ['success' => false, 'error' => 'exception', 'message' => $e->getMessage()];
211 }
212 }
213
214 /**
215 * Release a blocked seat (cancel reservation)
216 *
217 * @param int $blockId Block ID
218 * @param string $sessionId Session ID (must match)
219 * @return bool Success
220 */
221 public function releaseBlock(int $blockId, string $sessionId): bool {
222 global $wpdb;
223
224 // Only release if session matches and status is blocked
225 $result = $wpdb->delete(
226 $this->getTable($this->table),
227 [
228 'id' => $blockId,
229 'session_id' => $sessionId,
230 'status' => self::STATUS_BLOCKED
231 ],
232 ['%d', '%s', '%s']
233 );
234
235 return $result > 0;
236 }
237
238 /**
239 * Release all blocks for a session
240 *
241 * @param string $sessionId Session ID
242 * @return int Number of released blocks
243 */
244 public function releaseAllForSession(string $sessionId): int {
245 global $wpdb;
246
247 return $wpdb->delete(
248 $this->getTable($this->table),
249 [
250 'session_id' => $sessionId,
251 'status' => self::STATUS_BLOCKED
252 ],
253 ['%s', '%s']
254 );
255 }
256
257 /**
258 * Get active blocks for a session
259 *
260 * Returns all non-expired blocked seats for a session, optionally filtered by product/date.
261 * Useful for restoring user's selection on page reload.
262 *
263 * @param string $sessionId Session ID
264 * @param int|null $productId Product ID filter (optional)
265 * @param string|null $eventDate Event date filter (optional)
266 * @return array Array of block records with seat info
267 */
268 public function getSessionBlocks(string $sessionId, ?int $productId = null, ?string $eventDate = null): array {
269 global $wpdb;
270
271 $blocksTable = $this->getTable($this->table);
272 $seatsTable = $this->getTable('seats');
273 $now = current_time('mysql');
274
275 $sql = $wpdb->prepare(
276 "SELECT b.*, s.seat_identifier, s.meta as seat_meta
277 FROM {$blocksTable} b
278 LEFT JOIN {$seatsTable} s ON b.seat_id = s.id
279 WHERE b.session_id = %s
280 AND b.status = %s
281 AND b.expires_at > %s",
282 $sessionId,
283 self::STATUS_BLOCKED,
284 $now
285 );
286
287 if ($productId !== null) {
288 $sql .= $wpdb->prepare(" AND b.product_id = %d", $productId);
289 }
290
291 if ($eventDate !== null) {
292 $sql .= $wpdb->prepare(" AND b.event_date = %s", $eventDate);
293 }
294
295 $results = $wpdb->get_results($sql, ARRAY_A);
296
297 // Parse seat meta to get label
298 foreach ($results as &$row) {
299 $seatMeta = !empty($row['seat_meta']) ? json_decode($row['seat_meta'], true) : [];
300 $row['seat_label'] = $seatMeta['label'] ?? $row['seat_identifier'] ?? ('Seat ' . $row['seat_id']);
301 $row['seat_category'] = $seatMeta['category'] ?? '';
302 $row['seat_desc'] = $seatMeta['seat_desc'] ?? '';
303 }
304
305 return $results ?: [];
306 }
307
308 /**
309 * Update last_seen timestamp for seat blocks (heartbeat keep-alive)
310 *
311 * Used by WordPress heartbeat to track if user is still active.
312 * Only updates blocks that belong to the given session.
313 *
314 * @param array $blockIds Array of block IDs
315 * @param string $sessionId Session ID (must match)
316 * @return int Number of updated blocks
317 */
318 public function updateLastSeen(array $blockIds, string $sessionId): int {
319 global $wpdb;
320
321 if (empty($blockIds) || empty($sessionId)) {
322 return 0;
323 }
324
325 // Filter to integers
326 $blockIds = array_map('intval', $blockIds);
327 $blockIds = array_filter($blockIds, function($id) { return $id > 0; });
328
329 if (empty($blockIds)) {
330 return 0;
331 }
332
333 // Update last_seen for these blocks (only if session matches and still blocked)
334 $placeholders = implode(',', array_fill(0, count($blockIds), '%d'));
335 $values = array_merge(
336 [current_time('mysql'), $sessionId, self::STATUS_BLOCKED],
337 $blockIds
338 );
339
340 $affected = $wpdb->query(
341 $wpdb->prepare(
342 "UPDATE {$this->getTable($this->table)}
343 SET last_seen = %s
344 WHERE session_id = %s
345 AND status = %s
346 AND id IN ($placeholders)",
347 ...$values
348 )
349 );
350
351 return (int) $affected;
352 }
353
354 /**
355 * Confirm a seat block (order completed)
356 *
357 * @param int $blockId Block ID
358 * @param int $orderId WooCommerce order ID
359 * @param int $orderItemId Order item ID
360 * @param int $codeId Ticket code ID
361 * @return bool Success
362 */
363 public function confirmBlock(int $blockId, int $orderId, int $orderItemId, int $codeId): bool {
364 global $wpdb;
365
366 $result = $wpdb->update(
367 $this->getTable($this->table),
368 [
369 'status' => self::STATUS_CONFIRMED,
370 'order_id' => $orderId,
371 'code_id' => $codeId,
372 'expires_at' => null,
373 'meta' => $this->MAIN->getCore()->json_encode_with_error_handling([
374 'block_type' => self::BLOCK_TYPE_ORDER,
375 'order_item_id' => $orderItemId,
376 'confirmed_at' => current_time('mysql')
377 ])
378 ],
379 ['id' => $blockId],
380 ['%s', '%d', '%d', '%s', '%s'],
381 ['%d']
382 );
383
384 return $result !== false;
385 }
386
387 /**
388 * Confirm seat for order (by seat/product/session)
389 *
390 * @param int $seatId Seat ID
391 * @param int $productId Product ID
392 * @param string $sessionId Session ID
393 * @param int $orderId Order ID
394 * @param int $orderItemId Order item ID
395 * @param int $codeId Code ID
396 * @param string|null $eventDate Event date
397 * @return bool Success
398 */
399 public function confirmSeatForOrder(int $seatId, int $productId, string $sessionId, int $orderId, int $orderItemId, int $codeId, ?string $eventDate = null): bool {
400 global $wpdb;
401
402 $block = $wpdb->get_row(
403 $wpdb->prepare(
404 "SELECT * FROM {$this->getTable($this->table)}
405 WHERE seat_id = %d
406 AND product_id = %d
407 AND session_id = %s
408 AND (event_date = %s OR event_date IS NULL)
409 AND status = %s",
410 $seatId,
411 $productId,
412 $sessionId,
413 $eventDate,
414 self::STATUS_BLOCKED
415 ),
416 ARRAY_A
417 );
418
419 if (!$block) {
420 // Create confirmed block directly if session block doesn't exist
421 $result = $wpdb->insert(
422 $this->getTable($this->table),
423 [
424 'time' => current_time('mysql'),
425 'timezone' => wp_timezone_string(),
426 'seat_id' => $seatId,
427 'seatingplan_id' => $this->getSeatPlanId($seatId),
428 'product_id' => $productId,
429 'event_date' => $eventDate,
430 'session_id' => $sessionId,
431 'order_id' => $orderId,
432 'code_id' => $codeId,
433 'expires_at' => null,
434 'status' => self::STATUS_CONFIRMED,
435 'meta' => $this->MAIN->getCore()->json_encode_with_error_handling([
436 'block_type' => self::BLOCK_TYPE_ORDER,
437 'order_item_id' => $orderItemId,
438 'confirmed_at' => current_time('mysql')
439 ])
440 ],
441 ['%s', '%s', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s']
442 );
443 return $result !== false;
444 }
445
446 return $this->confirmBlock((int) $block['id'], $orderId, $orderItemId, $codeId);
447 }
448
449 /**
450 * Release seat by code ID (for refunds)
451 *
452 * @param int $codeId Ticket code ID
453 * @return bool Success
454 */
455 public function releaseSeatByCodeId(int $codeId): bool {
456 global $wpdb;
457
458 $result = $wpdb->delete(
459 $this->getTable($this->table),
460 ['code_id' => $codeId],
461 ['%d']
462 );
463
464 return $result !== false;
465 }
466
467 /**
468 * Release seat by order ID (for order cancellation)
469 *
470 * @param int $orderId Order ID
471 * @return int Number of released seats
472 */
473 public function releaseSeatsByOrderId(int $orderId): int {
474 global $wpdb;
475
476 return $wpdb->delete(
477 $this->getTable($this->table),
478 ['order_id' => $orderId],
479 ['%d']
480 );
481 }
482
483 /**
484 * Delete all blocks for a seating plan
485 *
486 * @param int $planId Seating plan ID
487 * @return int Number of deleted blocks
488 */
489 public function deleteByPlanId(int $planId): int {
490 global $wpdb;
491
492 return (int) $wpdb->delete(
493 $this->getTable($this->table),
494 ['seatingplan_id' => $planId],
495 ['%d']
496 );
497 }
498
499 /**
500 * Delete all blocks for a seat
501 *
502 * @param int $seatId Seat ID
503 * @return int Number of deleted blocks
504 */
505 public function deleteBySeatId(int $seatId): int {
506 global $wpdb;
507
508 return (int) $wpdb->delete(
509 $this->getTable($this->table),
510 ['seat_id' => $seatId],
511 ['%d']
512 );
513 }
514
515 /**
516 * Check if seat is available
517 *
518 * @param int $seatId Seat ID
519 * @param int $productId Product ID
520 * @param string|null $eventDate Event date
521 * @param string|null $excludeSessionId Session ID to exclude (allow own blocks)
522 * @return bool True if available
523 */
524 public function isSeatAvailable(int $seatId, int $productId, ?string $eventDate = null, ?string $excludeSessionId = null): bool {
525 global $wpdb;
526
527 // Validate IDs
528 if ($seatId <= 0 || $productId <= 0) {
529 $this->MAIN->getDB()->logError('isSeatAvailable: Invalid IDs - seat: ' . $seatId . ', product: ' . $productId);
530 return false;
531 }
532
533 $now = current_time('mysql');
534 $staleTimeout = $this->getStaleTimeout();
535
536 // Build stale condition
537 $staleCondition = '';
538 if ($staleTimeout > 0) {
539 $staleTime = date('Y-m-d H:i:s', current_time('timestamp') - $staleTimeout);
540 $staleCondition = $wpdb->prepare(" AND (last_seen IS NULL OR last_seen > %s)", $staleTime);
541 }
542
543 // Check for active blocks: confirmed OR (blocked AND not expired AND not stale)
544 // Optionally exclude blocks from the current session (user's own blocks are allowed)
545 if ($excludeSessionId) {
546 $count = $wpdb->get_var(
547 $wpdb->prepare(
548 "SELECT COUNT(*) FROM {$this->getTable($this->table)}
549 WHERE seat_id = %d
550 AND product_id = %d
551 AND (event_date = %s OR event_date IS NULL)
552 AND session_id != %s
553 AND (
554 status = %s
555 OR (status = %s AND expires_at > %s{$staleCondition})
556 )",
557 $seatId,
558 $productId,
559 $eventDate,
560 $excludeSessionId,
561 self::STATUS_CONFIRMED,
562 self::STATUS_BLOCKED,
563 $now
564 )
565 );
566 } else {
567 $count = $wpdb->get_var(
568 $wpdb->prepare(
569 "SELECT COUNT(*) FROM {$this->getTable($this->table)}
570 WHERE seat_id = %d
571 AND product_id = %d
572 AND (event_date = %s OR event_date IS NULL)
573 AND (
574 status = %s
575 OR (status = %s AND expires_at > %s{$staleCondition})
576 )",
577 $seatId,
578 $productId,
579 $eventDate,
580 self::STATUS_CONFIRMED,
581 self::STATUS_BLOCKED,
582 $now
583 )
584 );
585 }
586
587 return (int) $count === 0;
588 }
589
590 /**
591 * Get available seats for a plan/product/date
592 *
593 * @param int $planId Seating plan ID
594 * @param int $productId Product ID
595 * @param string|null $eventDate Event date
596 * @return array Array of available seat IDs
597 */
598 public function getAvailableSeatIds(int $planId, int $productId, ?string $eventDate = null): array {
599 global $wpdb;
600
601 // Get blocked seat IDs (includes time check)
602 $blockedIds = $this->getBlockedSeatIds($planId, $productId, $eventDate);
603
604 // Get all active seats for plan, excluding blocked ones
605 $sql = $wpdb->prepare(
606 "SELECT id FROM {$this->getTable('seats')}
607 WHERE seatingplan_id = %d AND aktiv = 1",
608 $planId
609 );
610
611 if (!empty($blockedIds)) {
612 $placeholders = implode(',', array_fill(0, count($blockedIds), '%d'));
613 $sql .= $wpdb->prepare(" AND id NOT IN ($placeholders)", ...$blockedIds);
614 }
615
616 $sql .= " ORDER BY sort_order ASC";
617
618 return $wpdb->get_col($sql);
619 }
620
621 /**
622 * Get blocked seat IDs for a plan/product/date
623 *
624 * @param int $planId Seating plan ID
625 * @param int $productId Product ID
626 * @param string|null $eventDate Event date
627 * @return array Array of blocked seat IDs
628 */
629 public function getBlockedSeatIds(int $planId, int $productId, ?string $eventDate = null): array {
630 global $wpdb;
631
632 $now = current_time('mysql');
633 $staleTimeout = $this->getStaleTimeout();
634
635 // Build stale condition
636 $staleCondition = '';
637 if ($staleTimeout > 0) {
638 $staleTime = date('Y-m-d H:i:s', current_time('timestamp') - $staleTimeout);
639 $staleCondition = $wpdb->prepare(" AND (last_seen IS NULL OR last_seen > %s)", $staleTime);
640 }
641
642 // Get seats that are: confirmed OR (blocked AND not expired AND not stale)
643 return $wpdb->get_col(
644 $wpdb->prepare(
645 "SELECT DISTINCT seat_id FROM {$this->getTable($this->table)}
646 WHERE seatingplan_id = %d
647 AND product_id = %d
648 AND (event_date = %s OR event_date IS NULL)
649 AND (
650 status = %s
651 OR (status = %s AND expires_at > %s{$staleCondition})
652 )",
653 $planId,
654 $productId,
655 $eventDate,
656 self::STATUS_CONFIRMED,
657 self::STATUS_BLOCKED,
658 $now
659 )
660 );
661 }
662
663 /**
664 * Clean expired blocks
665 *
666 * @param int $limit Max rows to delete per call
667 * @return int Number of deleted blocks
668 */
669 public function cleanExpiredBlocks(int $limit = 500): int {
670 global $wpdb;
671
672 $deleted = $wpdb->query(
673 $wpdb->prepare(
674 "DELETE FROM {$this->getTable($this->table)}
675 WHERE status = %s
676 AND expires_at IS NOT NULL
677 AND expires_at < %s
678 LIMIT %d",
679 self::STATUS_BLOCKED,
680 current_time('mysql'),
681 $limit
682 )
683 );
684
685 return (int) $deleted;
686 }
687
688 /**
689 * Get block timeout in minutes
690 *
691 * @return int Timeout in minutes
692 */
693 private function getBlockTimeout(): int {
694 $timeout = $this->MAIN->getOptions()->getOptionValue('seatingBlockTimeout');
695 return !empty($timeout) ? (int) $timeout : self::DEFAULT_BLOCK_TIMEOUT_MINUTES;
696 }
697
698 /**
699 * Get heartbeat stale timeout in seconds
700 *
701 * @return int Timeout in seconds (0 = disabled)
702 */
703 private function getStaleTimeout(): int {
704 $timeout = $this->MAIN->getOptions()->getOptionValue('seatingHeartbeatStaleTimeout');
705 return !empty($timeout) ? (int) $timeout : 60;
706 }
707
708 /**
709 * Build SQL condition for "active" blocks (not expired AND not stale)
710 *
711 * A block is active if:
712 * - status = 'confirmed', OR
713 * - status = 'blocked' AND not expired AND (no stale timeout OR last_seen is recent OR last_seen is NULL for new blocks)
714 *
715 * @param string $tableAlias Table alias (e.g., 'sb' or empty)
716 * @return string SQL condition
717 */
718 private function getActiveBlockCondition(string $tableAlias = ''): string {
719 $prefix = $tableAlias ? "{$tableAlias}." : '';
720 $now = current_time('mysql');
721 $staleTimeout = $this->getStaleTimeout();
722
723 // Base condition: confirmed OR (blocked AND not expired)
724 $condition = "({$prefix}status = '" . self::STATUS_CONFIRMED . "' OR ({$prefix}status = '" . self::STATUS_BLOCKED . "' AND {$prefix}expires_at > '{$now}'";
725
726 // Add stale check if enabled
727 if ($staleTimeout > 0) {
728 $staleTime = date('Y-m-d H:i:s', current_time('timestamp') - $staleTimeout);
729 // Block is NOT stale if: last_seen is NULL (new block, heartbeat not yet received) OR last_seen is recent
730 $condition .= " AND ({$prefix}last_seen IS NULL OR {$prefix}last_seen > '{$staleTime}')";
731 }
732
733 $condition .= "))";
734
735 return $condition;
736 }
737
738 /**
739 * Get seating plan ID for a seat
740 *
741 * @param int $seatId Seat ID
742 * @return int|null Plan ID or null
743 */
744 private function getSeatPlanId(int $seatId): ?int {
745 global $wpdb;
746
747 $planId = $wpdb->get_var(
748 $wpdb->prepare(
749 "SELECT seatingplan_id FROM {$this->getTable('seats')} WHERE id = %d",
750 $seatId
751 )
752 );
753
754 return $planId !== null ? (int) $planId : null;
755 }
756
757 /**
758 * Get block by ID
759 *
760 * @param int $blockId Block ID
761 * @return array|null Block data
762 */
763 public function getById(int $blockId): ?array {
764 global $wpdb;
765
766 $row = $wpdb->get_row(
767 $wpdb->prepare(
768 "SELECT * FROM {$this->getTable($this->table)} WHERE id = %d",
769 $blockId
770 ),
771 ARRAY_A
772 );
773
774 if ($row) {
775 $row['meta'] = $this->decodeAndMergeMeta($row['meta']);
776 }
777
778 return $row;
779 }
780
781 /**
782 * Get confirmed count for plan/date
783 *
784 * @param int $planId Plan ID
785 * @param string|null $eventDate Event date
786 * @return int Count
787 */
788 public function getConfirmedCount(int $planId, ?string $eventDate = null): int {
789 global $wpdb;
790
791 return (int) $wpdb->get_var(
792 $wpdb->prepare(
793 "SELECT COUNT(*) FROM {$this->getTable($this->table)}
794 WHERE seatingplan_id = %d
795 AND (event_date = %s OR event_date IS NULL)
796 AND status = %s",
797 $planId,
798 $eventDate,
799 self::STATUS_CONFIRMED
800 )
801 );
802 }
803
804 /**
805 * Get blocked count for plan/date
806 *
807 * @param int $planId Plan ID
808 * @param string|null $eventDate Event date
809 * @return int Count
810 */
811 public function getBlockedCount(int $planId, ?string $eventDate = null): int {
812 global $wpdb;
813
814 return (int) $wpdb->get_var(
815 $wpdb->prepare(
816 "SELECT COUNT(*) FROM {$this->getTable($this->table)}
817 WHERE seatingplan_id = %d
818 AND (event_date = %s OR event_date IS NULL)
819 AND status = %s
820 AND expires_at > %s",
821 $planId,
822 $eventDate,
823 self::STATUS_BLOCKED,
824 current_time('mysql')
825 )
826 );
827 }
828
829 /**
830 * Get all seats with status for plan/product/date
831 *
832 * @param int $planId Plan ID
833 * @param int $productId Product ID
834 * @param string|null $eventDate Event date
835 * @return array Array of seats with status
836 */
837 public function getSeatsWithStatus(int $planId, int $productId, ?string $eventDate = null): array {
838 global $wpdb;
839
840 $now = current_time('mysql');
841 $staleTimeout = $this->getStaleTimeout();
842
843 // Build stale condition for the JOIN
844 $staleCondition = '';
845 if ($staleTimeout > 0) {
846 $staleTime = date('Y-m-d H:i:s', current_time('timestamp') - $staleTimeout);
847 $staleCondition = $wpdb->prepare(" AND (sb.last_seen IS NULL OR sb.last_seen > %s)", $staleTime);
848 }
849
850 // Get all seats with active blocks (confirmed OR blocked+not expired+not stale)
851 $seats = $wpdb->get_results(
852 $wpdb->prepare(
853 "SELECT s.*, sb.status as block_status, sb.session_id, sb.order_id
854 FROM {$this->getTable('seats')} s
855 LEFT JOIN {$this->getTable($this->table)} sb
856 ON s.id = sb.seat_id
857 AND sb.product_id = %d
858 AND (sb.event_date = %s OR sb.event_date IS NULL)
859 AND (
860 sb.status = %s
861 OR (sb.status = %s AND sb.expires_at > %s{$staleCondition})
862 )
863 WHERE s.seatingplan_id = %d AND s.aktiv = 1
864 ORDER BY s.sort_order ASC",
865 $productId,
866 $eventDate,
867 self::STATUS_CONFIRMED,
868 self::STATUS_BLOCKED,
869 $now,
870 $planId
871 ),
872 ARRAY_A
873 );
874
875 // Get SeatManager for proper meta merging with defaults
876 $seatManager = $this->MAIN->getSeating()->getSeatManager();
877
878 foreach ($seats as &$seat) {
879 // Use SeatManager's prepareSeatMeta to ensure all default fields exist
880 $seat['meta'] = $seatManager->prepareSeatMeta($seat['meta'], $seat['seat_identifier'] ?? '');
881 $seat['availability'] = $seat['block_status'] === null ? 'free' :
882 ($seat['block_status'] === self::STATUS_CONFIRMED ? 'sold' : 'blocked');
883 }
884
885 return $seats;
886 }
887
888 /**
889 * Get changes since timestamp (for Live Monitor)
890 *
891 * @param int $planId Plan ID
892 * @param string|null $eventDate Event date
893 * @param string $sinceTimestamp Timestamp to get changes since
894 * @return array Array of changes
895 */
896 public function getChangesSince(int $planId, ?string $eventDate, string $sinceTimestamp): array {
897 global $wpdb;
898
899 if (empty($sinceTimestamp)) {
900 return [];
901 }
902
903 return $wpdb->get_results(
904 $wpdb->prepare(
905 "SELECT sb.*, s.seat_identifier, s.meta as seat_meta
906 FROM {$this->getTable($this->table)} sb
907 JOIN {$this->getTable('seats')} s ON sb.seat_id = s.id
908 WHERE sb.seatingplan_id = %d
909 AND (sb.event_date = %s OR sb.event_date IS NULL)
910 AND sb.time > %s
911 ORDER BY sb.time DESC
912 LIMIT 50",
913 $planId,
914 $eventDate,
915 $sinceTimestamp
916 ),
917 ARRAY_A
918 );
919 }
920 }
921