PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 / CategoryRepository.php

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

394 lines 13.0 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\Constants\ClassificationTypes;
8 use Yatra\Database\Tables\ClassificationsTable;
9 use Yatra\Database\Tables\TripClassificationsTable;
10 use Yatra\Database\Tables\TripsTable;
11 use Yatra\Database\Tables\ReviewsTable;
12
13 /**
14 * Category Repository
15 * Handles database operations for categories using ClassificationsTable
16 */
17 class CategoryRepository extends BaseRepository
18 {
19 /**
20 * Rich text fields specific to categories
21 */
22 protected array $richTextFields = ['description'];
23
24 /**
25 * Integer fields specific to categories
26 */
27 protected array $integerFields = ['parent_id', 'level', 'sorting', 'is_featured'];
28
29 /**
30 * JSON fields specific to categories
31 */
32 protected array $jsonFields = ['metadata'];
33
34 /**
35 * Constructor
36 */
37 public function __construct()
38 {
39 parent::__construct(ClassificationsTable::getTableName());
40 }
41
42 /**
43 * Get table name
44 */
45 protected function getTableName(): string
46 {
47 return ClassificationsTable::getTableName();
48 }
49
50 /**
51 * Find by slug
52 */
53 public function findBySlug(string $slug): ?\stdClass
54 {
55 $table = esc_sql($this->table);
56 $result = $this->wpdb->get_row(
57 $this->wpdb->prepare(
58 "SELECT * FROM `{$table}` WHERE type = %s AND slug = %s",
59 ClassificationTypes::CATEGORY,
60 $slug
61 )
62 );
63 return $result ?: null;
64 }
65
66 /**
67 * Override base all() method to filter by type = 'category'
68 */
69 public function all(array $args = []): array
70 {
71 // IMPORTANT: Always filter by type = 'category' for categories
72 $args['where']['type'] = ClassificationTypes::CATEGORY;
73 return parent::all($args);
74 }
75
76 /**
77 * Override base count() method to filter by type = 'category'
78 */
79 public function count(array $args = []): int
80 {
81 // IMPORTANT: Always filter by type = 'category' for categories
82 $args['where']['type'] = ClassificationTypes::CATEGORY;
83 return parent::count($args);
84 }
85
86 /**
87 * Override base find() method to filter by type = 'category'
88 */
89 public function find(int $id, bool $includeDeleted = false): ?\stdClass
90 {
91 $table = esc_sql($this->table);
92 $query = "SELECT * FROM `{$table}` WHERE type = %s AND id = %d";
93
94 if (!$includeDeleted && $this->hasSoftDelete()) {
95 $query .= " AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')";
96 }
97
98 $result = $this->wpdb->get_row($this->wpdb->prepare($query, ClassificationTypes::CATEGORY, $id));
99 return $result ?: null;
100 }
101
102 /**
103 * Search categories
104 */
105 public function search(string $search, array $args = []): array
106 {
107 $table = esc_sql($this->table);
108 $search = sanitize_text_field($search);
109
110 $where = ["type = %s"];
111 $where[] = "(name LIKE %s OR slug LIKE %s OR description LIKE %s)";
112 $searchTerm = '%' . $this->wpdb->esc_like($search) . '%';
113
114 // Build params alongside the WHERE clause (single pass — the previous
115 // version touched $params before it was initialised).
116 $params = [ClassificationTypes::CATEGORY, $searchTerm, $searchTerm, $searchTerm];
117
118 // Additional WHERE conditions. Column names are attacker-reachable map
119 // keys, so strip them to [A-Za-z0-9_] (same rule as BaseRepository);
120 // values stay parameterised.
121 if (isset($args['where']) && is_array($args['where'])) {
122 foreach ($args['where'] as $field => $value) {
123 $column = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $field);
124 if ($column === '') {
125 continue;
126 }
127 if (is_array($value)) {
128 if (empty($value)) {
129 continue;
130 }
131 $placeholders = implode(',', array_fill(0, count($value), '%s'));
132 $where[] = "`{$column}` IN ({$placeholders})";
133 $params = array_merge($params, array_values($value));
134 } else {
135 $where[] = "`{$column}` = %s";
136 $params[] = $value;
137 }
138 }
139 }
140
141 $whereClause = implode(' AND ', $where);
142
143 // ORDER BY — was raw interpolation of $args['order']. Sanitize the
144 // column to [A-Za-z0-9_] and whitelist the direction. Default unchanged.
145 $orderBy = 'name';
146 $orderDir = 'ASC';
147 if (isset($args['order']) && is_string($args['order']) && $args['order'] !== '') {
148 $parts = preg_split('/\s+/', trim($args['order']));
149 $col = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($parts[0] ?? ''));
150 if ($col !== '') {
151 $orderBy = $col;
152 }
153 $dir = strtoupper((string) ($parts[1] ?? 'ASC'));
154 $orderDir = in_array($dir, ['ASC', 'DESC'], true) ? $dir : 'ASC';
155 }
156 $orderClause = "ORDER BY `{$orderBy}` {$orderDir}";
157
158 // LIMIT — was raw interpolation. Cast to int; still support a legacy
159 // "offset, count" string form if any caller passes one.
160 $limitClause = '';
161 if (isset($args['limit'])) {
162 if (is_string($args['limit']) && strpos($args['limit'], ',') !== false) {
163 [$off, $cnt] = array_map('intval', explode(',', $args['limit'], 2));
164 if ($cnt > 0) {
165 $limitClause = "LIMIT {$off}, {$cnt}";
166 }
167 } else {
168 $limitVal = (int) $args['limit'];
169 if ($limitVal > 0) {
170 $limitClause = "LIMIT {$limitVal}";
171 }
172 }
173 }
174
175 $query = "SELECT * FROM `{$table}` WHERE {$whereClause} {$orderClause} {$limitClause}";
176
177 $results = $this->wpdb->get_results($this->wpdb->prepare($query, $params));
178 return $results ?: [];
179 }
180
181 /**
182 * Get published categories with trip counts
183 */
184 public function getPublishedWithTripCounts(): array
185 {
186 global $wpdb;
187
188 $catTable = esc_sql($this->table);
189 $relTable = TripClassificationsTable::getTableName();
190 $tripsTable = TripsTable::getTableName();
191 $reviewsTable = ReviewsTable::getTableName();
192
193 // COUNT(DISTINCT tc.trip_id) gives real number of trips per category.
194 // avg_rating is computed from approved reviews across all those trips.
195 // starting_price is computed in PHP using both regular trip prices and
196 // traveler-based pricing from recurring availability rules.
197 $sql = "SELECT c.*,
198 COUNT(DISTINCT tc.trip_id) AS trips_count,
199 COALESCE(AVG(r.rating), 0) AS avg_rating,
200 GROUP_CONCAT(DISTINCT tc.trip_id) AS trip_ids
201 FROM `{$catTable}` c
202 LEFT JOIN `{$relTable}` tc
203 ON tc.classification_id = c.id
204 AND tc.classification_type = %s
205 LEFT JOIN `{$tripsTable}` t
206 ON t.id = tc.trip_id
207 LEFT JOIN `{$reviewsTable}` r
208 ON r.trip_id = t.id AND r.status = 'approved'
209 WHERE c.type = %s AND c.status = 'publish'
210 GROUP BY c.id";
211
212 $rows = $this->wpdb->get_results($this->wpdb->prepare($sql, ClassificationTypes::CATEGORY, ClassificationTypes::CATEGORY)) ?: [];
213
214 // Compute starting prices for the trip IDs found.
215 $tripIds = [];
216 foreach ($rows as $row) {
217 if (!empty($row->trip_ids)) {
218 $tripIds = array_merge($tripIds, explode(',', $row->trip_ids));
219 }
220 }
221
222 $pricesByTrip = [];
223 if (!empty($tripIds)) {
224 $pricesByTrip = $this->computeStartingPriceForTripIds(array_unique($tripIds));
225 }
226
227 // Attach starting_price to each category row.
228 foreach ($rows as $row) {
229 $row->starting_price = 0;
230 if (!empty($row->trip_ids)) {
231 $tripIdsForCategory = explode(',', $row->trip_ids);
232 $pricesForCategory = array_intersect_key($pricesByTrip, array_flip($tripIdsForCategory));
233 $row->starting_price = !empty($pricesForCategory) ? min($pricesForCategory) : 0;
234 }
235 }
236
237 return $rows;
238 }
239
240 /**
241 * Compute starting prices for given trip IDs.
242 */
243 private function computeStartingPriceForTripIds(array $tripIds): array
244 {
245 global $wpdb;
246 if (empty($tripIds)) {
247 return [];
248 }
249
250 $tripsTable = TripsTable::getTableName();
251 $placeholders = implode(',', array_fill(0, count($tripIds), '%d'));
252
253 $prices = $wpdb->get_results($wpdb->prepare(
254 "SELECT id, original_price FROM `{$tripsTable}`
255 WHERE id IN ({$placeholders}) AND original_price > 0",
256 ...$tripIds
257 ));
258
259 $pricesByTrip = [];
260 foreach ($prices as $price) {
261 $pricesByTrip[$price->id] = (float) $price->original_price;
262 }
263
264 return $pricesByTrip;
265 }
266
267 /**
268 * Get status counts for categories
269 */
270 public function getStatusCounts(array $args = []): array
271 {
272 $table = esc_sql($this->table);
273
274 $sql = "SELECT status, COUNT(*) as count
275 FROM `{$table}`
276 WHERE type = %s
277 GROUP BY status";
278
279 $results = $this->wpdb->get_results($this->wpdb->prepare($sql, ClassificationTypes::CATEGORY)) ?: [];
280
281 $counts = [
282 'publish' => 0,
283 'draft' => 0,
284 'trash' => 0,
285 'total' => 0
286 ];
287
288 foreach ($results as $row) {
289 $status = $row->status;
290 $count = (int) $row->count;
291
292 // Map old status values to new ones if needed
293 if ($status === 'active') {
294 $status = 'publish';
295 } elseif ($status === 'inactive') {
296 $status = 'trash';
297 }
298
299 if (isset($counts[$status])) {
300 $counts[$status] += $count;
301 $counts['total'] += $count;
302 } else {
303 // Handle any unexpected statuses
304 $counts['total'] += $count;
305 }
306 }
307
308 // Ensure all status keys are present
309 $counts['publish'] = $counts['publish'] ?? 0;
310 $counts['draft'] = $counts['draft'] ?? 0;
311 $counts['trash'] = $counts['trash'] ?? 0;
312
313 return $counts;
314 }
315
316 /**
317 * Get subcategories by parent ID
318 */
319 public function getSubcategories(int $parentId, array $args = []): array
320 {
321 $args['where']['parent_id'] = $parentId;
322 return $this->all($args);
323 }
324
325 /**
326 * Get all categories with subcategories (hierarchical)
327 */
328 public function getHierarchical(array $args = []): array
329 {
330 // Get all top-level categories
331 $topLevelArgs = $args;
332 $topLevelArgs['where']['parent_id'] = null;
333 $categories = $this->all($topLevelArgs);
334
335 // For each category, get its subcategories
336 foreach ($categories as $category) {
337 $subArgs = $args;
338 unset($subArgs['where']['parent_id']); // Remove parent_id filter for subcategories
339 $category->subcategories = $this->getSubcategories((int) $category->id, $subArgs);
340 }
341
342 return $categories;
343 }
344
345 /**
346 * Get trip count for a category
347 *
348 * @param int $categoryId Category ID
349 * @return int Number of trips with this category
350 */
351 public function getTripCount(int $categoryId): int
352 {
353 global $wpdb;
354 $tripRepository = new \Yatra\Repositories\TripRepository();
355 $tripsTable = $tripRepository->getTableName();
356
357 // Use TripClassificationsTable for trip-category relationships
358 $tripClassificationsTable = TripClassificationsTable::getTableName();
359
360 return (int) $wpdb->get_var($wpdb->prepare(
361 "SELECT COUNT(DISTINCT t.id)
362 FROM `{$tripsTable}` t
363 INNER JOIN `{$tripClassificationsTable}` tc ON tc.trip_id = t.id
364 WHERE tc.classification_id = %d
365 AND tc.classification_type = %s
366 AND t.status != 'trash'",
367 $categoryId,
368 ClassificationTypes::CATEGORY
369 ));
370 }
371
372 /**
373 * Get trip count for category (direct field method)
374 *
375 * @param int $categoryId Category ID
376 * @return int Number of trips with this category
377 */
378 public function getTripCountDirect(int $categoryId): int
379 {
380 global $wpdb;
381 $tripRepository = new \Yatra\Repositories\TripRepository();
382 $tripTable = $tripRepository->getTableName();
383
384 return (int) $wpdb->get_var($wpdb->prepare(
385 "SELECT COUNT(*)
386 FROM `{$tripTable}` t
387 WHERE t.category_id = %d
388 AND t.status != 'trash'",
389 $categoryId
390 ));
391 }
392 }
393
394