PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
← All changes | app/Services/BookingService.php +320 -54 1.5.02 → 2.5.0 View file →
@@ -24,8 +24,183 @@
24 24 'calendar_id' => $calendarSlot->calendar_id,
25 25 'host_user_id' => $calendarSlot->user_id
26 26 ];
27 27
28 + $data = self::prepareBookingData($data, $calendarSlot);
29 +
30 + $guests = Arr::get($data, 'additional_guests', []);
31 +
32 + $bookingData = Arr::only(wp_parse_args($data, $defaults), (new Booking())->getFillable());
33 +
34 + $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
35 +
36 + $bookingData['event_type'] = $calendarSlot->event_type;
37 +
38 + $bookingData = apply_filters('fluent_booking/booking_data', $bookingData, $calendarSlot, $customFieldsData, $data);
39 +
40 + if (is_wp_error($bookingData)) {
41 + return $bookingData;
42 + }
43 +
44 + return self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, $guests);
45 + }
46 +
47 + public static function createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, $guests = [], $bookingIds = [])
48 + {
49 + if (is_array($bookingData['start_time'])) {
50 + return self::createMultiTimeBooking($bookingData, $calendarSlot, $customFieldsData, $guests);
51 + }
52 +
53 + if (is_array($bookingData['email'])) {
54 + return self::createMultiGuestBooking($bookingData, $calendarSlot, $customFieldsData);
55 + }
56 +
57 + do_action('fluent_booking/before_booking', $bookingData, $calendarSlot);
58 +
59 + $booking = Helper::dbTransaction(function () use ($bookingData) {
60 + return Booking::create($bookingData);
61 + });
62 +
63 + self::attachHosts($booking, $calendarSlot);
64 + self::updateParentInfo($booking, $bookingIds);
65 + self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
66 +
67 + $booking->load('calendar');
68 +
69 + $bookingStatus = $booking->status;
70 + $paymentStatus = $booking->payment_status;
71 +
72 + $bookingData = apply_filters('fluent_booking/after_booking_data', $bookingData, $booking, $calendarSlot, $customFieldsData);
73 +
74 + // this pre hook is for early actions that require for remote calendars and locations
75 + do_action('fluent_booking/pre_after_booking_' . $bookingStatus, $booking, $calendarSlot, $bookingData);
76 +
77 + // We are just renewing this as this may have been changed by the pre hook
78 + $booking = Booking::find($booking->id);
79 +
80 + if (self::preHookHasDispatched($bookingStatus, $paymentStatus, $booking)) {
81 + return $booking;
82 + }
83 +
84 + do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
85 +
86 + return $booking;
87 + }
88 +
89 + /**
90 + * Whether the pre hook already dispatched the lifecycle action, so
91 + * dispatching again would notify twice. Status is not the only sign: a
92 + * full-price coupon settles payment on a booking that stays pending for
93 + * manual confirmation.
94 + *
95 + * Loose on purpose - payment_status is nullable with no default, and
96 + * multi-time child rows are written as ''.
97 + *
98 + * @return bool
99 + */
100 + private static function preHookHasDispatched($bookingStatus, $paymentStatus, $booking)
101 + {
102 + return $bookingStatus != $booking->status
103 + || $paymentStatus != $booking->payment_status;
104 + }
105 +
106 + public static function createMultiTimeBooking($data, $calendarSlot, $customFieldsData, $guests)
107 + {
108 + $booking = [];
109 + $bookingIds = [];
110 + $createdBookingIds = [];
111 + $lastBooking = end($data['start_time']);
112 + $totalBooking = count($data['start_time']);
113 + $bookingTimes = array_combine($data['start_time'], $data['end_time']);
114 +
115 + if ($bookingTimes === false) {
116 + throw new \InvalidArgumentException(esc_html__('Booking start and end times are invalid.', 'fluent-booking'));
117 + }
118 +
119 + foreach ($bookingTimes as $startTime => $endTime) {
120 + $bookingData = $data;
121 +
122 + $bookingData['start_time'] = $startTime;
123 + $bookingData['end_time'] = $endTime;
124 +
125 + $isConfRequired = $calendarSlot->isConfirmationRequired($startTime);
126 + $bookingData['status'] = $isConfRequired ? 'pending' : $data['status'];
127 + $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
128 +
129 + if ($startTime == $lastBooking) {
130 + $createdBookingIds = $bookingIds;
131 + } else {
132 + $bookingData['parent_id'] = '';
133 + }
134 +
135 + if (Arr::get($data, 'payment_method')) {
136 + if ($startTime == $lastBooking) {
137 + $bookingData['quantity'] = $totalBooking;
138 + } else {
139 + $bookingData['payment_status'] = '';
140 + $bookingData['payment_method'] = '';
141 + }
142 + }
143 +
144 + $booking = self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, $guests, $createdBookingIds);
145 +
146 + $bookingIds[] = $booking->id;
147 + }
148 +
149 + return $booking;
150 + }
151 +
152 + public static function createMultiGuestBooking($data, $calendarSlot, $customFieldsData)
153 + {
154 + $booking = [];
155 + $bookingIds = [];
156 + $createdBookingIds = [];
157 + $lastBooking = end($data['email']);
158 + $totalBooking = count($data['email']);
159 + $guests = array_combine($data['email'], $data['first_name']);
160 +
161 + if ($guests === false) {
162 + throw new \InvalidArgumentException(esc_html__('Guest names and emails are invalid.', 'fluent-booking'));
163 + }
164 +
165 + foreach ($guests as $email => $name) {
166 + $bookingData = $data;
167 +
168 + $bookingData['email'] = $email;
169 +
170 + $nameArray = explode(' ', trim($name));
171 + $bookingData['first_name'] = array_shift($nameArray);
172 + $bookingData['last_name'] = implode(' ', $nameArray);
173 +
174 + if ($user = get_user_by('email', $email)) {
175 + $bookingData['person_user_id'] = $user->ID;
176 + }
177 +
178 + $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
179 +
180 + if ($email == $lastBooking) {
181 + $createdBookingIds = $bookingIds;
182 + }
183 +
184 + if (Arr::get($data, 'payment_method')) {
185 + if ($email == $lastBooking) {
186 + $bookingData['quantity'] = $totalBooking;
187 + } else {
188 + $bookingData['payment_status'] = '';
189 + $bookingData['payment_method'] = '';
190 + }
191 + }
192 +
193 + $booking = self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, [], $createdBookingIds);
194 +
195 + $bookingIds[] = $booking->id;
196 + }
197 +
198 + return $booking;
199 + }
200 +
201 + private static function prepareBookingData($data, $calendarSlot)
202 + {
28 203 if (empty($data['first_name']) && !empty($data['name'])) {
29 204 $nameArray = explode(' ', trim($data['name']));
30 205 $data['first_name'] = array_shift($nameArray);
31 206 $data['last_name'] = implode(' ', $nameArray);
@@ -53,64 +228,81 @@
53 228 if (empty($data['location_details'])) {
54 229 $data['location_details'] = LocationService::getLocationDetails($calendarSlot, [], []);
55 230 }
56 231
57 - $additionalGuests = Arr::get($data, 'additional_guests', []);
232 + if ($additionalGuests = Arr::get($data, 'additional_guests', [])) {
233 + if ($calendarSlot->isMultiGuestEvent()) {
234 + $guestEmails = array_map(function ($guest) {
235 + return $guest['email'];
236 + }, $additionalGuests);
237 + $data['email'] = array_merge($guestEmails, (array) $data['email']);
58 238
59 - $bookingData = Arr::only(wp_parse_args($data, $defaults), (new Booking())->getFillable());
239 + $guestNames = array_map(function ($guest) {
240 + return $guest['name'];
241 + }, $additionalGuests);
242 + $data['first_name'] = array_merge($guestNames, (array) ($data['first_name'] . ' ' . $data['last_name']));
60 243
61 - if ($calendarSlot->isMultiGuestEvent()){
62 - $event = Booking::select('group_id')
63 - ->where('event_id', $calendarSlot->id)
64 - ->where('calendar_id', $calendarSlot->calendar_id)
65 - ->where('start_time', $bookingData['start_time'])
66 - ->first();
244 + $data['additional_guests'] = [];
245 + }
246 + }
67 247
68 - $bookingData['group_id'] = $event ? $event->group_id : null;
248 + return $data;
249 + }
250 +
251 + private static function attachHosts($booking, $calendarSlot)
252 + {
253 + $hosts = [$booking->host_user_id];
254 + if ($calendarSlot->isMultiHostsEvent()) {
255 + $hosts = $calendarSlot->getHostIds();
69 256 }
70 257
71 - $bookingData['event_type'] = $calendarSlot->event_type;
258 + $hostData = [];
259 + foreach ($hosts as $hostId) {
260 + $hostData[$hostId] = ['status' => 'confirmed'];
261 + }
72 262
73 - $bookingData = apply_filters('fluent_booking/booking_data', $bookingData, $calendarSlot, $customFieldsData);
263 + $booking->hosts()->attach($hostData);
264 + }
74 265
75 - if (is_wp_error($bookingData)) {
76 - return $bookingData;
266 + protected static function getGroupId($calendarSlot, $bookingData)
267 + {
268 + if (!$calendarSlot->isMultiGuestEvent()) {
269 + return null;
77 270 }
78 271
79 - do_action('fluent_booking/before_booking', $bookingData, $calendarSlot);
272 + $event = Booking::select('group_id')
273 + ->where('event_id', $calendarSlot->id)
274 + ->where('calendar_id', $calendarSlot->calendar_id)
275 + ->where('start_time', $bookingData['start_time'])
276 + ->first();
80 277
81 - $booking = Booking::create($bookingData);
278 + return $event ? $event->group_id : null;
279 + }
82 280
281 + private static function updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot)
282 + {
83 283 if ($customFieldsData) {
84 284 Helper::updateBookingMeta($booking->id, 'custom_fields_data', $customFieldsData);
85 285 }
86 286
87 - if ($additionalGuests) {
88 - Helper::updateBookingMeta($booking->id, 'additional_guests', $additionalGuests);
287 + if ($guests) {
288 + Helper::updateBookingMeta($booking->id, 'additional_guests', $guests);
89 289 }
90 -
91 - $hosts = [$booking->host_user_id];
92 - if ($calendarSlot->isOneOffEvent()) {
93 - $hosts = $calendarSlot->getHostIds();
94 - }
95 290
96 - $hostData = [];
97 - foreach ($hosts as $hostId) {
98 - $hostData[$hostId] = ['status' => 'confirmed'];
291 + if ($quantity = Arr::get($bookingData, 'quantity')) {
292 + Helper::updateBookingMeta($booking->id, 'quantity', $quantity);
99 293 }
100 294
101 - $booking->hosts()->attach($hostData);
295 + do_action('fluent_booking/after_booking_meta_update', $booking, $bookingData, $customFieldsData, $calendarSlot);
296 + }
102 297
103 - $booking->load('calendar');
298 + private static function updateParentInfo($booking, $bookingIds)
299 + {
300 + if (!$bookingIds) {
301 + return;
302 + }
104 303
105 - // this pre hook is for early actions that require for remote calendars and locations
106 - do_action('fluent_booking/pre_after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
107 -
108 - // We are just renewing this as this may have been changed by the pre hook
109 - $booking = Booking::find($booking->id);
110 - do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
111 -
112 - return $booking;
304 + Booking::whereIn('id', $bookingIds)->update(['parent_id' => $booking->id]);
113 305 }
114 306
115 307 public static function getBookingConfirmationHtml(Booking $booking, $actionType = 'confirmation')
116 308 {
@@ -182,12 +374,12 @@
182 374 'content' => wpautop($booking->message)
183 375 ];
184 376 }
185 377
186 - $customFieldsData = $booking->getCustomFormData(true);
378 + $customFieldsData = $booking->getCustomFormData(true, true);
187 379
188 380 foreach ($customFieldsData as $dataKey => $data) {
189 - if (!empty($data['value'])) {
381 + if (!empty($data['value'])) {
190 382 $sections[$dataKey] = [
191 383 'title' => $data['label'],
192 384 'content' => $data['value']
193 385 ];
@@ -199,10 +391,15 @@
199 391 $subHeading = '';
200 392 if ($booking->status == 'scheduled') {
201 393 // translators: %s is the name of the person scheduled
202 394 $subHeading = sprintf(__('You are scheduled with %s', 'fluent-booking'), $author['name']);
395 +
396 + if ($actionType == 'confirmation' && $calendarSlot->allowMultiBooking()) {
397 + $bookingTime = (array) $sections['when']['content'];
398 + $sections['when']['content'] = array_merge($bookingTime, $booking->getOtherBookingTimes());
399 + }
203 400 }
204 -
401 +
205 402 // translators: %s is the status of the meeting
206 403 $title = sprintf(__('Your meeting has been %s', 'fluent-booking'), $bookingStatus);
207 404 if ($booking->status == 'pending' && $booking->payment_status != 'pending') {
208 405 $title = __('Your booking has been submitted', 'fluent-booking');
@@ -257,39 +454,108 @@
257 454 }
258 455
259 456 public static function generateBookingICS(Booking $booking)
260 457 {
261 - $author = $booking->getHostDetails(false);
262 -
263 458 // Initialize the ICS content
264 459 $icsContent = "BEGIN:VCALENDAR\r\n";
265 460 $icsContent .= "VERSION:2.0\r\n";
266 - $icsContent .= "PRODID:-//Google Inc//Fluent Booking//EN\r\n";
267 - $icsContent .= "METHOD:REQUEST\r\n";
461 + $icsContent .= "PRODID:-//FluentBooking//Fluent Booking//EN\r\n";
462 +
463 + // PUBLISH = plain "add to calendar" event. METHOD:REQUEST makes it an iTIP
464 + // invitation bound to the ATTENDEE, which Google Calendar then rejects/mishandles.
465 + $icsContent .= "METHOD:PUBLISH\r\n";
466 +
467 + foreach (self::getIcsBookings($booking) as $icsBooking) {
468 + $icsContent .= self::generateIcsEvent($icsBooking);
469 + }
470 +
471 + // Close the VCALENDAR component
472 + $icsContent .= "END:VCALENDAR\r\n";
473 +
474 + return $icsContent;
475 + }
476 +
477 + /**
478 + * A recurring or multiple-time booking is stored as one row per time, with
479 + * the last row as the parent the guest lands on. Its export carries every
480 + * confirmed time in the set, one VEVENT each, so an occurrence that was
481 + * cancelled or is still awaiting confirmation stays out of the calendar.
482 + */
483 + private static function getIcsBookings(Booking $booking)
484 + {
485 + if ($booking->parent_id) {
486 + return [$booking];
487 + }
488 +
489 + // Additional guests on a group booking are linked the same way, each
490 + // with their own email; their bookings are not this guest's to export.
491 + $childBookings = Booking::with(['calendar', 'calendar_event', 'booking_meta'])
492 + ->where('parent_id', $booking->id)
493 + ->where('email', $booking->email)
494 + ->whereIn('status', ['scheduled', 'completed'])
495 + ->get()
496 + ->all();
497 +
498 + if (!$childBookings) {
499 + return [$booking];
500 + }
501 +
502 + $bookings = array_merge($childBookings, [$booking]);
503 +
504 + usort($bookings, function ($first, $second) {
505 + return strtotime($first->start_time) - strtotime($second->start_time);
506 + });
507 +
508 + return $bookings;
509 + }
510 +
511 + private static function generateIcsEvent(Booking $booking)
512 + {
513 + $author = $booking->getHostDetails(false);
514 +
515 + $icsContent = "BEGIN:VEVENT\r\n";
268 516 $icsContent .= "STATUS:CONFIRMED\r\n";
517 + $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
518 + $icsContent .= "DTSTAMP:" . gmdate('Ymd\THis\Z') . "\r\n"; // Required by RFC5545; Google rejects ICS without it
269 519
270 - $icsContent .= "BEGIN:VEVENT\r\n";
271 - $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
520 + $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
272 521
273 - // Event details
274 - $icsContent .= "SUMMARY:" . $booking->getBookingTitle() . "\r\n";
275 - $icsContent .= "DESCRIPTION:" . $booking->getIcsBookingDescription() . "\r\n";
522 + // Escape per segment so the existing "\n" line-break escapes are not double-escaped.
523 + $descriptionSegments = array_map([self::class, 'escapeIcsText'], explode('\n', $booking->getIcsBookingDescription()));
524 + $icsContent .= "DESCRIPTION:" . implode('\n', $descriptionSegments) . "\r\n";
276 525
277 526 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
278 527 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
279 528 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
280 529
281 - $icsContent .= "LOCATION:" . $booking->getLocationAsText() . "\r\n";
530 + $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
282 531
283 - $icsContent .= "ORGANIZER;CN=\"" . $author['name'] . "\":mailto:" . $author['email'] . "\r\n";
532 + $organizerEmail = sanitize_email($author['email']) ?: $author['email'];
533 + $icsContent .= "ORGANIZER;CN=\"" . self::escapeIcsText($author['name']) . "\":mailto:" . $organizerEmail . "\r\n";
284 534
285 - $icsContent .= "ATTENDEE;CN=\"" . $booking->email . "\";ROLE=REQ-PARTICIPANT;RSVP=TRUE;PARTSTAT=ACCEPTED:mailto:" . $booking->email . "\r\n";
535 + $icsContent .= "END:VEVENT\r\n";
286 536
287 - $icsContent .= "END:VEVENT\r\n";
537 + return $icsContent;
538 + }
288 539
289 - // Close the VCALENDAR component
290 - $icsContent .= "END:VCALENDAR\r\n";
540 + /**
541 + * Escape text for use in ICS (iCalendar) content per RFC5545.
542 + * Escapes backslash, semicolon, comma and normalizes newlines to \\n.
543 + *
544 + * @param string $value Raw text value.
545 + * @return string Escaped value safe for ICS properties.
546 + */
547 + public static function escapeIcsText($value)
548 + {
549 + if (empty($value)) {
550 + return '';
551 + }
552 + $value = (string) $value;
553 + // Escape backslash first, then semicolon and comma (RFC5545 special chars).
554 + $value = str_replace(['\\', ';', ','], ['\\\\', '\\;', '\\,'], $value);
555 + // Normalize line breaks to literal \n in output (ICS uses \\n for newline in text).
556 + $value = str_replace(["\r\n", "\r", "\n"], "\\n", $value);
291 557
292 - return $icsContent;
558 + return $value;
293 559 }
294 560
295 561 }