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 +125 -18 1.10.02 → 2.5.0 View file →
@@ -55,9 +55,11 @@
55 55 }
56 56
57 57 do_action('fluent_booking/before_booking', $bookingData, $calendarSlot);
58 58
59 - $booking = Booking::create($bookingData);
59 + $booking = Helper::dbTransaction(function () use ($bookingData) {
60 + return Booking::create($bookingData);
61 + });
60 62
61 63 self::attachHosts($booking, $calendarSlot);
62 64 self::updateParentInfo($booking, $bookingIds);
63 65 self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
@@ -63,20 +65,45 @@
63 65 self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
64 66
65 67 $booking->load('calendar');
66 68
69 + $bookingStatus = $booking->status;
70 + $paymentStatus = $booking->payment_status;
71 +
67 72 $bookingData = apply_filters('fluent_booking/after_booking_data', $bookingData, $booking, $calendarSlot, $customFieldsData);
68 73
69 74 // this pre hook is for early actions that require for remote calendars and locations
70 - do_action('fluent_booking/pre_after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
75 + do_action('fluent_booking/pre_after_booking_' . $bookingStatus, $booking, $calendarSlot, $bookingData);
71 76
72 77 // We are just renewing this as this may have been changed by the pre hook
73 78 $booking = Booking::find($booking->id);
79 +
80 + if (self::preHookHasDispatched($bookingStatus, $paymentStatus, $booking)) {
81 + return $booking;
82 + }
83 +
74 84 do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
75 85
76 86 return $booking;
77 87 }
78 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 +
79 106 public static function createMultiTimeBooking($data, $calendarSlot, $customFieldsData, $guests)
80 107 {
81 108 $booking = [];
82 109 $bookingIds = [];
@@ -84,8 +111,12 @@
84 111 $lastBooking = end($data['start_time']);
85 112 $totalBooking = count($data['start_time']);
86 113 $bookingTimes = array_combine($data['start_time'], $data['end_time']);
87 114
115 + if ($bookingTimes === false) {
116 + throw new \InvalidArgumentException(esc_html__('Booking start and end times are invalid.', 'fluent-booking'));
117 + }
118 +
88 119 foreach ($bookingTimes as $startTime => $endTime) {
89 120 $bookingData = $data;
90 121
91 122 $bookingData['start_time'] = $startTime;
@@ -92,11 +123,14 @@
92 123 $bookingData['end_time'] = $endTime;
93 124
94 125 $isConfRequired = $calendarSlot->isConfirmationRequired($startTime);
95 126 $bookingData['status'] = $isConfRequired ? 'pending' : $data['status'];
127 + $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
96 128
97 129 if ($startTime == $lastBooking) {
98 130 $createdBookingIds = $bookingIds;
131 + } else {
132 + $bookingData['parent_id'] = '';
99 133 }
100 134
101 135 if (Arr::get($data, 'payment_method')) {
102 136 if ($startTime == $lastBooking) {
@@ -123,8 +157,12 @@
123 157 $lastBooking = end($data['email']);
124 158 $totalBooking = count($data['email']);
125 159 $guests = array_combine($data['email'], $data['first_name']);
126 160
161 + if ($guests === false) {
162 + throw new \InvalidArgumentException(esc_html__('Guest names and emails are invalid.', 'fluent-booking'));
163 + }
164 +
127 165 foreach ($guests as $email => $name) {
128 166 $bookingData = $data;
129 167
130 168 $bookingData['email'] = $email;
@@ -416,39 +454,108 @@
416 454 }
417 455
418 456 public static function generateBookingICS(Booking $booking)
419 457 {
420 - $author = $booking->getHostDetails(false);
421 -
422 458 // Initialize the ICS content
423 459 $icsContent = "BEGIN:VCALENDAR\r\n";
424 460 $icsContent .= "VERSION:2.0\r\n";
425 - $icsContent .= "PRODID:-//Google Inc//Fluent Booking//EN\r\n";
426 - $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";
427 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
428 519
429 - $icsContent .= "BEGIN:VEVENT\r\n";
430 - $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
520 + $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
431 521
432 - // Event details
433 - $icsContent .= "SUMMARY:" . $booking->getBookingTitle() . "\r\n";
434 - $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";
435 525
436 526 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
437 527 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
438 528 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
439 529
440 - $icsContent .= "LOCATION:" . $booking->getLocationAsText() . "\r\n";
530 + $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
441 531
442 - $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";
443 534
444 - $icsContent .= "ATTENDEE;CN=\"" . $booking->email . "\";ROLE=REQ-PARTICIPANT;RSVP=TRUE;PARTSTAT=ACCEPTED:mailto:" . $booking->email . "\r\n";
535 + $icsContent .= "END:VEVENT\r\n";
445 536
446 - $icsContent .= "END:VEVENT\r\n";
537 + return $icsContent;
538 + }
447 539
448 - // Close the VCALENDAR component
449 - $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);
450 557
451 - return $icsContent;
558 + return $value;
452 559 }
453 560
454 561 }