PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
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 2.0.11 All 82 releases
yatra / app / Repositories / ItineraryRepository.php

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

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