PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.7
Yatra – Travel Booking & Tour Operator Software v3.0.2.7
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Repositories / ItineraryRepository.php

ItineraryRepository.php in Yatra – Travel Booking & Tour Operator Software 3.0.2.7, at app/Repositories/ItineraryRepository.php

1,255 lines 47.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Repositories;
6
7 use Yatra\Repositories\BaseRepository;
8 use Yatra\Database\Tables\TripItineraryDaysTable;
9 use Yatra\Database\Tables\TripItineraryDayEntryTable;
10 use Yatra\Utils\QueryCache;
11 use Yatra\Utils\Cache;
12
13 /**
14 * Itinerary Repository
15 * Handles database operations for itinerary entries
16 */
17 class ItineraryRepository extends BaseRepository
18 {
19 protected function getTableName(): string
20 {
21 return TripItineraryDayEntryTable::getTableName();
22 }
23
24 /**
25 * Get or create a day for a trip
26 *
27 * @param int $tripId Trip ID
28 * @param int $dayNumber Day number
29 * @param string|null $dayTitle Day title (optional)
30 * @param string|null $dayDescription Day description (optional)
31 * @param bool $allowExisting If false, throws exception if day already exists (for new day creation)
32 * @return int Day ID
33 * @throws \InvalidArgumentException If day exists and $allowExisting is false
34 */
35 public function getOrCreateDay(int $tripId, int $dayNumber, ?string $dayTitle = null, ?string $dayDescription = null, bool $allowExisting = true): int
36 {
37 // Use QueryCache for caching day existence checks
38 $cacheKey = Cache::KEY_DAY_EXISTS . "_{$tripId}_day_{$dayNumber}";
39
40 $existingDay = $this->cacheQueryResult($cacheKey, function() use ($tripId, $dayNumber) {
41 global $wpdb;
42 $tableDays = TripItineraryDaysTable::getTableName();
43
44 return $wpdb->get_row(
45 $wpdb->prepare(
46 "SELECT * FROM `{$tableDays}`
47 WHERE trip_id = %d AND day_number = %d",
48 $tripId,
49 $dayNumber
50 )
51 );
52 }, Cache::DURATION_SHORT); // Cache for 10 minutes
53
54 if ($existingDay) {
55 // If not allowing existing days (for new day creation), throw error
56 if (!$allowExisting) {
57 // Get all existing day numbers for this trip
58 $existingDays = $this->cacheQueryResult(Cache::KEY_DAY_EXISTS . '_existing_days_trip_' . $tripId, function() use ($tripId) {
59 global $wpdb;
60 $tableDays = TripItineraryDaysTable::getTableName();
61
62 return $wpdb->get_col(
63 $wpdb->prepare(
64 "SELECT day_number FROM `{$tableDays}`
65 WHERE trip_id = %d
66 ORDER BY day_number ASC",
67 $tripId
68 )
69 ) ?: [];
70 }, Cache::DURATION_SHORT);
71
72 // Get next available day number
73 $maxDay = $this->cacheQueryResult(Cache::KEY_DAY_EXISTS . '_max_day_trip_' . $tripId, function() use ($tripId) {
74 global $wpdb;
75 $tableDays = TripItineraryDaysTable::getTableName();
76
77 return $wpdb->get_var(
78 $wpdb->prepare(
79 "SELECT MAX(day_number) FROM `{$tableDays}` WHERE trip_id = %d",
80 $tripId
81 )
82 ) ?: 0;
83 }, Cache::DURATION_SHORT);
84 $nextDay = (int) $maxDay + 1;
85
86 // Format existing days list (e.g., "1, 2" or "1, 2, 3")
87 $existingDaysList = implode(', ', $existingDays);
88
89 throw new \InvalidArgumentException(
90 sprintf(
91 __('Day %s already exists for this trip. Please use day %d instead.', 'yatra'),
92 $existingDaysList,
93 $nextDay
94 )
95 );
96 }
97
98 // Update day title and description if provided
99 $updateData = [];
100 $updateFormat = [];
101
102 if ($dayTitle !== null) {
103 $updateData['title'] = sanitize_text_field($dayTitle);
104 $updateFormat[] = '%s';
105 }
106
107 if ($dayDescription !== null) {
108 $updateData['description'] = wp_kses_post($dayDescription);
109 $updateFormat[] = '%s';
110 }
111
112 if (!empty($updateData)) {
113 $result = $wpdb->update(
114 $tableDays,
115 $updateData,
116 ['id' => (int) $existingDay->id],
117 $updateFormat,
118 ['%d']
119 );
120 }
121 return (int) $existingDay->id;
122 }
123
124 // Create new day
125 $insertData = [
126 'trip_id' => $tripId,
127 'day_number' => $dayNumber,
128 'title' => $dayTitle ? sanitize_text_field($dayTitle) : null,
129 'description' => $dayDescription ? wp_kses_post($dayDescription) : null,
130 'order' => $dayNumber - 1,
131 ];
132 $wpdb->insert(
133 $tableDays,
134 $insertData,
135 ['%d', '%d', '%s', '%s', '%d']
136 );
137 return (int) $wpdb->insert_id;
138 }
139
140 /**
141 * Create itinerary entry
142 * This creates either a day entry (in days table) or an activity entry (in entries table)
143 */
144 public function createEntry(array $data): int
145 {
146 global $wpdb;
147 $tableEntries = $this->getTableName(); // yatra_new_trip_itinerary_day_entry
148 $tableDays = TripItineraryDaysTable::getTableName();
149
150 // Determine if this is a day entry or an activity entry
151 // Day entries have no item_type_id and item_id (or they are explicitly 0)
152 // Note: empty() treats "0" as empty, so we need explicit checks
153 $itemTypeId = isset($data['item_type_id']) && $data['item_type_id'] !== '' && $data['item_type_id'] !== '0' && $data['item_type_id'] !== 0 ? (int) $data['item_type_id'] : null;
154 $itemId = isset($data['item_id']) && $data['item_id'] !== '' && $data['item_id'] !== '0' && $data['item_id'] !== 0 ? (int) $data['item_id'] : null;
155 $isDayEntry = $itemTypeId === null && $itemId === null;
156
157 if ($isDayEntry) {
158 // Creating a DAY entry - store in days table
159 // Check if day already exists for this trip and day number
160 $existingDay = $wpdb->get_row(
161 $wpdb->prepare(
162 "SELECT id FROM `{$tableDays}` WHERE trip_id = %d AND day_number = %d",
163 (int) $data['trip_id'],
164 (int) $data['day']
165 )
166 );
167
168 if ($existingDay) {
169 // Day already exists, update it
170 $wpdb->update(
171 $tableDays,
172 [
173 'title' => sanitize_text_field($data['day_title'] ?? $data['title'] ?? ''),
174 'description' => wp_kses_post($data['day_description'] ?? $data['description'] ?? ''),
175 ],
176 ['id' => $existingDay->id],
177 ['%s', '%s'],
178 ['%d']
179 );
180
181 do_action('yatra_itinerary_day_updated', (int) $existingDay->id, $data);
182
183 return (int) $existingDay->id;
184 } else {
185 // Create new day
186 $wpdb->insert(
187 $tableDays,
188 [
189 'trip_id' => (int) $data['trip_id'],
190 'day_number' => (int) $data['day'],
191 'title' => sanitize_text_field($data['day_title'] ?? $data['title'] ?? ''),
192 'description' => wp_kses_post($data['day_description'] ?? $data['description'] ?? ''),
193 'order' => (int) $data['day'],
194 ],
195 ['%d', '%d', '%s', '%s', '%d']
196 );
197
198 $dayId = (int) $wpdb->insert_id;
199 // Fire hook for cache invalidation
200 do_action('yatra_itinerary_day_created', $dayId, $data);
201
202 return $dayId;
203 }
204 } else {
205 // Creating an ACTIVITY entry - store in entries table
206 // First, ensure the day exists
207 $dayId = $this->getOrCreateDay(
208 (int) $data['trip_id'],
209 (int) $data['day'],
210 $data['day_title'] ?? null,
211 $data['day_description'] ?? null,
212 true // Allow existing days
213 );
214
215 // Format time field
216 $timeField = null;
217 if (!empty($data['start_time']) && !empty($data['end_time'])) {
218 $timeField = $data['start_time'] . ' - ' . $data['end_time'];
219 } elseif (!empty($data['start_time'])) {
220 $timeField = $data['start_time'];
221 }
222
223 // Get max order for this day
224 $maxOrder = $wpdb->get_var(
225 $wpdb->prepare(
226 "SELECT MAX(`order`) FROM `{$tableEntries}` WHERE day_id = %d",
227 $dayId
228 )
229 ) ?: 0;
230
231 // Insert activity entry
232 $wpdb->insert(
233 $tableEntries,
234 [
235 'day_id' => $dayId,
236 'trip_id' => (int) $data['trip_id'],
237 'title' => sanitize_text_field($data['title'] ?? ''),
238 'description' => wp_kses_post($data['description'] ?? ''),
239 'item_type_id' => !empty($data['item_type_id']) ? (int) $data['item_type_id'] : null,
240 'item_id' => !empty($data['item_id']) ? (int) $data['item_id'] : null,
241 'item_type' => $data['item_type'] ?? null,
242 'item_name' => $data['item_name'] ?? null,
243 'item_icon' => $data['item_icon'] ?? null,
244 'time' => $timeField,
245 'start_time' => !empty($data['start_time']) ? sanitize_text_field($data['start_time']) : null,
246 'end_time' => !empty($data['end_time']) ? sanitize_text_field($data['end_time']) : null,
247 'time_type' => !empty($data['time_type']) ? sanitize_text_field($data['time_type']) : 'exact',
248 'location' => !empty($data['location']) ? sanitize_text_field($data['location']) : null,
249 'location_latitude' => !empty($data['location_latitude']) ? (float) $data['location_latitude'] : null,
250 'location_longitude' => !empty($data['location_longitude']) ? (float) $data['location_longitude'] : null,
251 'duration' => !empty($data['duration']) ? sanitize_text_field($data['duration']) : null,
252 'cost' => !empty($data['cost']) ? (float) $data['cost'] : null,
253 'cost_per_person' => !empty($data['cost_per_person']) ? 1 : 0,
254 'notes' => !empty($data['notes']) ? sanitize_textarea_field($data['notes']) : null,
255 'included_items' => !empty($data['included_items']) ? json_encode($data['included_items']) : null,
256 'excluded_items' => !empty($data['excluded_items']) ? json_encode($data['excluded_items']) : null,
257 'status' => !empty($data['status']) ? sanitize_text_field($data['status']) : 'publish',
258 'order' => (int) $maxOrder + 1,
259 ],
260 [
261 '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s',
262 '%s', '%s', '%s', '%s', '%s', '%s', '%f', '%f', '%f', '%d', '%s',
263 '%s', '%s', '%s', '%d'
264 ]
265 );
266
267 $entryId = (int) $wpdb->insert_id;
268 // Fire hook for cache invalidation
269 do_action('yatra_itinerary_activity_created', $entryId, $data);
270
271 return $entryId;
272 }
273 }
274
275 /**
276 * Update itinerary entry
277 * @param int $entryId Entry ID
278 * @param array $data Update data
279 * @param string|null $mode 'day' or 'activity' to specify which table to update
280 */
281 public function updateEntry(int $entryId, array $data, ?string $mode = null): bool
282 {
283 global $wpdb;
284 $tableEntries = $this->getTableName();
285 $tableDays = TripItineraryDaysTable::getTableName();
286
287 // Use mode parameter to determine which table to update
288 // If mode is 'day', update days table
289 if ($mode === 'day') {
290 // Update day entry in days table
291 $dayEntry = $wpdb->get_row(
292 $wpdb->prepare("SELECT * FROM `{$tableDays}` WHERE id = %d", $entryId)
293 );
294
295 if (!$dayEntry) {
296 return false;
297 }
298 // This is a day entry - update in days table
299 $updateData = [];
300 $updateFormat = [];
301
302 if (isset($data['day_title']) || isset($data['title'])) {
303 $updateData['title'] = sanitize_text_field($data['day_title'] ?? $data['title'] ?? '');
304 $updateFormat[] = '%s';
305 }
306
307 if (isset($data['day_description']) || isset($data['description'])) {
308 $updateData['description'] = wp_kses_post($data['day_description'] ?? $data['description'] ?? '');
309 $updateFormat[] = '%s';
310 }
311
312 // Only update day_number if it's actually changing to avoid unique constraint violation
313 if (isset($data['day']) && (int) $data['day'] !== (int) $dayEntry->day_number) {
314 $updateData['day_number'] = (int) $data['day'];
315 $updateFormat[] = '%d';
316 }
317
318 if (!empty($updateData)) {
319 // Suppress error display - errors will be caught by service layer
320 $wpdb->suppress_errors();
321
322 $result = $wpdb->update(
323 $tableDays,
324 $updateData,
325 ['id' => $entryId],
326 $updateFormat,
327 ['%d']
328 );
329
330 // Re-enable error display
331 $wpdb->show_errors();
332
333 // Fire hook for cache invalidation
334 do_action('yatra_itinerary_day_updated', $entryId, $data);
335
336 return $result !== false;
337 }
338
339 return true;
340 }
341
342 // Mode is 'activity' or not specified - update activity entry only
343 // Get existing activity entry
344 $existingEntry = $wpdb->get_row(
345 $wpdb->prepare("SELECT * FROM `{$tableEntries}` WHERE id = %d", $entryId)
346 );
347
348 if (!$existingEntry) {
349 return false;
350 }
351
352 // When mode='activity', we ONLY update the activity entry itself
353 // We do NOT touch the day table at all
354 // The day_id should remain the same unless explicitly changed
355
356 // Format time field
357 $timeField = null;
358 if (!empty($data['start_time']) && !empty($data['end_time'])) {
359 $timeField = $data['start_time'] . ' - ' . $data['end_time'];
360 } elseif (!empty($data['start_time'])) {
361 $timeField = $data['start_time'];
362 } elseif (isset($data['time'])) {
363 $timeField = $data['time'];
364 }
365
366 // Prepare included_items and excluded_items as JSON
367 $includedItemsJson = null;
368 if (isset($data['included_items'])) {
369 if (is_array($data['included_items'])) {
370 $includedItemsJson = json_encode($data['included_items']);
371 } elseif (is_string($data['included_items'])) {
372 $includedItemsJson = $data['included_items'];
373 }
374 }
375
376 $excludedItemsJson = null;
377 if (isset($data['excluded_items'])) {
378 if (is_array($data['excluded_items'])) {
379 $excludedItemsJson = json_encode($data['excluded_items']);
380 } elseif (is_string($data['excluded_items'])) {
381 $excludedItemsJson = $data['excluded_items'];
382 }
383 }
384
385 // Prepare update data
386 $updateData = [];
387 $updateFormat = [];
388
389 if (isset($data['title'])) {
390 $updateData['title'] = sanitize_text_field($data['title']);
391 $updateFormat[] = '%s';
392 }
393
394 if (isset($data['description'])) {
395 $updateData['description'] = !empty($data['description']) ? wp_kses_post($data['description']) : null;
396 $updateFormat[] = '%s';
397 }
398
399 if ($timeField !== null) {
400 $updateData['time'] = $timeField;
401 $updateFormat[] = '%s';
402 }
403
404 if (isset($data['start_time'])) {
405 $updateData['start_time'] = !empty($data['start_time']) ? sanitize_text_field($data['start_time']) : null;
406 $updateFormat[] = '%s';
407 }
408
409 if (isset($data['end_time'])) {
410 $updateData['end_time'] = !empty($data['end_time']) ? sanitize_text_field($data['end_time']) : null;
411 $updateFormat[] = '%s';
412 }
413
414 if (isset($data['time_type'])) {
415 $updateData['time_type'] = sanitize_text_field($data['time_type']);
416 $updateFormat[] = '%s';
417 }
418
419 if (isset($data['location'])) {
420 $updateData['location'] = !empty($data['location']) ? sanitize_text_field($data['location']) : null;
421 $updateFormat[] = '%s';
422 }
423
424 if (isset($data['location_latitude'])) {
425 $updateData['location_latitude'] = !empty($data['location_latitude']) ? (float) $data['location_latitude'] : null;
426 $updateFormat[] = '%f';
427 }
428
429 if (isset($data['location_longitude'])) {
430 $updateData['location_longitude'] = !empty($data['location_longitude']) ? (float) $data['location_longitude'] : null;
431 $updateFormat[] = '%f';
432 }
433
434 if (isset($data['duration'])) {
435 $updateData['duration'] = !empty($data['duration']) ? sanitize_text_field($data['duration']) : null;
436 $updateFormat[] = '%s';
437 }
438
439 if (isset($data['cost'])) {
440 $updateData['cost'] = !empty($data['cost']) ? (float) $data['cost'] : null;
441 $updateFormat[] = '%f';
442 }
443
444 if (isset($data['cost_per_person'])) {
445 $updateData['cost_per_person'] = !empty($data['cost_per_person']) ? 1 : 0;
446 $updateFormat[] = '%d';
447 }
448
449 if (isset($data['notes'])) {
450 $updateData['notes'] = !empty($data['notes']) ? wp_kses_post($data['notes']) : null;
451 $updateFormat[] = '%s';
452 }
453
454 if ($includedItemsJson !== null) {
455 $updateData['included_items'] = $includedItemsJson;
456 $updateFormat[] = '%s';
457 }
458
459 if ($excludedItemsJson !== null) {
460 $updateData['excluded_items'] = $excludedItemsJson;
461 $updateFormat[] = '%s';
462 }
463
464 if (isset($data['gallery'])) {
465 if (!empty($data['gallery']) && is_array($data['gallery'])) {
466 // Process gallery items to ensure attachment IDs are properly saved
467 $processedGallery = [];
468 foreach ($data['gallery'] as $item) {
469 $processedItem = [
470 'id' => $item['id'] ?? '',
471 'attachment_id' => isset($item['attachment_id']) ? (int) $item['attachment_id'] : 0,
472 'type' => $item['type'] ?? 'image',
473 'alt_text' => $item['alt_text'] ?? '',
474 'caption' => $item['caption'] ?? '',
475 ];
476
477 // Keep URL and thumbnail_url for reference but they'll be regenerated from attachment_id
478 if (isset($item['url'])) {
479 $processedItem['url'] = $item['url'];
480 }
481 if (isset($item['thumbnail_url'])) {
482 $processedItem['thumbnail_url'] = $item['thumbnail_url'];
483 }
484
485 $processedGallery[] = $processedItem;
486 }
487 $updateData['gallery'] = json_encode($processedGallery);
488 } else {
489 $updateData['gallery'] = null;
490 }
491 $updateFormat[] = '%s';
492 }
493
494 if (isset($data['video_url'])) {
495 $updateData['video_url'] = !empty($data['video_url']) ? esc_url_raw($data['video_url']) : null;
496 $updateFormat[] = '%s';
497 }
498
499 if (isset($data['item_type_id'])) {
500 $updateData['item_type_id'] = !empty($data['item_type_id']) ? (int) $data['item_type_id'] : null;
501 $updateFormat[] = '%d';
502 }
503
504 if (isset($data['item_id'])) {
505 $updateData['item_id'] = !empty($data['item_id']) ? (int) $data['item_id'] : null;
506 $updateFormat[] = '%d';
507 }
508
509 if (isset($data['status'])) {
510 $updateData['status'] = sanitize_text_field($data['status']);
511 $updateFormat[] = '%s';
512 }
513
514 // Note: We do NOT update day_id when mode='activity'
515 // The activity stays in its current day unless explicitly moved via different logic
516
517 if (!empty($updateData)) {
518 $wpdb->update(
519 $tableEntries,
520 $updateData,
521 ['id' => $entryId],
522 $updateFormat,
523 ['%d']
524 );
525
526 // Fire hook for cache invalidation
527 do_action('yatra_itinerary_activity_updated', $entryId, $data);
528 }
529
530 // Note: Images are stored in metadata in the new structure, not in a separate table
531
532 return true;
533 }
534
535 /**
536 * Get item_type_id and item_id from activity_type string
537 */
538 private function getItemIdsFromActivityType(string $activityType): ?array
539 {
540 // Use QueryCache for caching activity type lookups
541 $cacheKey = Cache::KEY_ACTIVITY_TYPE_LOOKUP . '_' . md5($activityType);
542
543 $result = $this->cacheQueryResult($cacheKey, function () use ($activityType) {
544 global $wpdb;
545 $itemRepository = new \Yatra\Repositories\ItemRepository();
546 $itemTypeRepository = new \Yatra\Repositories\ItemTypeRepository();
547 $tableItems = $itemRepository->getTableName();
548 $tableItemTypes = $itemTypeRepository->getTableName();
549
550 // First try to find by item name (exact match)
551 $item = $wpdb->get_row(
552 $wpdb->prepare("SELECT id, type_id FROM `{$tableItems}` WHERE name = %s LIMIT 1", $activityType)
553 );
554
555 if ($item) {
556 return (object) [
557 'item_type_id' => (int) $item->type_id,
558 'item_id' => (int) $item->id,
559 ];
560 }
561
562 // Fallback: try to find by item type name
563 $itemType = $wpdb->get_row(
564 $wpdb->prepare("SELECT id FROM `{$tableItemTypes}` WHERE name = %s LIMIT 1", $activityType)
565 );
566
567 if ($itemType) {
568 // Get first item of this type
569 $firstItem = $wpdb->get_row(
570 $wpdb->prepare("SELECT id FROM `{$tableItems}` WHERE type_id = %d LIMIT 1", (int) $itemType->id)
571 );
572
573 if ($firstItem) {
574 return (object) [
575 'item_type_id' => (int) $itemType->id,
576 'item_id' => (int) $firstItem->id,
577 ];
578 }
579 }
580
581 return null;
582 }, Cache::DURATION_LOOKUPS);
583
584 return $result ? [
585 'item_type_id' => (int) $result->item_type_id,
586 'item_id' => (int) $result->item_id,
587 ] : null;
588 }
589
590 /**
591 * Get activity type from item_type_id and item_id
592 */
593 private function getActivityTypeFromItems(int $itemTypeId, int $itemId): ?string
594 {
595 if ($itemTypeId <= 0 || $itemId <= 0) {
596 return null;
597 }
598
599 // Use QueryCache for caching activity type lookups
600 $cacheKey = Cache::KEY_ACTIVITY_TYPE_FROM_ITEMS . '_' . $itemTypeId . '_' . $itemId;
601
602 return $this->cacheQueryResult($cacheKey, function() use ($itemTypeId, $itemId) {
603 global $wpdb;
604 $itemRepository = new \Yatra\Repositories\ItemRepository();
605 $itemTypeRepository = new \Yatra\Repositories\ItemTypeRepository();
606 $tableItems = $itemRepository->getTableName();
607 $tableItemTypes = $itemTypeRepository->getTableName();
608
609 // Get item name
610 $item = $wpdb->get_row(
611 $wpdb->prepare("SELECT name FROM `{$tableItems}` WHERE id = %d", $itemId)
612 );
613
614 if ($item && !empty($item->name)) {
615 return $item->name;
616 }
617
618 // Fallback: get item type name
619 $itemType = $wpdb->get_row(
620 $wpdb->prepare("SELECT name FROM `{$tableItemTypes}` WHERE id = %d", $itemTypeId)
621 );
622
623 return $itemType && !empty($itemType->name) ? $itemType->name : null;
624 }, Cache::DURATION_LOOKUPS);
625 }
626
627 /**
628 * Get activity entry with related data (from entries table)
629 * @param int $entryId Entry ID
630 * @return object|null Entry object with relations or null if not found
631 */
632 public function getActivityEntry(int $entryId): ?\stdClass
633 {
634 // Use QueryCache for caching activity entry with joins
635 $cacheKey = Cache::KEY_ACTIVITY_ENTRY . '_' . $entryId;
636
637 return $this->cacheQueryResult($cacheKey, function() use ($entryId) {
638 global $wpdb;
639 $tableEntries = $this->getTableName();
640 $tableDays = TripItineraryDaysTable::getTableName();
641 $tableClassifications = \Yatra\Database\Tables\ClassificationsTable::getTableName();
642
643 $entry = $wpdb->get_row(
644 $wpdb->prepare(
645 "SELECT e.*,
646 i.name as item_name,
647 it.name as item_type_name,
648 it.icon as item_type_icon
649 FROM `{$tableEntries}` e
650 LEFT JOIN `{$tableClassifications}` i ON e.item_id = i.id AND i.type = 'item'
651 LEFT JOIN `{$tableClassifications}` it ON e.item_type_id = it.id AND it.type = 'item_type'
652 WHERE e.id = %d",
653 $entryId
654 )
655 );
656
657 if (!$entry) {
658 return null;
659 }
660
661 // Get day info
662 $day = $wpdb->get_row(
663 $wpdb->prepare("SELECT * FROM `{$tableDays}` WHERE id = %d", (int) $entry->day_id)
664 );
665
666 if ($day) {
667 $entry->day = (int) $day->day_number;
668 $entry->day_number = (int) $day->day_number;
669 $entry->day_title = $day->title;
670 $entry->day_description = $day->description;
671 }
672
673 // Decode included/excluded items JSON columns
674 $entry->included_items = $this->decodeAmenityItems($entry->included_items ?? null);
675 $entry->excluded_items = $this->decodeAmenityItems($entry->excluded_items ?? null);
676
677 // Images are stored in metadata
678 $entry->images = [];
679
680 // Parse time field to start_time and end_time
681 if (!empty($entry->time)) {
682 $timeParts = preg_split('/\s*-\s*|\s+to\s+/i', $entry->time);
683 if (count($timeParts) >= 2) {
684 $entry->start_time = trim($timeParts[0]);
685 $entry->end_time = trim($timeParts[1]);
686 } elseif (count($timeParts) === 1) {
687 $entry->start_time = trim($timeParts[0]);
688 $entry->end_time = null;
689 }
690 }
691
692 return $entry;
693 }, Cache::DURATION_ITINERARY); // Cache for 30 minutes
694 }
695
696 /**
697 * Get entry with related data
698 * @param int $entryId Entry ID
699 * @return object|null Entry object with relations or null if not found
700 */
701 public function getEntryWithRelations(int $entryId): ?\stdClass
702 {
703 // Use QueryCache for caching this expensive query with joins
704 $cacheKey = Cache::KEY_ITINERARY_ENTRY_WITH_RELATIONS . '_' . $entryId;
705
706 return $this->cacheQueryResult($cacheKey, function() use ($entryId) {
707 global $wpdb;
708 $tableEntries = $this->getTableName();
709 $tableDays = TripItineraryDaysTable::getTableName();
710
711 // First check if this is a day entry (stored in days table)
712 $dayEntry = $wpdb->get_row(
713 $wpdb->prepare("SELECT * FROM `{$tableDays}` WHERE id = %d", $entryId)
714 );
715
716 if ($dayEntry) {
717 // This is a day entry - create entry object from day data
718 $entry = (object) [
719 'id' => $dayEntry->id,
720 'trip_id' => $dayEntry->trip_id,
721 'day_id' => $dayEntry->id,
722 'day' => $dayEntry->day_number, // Add 'day' field for form compatibility
723 'title' => $dayEntry->title,
724 'description' => $dayEntry->description,
725 'location' => null,
726 'duration' => null,
727 'time' => null,
728 'start_time' => null,
729 'end_time' => null,
730 'time_type' => 'exact',
731 'cost' => null,
732 'cost_per_person' => false,
733 'notes' => $dayEntry->notes ?? null,
734 'included_items' => [],
735 'excluded_items' => [],
736 'item_type_id' => 0,
737 'item_id' => 0,
738 'status' => 'publish',
739 'order' => $dayEntry->order ?? $dayEntry->day_number,
740 'images' => [],
741 'day_number' => $dayEntry->day_number,
742 'day_title' => $dayEntry->title,
743 'day_description' => $dayEntry->description,
744 ];
745
746
747 return $entry;
748 }
749
750 // If not a day entry, look in the entries table (for activities)
751 $tableClassifications = \Yatra\Database\Tables\ClassificationsTable::getTableName();
752
753 $sql = $wpdb->prepare(
754 "SELECT e.*,
755 i.name as item_name,
756 it.name as item_type_name,
757 it.icon as item_type_icon
758 FROM `{$tableEntries}` e
759 LEFT JOIN `{$tableClassifications}` i ON e.item_id = i.id AND i.type = 'item'
760 LEFT JOIN `{$tableClassifications}` it ON e.item_type_id = it.id AND it.type = 'item_type'
761 WHERE e.id = %d",
762 $entryId
763 );
764
765 $entry = $wpdb->get_row($sql);
766
767 if (!$entry) {
768 return null;
769 }
770
771 // Debug: Log the actual values from database
772 // Get day info for activity entries
773 $day = $wpdb->get_row(
774 $wpdb->prepare("SELECT * FROM `{$tableDays}` WHERE id = %d", (int) $entry->day_id)
775 );
776
777 if ($day) {
778 $entry->day = (int) $day->day_number; // Add 'day' field for form compatibility
779 $entry->day_number = (int) $day->day_number;
780 $entry->day_title = $day->title;
781 $entry->day_description = $day->description;
782
783 } else {
784 }
785
786 // Decode included/excluded items JSON columns (stored directly on the entry)
787 $entry->included_items = $this->decodeAmenityItems($entry->included_items ?? null);
788 $entry->excluded_items = $this->decodeAmenityItems($entry->excluded_items ?? null);
789
790 // Images are stored in metadata in the new structure
791 $entry->images = [];
792
793 // Parse time field to start_time and end_time
794 if (!empty($entry->time)) {
795 $timeParts = preg_split('/\s*-\s*|\s+to\s+/i', $entry->time);
796 if (count($timeParts) >= 2) {
797 $entry->start_time = trim($timeParts[0]);
798 $entry->end_time = trim($timeParts[1]);
799 } elseif (count($timeParts) === 1) {
800 $entry->start_time = trim($timeParts[0]);
801 $entry->end_time = null;
802 }
803 }
804
805 return $entry;
806 }, Cache::DURATION_ITINERARY); // Cache for 30 minutes
807 }
808
809 /**
810 * Decode included/excluded items JSON column
811 */
812 private function decodeAmenityItems($value): array
813 {
814 if (empty($value)) {
815 return [];
816 }
817
818 if (is_array($value)) {
819 return $value;
820 }
821
822 if (is_string($value)) {
823 $decoded = json_decode($value, true);
824 return is_array($decoded) ? $decoded : [];
825 }
826
827 return [];
828 }
829
830 /**
831 * Delete entry and related data
832 * Handles both day entries (from days table) and activity entries (from entries table)
833 * @param int $id Entry ID
834 * @param string|null $mode 'day' or 'activity' to specify which table to delete from
835 */
836 public function delete(int $id, ?string $mode = null): bool
837 {
838 global $wpdb;
839 $tableEntries = $this->getTableName();
840 $tableDays = TripItineraryDaysTable::getTableName();
841
842 // If mode is explicitly 'activity', skip day entry check
843 if ($mode === 'activity') {
844 $dayEntry = null;
845 } else {
846 // Check if this is a day ID (from days table)
847 $dayEntry = $wpdb->get_row(
848 $wpdb->prepare("SELECT * FROM `{$tableDays}` WHERE id = %d", $id)
849 );
850 }
851
852 if ($dayEntry) {
853 // This is a day entry - delete the day and all its activities
854 // Delete all activity entries for this day (CASCADE will handle this via foreign key)
855 // But we'll do it explicitly for clarity
856 $wpdb->delete($tableEntries, ['day_id' => $id], ['%d']);
857
858 // Delete the day itself
859 $result = $wpdb->delete($tableDays, ['id' => $id], ['%d']);
860
861 // Fire hook for cache invalidation
862 do_action('yatra_itinerary_day_deleted', $id);
863
864 return $result !== false;
865 }
866
867 // Check if this is an activity entry ID (from entries table)
868 $activityEntry = $wpdb->get_row(
869 $wpdb->prepare("SELECT * FROM `{$tableEntries}` WHERE id = %d", $id)
870 );
871
872 if ($activityEntry) {
873 // This is an activity entry - just delete it
874 $result = $wpdb->delete($tableEntries, ['id' => $id], ['%d']);
875
876 // Fire hook for cache invalidation
877 do_action('yatra_itinerary_activity_deleted', $id);
878
879 return $result !== false;
880 }
881
882 // Entry not found in either table
883 return false;
884 }
885
886 /**
887 * Bulk delete entries
888 * @param array $ids Array of entry IDs to delete
889 * @return array ['deleted' => count, 'failed' => count]
890 */
891 public function bulkDelete(array $ids): array
892 {
893 global $wpdb;
894 $tableEntries = $this->getTableName();
895
896 $tableDays = TripItineraryDaysTable::getTableName();
897
898 if (empty($ids)) {
899 return ['deleted' => 0, 'failed' => 0];
900 }
901
902 // Sanitize IDs
903 $ids = array_map('intval', $ids);
904 $ids = array_filter($ids, function($id) {
905 return $id > 0;
906 });
907
908 if (empty($ids)) {
909 return ['deleted' => 0, 'failed' => 0];
910 }
911
912 $deleted = 0;
913 $failed = 0;
914 $processedDayIds = []; // Track days we've already processed
915
916 foreach ($ids as $id) {
917 try {
918 // Get the entry to check if it's a day entry
919 $entry = $wpdb->get_row(
920 $wpdb->prepare("SELECT day_id, item_type_id, item_id FROM `{$tableEntries}` WHERE id = %d", $id)
921 );
922
923 if (!$entry) {
924 $failed++;
925 continue;
926 }
927
928 $dayId = (int) $entry->day_id;
929 $isDayEntry = ($entry->item_type_id === null || $entry->item_type_id === 0) &&
930 ($entry->item_id === null || $entry->item_id === 0);
931
932 // If this is a day entry, delete all entries for this day
933 if ($isDayEntry) {
934 // Skip if we've already processed this day
935 if (in_array($dayId, $processedDayIds)) {
936 continue;
937 }
938
939 $processedDayIds[] = $dayId;
940
941 // Get all entry IDs for this day
942 $dayEntryIds = $wpdb->get_col(
943 $wpdb->prepare("SELECT id FROM `{$tableEntries}` WHERE day_id = %d", $dayId)
944 );
945
946 if (!empty($dayEntryIds)) {
947 // Delete images for all entries
948 $placeholders = implode(',', array_fill(0, count($dayEntryIds), '%d'));
949 $wpdb->query(
950 $wpdb->prepare(
951 "DELETE FROM `{$tableImages}` WHERE entry_id IN ($placeholders)",
952 ...$dayEntryIds
953 )
954 );
955
956 // Delete all entries for this day
957 $wpdb->delete($tableEntries, ['day_id' => $dayId], ['%d']);
958
959 // Delete the day itself
960 $wpdb->delete($tableDays, ['id' => $dayId], ['%d']);
961 }
962
963 $deleted++;
964 } else {
965 // For activity entries, just delete the entry and its images
966 // Delete related images
967 $wpdb->delete($tableImages, ['entry_id' => $id], ['%d']);
968
969 // Delete entry
970 $result = $wpdb->delete($tableEntries, ['id' => $id], ['%d']);
971
972 if ($result !== false) {
973 $deleted++;
974 } else {
975 $failed++;
976 }
977 }
978 } catch (\Exception $e) {
979 $failed++;
980 }
981 }
982
983 return ['deleted' => $deleted, 'failed' => $failed];
984 }
985
986 /**
987 * Get day entry ID by day_id
988 * Returns the entry ID where item_type_id and item_id are null for a given day_id
989 */
990 public function getDayEntryIdByDayId(int $dayId): ?int
991 {
992 global $wpdb;
993
994 // Day entries are now stored in the days table itself
995 $tableDays = TripItineraryDaysTable::getTableName();
996
997 // Get the day entry ID from the days table
998 $entryId = $wpdb->get_var(
999 $wpdb->prepare(
1000 "SELECT id FROM `{$tableDays}`
1001 WHERE id = %d",
1002 $dayId
1003 )
1004 );
1005
1006 return $entryId ? (int) $entryId : null;
1007 }
1008
1009 /**
1010 * Get day by ID
1011 *
1012 * @param int $dayId Day ID
1013 * @return object|null Day object or null if not found
1014 */
1015 public function getDayById(int $dayId): ?object
1016 {
1017 global $wpdb;
1018
1019 $tableDays = TripItineraryDaysTable::getTableName();
1020
1021 return $wpdb->get_row($wpdb->prepare(
1022 "SELECT day_number FROM `{$tableDays}` WHERE id = %d",
1023 $dayId
1024 )
1025 );
1026
1027 if ($entryId) {
1028 return (int) $entryId;
1029 }
1030
1031 // Day entry doesn't exist - get day info and create it
1032 $day = $wpdb->get_row(
1033 $wpdb->prepare("SELECT trip_id, day_number, title FROM `{$tableDays}` WHERE id = %d", $dayId)
1034 );
1035
1036 if (!$day) {
1037 return null; // Day doesn't exist
1038 }
1039
1040 // Create the day entry
1041 $wpdb->insert(
1042 $tableEntries,
1043 [
1044 'trip_id' => (int) $day->trip_id,
1045 'day_id' => $dayId,
1046 'title' => $day->title ?: sprintf(__('Day %d', 'yatra'), (int) $day->day_number),
1047 'description' => '',
1048 'location' => null,
1049 'duration' => null,
1050 'time' => null,
1051 'start_time' => null,
1052 'end_time' => null,
1053 'time_type' => 'exact',
1054 'cost' => null,
1055 'cost_per_person' => 0,
1056 'notes' => null,
1057 'included_items' => null,
1058 'excluded_items' => null,
1059 'item_type_id' => null,
1060 'item_id' => null,
1061 'status' => 'draft',
1062 'order' => 0,
1063 ],
1064 ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%f', '%d', '%s', '%s', '%s', '%d', '%d', '%s', '%d']
1065 );
1066
1067 return (int) $wpdb->insert_id;
1068 }
1069
1070 /**
1071 * Get all itinerary entries for a specific trip
1072 */
1073 public function getByTripId(int $tripId): array
1074 {
1075 // Use Cache for caching this expensive query with joins (Updated: 2026-02-28)
1076 $cacheKey = Cache::KEY_ITINERARY_BY_TRIP_ID . '_' . $tripId;
1077
1078 return $this->cacheQueryResult($cacheKey, function() use ($tripId) {
1079 global $wpdb;
1080 $tableEntries = $this->getTableName();
1081 $tableDays = TripItineraryDaysTable::getTableName();
1082
1083 // Get days for this trip first
1084 $days = $wpdb->get_results($wpdb->prepare(
1085 "SELECT * FROM `{$tableDays}`
1086 WHERE trip_id = %d
1087 ORDER BY day_number ASC",
1088 $tripId
1089 )) ?: [];
1090
1091 $allEntries = [];
1092
1093 // Process each day
1094 foreach ($days as $day) {
1095 $dayNumber = (int) $day->day_number;
1096
1097 // Add day entry (summary of the day)
1098 $dayEntryObj = (object) [
1099 'id' => $day->id, // Use actual day ID from days table
1100 'trip_id' => $tripId,
1101 'day_id' => $day->id,
1102 'day' => $dayNumber,
1103 'day_number' => $dayNumber,
1104 'day_title' => $day->title,
1105 'title' => $day->title,
1106 'day_description' => $day->description,
1107 'description' => $day->description,
1108 'item_type_id' => 0, // 0 indicates this is a day entry
1109 'item_id' => 0, // 0 indicates this is a day entry
1110 'item_type' => null,
1111 'item_name' => null,
1112 'item_icon' => null,
1113 'time' => null,
1114 'start_time' => null,
1115 'end_time' => null,
1116 'time_type' => 'exact',
1117 'location' => null,
1118 'duration' => null,
1119 'cost' => null,
1120 'cost_per_person' => false,
1121 'notes' => $day->notes ?? null,
1122 'status' => 'publish',
1123 'order' => $day->order ?? $dayNumber
1124 ];
1125
1126 $allEntries[] = $dayEntryObj;
1127
1128 // Get activities for this day
1129 $tableClassifications = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1130 $activities = $wpdb->get_results($wpdb->prepare(
1131 "SELECT e.*,
1132 i.name as item_name,
1133 it.name as item_type_name,
1134 it.icon as item_type_icon
1135 FROM `{$tableEntries}` e
1136 LEFT JOIN `{$tableClassifications}` i ON e.item_id = i.id AND i.type = 'item'
1137 LEFT JOIN `{$tableClassifications}` it ON e.item_type_id = it.id AND it.type = 'item_type'
1138 WHERE e.day_id = %d
1139 ORDER BY e.order ASC",
1140 $day->id
1141 )) ?: [];
1142
1143 // Add activities to entries
1144 foreach ($activities as $entry) {
1145 $includedItems = $this->decodeAmenityItems($entry->included_items ?? null);
1146 $excludedItems = $this->decodeAmenityItems($entry->excluded_items ?? null);
1147
1148 $entryObj = (object) [
1149 'id' => $entry->id,
1150 'trip_id' => $tripId,
1151 'day_id' => $day->id,
1152 'day' => $dayNumber,
1153 'day_number' => $dayNumber,
1154 'day_title' => $day->title,
1155 'day_description' => $day->description,
1156 'title' => $entry->title,
1157 'description' => $entry->description,
1158 'item_type_id' => $entry->item_type_id,
1159 'item_id' => $entry->item_id,
1160 'item_type' => $entry->item_type_name,
1161 'item_name' => $entry->item_name,
1162 'item_icon' => $entry->item_type_icon,
1163 'time' => $entry->time,
1164 'start_time' => $entry->start_time,
1165 'end_time' => $entry->end_time,
1166 'time_type' => $entry->time_type ?? 'exact',
1167 'location' => $entry->location,
1168 'location_latitude' => $entry->location_latitude,
1169 'location_longitude' => $entry->location_longitude,
1170 'duration' => $entry->duration,
1171 'cost' => $entry->cost,
1172 'cost_per_person' => $entry->cost_per_person ?? false,
1173 'notes' => $entry->notes,
1174 'included_items' => $includedItems,
1175 'excluded_items' => $excludedItems,
1176 'status' => $entry->status ?? 'publish',
1177 'order' => $entry->order ?? 0
1178 ];
1179 $allEntries[] = $entryObj;
1180 }
1181 }
1182
1183 return $allEntries;
1184 }, Cache::DURATION_ITINERARY); // Cache for 30 minutes
1185 }
1186
1187 /**
1188 * Find day entry by trip and day number
1189 *
1190 * @param int $tripId Trip ID
1191 * @param int $dayNumber Day number
1192 * @return object|null Day entry object or null if not found
1193 */
1194 public function findDayEntryByTripAndDayNumber(int $tripId, int $dayNumber): ?object
1195 {
1196 global $wpdb;
1197 // Query the days table, not the entries table
1198 $tableDays = TripItineraryDaysTable::getTableName();
1199
1200 // Find the day record for the given trip and day number
1201 return $wpdb->get_row($wpdb->prepare(
1202 "SELECT * FROM `{$tableDays}`
1203 WHERE trip_id = %d
1204 AND day_number = %d
1205 LIMIT 1",
1206 $tripId,
1207 $dayNumber
1208 ));
1209 }
1210
1211 /**
1212 * Find day by trip and day number (alias for findDayEntryByTripAndDayNumber)
1213 *
1214 * @param int $tripId Trip ID
1215 * @param int $dayNumber Day number
1216 * @return object|null Day record object or null if not found
1217 */
1218 public function findDayByTripAndDayNumber(int $tripId, int $dayNumber): ?object
1219 {
1220 return $this->findDayEntryByTripAndDayNumber($tripId, $dayNumber);
1221 }
1222
1223 /**
1224 * Get all published itinerary entries with coordinates for a trip
1225 * Used for map display functionality
1226 *
1227 * @param int $tripId Trip ID
1228 * @return array Array of entries with coordinates and day information
1229 */
1230 public function getEntriesWithCoordinatesForMap(int $tripId): array
1231 {
1232 // Validate trip ID
1233 if (empty($tripId) || $tripId <= 0) {
1234 return [];
1235 }
1236
1237 global $wpdb;
1238
1239 $entries_table = TripItineraryDayEntryTable::getTableName();
1240 $days_table = TripItineraryDaysTable::getTableName();
1241
1242 return $wpdb->get_results($wpdb->prepare(
1243 "SELECT e.*, d.day_number, d.title as day_title
1244 FROM {$entries_table} e
1245 LEFT JOIN {$days_table} d ON e.day_id = d.id
1246 WHERE e.trip_id = %d
1247 AND e.location_latitude IS NOT NULL
1248 AND e.location_longitude IS NOT NULL
1249 AND e.status = 'publish'
1250 ORDER BY d.day_number ASC, e.order ASC",
1251 $tripId
1252 )) ?: [];
1253 }
1254 }
1255