PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 / FluentBookingService.php

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

413 lines 14.1 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\Ticket;
7 use FluentSupport\App\Services\Helper;
8 use FluentSupport\Framework\Support\Arr;
9
10 class FluentBookingService
11 {
12
13 /**
14 * Registers WordPress hooks for the booking-scheduled and agent-response events.
15 *
16 * @return void
17 */
18 public function init()
19 {
20 add_action('fluent_booking/after_booking_scheduled', [$this, 'handleBookingScheduled'], 20, 3);
21 add_action('fluent_support/response_added_by_agent', [$this, 'promoteGeneratedBookingLinksFromResponse'], 20, 3);
22 }
23
24 /**
25 * Returns the plugin's active/configured state.
26 *
27 * Pass $eventTypes from a prior getEventTypes() call to avoid a redundant DB query.
28 *
29 * @param array|null $eventTypes
30 * @return array
31 */
32 public function getStatus($eventTypes = null)
33 {
34 $active = $this->isActive();
35 $eventTypes = is_array($eventTypes) ? $eventTypes : ($active ? $this->getEventTypes() : []);
36
37 return [
38 'active' => $active,
39 'configured' => $active && !empty($eventTypes),
40 'admin_url' => esc_url_raw(admin_url('admin.php?page=fluent-booking#/')),
41 'message' => $active
42 ? ''
43 : __('FluentBooking is not installed or active.', 'fluent-support')
44 ];
45 }
46
47 /**
48 * Returns all active, publicly-accessible event types the current user can read, shaped for the frontend selector.
49 *
50 * @return array
51 */
52 public function getEventTypes()
53 {
54 if (!$this->isActive()) {
55 return [];
56 }
57
58 $events = \FluentBooking\App\Models\CalendarSlot::query()
59 ->with('calendar')
60 ->where('status', 'active')
61 ->orderBy('id', 'DESC')
62 ->get();
63
64 $eventTypes = [];
65
66 foreach ($events as $event) {
67 if (!$event->calendar || !self::canUseEvent($event)) {
68 continue;
69 }
70
71 $eventTypes[] = [
72 'id' => (int) $event->id,
73 'title' => sanitize_text_field($event->title),
74 'duration' => (int) $event->duration,
75 'calendar_title' => sanitize_text_field($event->calendar->title),
76 ];
77 }
78
79 return $eventTypes;
80 }
81
82 /**
83 * Generates a tokenised booking URL and both HTML and plain-text email content.
84 *
85 * Each selected slot is validated against live availability before being embedded.
86 *
87 * @param Ticket $ticket
88 * @param int $eventId
89 * @param string $message
90 * @param array $selectedSlots
91 * @param string $timezone
92 * @throws \Exception
93 * @return array
94 */
95 public function createBookingLink(Ticket $ticket, $eventId, $message = '', $selectedSlots = [], $timezone = '')
96 {
97 if (!$this->isActive()) {
98 throw new \Exception(esc_html__('FluentBooking is not installed or active.', 'fluent-support'));
99 }
100
101 $event = \FluentBooking\App\Models\CalendarSlot::with('calendar')->find($eventId);
102
103 if (!$event || !$event->calendar || !self::canUseEvent($event)) {
104 throw new \Exception(esc_html__('Selected FluentBooking event type is not available.', 'fluent-support'));
105 }
106
107 $linkToken = bin2hex(random_bytes(16));
108 $bookingUrl = self::addBookingLinkToken(self::getEventUrl($event, $ticket), $linkToken);
109 $message = trim(wp_unslash($message));
110 $availability = new BookingAvailabilityHelper();
111
112 $selectedSlots = $availability->sanitizeSelectedSlots($selectedSlots, $event, $ticket, (int) $event->getDuration());
113 $selectedSlots = $availability->addTokenToSlots($selectedSlots, $linkToken);
114 $slotsHtml = $availability->formatSlotsHtml($selectedSlots, $event, $timezone);
115
116 $htmlParts = [];
117
118 if ($message !== '') {
119 $htmlParts[] = '<p>' . esc_html($message) . '</p>';
120 }
121
122 if ($slotsHtml) {
123 $htmlParts[] = $slotsHtml;
124 }
125
126 $htmlParts[] = '<p><a href="' . esc_url($bookingUrl) . '" target="_blank" rel="noopener">' . esc_html__('See all available times', 'fluent-support') . '</a></p>';
127
128 return [
129 'html' => wp_kses_post(implode('', $htmlParts)),
130 'plain_text' => $availability->formatSlotsPlainText($message, $selectedSlots, $bookingUrl),
131 ];
132 }
133
134 /**
135 * Fetches and formats available time slots for the given event and date range, grouped by day for the UI preview.
136 *
137 * @param int $eventId
138 * @param string $range
139 * @param string $timezone
140 * @param int|null $duration
141 * @param Ticket|null $ticket
142 * @param array $selectedDates
143 * @param string $calendarMonth
144 * @throws \Exception
145 * @return array
146 */
147 public function getAvailabilitySlots($eventId, $range = 'next_3_days', $timezone = '', $duration = null, ?Ticket $ticket = null, $selectedDates = [], $calendarMonth = '')
148 {
149 if (!$this->isActive()) {
150 throw new \Exception(esc_html__('FluentBooking is not installed or active.', 'fluent-support'));
151 }
152
153 $event = \FluentBooking\App\Models\CalendarSlot::with('calendar')->find($eventId);
154
155 if (!$event || !$event->calendar || !self::canUseEvent($event)) {
156 throw new \Exception(esc_html__('Selected FluentBooking event type is not available.', 'fluent-support'));
157 }
158
159 $availability = new BookingAvailabilityHelper();
160 $timezone = $availability->sanitizeTimezone($timezone, $event->calendar->author_timezone);
161 $rangeData = $availability->getAvailabilityRange($range, $timezone, $selectedDates, $calendarMonth);
162
163 $days = array_map(function ($day) {
164 $day['slots'] = array_map(function ($slot) {
165 return [
166 'id' => $slot['id'],
167 'start' => $slot['start'],
168 'time_label' => $slot['time_label'],
169 ];
170 }, $day['slots']);
171
172 return $day;
173 }, $availability->fetchFormattedDays($event, $rangeData, $timezone, $ticket, (int) $event->getDuration($duration)));
174
175 return [
176 'timezone' => $timezone,
177 'max_lookup_date' => $event->getMaxLookUpDate(),
178 'days' => $days,
179 ];
180 }
181
182 /**
183 * Returns upcoming and past bookings for the ticket's customer.
184 *
185 * When stored link tokens exist the query is scoped to those event types and date range.
186 *
187 * @param Ticket $ticket
188 * @return array
189 */
190 public function getTicketMeetings(Ticket $ticket)
191 {
192 $links = new BookingLinkManager();
193 $generatedLinks = $links->getLinksForTicket($ticket);
194
195 $meetings = [
196 'upcoming' => [],
197 'past' => [],
198 'message' => '',
199 ];
200
201 if (!$this->isActive() || !$ticket->customer || !$ticket->customer->email) {
202 $meetings['message'] = __('FluentBooking is not installed or the ticket customer has no email address.', 'fluent-support');
203 return $meetings;
204 }
205
206 $eventIds = array_values(array_unique(array_filter(array_column($generatedLinks, 'event_type_id'))));
207 $earliestLinkDate = $links->getEarliestLinkDate($generatedLinks);
208
209 $bookingQuery = \FluentBooking\App\Models\Booking::query()
210 ->with(['calendar', 'calendar_event'])
211 ->where('email', sanitize_email($ticket->customer->email))
212 ->where('status', '!=', 'reserved');
213
214 if ($eventIds) {
215 $bookingQuery->whereIn('event_id', $eventIds);
216 }
217
218 if ($earliestLinkDate) {
219 $bookingQuery->where('created_at', '>=', $earliestLinkDate);
220 }
221
222 $bookings = $bookingQuery->orderBy('start_time', 'DESC')->limit(10)->get();
223
224 foreach ($bookings as $booking) {
225 $formatted = $links->formatBooking($booking);
226
227 if (strtotime($booking->end_time) >= time() && $booking->status === 'scheduled') {
228 $meetings['upcoming'][] = $formatted;
229 } else {
230 $meetings['past'][] = $formatted;
231 }
232 }
233
234 usort($meetings['upcoming'], function ($a, $b) {
235 return strtotime($a['start_time']) <=> strtotime($b['start_time']);
236 });
237
238 if (!$bookings->count() && $generatedLinks) {
239 $meetings['message'] = __('No meeting booked yet from these links.', 'fluent-support');
240 } elseif (!$bookings->count()) {
241 $meetings['message'] = __('No FluentBooking meetings found for this customer.', 'fluent-support');
242 }
243
244 return $meetings;
245 }
246
247 /**
248 * Hook: fluent_booking/after_booking_scheduled.
249 *
250 * Appends an internal timeline note when a customer books via a link sent from this ticket.
251 *
252 * @param object $booking
253 * @param object $calendarSlot
254 * @param array $bookingData
255 * @return void
256 */
257 public function handleBookingScheduled($booking, $calendarSlot, $bookingData = [])
258 {
259 $links = new BookingLinkManager();
260 $ticket = $links->getTicketFromBooking($booking, $bookingData);
261
262 if (!$ticket || $links->hasTimelineNote($booking)) {
263 return;
264 }
265
266 $formatted = $links->formatBooking($booking);
267 $personId = (int) $ticket->agent_id ?: (int) $ticket->customer_id;
268 $eventTitle = Arr::get($formatted, 'event_title') ?: __('Meeting', 'fluent-support');
269
270 $content = sprintf(
271 '<strong>%1$s</strong><br>%2$s<br>%3$s',
272 esc_html__('Meeting scheduled via FluentBooking', 'fluent-support'),
273 esc_html($eventTitle),
274 esc_html(Arr::get($formatted, 'date_text'))
275 );
276
277 if ($timezone = Arr::get($formatted, 'timezone')) {
278 $content .= '<br><span>' . esc_html($timezone) . '</span>';
279 }
280
281 if ($adminUrl = Arr::get($formatted, 'admin_url')) {
282 $content .= '<br><a href="' . esc_url($adminUrl) . '" target="_blank" rel="noopener">' . esc_html__('View booking', 'fluent-support') . '</a>';
283 }
284
285 Conversation::create([
286 'ticket_id' => $ticket->id,
287 'person_id' => $personId,
288 'message_id' => 'fluent_booking_scheduled_' . (int) $booking->id,
289 'conversation_type' => 'internal_info',
290 'source' => 'fluent_booking',
291 'content' => wp_kses_post($content)
292 ]);
293 }
294
295 /**
296 * Hook: fluent_support/response_added_by_agent.
297 *
298 * Scans outgoing response content for tokenised booking URLs and persists any found as ticket meta.
299 *
300 * @param object $response
301 * @param Ticket $ticket
302 * @param object $person
303 * @return void
304 */
305 public function promoteGeneratedBookingLinksFromResponse($response, Ticket $ticket, $person)
306 {
307 if (empty($response->content) || $response->conversation_type !== 'response') {
308 return;
309 }
310
311 $links = new BookingLinkManager();
312 $bookingLinks = $links->extractLinksFromContent($response->content);
313
314 if (!$bookingLinks) {
315 return;
316 }
317
318 foreach ($bookingLinks as $bookingLink) {
319 $links->storeLinkFromUrl($ticket, $person, $bookingLink);
320 }
321 }
322
323 /**
324 * Returns true when FluentBooking is installed.
325 *
326 * @return bool
327 */
328 public function isActive()
329 {
330 if (!defined('FLUENT_BOOKING_VERSION')) {
331 return false;
332 }
333 return Helper::getBusinessSettings('enable_fluent_booking_integration', 'yes') === 'yes';
334 }
335
336 /**
337 * Returns true when the event's landing-page sharing is enabled and includes this slot.
338 *
339 * @param object $event
340 * @return bool
341 */
342 public static function canUseEvent($event)
343 {
344 $settings = \FluentBooking\App\Services\LandingPage\LandingPageHelper::getSettings($event->calendar, 'public');
345
346 if (Arr::get($settings, 'enabled') !== 'yes') {
347 return false;
348 }
349
350 if (Arr::get($settings, 'show_type') === 'all') {
351 return true;
352 }
353
354 return in_array((int) $event->id, array_map('intval', Arr::get($settings, 'enabled_slots', [])), true);
355 }
356
357 /**
358 * Builds the booking landing-page URL for an event.
359 *
360 * Uses Calendar::getLandingPageUrl() so FluentBooking owns its URL format (pretty-slug vs query-string).
361 * canUseEvent() has already confirmed the landing page is enabled, so $isForce = true skips the
362 * redundant settings re-check inside getLandingPageUrl().
363 * Appends customer name, email, and ticket ID when a ticket is provided.
364 *
365 * @param object $event
366 * @param Ticket|null $ticket
367 * @return string
368 */
369 public static function getEventUrl($event, ?Ticket $ticket = null)
370 {
371 $calendarBaseUrl = $event->calendar->getLandingPageUrl(true);
372
373 if (!$calendarBaseUrl) {
374 return '';
375 }
376
377 $url = defined('FLUENT_BOOKING_LANDING_SLUG')
378 ? $calendarBaseUrl . '/' . $event->slug
379 : $calendarBaseUrl . '&event=' . $event->slug;
380
381 if (!$ticket || !$ticket->customer) {
382 return esc_url_raw($url);
383 }
384
385 $customer = $ticket->customer;
386
387 return esc_url_raw(add_query_arg(array_filter([
388 'invitee_name' => sanitize_text_field($customer->full_name),
389 'invitee_email' => sanitize_email($customer->email),
390 'fs_ticket_id' => (int) $ticket->id
391 ]), $url));
392 }
393
394 /**
395 * Appends fs_booking_link_token to a URL so a completed booking can be traced back to the originating ticket link.
396 *
397 * @param string $url
398 * @param string $linkToken
399 * @return string
400 */
401 public static function addBookingLinkToken($url, $linkToken)
402 {
403 $linkToken = sanitize_key($linkToken);
404
405 if (!$url || !$linkToken) {
406 return esc_url_raw($url);
407 }
408
409 return esc_url_raw(add_query_arg(['fs_booking_link_token' => $linkToken], $url));
410 }
411
412 }
413