PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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.7, at app/Repositories/ItineraryRepository.php

1,242 lines 47.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 // Explicit 'day' mode must never fall through to delete an activity.
888 // Day ids and activity ids come from different tables and can collide
889 // numerically; if the day no longer exists (e.g. already removed in
890 // another tab), bail rather than silently deleting a same-id activity.
891 if ($mode === 'day') {
892 return false;
893 }
894
895 // Check if this is an activity entry ID (from entries table)
896 $activityEntry = $wpdb->get_row(
897 $wpdb->prepare("SELECT * FROM `{$tableEntries}` WHERE id = %d", $id)
898 );
899
900 if ($activityEntry) {
901 // This is an activity entry - just delete it
902 $result = $wpdb->delete($tableEntries, ['id' => $id], ['%d']);
903
904 // Fire hook for cache invalidation
905 do_action('yatra_itinerary_activity_deleted', $id);
906
907 return $result !== false;
908 }
909
910 // Entry not found in either table
911 return false;
912 }
913
914 /**
915 * Bulk delete itinerary days and/or activity entries.
916 *
917 * Days and activities live in two different tables with independent
918 * auto-increment id spaces, so a single flat list of ids is ambiguous
919 * (the same number can be a valid day id *and* a valid activity id). The
920 * caller therefore tells us which is which: `$dayIds` are day-table ids
921 * (deleting one removes the day row and all of its activities) and `$ids`
922 * are activity-entry ids. Each id is routed through the single-item
923 * {@see self::delete()} with an explicit mode so the correct table is
924 * always used.
925 *
926 * @param array $ids Activity entry ids (day_entry table)
927 * @param array $dayIds Day ids (days table); the day and its activities are removed
928 * @return array ['deleted' => count, 'failed' => count]
929 */
930 public function bulkDelete(array $ids, array $dayIds = []): array
931 {
932 $deleted = 0;
933 $failed = 0;
934
935 // Delete whole days first — this also removes every activity that
936 // belongs to the day, so any of those activity ids that also appear in
937 // $ids become harmless no-ops below.
938 $dayIds = array_unique(array_filter(array_map('intval', $dayIds), static function ($id) {
939 return $id > 0;
940 }));
941 foreach ($dayIds as $dayId) {
942 try {
943 if ($this->delete($dayId, 'day')) {
944 $deleted++;
945 } else {
946 $failed++;
947 }
948 } catch (\Throwable $e) {
949 $failed++;
950 }
951 }
952
953 // Delete standalone activity entries.
954 $ids = array_unique(array_filter(array_map('intval', $ids), static function ($id) {
955 return $id > 0;
956 }));
957 foreach ($ids as $id) {
958 try {
959 if ($this->delete($id, 'activity')) {
960 $deleted++;
961 } else {
962 $failed++;
963 }
964 } catch (\Throwable $e) {
965 $failed++;
966 }
967 }
968
969 return ['deleted' => $deleted, 'failed' => $failed];
970 }
971
972 /**
973 * Get day entry ID by day_id
974 * Returns the entry ID where item_type_id and item_id are null for a given day_id
975 */
976 public function getDayEntryIdByDayId(int $dayId): ?int
977 {
978 global $wpdb;
979
980 // Day entries are now stored in the days table itself
981 $tableDays = TripItineraryDaysTable::getTableName();
982
983 // Get the day entry ID from the days table
984 $entryId = $wpdb->get_var(
985 $wpdb->prepare(
986 "SELECT id FROM `{$tableDays}`
987 WHERE id = %d",
988 $dayId
989 )
990 );
991
992 return $entryId ? (int) $entryId : null;
993 }
994
995 /**
996 * Get day by ID
997 *
998 * @param int $dayId Day ID
999 * @return object|null Day object or null if not found
1000 */
1001 public function getDayById(int $dayId): ?object
1002 {
1003 global $wpdb;
1004
1005 $tableDays = TripItineraryDaysTable::getTableName();
1006
1007 return $wpdb->get_row($wpdb->prepare(
1008 "SELECT day_number FROM `{$tableDays}` WHERE id = %d",
1009 $dayId
1010 )
1011 );
1012
1013 if ($entryId) {
1014 return (int) $entryId;
1015 }
1016
1017 // Day entry doesn't exist - get day info and create it
1018 $day = $wpdb->get_row(
1019 $wpdb->prepare("SELECT trip_id, day_number, title FROM `{$tableDays}` WHERE id = %d", $dayId)
1020 );
1021
1022 if (!$day) {
1023 return null; // Day doesn't exist
1024 }
1025
1026 // Create the day entry
1027 $wpdb->insert(
1028 $tableEntries,
1029 [
1030 'trip_id' => (int) $day->trip_id,
1031 'day_id' => $dayId,
1032 /* translators: %d: itinerary day number. */
1033 'title' => $day->title ?: sprintf(__('Day %d', 'yatra'), (int) $day->day_number),
1034 'description' => '',
1035 'location' => null,
1036 'duration' => null,
1037 'time' => null,
1038 'start_time' => null,
1039 'end_time' => null,
1040 'time_type' => 'exact',
1041 'cost' => null,
1042 'cost_per_person' => 0,
1043 'notes' => null,
1044 'included_items' => null,
1045 'excluded_items' => null,
1046 'item_type_id' => null,
1047 'item_id' => null,
1048 'status' => 'draft',
1049 'order' => 0,
1050 ],
1051 ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%f', '%d', '%s', '%s', '%s', '%d', '%d', '%s', '%d']
1052 );
1053
1054 return (int) $wpdb->insert_id;
1055 }
1056
1057 /**
1058 * Get all itinerary entries for a specific trip
1059 */
1060 public function getByTripId(int $tripId): array
1061 {
1062 // Use Cache for caching this expensive query with joins (Updated: 2026-02-28)
1063 $cacheKey = Cache::KEY_ITINERARY_BY_TRIP_ID . '_' . $tripId;
1064
1065 return $this->cacheQueryResult($cacheKey, function() use ($tripId) {
1066 global $wpdb;
1067 $tableEntries = $this->getTableName();
1068 $tableDays = TripItineraryDaysTable::getTableName();
1069
1070 // Get days for this trip first
1071 $days = $wpdb->get_results($wpdb->prepare(
1072 "SELECT * FROM `{$tableDays}`
1073 WHERE trip_id = %d
1074 ORDER BY day_number ASC",
1075 $tripId
1076 )) ?: [];
1077
1078 $allEntries = [];
1079
1080 // Process each day
1081 foreach ($days as $day) {
1082 $dayNumber = (int) $day->day_number;
1083
1084 // Add day entry (summary of the day)
1085 $dayEntryObj = (object) [
1086 'id' => $day->id, // Use actual day ID from days table
1087 'trip_id' => $tripId,
1088 'day_id' => $day->id,
1089 'day' => $dayNumber,
1090 'day_number' => $dayNumber,
1091 'day_title' => $day->title,
1092 'title' => $day->title,
1093 'day_description' => $day->description,
1094 'description' => $day->description,
1095 'item_type_id' => 0, // 0 indicates this is a day entry
1096 'item_id' => 0, // 0 indicates this is a day entry
1097 'item_type' => null,
1098 'item_name' => null,
1099 'item_icon' => null,
1100 'time' => null,
1101 'start_time' => null,
1102 'end_time' => null,
1103 'time_type' => 'exact',
1104 'location' => null,
1105 'duration' => null,
1106 'cost' => null,
1107 'cost_per_person' => false,
1108 'notes' => $day->notes ?? null,
1109 'status' => 'publish',
1110 'order' => $day->order ?? $dayNumber
1111 ];
1112
1113 $allEntries[] = $dayEntryObj;
1114
1115 // Get activities for this day
1116 $tableClassifications = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1117 $activities = $wpdb->get_results($wpdb->prepare(
1118 "SELECT e.*,
1119 i.name as item_name,
1120 it.name as item_type_name,
1121 it.icon as item_type_icon
1122 FROM `{$tableEntries}` e
1123 LEFT JOIN `{$tableClassifications}` i ON e.item_id = i.id AND i.type = 'item'
1124 LEFT JOIN `{$tableClassifications}` it ON e.item_type_id = it.id AND it.type = 'item_type'
1125 WHERE e.day_id = %d
1126 ORDER BY e.order ASC",
1127 $day->id
1128 )) ?: [];
1129
1130 // Add activities to entries
1131 foreach ($activities as $entry) {
1132 $includedItems = $this->decodeAmenityItems($entry->included_items ?? null);
1133 $excludedItems = $this->decodeAmenityItems($entry->excluded_items ?? null);
1134
1135 $entryObj = (object) [
1136 'id' => $entry->id,
1137 'trip_id' => $tripId,
1138 'day_id' => $day->id,
1139 'day' => $dayNumber,
1140 'day_number' => $dayNumber,
1141 'day_title' => $day->title,
1142 'day_description' => $day->description,
1143 'title' => $entry->title,
1144 'description' => $entry->description,
1145 'item_type_id' => $entry->item_type_id,
1146 'item_id' => $entry->item_id,
1147 'item_type' => $entry->item_type_name,
1148 'item_name' => $entry->item_name,
1149 'item_icon' => $entry->item_type_icon,
1150 'time' => $entry->time,
1151 'start_time' => $entry->start_time,
1152 'end_time' => $entry->end_time,
1153 'time_type' => $entry->time_type ?? 'exact',
1154 'location' => $entry->location,
1155 'location_latitude' => $entry->location_latitude,
1156 'location_longitude' => $entry->location_longitude,
1157 'duration' => $entry->duration,
1158 'cost' => $entry->cost,
1159 'cost_per_person' => $entry->cost_per_person ?? false,
1160 'notes' => $entry->notes,
1161 'included_items' => $includedItems,
1162 'excluded_items' => $excludedItems,
1163 'status' => $entry->status ?? 'publish',
1164 'order' => $entry->order ?? 0
1165 ];
1166 $allEntries[] = $entryObj;
1167 }
1168 }
1169
1170 return $allEntries;
1171 }, Cache::DURATION_ITINERARY); // Cache for 30 minutes
1172 }
1173
1174 /**
1175 * Find day entry by trip and day number
1176 *
1177 * @param int $tripId Trip ID
1178 * @param int $dayNumber Day number
1179 * @return object|null Day entry object or null if not found
1180 */
1181 public function findDayEntryByTripAndDayNumber(int $tripId, int $dayNumber): ?object
1182 {
1183 global $wpdb;
1184 // Query the days table, not the entries table
1185 $tableDays = TripItineraryDaysTable::getTableName();
1186
1187 // Find the day record for the given trip and day number
1188 return $wpdb->get_row($wpdb->prepare(
1189 "SELECT * FROM `{$tableDays}`
1190 WHERE trip_id = %d
1191 AND day_number = %d
1192 LIMIT 1",
1193 $tripId,
1194 $dayNumber
1195 ));
1196 }
1197
1198 /**
1199 * Find day by trip and day number (alias for findDayEntryByTripAndDayNumber)
1200 *
1201 * @param int $tripId Trip ID
1202 * @param int $dayNumber Day number
1203 * @return object|null Day record object or null if not found
1204 */
1205 public function findDayByTripAndDayNumber(int $tripId, int $dayNumber): ?object
1206 {
1207 return $this->findDayEntryByTripAndDayNumber($tripId, $dayNumber);
1208 }
1209
1210 /**
1211 * Get all published itinerary entries with coordinates for a trip
1212 * Used for map display functionality
1213 *
1214 * @param int $tripId Trip ID
1215 * @return array Array of entries with coordinates and day information
1216 */
1217 public function getEntriesWithCoordinatesForMap(int $tripId): array
1218 {
1219 // Validate trip ID
1220 if (empty($tripId) || $tripId <= 0) {
1221 return [];
1222 }
1223
1224 global $wpdb;
1225
1226 $entries_table = TripItineraryDayEntryTable::getTableName();
1227 $days_table = TripItineraryDaysTable::getTableName();
1228
1229 return $wpdb->get_results($wpdb->prepare(
1230 "SELECT e.*, d.day_number, d.title as day_title
1231 FROM {$entries_table} e
1232 LEFT JOIN {$days_table} d ON e.day_id = d.id
1233 WHERE e.trip_id = %d
1234 AND e.location_latitude IS NOT NULL
1235 AND e.location_longitude IS NOT NULL
1236 AND e.status = 'publish'
1237 ORDER BY d.day_number ASC, e.order ASC",
1238 $tripId
1239 )) ?: [];
1240 }
1241 }
1242