PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.2.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.2.0
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
fluent-support / app / Services / Integrations / FluentBooking / BookingLinkManager.php

BookingLinkManager.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.2.0, at app/Services/Integrations/FluentBooking/BookingLinkManager.php

461 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Services\Integrations\FluentBooking;
4
5 use FluentSupport\App\Models\Conversation;
6 use FluentSupport\App\Models\Meta;
7 use FluentSupport\App\Models\Ticket;
8 use FluentSupport\App\Services\Helper;
9 use FluentSupport\Framework\Support\Arr;
10
11 class BookingLinkManager
12 {
13 /**
14 * Persists a booking link record as serialised ticket meta.
15 *
16 * @param Ticket $ticket
17 * @param array $metadata
18 * @return void
19 */
20 public function storeLink(Ticket $ticket, array $metadata)
21 {
22 Meta::create([
23 'object_type' => 'ticket',
24 'object_id' => (int) $ticket->id,
25 'key' => 'fluent_booking_link',
26 'value' => maybe_serialize($metadata)
27 ]);
28 }
29
30 /**
31 * Extracts a link token and event details from a URL and stores them as ticket meta, skipping duplicates.
32 *
33 * @param Ticket $ticket
34 * @param object $person
35 * @param string $bookingUrl
36 * @return void
37 */
38 public function storeLinkFromUrl(Ticket $ticket, $person, $bookingUrl)
39 {
40 $bookingUrl = esc_url_raw($bookingUrl);
41 $linkToken = $this->getLinkTokenFromUrl($bookingUrl);
42
43 if (!$bookingUrl || !$linkToken || $this->hasStoredLink($ticket, $linkToken)) {
44 return;
45 }
46
47 $event = $this->getEventFromUrl($bookingUrl);
48
49 if (!$event || !$event->calendar || !FluentBookingService::canUseEvent($event)) {
50 return;
51 }
52
53 $this->storeLink($ticket, [
54 'event_type_id' => (int) $event->id,
55 'event_type_title' => sanitize_text_field($event->title),
56 'calendar_id' => (int) $event->calendar_id,
57 'calendar_title' => sanitize_text_field($event->calendar->title),
58 'booking_url' => $bookingUrl,
59 'link_token' => sanitize_key($linkToken),
60 'ticket_id' => (int) $ticket->id,
61 'customer_id' => (int) $ticket->customer_id,
62 'agent_id' => $person ? (int) $person->id : 0,
63 'created_at' => current_time('mysql')
64 ]);
65 }
66
67 /**
68 * Returns true when this link token is already recorded in the ticket's booking link meta.
69 *
70 * @param Ticket $ticket
71 * @param string $linkToken
72 * @return bool
73 */
74 public function hasStoredLink(Ticket $ticket, $linkToken)
75 {
76 return (bool) $this->metaQuery()
77 ->where('object_id', (int) $ticket->id)
78 ->where('value', 'LIKE', '%' . sanitize_key($linkToken) . '%')
79 ->exists();
80 }
81
82 /**
83 * Returns all deserialised booking link records stored in this ticket's meta, most recent first.
84 *
85 * @param Ticket $ticket
86 * @return array
87 */
88 public function getLinksForTicket(Ticket $ticket)
89 {
90 return $this->metaQuery()
91 ->where('object_id', (int) $ticket->id)
92 ->orderBy('id', 'DESC')
93 ->limit(5)
94 ->get()
95 ->map(function ($meta) {
96 $link = Helper::safeUnserialize($meta->value);
97 return is_array($link) ? $link : [];
98 })
99 ->filter()
100 ->toArray();
101 }
102
103 /**
104 * Returns a DB-ready datetime one day before the oldest stored link's creation date.
105 *
106 * Used to bound the booking query when scoping by generated links.
107 *
108 * @param array $generatedLinks
109 * @return string
110 */
111 public function getEarliestLinkDate($generatedLinks)
112 {
113 if (!$generatedLinks || !is_array($generatedLinks)) {
114 return '';
115 }
116
117 $dates = array_filter(array_map(function ($link) {
118 $createdAt = Arr::get($link, 'created_at');
119 return $createdAt && strtotime($createdAt) ? $createdAt : '';
120 }, $generatedLinks));
121
122 if (!$dates) {
123 return '';
124 }
125
126 return gmdate('Y-m-d H:i:s', strtotime(min($dates)) - DAY_IN_SECONDS);
127 }
128
129 /**
130 * Extracts all tokenised booking URLs (containing fs_booking_link_token) from HTML content.
131 *
132 * @param string $content
133 * @return array
134 */
135 public function extractLinksFromContent($content)
136 {
137 $content = html_entity_decode((string) $content, ENT_QUOTES, 'UTF-8');
138
139 if (!preg_match_all('/https?:\/\/[^\s"\']*fs_booking_link_token=[a-f0-9]{32}[^\s"\']*/i', $content, $matches)) {
140 return [];
141 }
142
143 return array_values(array_unique(array_map('esc_url_raw', $matches[0])));
144 }
145
146 /**
147 * Returns the fs_booking_link_token query param value from a URL.
148 *
149 * @param string $url
150 * @return string
151 */
152 public function getLinkTokenFromUrl($url)
153 {
154 return sanitize_key(Arr::get($this->parseUrlQueryArgs($url), 'fs_booking_link_token'));
155 }
156
157 /**
158 * Resolves a CalendarSlot from a booking URL.
159 *
160 * Tries host/event query params first; falls back to extracting calendar and event slugs from the URL path.
161 *
162 * @param string $bookingUrl
163 * @return \FluentBooking\App\Models\CalendarSlot|null
164 */
165 public function getEventFromUrl($bookingUrl)
166 {
167 $queryArgs = $this->parseUrlQueryArgs($bookingUrl);
168 $calendarSlug = sanitize_text_field(Arr::get($queryArgs, 'host'));
169 $eventSlug = sanitize_text_field(Arr::get($queryArgs, 'event'));
170
171 if (!$calendarSlug || !$eventSlug) {
172 $path = trim((string) wp_parse_url($bookingUrl, PHP_URL_PATH), '/');
173 $parts = array_values(array_filter(explode('/', $path)));
174
175 if (count($parts) >= 2) {
176 $eventSlug = sanitize_text_field(array_pop($parts));
177 $calendarSlug = sanitize_text_field(array_pop($parts));
178 }
179 }
180
181 if (!$calendarSlug || !$eventSlug) {
182 return null;
183 }
184
185 return \FluentBooking\App\Models\CalendarSlot::query()
186 ->with('calendar')
187 ->where('slug', $eventSlug)
188 ->whereHas('calendar', function ($query) use ($calendarSlug) {
189 $query->where('slug', $calendarSlug);
190 })
191 ->first();
192 }
193
194 /**
195 * Resolves which ticket a new booking belongs to.
196 *
197 * Tries matching via the fs_ticket_id query param first, then falls back to
198 * fs_booking_link_token stored in ticket meta.
199 *
200 * @param object $booking
201 * @param array $bookingData
202 * @return Ticket|null
203 */
204 public function getTicketFromBooking($booking, $bookingData = [])
205 {
206 $queryArgs = $this->getBookingSourceQueryArgs($booking, $bookingData);
207 $ticketId = absint(Arr::get($queryArgs, 'fs_ticket_id'));
208
209 if ($ticketId) {
210 $ticket = Ticket::with('customer')->find($ticketId);
211
212 if ($ticket && $this->bookingMatchesTicket($booking, $ticket)) {
213 return $ticket;
214 }
215 }
216
217 $linkToken = sanitize_key(Arr::get($queryArgs, 'fs_booking_link_token'));
218
219 if ($linkToken) {
220 $ticket = $this->findTicketByToken($booking, $linkToken);
221
222 if ($ticket) {
223 return $ticket;
224 }
225 }
226
227 return null;
228 }
229
230 /**
231 * Returns true when an internal_info conversation entry already exists for this booking.
232 *
233 * Prevents duplicate timeline notes from being created.
234 *
235 * @param object $booking
236 * @return bool
237 */
238 public function hasTimelineNote($booking)
239 {
240 return (bool) Conversation::where('message_id', 'fluent_booking_scheduled_' . (int) $booking->id)
241 ->where('source', 'fluent_booking')
242 ->exists();
243 }
244
245 /**
246 * Maps a Booking model to a sanitised array for frontend display.
247 *
248 * Includes local time conversion and join-URL extraction.
249 *
250 * @param object $booking
251 * @return array
252 */
253 public function formatBooking($booking)
254 {
255 $joinUrl = '';
256 $timezone = $this->getDisplayTimezone($booking);
257
258 if (method_exists($booking, 'getLocationAsText')) {
259 $locationText = $booking->getLocationAsText();
260 if (filter_var($locationText, FILTER_VALIDATE_URL)) {
261 $joinUrl = esc_url_raw($locationText);
262 }
263 }
264
265 return [
266 'id' => (int) $booking->id,
267 'event_title' => $booking->calendar_event ? sanitize_text_field($booking->calendar_event->title) : '',
268 'calendar_title' => $booking->calendar ? sanitize_text_field($booking->calendar->title) : '',
269 'status' => sanitize_key($booking->status),
270 'status_label' => sanitize_text_field(method_exists($booking, 'getStatusLabel') ? $booking->getStatusLabel() : ucwords(str_replace('_', ' ', $booking->status))),
271 'start_time' => sanitize_text_field($booking->start_time),
272 'local_start_time' => sanitize_text_field($this->convertTime($booking->start_time, $timezone)),
273 'timezone' => sanitize_text_field($timezone),
274 'date_text' => sanitize_text_field(method_exists($booking, 'getFullBookingDateTimeText') ? $booking->getFullBookingDateTimeText($timezone) : $booking->start_time),
275 'admin_url' => method_exists($booking, 'getAdminViewUrl') ? esc_url_raw($booking->getAdminViewUrl()) : '',
276 'join_url' => esc_url_raw($joinUrl),
277 'internal_note' => !empty($booking->internal_note) ? sanitize_textarea_field(wp_strip_all_tags($booking->internal_note)) : '',
278 ];
279 }
280
281 /**
282 * Returns a pre-scoped Meta query for ticket booking_link records.
283 *
284 * Shared by lookup, existence-check, and fetch operations.
285 *
286 * @return \FluentSupport\Framework\Database\Orm\Builder
287 */
288 private function metaQuery()
289 {
290 return Meta::where('object_type', 'ticket')
291 ->where('key', 'fluent_booking_link');
292 }
293
294 /**
295 * Parses the query string of a URL into an associative array.
296 *
297 * @param string $url
298 * @return array
299 */
300 private function parseUrlQueryArgs($url)
301 {
302 $query = wp_parse_url($url, PHP_URL_QUERY);
303
304 if (!$query) {
305 return [];
306 }
307
308 parse_str($query, $queryArgs);
309
310 return is_array($queryArgs) ? $queryArgs : [];
311 }
312
313 /**
314 * Returns query args from the booking's source URL.
315 *
316 * Prefers $bookingData['source_url'] over the booking model's own field.
317 *
318 * @param object $booking
319 * @param array $bookingData
320 * @return array
321 */
322 private function getBookingSourceQueryArgs($booking, $bookingData = [])
323 {
324 $sourceUrl = Arr::get($bookingData, 'source_url') ?: (!empty($booking->source_url) ? $booking->source_url : '');
325
326 return $sourceUrl ? $this->parseUrlQueryArgs($sourceUrl) : [];
327 }
328
329 /**
330 * Returns true when the booking's email case-insensitively matches the ticket customer's email.
331 *
332 * @param object $booking
333 * @param Ticket $ticket
334 * @return bool
335 */
336 private function bookingEmailMatchesTicket($booking, Ticket $ticket)
337 {
338 return $ticket->customer
339 && !empty($booking->email)
340 && strtolower(sanitize_email($ticket->customer->email)) === strtolower(sanitize_email($booking->email));
341 }
342
343 /**
344 * Returns true when the email matches and the ticket has a stored booking link for this event.
345 *
346 * @param object $booking
347 * @param Ticket $ticket
348 * @return bool
349 */
350 private function bookingMatchesTicket($booking, Ticket $ticket)
351 {
352 return $this->bookingEmailMatchesTicket($booking, $ticket)
353 && $this->ticketHasGeneratedEventLink($ticket, (int) $booking->event_id);
354 }
355
356 /**
357 * Returns true when any stored booking link for this ticket targets the given event ID.
358 *
359 * @param Ticket $ticket
360 * @param int $eventId
361 * @return bool
362 */
363 private function ticketHasGeneratedEventLink(Ticket $ticket, $eventId)
364 {
365 foreach ($this->getLinksForTicket($ticket) as $link) {
366 if ((int) Arr::get($link, 'event_type_id') === (int) $eventId) {
367 return true;
368 }
369 }
370
371 return false;
372 }
373
374 /**
375 * Searches stored link-token meta across all tickets and returns the first whose email and event ID match the booking.
376 *
377 * @param object $booking
378 * @param string $linkToken
379 * @return Ticket|null
380 */
381 private function findTicketByToken($booking, $linkToken)
382 {
383 $linkToken = sanitize_key($linkToken);
384
385 if (!$linkToken) {
386 return null;
387 }
388
389 $metaRows = $this->metaQuery()
390 ->where('value', 'LIKE', '%' . $linkToken . '%')
391 ->orderBy('id', 'DESC')
392 ->limit(5)
393 ->get();
394
395 foreach ($metaRows as $meta) {
396 $link = Helper::safeUnserialize($meta->value);
397
398 if (!is_array($link) || sanitize_key(Arr::get($link, 'link_token')) !== $linkToken) {
399 continue;
400 }
401
402 $ticket = Ticket::with('customer')->find((int) $meta->object_id);
403
404 if ($ticket && $this->bookingMatchesGeneratedLink($booking, $ticket, $link)) {
405 return $ticket;
406 }
407 }
408
409 return null;
410 }
411
412 /**
413 * Returns true when the booking email matches the ticket customer and the stored link targets the same event.
414 *
415 * @param object $booking
416 * @param Ticket $ticket
417 * @param array $link
418 * @return bool
419 */
420 private function bookingMatchesGeneratedLink($booking, Ticket $ticket, array $link)
421 {
422 return $this->bookingEmailMatchesTicket($booking, $ticket)
423 && (int) Arr::get($link, 'event_type_id') === (int) $booking->event_id;
424 }
425
426 /**
427 * Returns the display timezone for a booking, preferring the calendar author timezone.
428 *
429 * @param object $booking
430 * @return string
431 */
432 private function getDisplayTimezone($booking)
433 {
434 if ($booking->calendar && !empty($booking->calendar->author_timezone)) {
435 return $booking->calendar->author_timezone;
436 }
437
438 if (method_exists($booking, 'getHostTimezone') && $timezone = $booking->getHostTimezone()) {
439 return $timezone;
440 }
441
442 return wp_timezone_string();
443 }
444
445 /**
446 * Converts a UTC booking time string to the given timezone.
447 *
448 * @param string $time
449 * @param string $timezone
450 * @return string
451 */
452 private function convertTime($time, $timezone)
453 {
454 if (!$time) {
455 return $time;
456 }
457
458 return \FluentBooking\App\Services\DateTimeHelper::convertFromUtc($time, $timezone, 'Y-m-d H:i:s');
459 }
460 }
461