| @@ -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 | */ |