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
fluent-booking / app / Modules / MCP / Support / BookingProjector.php

BookingProjector.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.5.0, at app/Modules/MCP/Support/BookingProjector.php

287 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Support;
4
5 use FluentBooking\App\Models\Booking;
6 use FluentBooking\App\Models\BookingActivity;
7 use FluentBooking\Framework\Support\Arr;
8
9 defined('ABSPATH') || exit;
10
11 /**
12 * Booking model → agent payload, at two levels of detail. Enforces the
13 * response budget in docs/mcp-server-spec.md §10.
14 *
15 * `row()` is the ~120-token collection shape. `full()` is a single-record read;
16 * form answers, guests, hosts and activity are still opt-in via `include[]`.
17 *
18 * `row()` masks the attendee email; `full()` returns it.
19 *
20 * Neither emits the booking `hash`: it is a bearer credential that lets an
21 * unauthenticated request cancel the meeting
22 * (FrontEndHandler::ajaxHandleCancelMeeting()). `get-booking` still accepts one.
23 *
24 * Attendee-typed text goes through MCPHelper::untrusted() and is grouped under
25 * `attendee_supplied`, since the agent reading it also holds write tools.
26 */
27 class BookingProjector
28 {
29 /**
30 * Relations to eager-load for row(), to avoid a query per row.
31 *
32 * @return array
33 */
34 public static function rowRelations()
35 {
36 return ['calendar_event'];
37 }
38
39 /**
40 * Compact projection for collections.
41 *
42 * @param Booking $booking
43 * @param string $timezone resolved IANA identifier
44 * @param bool $includePii unmask the attendee email
45 * @return array
46 */
47 public static function row(Booking $booking, $timezone, $includePii = false)
48 {
49 $event = $booking->calendar_event;
50
51 $email = (string) $booking->email;
52
53 return array_merge(
54 [
55 'id' => (int) $booking->id,
56 'event_id' => (int) $booking->event_id,
57 'event_title' => $event ? MCPHelper::untrusted($event->title, 200) : '',
58 'event_type' => $booking->event_type,
59 'calendar_id' => (int) $booking->calendar_id,
60 'host_user_id' => (int) $booking->host_user_id,
61 'group_id' => $booking->group_id === null ? null : (int) $booking->group_id,
62 'status' => $booking->status,
63 'duration' => (int) $booking->slot_minutes,
64 // Attendee-authored, so neutralised even at the top level.
65 'attendee' => MCPHelper::untrusted(trim($booking->first_name . ' ' . $booking->last_name), 200),
66 'email' => $includePii ? $email : MCPHelper::maskEmail($email),
67 ],
68 MCPHelper::timePair($booking->start_time, $timezone, 'start'),
69 MCPHelper::timePair($booking->end_time, $timezone, 'end')
70 );
71 }
72
73 /**
74 * Full projection for a single-record read.
75 *
76 * @param Booking $booking
77 * @param string $timezone
78 * @param array $include any of: custom_fields, attendees, hosts, activities
79 * @return array
80 */
81 public static function full(Booking $booking, $timezone, $include = [])
82 {
83 $include = (array) $include;
84
85 $event = $booking->calendar_event;
86
87 $data = array_merge(
88 self::row($booking, $timezone, true),
89 [
90 'attendee_timezone' => in_array($booking->person_time_zone, timezone_identifiers_list(), true)
91 ? $booking->person_time_zone
92 : null,
93 'phone' => MCPHelper::untrusted($booking->phone, 60),
94 'country' => $booking->country,
95 // Host-authored, but still stripped: operators paste attendee
96 // mail into notes.
97 'internal_note' => MCPHelper::untrusted($booking->internal_note),
98 'location' => MCPHelper::untrusted($booking->getLocationAsText(), 500),
99 'source' => $booking->source,
100 'payment_status' => $booking->payment_status,
101 'payment_method' => $booking->payment_method,
102 'created_at' => self::asString($booking->created_at),
103 'event_duration' => $event ? (int) $event->duration : null,
104 ]
105 );
106
107 if (in_array('attendees', $include, true)) {
108 $data['additional_guests'] = array_values((array) $booking->getAdditionalGuests());
109 $data['total_guests'] = (int) $booking->getTotalGuestCount();
110 }
111
112 if (in_array('hosts', $include, true)) {
113 $data['hosts'] = self::hosts($booking);
114 }
115
116 if (in_array('activities', $include, true)) {
117 $data['activities'] = self::activities($booking, $timezone);
118 }
119
120 $data['attendee_supplied'] = self::attendeeSupplied(
121 $booking,
122 in_array('custom_fields', $include, true)
123 );
124
125 return $data;
126 }
127
128 /**
129 * Everything on this booking a member of the public typed, in one labelled
130 * object so the agent can tell it apart from site data. Every value has
131 * been through MCPHelper::untrusted().
132 *
133 * @param Booking $booking
134 * @param bool $withCustomFields
135 * @return array
136 */
137 private static function attendeeSupplied(Booking $booking, $withCustomFields)
138 {
139 $supplied = ['_trust' => MCPHelper::TRUST_NOTICE];
140
141 if ($message = MCPHelper::untrusted($booking->getMessage())) {
142 $supplied['message'] = $message;
143 }
144
145 // Keys only when set; a booking has at most one of these.
146 if ($cancel = MCPHelper::untrusted($booking->getCancelReason(true))) {
147 $supplied['cancel_reason'] = $cancel;
148 $supplied['cancelled_by'] = $booking->cancelled_by;
149 }
150
151 if ($reject = MCPHelper::untrusted($booking->getRejectReason(true))) {
152 $supplied['reject_reason'] = $reject;
153 }
154
155 if ($reschedule = MCPHelper::untrusted($booking->getRescheduleReason())) {
156 $supplied['reschedule_reason'] = $reschedule;
157 }
158
159 if ($withCustomFields) {
160 $supplied['custom_fields'] = self::customFields($booking);
161 }
162
163 return $supplied;
164 }
165
166 /**
167 * The attendee's custom-question answers as a list of {field, label, value}.
168 *
169 * @param Booking $booking
170 * @return array
171 */
172 private static function customFields(Booking $booking)
173 {
174 $fields = $booking->getCustomFormData();
175
176 if (!is_array($fields)) {
177 return [];
178 }
179
180 $out = [];
181
182 foreach ($fields as $key => $field) {
183 if (is_array($field)) {
184 $label = Arr::get($field, 'label', $key);
185 $value = Arr::get($field, 'value', '');
186 } else {
187 $label = $key;
188 $value = $field;
189 }
190
191 // A list, not a map keyed by label: two labels can strip to the
192 // same string and one answer would overwrite the other.
193 $out[] = [
194 'field' => (string) $key,
195 // Labels are untrusted too: an agent can add fields via
196 // manage-event-type.
197 'label' => MCPHelper::untrusted($label, 200),
198 'value' => MCPHelper::untrusted($value),
199 ];
200 }
201
202 return $out;
203 }
204
205 /**
206 * Hosts on the booking. Team events (round-robin, collective) put every
207 * assigned host on the pivot table, so host_user_id alone under-reports.
208 *
209 * @param Booking $booking
210 * @return array
211 */
212 private static function hosts(Booking $booking)
213 {
214 $hosts = [];
215 $hostRows = $booking->bookingHosts;
216 $userIds = [];
217
218 foreach ($hostRows as $bookingHost) {
219 $userIds[] = (int) $bookingHost->user_id;
220 }
221
222 // Prime the user cache in one query.
223 if ($userIds) {
224 cache_users(array_unique($userIds));
225 }
226
227 foreach ($hostRows as $bookingHost) {
228 $user = get_user_by('ID', $bookingHost->user_id);
229
230 $hosts[] = [
231 'user_id' => (int) $bookingHost->user_id,
232 'name' => $user ? MCPHelper::untrusted($user->display_name, 200) : '',
233 'status' => $bookingHost->status,
234 ];
235 }
236
237 return $hosts;
238 }
239
240 /**
241 * The booking's activity timeline, newest 20 first.
242 *
243 * @param Booking $booking
244 * @param string $timezone
245 * @return array
246 */
247 private static function activities(Booking $booking, $timezone)
248 {
249 $activities = [];
250
251 $records = $booking->booking_activities()
252 ->where('type', '!=', BookingActivity::TYPE_NOTE)
253 ->orderBy('id', 'DESC')
254 ->limit(20)
255 ->get();
256
257 foreach ($records as $activity) {
258 $activities[] = array_merge(
259 [
260 'type' => $activity->type,
261 'title' => $activity->title,
262 // Descriptions can embed attendee text, e.g. cancel reasons.
263 'description' => MCPHelper::untrusted($activity->description, 500),
264 ],
265 MCPHelper::timePair($activity->created_at, $timezone, 'at')
266 );
267 }
268
269 return $activities;
270 }
271
272 /**
273 * DateTime to a plain string; JSON-encoding the object gives three keys.
274 *
275 * @param mixed $value
276 * @return string|null
277 */
278 private static function asString($value)
279 {
280 if ($value instanceof \DateTimeInterface) {
281 return $value->format('Y-m-d H:i:s');
282 }
283
284 return $value === null ? null : (string) $value;
285 }
286 }
287