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

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