PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
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 1.7.2 All 33 releases
← All changes | app/Services/BookingService.php +84 -18 1.5.21trunk View file →
@@ -34,9 +34,9 @@
34 34 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
35 35
36 36 $bookingData['event_type'] = $calendarSlot->event_type;
37 37
38 - $bookingData = apply_filters('fluent_booking/booking_data', $bookingData, $calendarSlot, $customFieldsData);
38 + $bookingData = apply_filters('fluent_booking/booking_data', $bookingData, $calendarSlot, $customFieldsData, $data);
39 39
40 40 if (is_wp_error($bookingData)) {
41 41 return $bookingData;
42 42 }
@@ -59,22 +59,49 @@
59 59 $booking = Booking::create($bookingData);
60 60
61 61 self::attachHosts($booking, $calendarSlot);
62 62 self::updateParentInfo($booking, $bookingIds);
63 - self::updateMetas($booking, $bookingData, $guests, $customFieldsData);
63 + self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
64 64
65 65 $booking->load('calendar');
66 66
67 + $bookingStatus = $booking->status;
68 + $paymentStatus = $booking->payment_status;
69 +
70 + $bookingData = apply_filters('fluent_booking/after_booking_data', $bookingData, $booking, $calendarSlot, $customFieldsData);
71 +
67 72 // this pre hook is for early actions that require for remote calendars and locations
68 - do_action('fluent_booking/pre_after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
73 + do_action('fluent_booking/pre_after_booking_' . $bookingStatus, $booking, $calendarSlot, $bookingData);
69 74
70 75 // We are just renewing this as this may have been changed by the pre hook
71 76 $booking = Booking::find($booking->id);
77 +
78 + if (self::preHookHasDispatched($bookingStatus, $paymentStatus, $booking)) {
79 + return $booking;
80 + }
81 +
72 82 do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
73 83
74 84 return $booking;
75 85 }
76 86
87 + /**
88 + * Whether the pre hook already dispatched the lifecycle action, so
89 + * dispatching again would notify twice. Status is not the only sign: a
90 + * full-price coupon settles payment on a booking that stays pending for
91 + * manual confirmation.
92 + *
93 + * Loose on purpose - payment_status is nullable with no default, and
94 + * multi-time child rows are written as ''.
95 + *
96 + * @return bool
97 + */
98 + private static function preHookHasDispatched($bookingStatus, $paymentStatus, $booking)
99 + {
100 + return $bookingStatus != $booking->status
101 + || $paymentStatus != $booking->payment_status;
102 + }
103 +
77 104 public static function createMultiTimeBooking($data, $calendarSlot, $customFieldsData, $guests)
78 105 {
79 106 $booking = [];
80 107 $bookingIds = [];
@@ -82,8 +109,12 @@
82 109 $lastBooking = end($data['start_time']);
83 110 $totalBooking = count($data['start_time']);
84 111 $bookingTimes = array_combine($data['start_time'], $data['end_time']);
85 112
113 + if ($bookingTimes === false) {
114 + throw new \InvalidArgumentException(esc_html__('Booking start and end times are invalid.', 'fluent-booking'));
115 + }
116 +
86 117 foreach ($bookingTimes as $startTime => $endTime) {
87 118 $bookingData = $data;
88 119
89 120 $bookingData['start_time'] = $startTime;
@@ -90,11 +121,14 @@
90 121 $bookingData['end_time'] = $endTime;
91 122
92 123 $isConfRequired = $calendarSlot->isConfirmationRequired($startTime);
93 124 $bookingData['status'] = $isConfRequired ? 'pending' : $data['status'];
125 + $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
94 126
95 127 if ($startTime == $lastBooking) {
96 128 $createdBookingIds = $bookingIds;
129 + } else {
130 + $bookingData['parent_id'] = '';
97 131 }
98 132
99 133 if (Arr::get($data, 'payment_method')) {
100 134 if ($startTime == $lastBooking) {
@@ -121,8 +155,12 @@
121 155 $lastBooking = end($data['email']);
122 156 $totalBooking = count($data['email']);
123 157 $guests = array_combine($data['email'], $data['first_name']);
124 158
159 + if ($guests === false) {
160 + throw new \InvalidArgumentException(esc_html__('Guest names and emails are invalid.', 'fluent-booking'));
161 + }
162 +
125 163 foreach ($guests as $email => $name) {
126 164 $bookingData = $data;
127 165
128 166 $bookingData['email'] = $email;
@@ -198,9 +236,9 @@
198 236
199 237 $guestNames = array_map(function ($guest) {
200 238 return $guest['name'];
201 239 }, $additionalGuests);
202 - $data['first_name'] = array_merge($guestNames, (array) $data['first_name']);
240 + $data['first_name'] = array_merge($guestNames, (array) ($data['first_name'] . ' ' . $data['last_name']));
203 241
204 242 $data['additional_guests'] = [];
205 243 }
206 244 }
@@ -210,9 +248,9 @@
210 248
211 249 private static function attachHosts($booking, $calendarSlot)
212 250 {
213 251 $hosts = [$booking->host_user_id];
214 - if ($calendarSlot->isOneOffEvent()) {
252 + if ($calendarSlot->isMultiHostsEvent()) {
215 253 $hosts = $calendarSlot->getHostIds();
216 254 }
217 255
218 256 $hostData = [];
@@ -237,9 +275,9 @@
237 275
238 276 return $event ? $event->group_id : null;
239 277 }
240 278
241 - private static function updateMetas($booking, $bookingData, $guests, $customFieldsData)
279 + private static function updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot)
242 280 {
243 281 if ($customFieldsData) {
244 282 Helper::updateBookingMeta($booking->id, 'custom_fields_data', $customFieldsData);
245 283 }
@@ -250,8 +288,10 @@
250 288
251 289 if ($quantity = Arr::get($bookingData, 'quantity')) {
252 290 Helper::updateBookingMeta($booking->id, 'quantity', $quantity);
253 291 }
292 +
293 + do_action('fluent_booking/after_booking_meta_update', $booking, $bookingData, $customFieldsData, $calendarSlot);
254 294 }
255 295
256 296 private static function updateParentInfo($booking, $bookingIds)
257 297 {
@@ -332,12 +372,12 @@
332 372 'content' => wpautop($booking->message)
333 373 ];
334 374 }
335 375
336 - $customFieldsData = $booking->getCustomFormData(true);
376 + $customFieldsData = $booking->getCustomFormData(true, true);
337 377
338 378 foreach ($customFieldsData as $dataKey => $data) {
339 - if (!empty($data['value'])) {
379 + if (!empty($data['value'])) {
340 380 $sections[$dataKey] = [
341 381 'title' => $data['label'],
342 382 'content' => $data['value']
343 383 ];
@@ -417,29 +457,34 @@
417 457
418 458 // Initialize the ICS content
419 459 $icsContent = "BEGIN:VCALENDAR\r\n";
420 460 $icsContent .= "VERSION:2.0\r\n";
421 - $icsContent .= "PRODID:-//Google Inc//Fluent Booking//EN\r\n";
422 - $icsContent .= "METHOD:REQUEST\r\n";
423 - $icsContent .= "STATUS:CONFIRMED\r\n";
461 + $icsContent .= "PRODID:-//FluentBooking//Fluent Booking//EN\r\n";
424 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 +
425 467 $icsContent .= "BEGIN:VEVENT\r\n";
468 + $icsContent .= "STATUS:CONFIRMED\r\n";
426 469 $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
470 + $icsContent .= "DTSTAMP:" . gmdate('Ymd\THis\Z') . "\r\n"; // Required by RFC5545; Google rejects ICS without it
427 471
428 - // Event details
429 - $icsContent .= "SUMMARY:" . $booking->getBookingTitle() . "\r\n";
430 - $icsContent .= "DESCRIPTION:" . $booking->getIcsBookingDescription() . "\r\n";
472 + $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
431 473
474 + // Escape per segment so the existing "\n" line-break escapes are not double-escaped.
475 + $descriptionSegments = array_map([self::class, 'escapeIcsText'], explode('\n', $booking->getIcsBookingDescription()));
476 + $icsContent .= "DESCRIPTION:" . implode('\n', $descriptionSegments) . "\r\n";
477 +
432 478 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
433 479 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
434 480 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
435 481
436 - $icsContent .= "LOCATION:" . $booking->getLocationAsText() . "\r\n";
482 + $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
437 483
438 - $icsContent .= "ORGANIZER;CN=\"" . $author['name'] . "\":mailto:" . $author['email'] . "\r\n";
484 + $organizerEmail = sanitize_email($author['email']) ?: $author['email'];
485 + $icsContent .= "ORGANIZER;CN=\"" . self::escapeIcsText($author['name']) . "\":mailto:" . $organizerEmail . "\r\n";
439 486
440 - $icsContent .= "ATTENDEE;CN=\"" . $booking->email . "\";ROLE=REQ-PARTICIPANT;RSVP=TRUE;PARTSTAT=ACCEPTED:mailto:" . $booking->email . "\r\n";
441 -
442 487 $icsContent .= "END:VEVENT\r\n";
443 488
444 489 // Close the VCALENDAR component
445 490 $icsContent .= "END:VCALENDAR\r\n";
@@ -444,7 +489,28 @@
444 489 // Close the VCALENDAR component
445 490 $icsContent .= "END:VCALENDAR\r\n";
446 491
447 492 return $icsContent;
493 + }
494 +
495 + /**
496 + * Escape text for use in ICS (iCalendar) content per RFC5545.
497 + * Escapes backslash, semicolon, comma and normalizes newlines to \\n.
498 + *
499 + * @param string $value Raw text value.
500 + * @return string Escaped value safe for ICS properties.
501 + */
502 + public static function escapeIcsText($value)
503 + {
504 + if (empty($value)) {
505 + return '';
506 + }
507 + $value = (string) $value;
508 + // Escape backslash first, then semicolon and comma (RFC5545 special chars).
509 + $value = str_replace(['\\', ';', ','], ['\\\\', '\\;', '\\,'], $value);
510 + // Normalize line breaks to literal \n in output (ICS uses \\n for newline in text).
511 + $value = str_replace(["\r\n", "\r", "\n"], "\\n", $value);
512 +
513 + return $value;
448 514 }
449 515
450 516 }