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

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

326 lines 12.0 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\Framework\Support\Arr;
7
8 defined('ABSPATH') || exit;
9
10 /**
11 * Booking model → agent-facing payload, at two levels of detail.
12 *
13 * This class is where the response budget in docs/mcp-server-spec.md §10 is
14 * actually enforced, so both shapes are deliberate rather than "whatever the
15 * model has".
16 *
17 * `row()` is what a collection returns: about 120 tokens, enough to identify a
18 * booking, sort it, and decide whether to open it. `full()` is a single-record
19 * read and can afford everything — but even there the expensive pieces (form
20 * answers, guests, hosts, activity) are opt-in through `include[]`, because most
21 * questions about a booking do not need any of them.
22 *
23 * PII rule: `row()` masks the attendee email. A collection of twenty bookings
24 * has no business emitting twenty live addresses — that is both a disclosure
25 * surface and a pointless token cost. The real address is available from
26 * `full()`, which is a deliberate single-record read the caller had to ask for.
27 *
28 * Neither shape emits the booking `hash`. That value is a bearer credential:
29 * FrontEndHandler::ajaxHandleCancelMeeting() accepts it from an unauthenticated
30 * request as sufficient authority to cancel the meeting. Putting twenty of them
31 * in a list response would push twenty cancel-anything tokens into a model
32 * provider's context and whatever transcript the client keeps — while masking
33 * the email beside them. Agents address bookings by `id`; `get-booking` still
34 * ACCEPTS a hash so an operator can paste one, it just never hands one out.
35 *
36 * Trust rule: every string an attendee typed goes through MCPHelper::untrusted()
37 * and is grouped under one `attendee_supplied` object carrying an explicit
38 * warning, rather than being scattered among fields the site itself wrote. The
39 * agent reading this response also holds create-booking, manage-booking and the
40 * scheduling write tools, so "who wrote this text" is a security property here,
41 * not a presentation detail.
42 */
43 class BookingProjector
44 {
45 /**
46 * Relations a collection query needs eager-loaded. Without this a
47 * twenty-row list fires twenty extra queries for the event title alone.
48 *
49 * @return array
50 */
51 public static function rowRelations()
52 {
53 return ['calendar_event'];
54 }
55
56 /**
57 * Compact projection for collections.
58 *
59 * @param Booking $booking
60 * @param string $timezone resolved IANA identifier
61 * @param bool $includePii unmask the attendee email
62 * @return array
63 */
64 public static function row(Booking $booking, $timezone, $includePii = false)
65 {
66 $event = $booking->calendar_event;
67
68 $email = (string) $booking->email;
69
70 return array_merge(
71 [
72 'id' => (int) $booking->id,
73 'event_id' => (int) $booking->event_id,
74 'event_title' => $event ? MCPHelper::untrusted($event->title, 200) : '',
75 'event_type' => $booking->event_type,
76 'calendar_id' => (int) $booking->calendar_id,
77 'host_user_id' => (int) $booking->host_user_id,
78 'group_id' => $booking->group_id === null ? null : (int) $booking->group_id,
79 'status' => $booking->status,
80 'duration' => (int) $booking->slot_minutes,
81 // Attendee-authored, so neutralised even though it sits at the
82 // top level: a name is needed for display on every row, and a
83 // display name is a poor place to hide an instruction.
84 'attendee' => MCPHelper::untrusted(trim($booking->first_name . ' ' . $booking->last_name), 200),
85 'email' => $includePii ? $email : MCPHelper::maskEmail($email),
86 ],
87 MCPHelper::timePair($booking->start_time, $timezone, 'start'),
88 MCPHelper::timePair($booking->end_time, $timezone, 'end')
89 );
90 }
91
92 /**
93 * Full projection for a single-record read.
94 *
95 * @param Booking $booking
96 * @param string $timezone
97 * @param array $include any of: custom_fields, attendees, hosts, activities
98 * @return array
99 */
100 public static function full(Booking $booking, $timezone, $include = [])
101 {
102 $include = (array) $include;
103
104 $event = $booking->calendar_event;
105
106 $data = array_merge(
107 self::row($booking, $timezone, true),
108 [
109 'attendee_timezone' => in_array($booking->person_time_zone, timezone_identifiers_list(), true)
110 ? $booking->person_time_zone
111 : null,
112 'phone' => MCPHelper::untrusted($booking->phone, 60),
113 'country' => $booking->country,
114 // Host-authored, so it stays out of the untrusted envelope —
115 // but still stripped, because operators paste attendee mail
116 // into these.
117 'internal_note' => MCPHelper::untrusted($booking->internal_note),
118 'location' => MCPHelper::untrusted($booking->getLocationAsText(), 500),
119 'source' => $booking->source,
120 'payment_status' => $booking->payment_status,
121 'payment_method' => $booking->payment_method,
122 'created_at' => self::asString($booking->created_at),
123 'event_duration' => $event ? (int) $event->duration : null,
124 ]
125 );
126
127 if (in_array('attendees', $include, true)) {
128 $data['additional_guests'] = array_values((array) $booking->getAdditionalGuests());
129 $data['total_guests'] = (int) $booking->getTotalGuestCount();
130 }
131
132 if (in_array('hosts', $include, true)) {
133 $data['hosts'] = self::hosts($booking);
134 }
135
136 if (in_array('activities', $include, true)) {
137 $data['activities'] = self::activities($booking, $timezone);
138 }
139
140 // Last key in the object, and the only one that carries the warning.
141 $data['attendee_supplied'] = self::attendeeSupplied(
142 $booking,
143 in_array('custom_fields', $include, true)
144 );
145
146 return $data;
147 }
148
149 /**
150 * Everything on this booking that a member of the public typed.
151 *
152 * Kept as one labelled object rather than spread through the response, for
153 * the same reason a query parameter is bound rather than concatenated: the
154 * consumer needs to be able to tell, structurally, where its own data ends
155 * and someone else's input begins. The consumer here is a model that also
156 * holds the write tools, and the input arrives through an unauthenticated
157 * booking form.
158 *
159 * Every value has already been through MCPHelper::untrusted().
160 *
161 * @param Booking $booking
162 * @param bool $withCustomFields
163 * @return array
164 */
165 private static function attendeeSupplied(Booking $booking, $withCustomFields)
166 {
167 $supplied = ['_trust' => MCPHelper::TRUST_NOTICE];
168
169 if ($message = MCPHelper::untrusted($booking->getMessage())) {
170 $supplied['message'] = $message;
171 }
172
173 // A booking carries at most one of these, so always-present nulls would
174 // be three wasted keys on every read.
175 if ($cancel = MCPHelper::untrusted($booking->getCancelReason(true))) {
176 $supplied['cancel_reason'] = $cancel;
177 $supplied['cancelled_by'] = $booking->cancelled_by;
178 }
179
180 if ($reject = MCPHelper::untrusted($booking->getRejectReason(true))) {
181 $supplied['reject_reason'] = $reject;
182 }
183
184 if ($reschedule = MCPHelper::untrusted($booking->getRescheduleReason())) {
185 $supplied['reschedule_reason'] = $reschedule;
186 }
187
188 if ($withCustomFields) {
189 $supplied['custom_fields'] = self::customFields($booking);
190 }
191
192 return $supplied;
193 }
194
195 /**
196 * The attendee's answers to the event's custom questions, as a list of
197 * {field, label, value}. The formatted form carries render metadata the
198 * agent has no use for.
199 *
200 * @param Booking $booking
201 * @return array
202 */
203 private static function customFields(Booking $booking)
204 {
205 $fields = $booking->getCustomFormData();
206
207 if (!is_array($fields)) {
208 return [];
209 }
210
211 $out = [];
212
213 foreach ($fields as $key => $field) {
214 if (is_array($field)) {
215 $label = Arr::get($field, 'label', $key);
216 $value = Arr::get($field, 'value', '');
217 } else {
218 $label = $key;
219 $value = $field;
220 }
221
222 // A LIST keyed by the stable field name, not a map keyed by the
223 // display label. Labels are attendee-visible text that has just been
224 // stripped and truncated, so two distinct fields ("<b>Phone</b>" and
225 // "Phone") can normalise to the same string — and as array keys the
226 // second would silently overwrite the first, losing an answer with
227 // no trace.
228 $out[] = [
229 'field' => (string) $key,
230 // Both halves are attendee-reachable: the answer obviously, and
231 // the label on any field an agent was allowed to add through
232 // manage-event-type.
233 'label' => MCPHelper::untrusted($label, 200),
234 'value' => MCPHelper::untrusted($value),
235 ];
236 }
237
238 return $out;
239 }
240
241 /**
242 * Hosts on the booking. Team events (round-robin, collective) put every
243 * assigned host on the pivot table, so host_user_id alone under-reports.
244 *
245 * @param Booking $booking
246 * @return array
247 */
248 private static function hosts(Booking $booking)
249 {
250 $hosts = [];
251 $hostRows = $booking->bookingHosts;
252 $userIds = [];
253
254 foreach ($hostRows as $bookingHost) {
255 $userIds[] = (int) $bookingHost->user_id;
256 }
257
258 // One query for the lot rather than one per host.
259 if ($userIds) {
260 cache_users(array_unique($userIds));
261 }
262
263 foreach ($hostRows as $bookingHost) {
264 $user = get_user_by('ID', $bookingHost->user_id);
265
266 $hosts[] = [
267 'user_id' => (int) $bookingHost->user_id,
268 'name' => $user ? MCPHelper::untrusted($user->display_name, 200) : '',
269 'status' => $bookingHost->status,
270 ];
271 }
272
273 return $hosts;
274 }
275
276 /**
277 * The booking's activity timeline, newest first and capped: an old booking
278 * can carry dozens of entries and the recent ones are what explain its
279 * current state.
280 *
281 * @param Booking $booking
282 * @param string $timezone
283 * @return array
284 */
285 private static function activities(Booking $booking, $timezone)
286 {
287 $activities = [];
288
289 $records = $booking->booking_activities()
290 ->orderBy('id', 'DESC')
291 ->limit(20)
292 ->get();
293
294 foreach ($records as $activity) {
295 $activities[] = array_merge(
296 [
297 'type' => $activity->type,
298 'title' => $activity->title,
299 // Activity descriptions embed cancellation reasons and
300 // other attendee text, so they are untrusted too.
301 'description' => MCPHelper::untrusted($activity->description, 500),
302 ],
303 MCPHelper::timePair($activity->created_at, $timezone, 'at')
304 );
305 }
306
307 return $activities;
308 }
309
310 /**
311 * The ORM returns DateTime objects for timestamp columns; JSON-encoding one
312 * produces three keys where a single string will do.
313 *
314 * @param mixed $value
315 * @return string|null
316 */
317 private static function asString($value)
318 {
319 if ($value instanceof \DateTimeInterface) {
320 return $value->format('Y-m-d H:i:s');
321 }
322
323 return $value === null ? null : (string) $value;
324 }
325 }
326