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/Modules/MCP/Support/BookingProjector.php +28 -67 2.4.0 → 2.5.0 View file →
@@ -2,50 +2,33 @@
2 2
3 3 namespace FluentBooking\App\Modules\MCP\Support;
4 4
5 5 use FluentBooking\App\Models\Booking;
6 +use FluentBooking\App\Models\BookingActivity;
6 7 use FluentBooking\Framework\Support\Arr;
7 8
8 9 defined('ABSPATH') || exit;
9 10
10 11 /**
11 - * Booking model → agent-facing payload, at two levels of detail.
12 + * Booking model → agent payload, at two levels of detail. Enforces the
13 + * response budget in docs/mcp-server-spec.md §10.
12 14 *
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".
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[]`.
16 17 *
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.
18 + * `row()` masks the attendee email; `full()` returns it.
22 19 *
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.
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.
27 23 *
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.
24 + * Attendee-typed text goes through MCPHelper::untrusted() and is grouped under
25 + * `attendee_supplied`, since the agent reading it also holds write tools.
42 26 */
43 27 class BookingProjector
44 28 {
45 29 /**
46 - * Relations a collection query needs eager-loaded. Without this a
47 - * twenty-row list fires twenty extra queries for the event title alone.
30 + * Relations to eager-load for row(), to avoid a query per row.
48 31 *
49 32 * @return array
50 33 */
51 34 public static function rowRelations()
@@ -77,11 +60,9 @@
77 60 'host_user_id' => (int) $booking->host_user_id,
78 61 'group_id' => $booking->group_id === null ? null : (int) $booking->group_id,
79 62 'status' => $booking->status,
80 63 '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.
64 + // Attendee-authored, so neutralised even at the top level.
84 65 'attendee' => MCPHelper::untrusted(trim($booking->first_name . ' ' . $booking->last_name), 200),
85 66 'email' => $includePii ? $email : MCPHelper::maskEmail($email),
86 67 ],
87 68 MCPHelper::timePair($booking->start_time, $timezone, 'start'),
@@ -110,11 +91,10 @@
110 91 ? $booking->person_time_zone
111 92 : null,
112 93 'phone' => MCPHelper::untrusted($booking->phone, 60),
113 94 '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.
95 + // Host-authored, but still stripped: operators paste attendee
96 + // mail into notes.
117 97 'internal_note' => MCPHelper::untrusted($booking->internal_note),
118 98 'location' => MCPHelper::untrusted($booking->getLocationAsText(), 500),
119 99 'source' => $booking->source,
120 100 'payment_status' => $booking->payment_status,
@@ -136,9 +116,8 @@
136 116 if (in_array('activities', $include, true)) {
137 117 $data['activities'] = self::activities($booking, $timezone);
138 118 }
139 119
140 - // Last key in the object, and the only one that carries the warning.
141 120 $data['attendee_supplied'] = self::attendeeSupplied(
142 121 $booking,
143 122 in_array('custom_fields', $include, true)
144 123 );
@@ -146,19 +125,12 @@
146 125 return $data;
147 126 }
148 127
149 128 /**
150 - * Everything on this booking that a member of the public typed.
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().
151 132 *
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 133 * @param Booking $booking
162 134 * @param bool $withCustomFields
163 135 * @return array
164 136 */
@@ -169,10 +141,9 @@
169 141 if ($message = MCPHelper::untrusted($booking->getMessage())) {
170 142 $supplied['message'] = $message;
171 143 }
172 144
173 - // A booking carries at most one of these, so always-present nulls would
174 - // be three wasted keys on every read.
145 + // Keys only when set; a booking has at most one of these.
175 146 if ($cancel = MCPHelper::untrusted($booking->getCancelReason(true))) {
176 147 $supplied['cancel_reason'] = $cancel;
177 148 $supplied['cancelled_by'] = $booking->cancelled_by;
178 149 }
@@ -192,11 +163,9 @@
192 163 return $supplied;
193 164 }
194 165
195 166 /**
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.
167 + * The attendee's custom-question answers as a list of {field, label, value}.
199 168 *
200 169 * @param Booking $booking
201 170 * @return array
202 171 */
@@ -218,18 +187,13 @@
218 187 $label = $key;
219 188 $value = $field;
220 189 }
221 190
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.
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.
228 193 $out[] = [
229 194 '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
195 + // Labels are untrusted too: an agent can add fields via
232 196 // manage-event-type.
233 197 'label' => MCPHelper::untrusted($label, 200),
234 198 'value' => MCPHelper::untrusted($value),
235 199 ];
@@ -254,9 +218,9 @@
254 218 foreach ($hostRows as $bookingHost) {
255 219 $userIds[] = (int) $bookingHost->user_id;
256 220 }
257 221
258 - // One query for the lot rather than one per host.
222 + // Prime the user cache in one query.
259 223 if ($userIds) {
260 224 cache_users(array_unique($userIds));
261 225 }
262 226
@@ -273,11 +237,9 @@
273 237 return $hosts;
274 238 }
275 239
276 240 /**
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.
241 + * The booking's activity timeline, newest 20 first.
280 242 *
281 243 * @param Booking $booking
282 244 * @param string $timezone
283 245 * @return array
@@ -286,8 +248,9 @@
286 248 {
287 249 $activities = [];
288 250
289 251 $records = $booking->booking_activities()
252 + ->where('type', '!=', BookingActivity::TYPE_NOTE)
290 253 ->orderBy('id', 'DESC')
291 254 ->limit(20)
292 255 ->get();
293 256
@@ -295,10 +258,9 @@
295 258 $activities[] = array_merge(
296 259 [
297 260 'type' => $activity->type,
298 261 'title' => $activity->title,
299 - // Activity descriptions embed cancellation reasons and
300 - // other attendee text, so they are untrusted too.
262 + // Descriptions can embed attendee text, e.g. cancel reasons.
301 263 'description' => MCPHelper::untrusted($activity->description, 500),
302 264 ],
303 265 MCPHelper::timePair($activity->created_at, $timezone, 'at')
304 266 );
@@ -307,10 +269,9 @@
307 269 return $activities;
308 270 }
309 271
310 272 /**
311 - * The ORM returns DateTime objects for timestamp columns; JSON-encoding one
312 - * produces three keys where a single string will do.
273 + * DateTime to a plain string; JSON-encoding the object gives three keys.
313 274 *
314 275 * @param mixed $value
315 276 * @return string|null
316 277 */