# 09 — RM Create Event

## Overview

The most consequential **write** in VisoAdmin: an RM creates an event on behalf of a client. Touches `events`, `event_services`, `client_rm_requests`, `notifications`, AND POSTs to the Viso-Chat socket bridge for realtime push and live chat broadcast.

**Status:** Live

This feature is the bridge between Feature 08 (RM Requests) and the public-app event flow consumed by vendors.

### Two-step flow (Create → Send Confirmation)

Event creation is split into two RM actions so the RM can draft, review, and edit before pushing the confirmation card to the client.

1. **Create Event** — `storeEvent` inserts the event as `events.confirmation_status = 'draft'`. It does **NOT** post any chat message, does **NOT** push to the client, and does **NOT** mutate `client_rm_requests.status` (Path C — see Feature 08; the column stays at 1 "Chat Initiated").
2. **Send Confirmation** — explicit RM click on a draft event flips it to `pending`, sets `confirmation_expires_at = min(now+24h, event_date−4h)`, posts an `action` chat message to the RM↔Client thread, and pushes "Please confirm your event details" to the client via the socket bridge.

Drafts and rejected/expired confirmations can also be **edited**: the same form view (`rm/createEvent.blade.php`) is reused for an "Edit Draft" or "Edit and Resend" path (see Routes below). On resend from a rejected/expired event, the row is UPDATED in place — `event_uuid` is stable, `event_services` is wiped and re-inserted, and the confirmation flow is dispatched again.

---

## User Stories

| ID | As a | I want to | So that |
|----|------|-----------|---------|
| RMC-01 | RM | Click "Create Event" on an RM request detail page | Start the booking |
| RMC-02 | RM | See the form pre-filled with the client's name, request's country/state/city/category/budget | Save typing |
| RMC-03 | RM | Choose event type (personal/professional), name, date, time, guest count, description | Set the event |
| RMC-04 | RM | Add 1-N services, each with a budget | Define what the event needs |
| RMC-05 | RM | Receive validation errors inline | Don't waste time |
| RMC-06 | RM | After save, see a success flash and bounce back to the RM request detail (now with a draft event) | Confirm it worked |
| RMC-07 | RM | Review/edit the draft, then explicitly click **Send Confirmation** to push the card to the client | Avoid premature client-facing pings |
| RMC-08 | RM | Re-edit and resend the confirmation if the client rejected it or it expired | Recover without re-creating the event |
| RMC-09 | Client | Receive an in-app notification + realtime push only when the RM sends the confirmation (not when the draft is created) | Avoid confusing duplicate pings |

---

## Screens & Flows

```
/rm/rm-request-details/{uuid}  ──── click "Create Event" ────▶ /rm/create-event/{uuid}
                                                                       │
                                                                       ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ rm/createEvent.blade.php                                                    │
│  (reused for: new draft, Edit Draft, Edit and Resend — driven by            │
│   $isResubmit + $existingEvent passed in by controller; form action +       │
│   submit button label switch between Create / Update accordingly)           │
│  - rm_request_uuid (hidden)                                                 │
│  - client_id (hidden, from RM request)                                      │
│  - country dropdown (HARD-PINNED to UAE)                                    │
│  - state dropdown (UAE states only, pre-selected from rm.state_id)          │
│  - city dropdown (filtered by state, pre-selected from rm.city_id)          │
│  - type (radio: personal/professional)                                      │
│  - category dropdown (pre-selected from rm.category)                        │
│  - event_name, event_date, event_start_time, guest_count, description       │
│  - services[] (id, budget) – multi-row                                      │
│  - rm.budget shown READ-ONLY for reference                                  │
└──────────────────────────────────────────┬──────────────────────────────────┘
                                           │ POST /rm/store-event       (new draft)
                                           │   — or —
                                           │ POST /rm/resubmit-event    (edit path)
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ RmController::storeEvent() — DB::beginTransaction()                         │
│                                                                             │
│  1. Generate unique human_usable_id ("EVN" + 9 random digits)               │
│  2. INSERT events row (uuid, client_id, type, category, country/state/city, │
│     event_name, description, date, time, guest_count,                       │
│     confirmation_status='draft')                                            │
│  3. FOR each service: INSERT event_services row (uuid, event_id=event.uuid, │
│     service_id, amount, expected_event_date, expected_start_time)           │
│  4. UPDATE client_rm_requests SET record_id, event_id (both = event.uuid),  │
│     record_type='event', country/state/city/category, budget=sum            │
│     — NOTE: client_rm_requests.status is NOT touched (Path C, Feature 08).  │
│       It stays at 1 ("Chat Initiated"). Event lifecycle now lives on        │
│       events.confirmation_status.                                           │
│  5. COMMIT                                                                  │
│                                                                             │
│  NO chat message, NO socket push at this stage — draft is silent.           │
│  Flash success, redirect to /rm/rm-request-details/{uuid}                   │
└──────────────────────────────────────────┬──────────────────────────────────┘
                                           │  RM reviews draft, then clicks
                                           │  [Send Confirmation]
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ POST /rm/send-confirmation/{id}                                             │
│ RmController::sendConfirmation()                                            │
│                                                                             │
│  Guard: event.confirmation_status must == 'draft'                           │
│  1. UPDATE events                                                           │
│       confirmation_status   = 'pending'                                     │
│       confirmation_expires_at = min(now+24h, event_date - 4h)               │
│  2. dispatchConfirmationFlow($event):                                       │
│       a. postEventConfirmationMessage($event)                               │
│          - look up conversation via                                         │
│              rm_conversation JOIN client_rm_requests                        │
│                ON rm_conversation.rm_request_id = client_rm_requests.id     │
│              WHERE client_rm_requests.event_id = $event->uuid               │
│            (the specific conversation tied to THIS event's RM request,      │
│             not just any conversation between this RM and client).          │
│            Falls back to inserting a new rm_conversation if none.           │
│          - INSERT rm_chat row                                               │
│              message_type = 'action'                                        │
│              metadata     = {kind:'event_confirmation',                     │
│                              event_id, summary:{...}}                       │
│              sender_id    = auth()->id()  (RM admin)                        │
│          - POST <SOCKET_URL>/chat-broadcast so already-open chat            │
│            panels render the new message live.                              │
│       b. POST <SOCKET_URL>/notification                                     │
│            {receiverId=client_id,                                           │
│             title:"Please confirm your event details",                      │
│             body, props:{event_id, event_name}}                             │
└─────────────────────────────────────────────────────────────────────────────┘
                                           │  If client rejects or window expires
                                           │  RM clicks [Edit and Resend]
                                           ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ GET  /rm/edit-event/{id}     → editEventForm()                              │
│       renders rm/createEvent.blade.php with $isResubmit=true,               │
│       $existingEvent pre-filled                                             │
│                                                                             │
│ POST /rm/resubmit-event      → resubmitEvent()                              │
│   Guard: event.confirmation_status ∈ {draft, rejected, expired}             │
│   Branches by previous state:                                               │
│     - was 'draft'             → UPDATE fields, stays at 'draft'             │
│                                 NO chat message, NO push (Edit Draft)       │
│     - was 'rejected'|'expired'→ UPDATE fields, flips to 'pending'           │
│                                 reset confirmation_expires_at,              │
│                                 call dispatchConfirmationFlow($event)       │
│                                 (fresh action card + "please review" push)  │
│   event_services are WIPED and re-inserted. The events row is updated in    │
│   place — event_uuid is stable, so all downstream links remain valid.       │
└─────────────────────────────────────────────────────────────────────────────┘
```

### Routes & Actions

| Route | Method | Handler | Description |
|-------|--------|---------|-------------|
| `/rm/create-event/{uuid}` | GET | `RmController::createEventForm()` | Render form (new draft). Server-side guard: a chat conversation between the RM and client MUST already exist on this rm_request; otherwise redirects back with a warning flash. |
| `/rm/store-event` | POST | `RmController::storeEvent()` | Persist as draft (no notification) |
| `/rm/send-confirmation/{id}` | POST | `RmController::sendConfirmation()` | Flip draft → pending; post action chat message + push client |
| `/rm/edit-event/{id}` | GET | `RmController::editEventForm()` | Render form pre-filled (`$isResubmit=true`, `$existingEvent=...`) |
| `/rm/resubmit-event` | POST | `RmController::resubmitEvent()` | Update in place; if was draft stays draft, if was rejected/expired flips to pending and dispatches confirmation |
| `/location/get-countries-for-dropdown` | GET | `LocationController::getCountryForDropdown()` | Active countries (used by form) |
| `/location/get-states-for-dropdown` | GET | `LocationController::getStatesForDropdown()` | Active states |
| `/location/get-cities-by-state/{stateId}` | GET | `LocationController::getCitiesByState()` | Active cities for state |

> **Permissions:** the three new routes (`rm/send-confirmation`, `rm/edit-event`, `rm/resubmit-event`) are mapped to the RM permission group (8) inside `GlobalMethods::permissionGroup($uri)`. When adding any further sibling routes, extend that map or the `UserPermission` middleware will reject the request.

### Helpers (in `RmController`)

- **`dispatchConfirmationFlow($event)`** — shared bundler called from both `sendConfirmation` and `resubmitEvent` (rejected/expired branch). Performs (1) `postEventConfirmationMessage($event)` and (2) the "please review" socket push, in that order. Keeps the two paths identical so behaviour can't drift.
- **`postEventConfirmationMessage($event)`** — owns the conversation lookup + chat write:
  - Find the conversation via `rm_conversation JOIN client_rm_requests ON rm_conversation.rm_request_id = client_rm_requests.id WHERE client_rm_requests.event_id = $event->uuid`. This pins the message to the conversation that belongs to *this event's* RM request, not just any conversation between this RM and client (an RM may have multiple requests with the same client).
  - If no conversation exists, INSERT a new `rm_conversation` row and use its id.
  - INSERT an `rm_chat` row with `message_type='action'`, `metadata={kind:'event_confirmation', event_id, summary:{...}}`, `sender_id=auth()->id()`.
  - POST the saved message to `<SOCKET_URL>/chat-broadcast` so any chat panel currently open re-renders without a refresh.

### Blade + JS Bundle

- View: `resources/views/rm/createEvent.blade.php` — same view drives Create / Edit Draft / Edit and Resend; `$isResubmit` + `$existingEvent` switch the form action and submit button label. HTML5 validation attributes (`minlength`, `maxlength`, `min`, `max`, `step`, `inputmode`, dynamic `min`/`max` on `event_date`) are declared here as the first validation layer.
- Layout: `resources/views/layouts/dashboard.blade.php` — scoped `@if($js_file == 'createEvent.js')` block CDN-loads `jquery-validation@1.19.5` (`jquery.validate.min.js` + `additional-methods.min.js`), pinned by version. Same pattern the file already uses for select2.
- JS: `public/assets/custom/js/createEvent.js` (referenced via `$js_file`) — owns all custom jQuery Validate methods, field-rule wiring, cross-field cascades, dynamic service-row rule registration, and the submitHandler pipeline. See [Form Validation](#form-validation).
- Controller helper: `RmController::validateEventForm(Request $request): void` — shared server-side rules called from both `storeEvent` and `resubmitEvent`. Authoritative gate. See [Form Validation](#form-validation) → [Defense in depth](#defense-in-depth).
- Detail-page JS: `public/assets/custom/js/rm.js`
  - The **Create Event** button on `rm/details.blade.php` is rendered with a `hide` class when no conversation exists yet. When the RM clicks **Start Chat** and the AJAX returns success, the handler calls `$("#createEventBtn").removeClass("hide")` to reveal Create Event without a page refresh — matching the server-side guard in `createEventForm`.
  - See Feature 08 for the message-type-aware chat renderer (`renderChatMessage`, `renderEventStatusBadge`) that displays the action card with its live `event_status` pill.

---

## Data Model

### Inputs (POST /rm/store-event)

```php
{
  rm_request_uuid:  string,            // required, identifies the parent
  client_id:        int,               // required, the event owner
  type:             'personal'|'professional',
  category:         int,               // categories.id
  country_id:       int,               // UAE only
  state_id:         int,
  cities_id:        int,
  event_name:       string max 100,
  event_date:       date,
  event_start_time: string,
  guest_count:      int,
  description:      string max 500,
  services: [
    { id: <uuid string>, budget: numeric },
    ...
  ],
}
```

### Outputs

- New `events` row with:
  ```php
  uuid:                Str::uuid()->toString(),
  human_usable_id:     "EVN" + str_pad(mt_rand(1, 999999999), 9, '0', LEFT),
  confirmation_status: 'draft',
  ```
- N new `event_services` rows where `event_id = event.uuid` (string UUID).
- Updated `client_rm_requests` row with `record_type='event'`, `record_id` + `event_id` = event.uuid, `budget=<sum of service budgets>`. **`client_rm_requests.status` is NOT mutated** — see Path C in Feature 08.
- **No** notification row, **no** chat message, **no** socket push at this stage. Those are deferred to `sendConfirmation`.

### `sendConfirmation` / `resubmitEvent` (rejected/expired branch) outputs

- UPDATE `events` row: `confirmation_status='pending'`, `confirmation_expires_at = min(now()+24h, event_date − 4h)`.
- New `rm_chat` row with `message_type='action'`, `metadata={kind:'event_confirmation', event_id, summary:{...}}` in the conversation tied to this event's rm_request.
- HTTP POST to `<SOCKET_URL>/chat-broadcast` so live chat panels render the new action card.
- HTTP POST to `<SOCKET_URL>/notification` (best-effort) with title "Please confirm your event details".

### `notifications` row created

```php
{
  uuid:              Str::uuid(),
  module:            'rm-event',
  message:           "Your event '<name>' has been created by your Relationship Manager",
  status:            0,
  image:             '',
  created_by:        auth()->id(),   // RM admin id
  created_type:      'admin',
  country_id:        $event->country_id,
  created_for:       $client_id,
  created_for_type:  'client',
  is_broadcast:      0,
  is_seen:           0,
  notification_type: 'inapp',
  payload:           '{"event_id":..., "event_name":..., "created_at":...}',
}
```

---

## Validations & Business Rules

> **Three-layer validation.** Every user-supplied field is guarded at three levels — HTML5 attribute, client-side jQuery Validate rule, and server-side Laravel rule via the shared `validateEventForm(Request $request)` helper on `RmController`. The full side-by-side matrix is in the [Validation Matrix](#validation-matrix) section below.

### High-level business rules

| Rule | Detail |
|------|--------|
| `services` array | `required \| array \| min:1` (must have at least one service row) |
| Per service `id` | `required \| string \| distinct` — the master service UUID, and no duplicates across rows |
| Per service `budget` | `required \| numeric \| min:0 \| max:999999` |
| `event_date` window | Must be `> today` and `<= today + 1 year` (`after:today \| before:+1 year`) |
| Combined date + time floor | `Carbon::parse(event_date . ' ' . event_start_time)` must be `>= now + 4h`. Mirrors the `confirmation_expires_at = event_date − 4h` floor so a draft can always be sent. Enforced by a closure in server rules AND the `combinedDateTimeFuture` client method. |
| `event_start_time` format | Server regex `/^\d{2}:\d{2}(:\d{2})?$/`; client `timeFormat` custom method + `step=60` HTML5 attribute (minute granularity, no seconds picker). |
| Foreign key existence | `category`, `country_id`, `state_id`, `cities_id` all carry `exists:<table>,id` on the server — no more silent orphan writes if a category is deleted mid-form. |
| If RM request not found | Flash error, redirect back to RM list |
| If event already exists for this RM request | Flash warning, redirect back (no re-create allowed; edit goes via `/rm/edit-event/{id}`) |
| **Chat-initiated guard** | `createEventForm` redirects with a warning if no `rm_conversation` row exists for this rm_request — RM must Start Chat first. Mirrored on the UI by the `hide` class on `#createEventBtn`. |
| `sendConfirmation` guard | Event must be `confirmation_status='draft'` |
| `resubmitEvent` guard | Event must be `confirmation_status ∈ {draft, rejected, expired}`; `confirmed` and `pending` cannot be re-edited from this surface |
| Confirmation expiry rule | `confirmation_expires_at = min(now()+24h, event_date − 4h)`. Set on send and reset on resubmit-from-rejected/expired. |
| Resubmit wipes services | `event_services` rows for this `event_id` are deleted and re-inserted from the form's services[] array; events row itself is UPDATEd in place (event_uuid stable). |
| If UAE not in `countries` master | Flash error, redirect — form refuses to render |
| `human_usable_id` uniqueness | `do { … } while(EventModel::where('human_usable_id', $candidate)->exists())` |
| Transaction-bounded DB writes | DB::beginTransaction / commit / rollback |
| Notification + socket are OUT of the transaction | Wrapped in try/catch; failures logged only — event still commits |
| Socket URL transform | `ws://` → `http://`, `wss://` → `https://`, then `rtrim('/')`, then `+/notification` |

---

## Form Validation

Full three-layer validation was wired into the create-event form. Client rules exist for UX (inline errors, no round-trip); server rules via `RmController::validateEventForm()` are authoritative.

### Validation Matrix

Each field is guarded at up to three layers. HTML5 attributes fire first in the browser, jQuery Validate rules run on submit before AJAX, and the server rules run inside `validateEventForm()` (called from both `storeEvent` and `resubmitEvent`).

| Field | HTML5 attrs | jQuery Validate rules | Server rules |
|-------|-------------|-----------------------|--------------|
| `rm_request_uuid` (hidden) | — | — | `required \| string` |
| `client_id` (hidden) | — | — | `required \| integer` |
| `type` | `required` (radio) | `required, valueNotEquals` | `required \| string \| in:personal,professional` |
| `category` | `required` | `required, valueNotEquals` | `required \| integer \| exists:categories,id` |
| `country_id` (hidden, UAE-pinned) | — | `required` (included via `ignore: ':hidden:not([name="country_id"])'`) | `required \| integer \| exists:countries,id` |
| `state_id` | `required` | `required, valueNotEquals` | `required \| integer \| exists:states,id` |
| `cities_id` | `required` | `required, valueNotEquals` | `required \| integer \| exists:cities,id` |
| `event_name` | `required, minlength=3, maxlength=100` | `required, minlength=3, maxlength=100, noHtml, notWhitespaceOnly` | `required \| string \| min:3 \| max:100` |
| `guest_count` | `required, min=1, max=9999, step=1, inputmode=numeric` | `required, digits, min=1, max=9999` | `required \| integer \| min:1 \| max:9999` |
| `event_date` | `type=text`, `required`, `placeholder="DD-MM-YYYY"` — the picker (Flatpickr) owns bounds; no HTML5 `min`/`max`. Real input is hidden and posts `YYYY-MM-DD`; a visible altInput sibling shows `DD-MM-YYYY`. See [Date Picker (Flatpickr)](#date-picker-flatpickr). | `required, date, futureDate, within12Months` — error messages formatted as **DD-MM-YYYY** (UAE convention) via the dynamic message functions on `futureDate` / `within12Months`; the `date` message reads "Please enter a valid date (DD-MM-YYYY)". Validator config is patched so the now-hidden `event_date` still participates: `ignore: ':hidden:not([name="country_id"]):not([name="event_date"])'`; `highlight`/`unhighlight` also toggle `.is-invalid` on the visible altInput sibling (`el.next('.form-control')`); `errorPlacement` inserts the error span after the altInput sibling. | `required \| date \| after:today \| before:+1 year` (unchanged — hidden real input posts `YYYY-MM-DD`) |
| `event_start_time` | `required, step=60` | `required, timeFormat, combinedDateTimeFuture` + **on-input revalidation trigger** (`.on('input change blur', ...)` calls `.valid()`) so the field re-checks the moment a valid `HH:MM` is committed; blade pre-fill is trimmed to `HH:MM` via `substr($rawTime, 0, 5)` to avoid the `HH:MM:SS`-vs-`step=60` false-empty state | `required \| regex:/^\d{2}:\d{2}(:\d{2})?$/` + closure enforcing combined date+time ≥ `now + 4h` |
| `description` | `required, minlength=10, maxlength=500` | `required, minlength=10, maxlength=500, notWhitespaceOnly` | `required \| string \| min:10 \| max:500` |
| `services[i].id` | `required` (select) | `required, valueNotEquals` (registered dynamically) | `required \| string \| distinct` |
| `services[i].budget` | `required, max=999999, **step=1**, **inputmode=numeric**` — blade pre-fill cast via `(int)($row->amount ?? 0)` | `required, **digits**, min=0, max=999999` (registered dynamically; was `number` — now integer-only) | `required \| **integer** \| min:0 \| max:999999` |

Error styling uses Bootstrap: `.is-invalid` on the input + a `<span class="invalid-feedback d-block">` sibling below. On submit failure the page scrolls to ~120px above the first invalid field.

### Custom jQuery Validate methods

Added inside `createEvent.js` alongside the CDN-loaded plugin (`jquery-validation@1.19.5` — pinned in `dashboard.blade.php` alongside select2).

| Method | Enforces |
|--------|----------|
| `valueNotEquals(value, element, arg)` | For `<select>` fields whose empty option value is `""` — treats it as unselected. |
| `notWhitespaceOnly` | Rejects strings that are only spaces/tabs/newlines. |
| `noHtml` | Rejects strings containing `<...>` — defensive XSS block at the UI layer. Server escaping is still authoritative. |
| `futureDate` | `event_date >= tomorrow`. **Message is a function** (not a string) so the boundary date is embedded as `DD-MM-YYYY`: `"Event date must be on or after " + formatDDMMYYYY(tomorrow)`. The static `event_date.futureDate` entry was removed from the `messages: {...}` block since it's now provided dynamically by the method itself. |
| `within12Months` | `event_date <= now + 1 year`. **Message is a function** (not a string) so the boundary date is embedded as `DD-MM-YYYY`. The static `event_date.within12Months` entry was removed from the `messages: {...}` block. |
| `timeFormat` | `HH:MM` regex — mirrors the server regex minus the optional seconds group. |
| `combinedDateTimeFuture` | `Carbon-equivalent(event_date + event_start_time) >= now + 4h`. Mirrors the server closure so the client blocks the same case. |

**Supporting helper:**

| Helper | Purpose |
|--------|---------|
| `formatDDMMYYYY(d)` | Local (non-jQuery-Validate) helper that formats a JS `Date` as `DD-MM-YYYY`. Used by the message functions on `futureDate` and `within12Months` to render UAE-convention boundary dates in inline errors. |

### Date Picker (Flatpickr)

The `event_date` field previously used `<input type="date">` with HTML5 `min` / `max`. That path was abandoned because Chrome's per-input constraint-validation tooltip is locked to the browser's ISO `YYYY-MM-DD` format and cannot be overridden — UAE RMs expect `DD-MM-YYYY`. Dropping the `min` / `max` attrs killed the tooltip but also killed the picker's greying of past/out-of-range dates (and see the [gotcha table](#ux-gotchas-fixed): `novalidate` on the form does not suppress this per-input UI).

Replaced with **Flatpickr 4.6.13** (MIT), CDN-pinned in `dashboard.blade.php` inside the `@if($js_file == 'createEvent.js')` block alongside jQuery Validate:

```blade
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
```

Initialised from `initDatePicker()` in `createEvent.js`, called from `document.ready`:

```js
flatpickr('#event_date', {
    dateFormat: 'Y-m-d',   // value posted to server (Laravel-friendly)
    altInput:   true,      // create visible sibling with alt format
    altFormat:  'd-m-Y',   // what the RM sees: DD-MM-YYYY
    minDate:    tomorrow,  // computed from new Date()
    maxDate:    +365 days,
    allowInput: true,
    onChange:   () => $('#event_date').valid(),
    onClose:    () => $('#event_date').valid(),
});
```

**Two-DOM-node structure.** `altInput: true` produces two inputs in the DOM:
- The original `#event_date` — now hidden, holds the `YYYY-MM-DD` value that gets POSTed (server rules unchanged).
- A Flatpickr-created visible sibling (`.form-control` — inserted as `#event_date.next()`) that shows `DD-MM-YYYY` to the RM.

**Bounds enforced at three layers** (mirrors the rest of the form):
1. **Picker** — `minDate: tomorrow`, `maxDate: +365 days` grey out disallowed dates in the calendar UI.
2. **jQuery Validate** — `futureDate` + `within12Months` custom methods re-check on submit and on `onChange`/`onClose`.
3. **Server** — `event_date => required|date|after:today|before:+1 year` in `validateEventForm()` is authoritative and unchanged.

**jQuery Validate config adjustments** required because `event_date` is now hidden (see the Validation Matrix row above for the field-level view):
- `ignore` selector adds an `:not([name="event_date"])` exception so the hidden real input still participates.
- `highlight` / `unhighlight` also toggle `.is-invalid` on the visible altInput sibling (`el.next('.form-control')`) — otherwise only the hidden input gets styled and the RM sees nothing.
- `errorPlacement` for `event_date` inserts the error span after the altInput sibling so the message lands directly beneath the picker the RM is looking at.

#### Shared helpers (`datepicker-utils.js`)

The Flatpickr wiring above — UAE date-format defaults, future-date bounds, and the altInput jQuery Validate glue — was extracted into `public/assets/custom/js/datepicker-utils.js` so any future admin page needing a date picker can opt in without re-deriving the DOM knowledge. `createEvent.js`'s `initDatePicker()` now delegates to `ootboFutureDatePicker('#event_date')`, and its validate() `ignore` / `highlight` / `unhighlight` / `errorPlacement` come from the glue helper's return values instead of being inlined.

The file exposes three helpers:

| Helper | Signature | Purpose |
|--------|-----------|---------|
| `ootboDatePicker` | `(selector, opts) → Flatpickr instance` | Base Flatpickr wrapper with UAE defaults: `dateFormat: 'Y-m-d'`, `altInput: true`, `altFormat: 'd-m-Y'`, `allowInput: true`. Any `opts` shallow-merge on top. |
| `ootboFutureDatePicker` | `(selector, opts) → Flatpickr instance` | Preset for future-date pickers. Sets `minDate = tomorrow` and `maxDate = +N days` (from `opts.maxDaysAhead`, default `365`), then delegates to `ootboDatePicker`. |
| `ootboFlatpickrValidateGlue` | `(fieldNames: string \| string[]) → { ignoreExtra, highlight, unhighlight, errorPlacement }` | Encapsulates the DOM knowledge that Flatpickr's `altInput` mode splits the field into a hidden real input + visible `.form-control` sibling. Caller merges the returned fragments into its jQuery Validate `validate({...})` config. |

**Where it lives & how it's loaded.** The file sits alongside the other custom scripts at `public/assets/custom/js/datepicker-utils.js` and is loaded from `resources/views/layouts/dashboard.blade.php` in the same scoped `@if($js_file == 'createEvent.js')` block as Flatpickr itself:

```blade
<script src="{{ url('assets/custom/js/') }}/datepicker-utils.js"></script>
```

Keeping it inside the same `@if` (rather than the always-on section) means non-date-picker pages don't pay the extra script cost.

**Opting in from a new page** — three-step recipe:

1. **Add the page to the `@if`** — extend the condition in `dashboard.blade.php` to include the new `$js_file` (e.g. `@if($js_file == 'createEvent.js' || $js_file == 'someNewPage.js')`) so Flatpickr + `datepicker-utils.js` load on it too.
2. **Call the helper** — from the page's JS, `ootboFutureDatePicker('#some-input')` for a standard future-date picker, or `ootboDatePicker('#some-input', {...})` for a custom range. The returned Flatpickr instance is available if the page needs to programmatically re-open / clear / set the date later.
3. **Merge the validate glue** — if the page uses jQuery Validate, spread `ootboFlatpickrValidateGlue('some-input')` into its `validate({...})` config so the hidden real input is not skipped by `:hidden`, the visible altInput sibling gets `.is-invalid` styling, and the error span lands beneath the picker. Pages without jQuery Validate can skip this step entirely.

**Separation from `comman.js`.** `datepicker-utils.js` is deliberately a new file, not an addition to the pre-existing `comman.js` (typo preserved for backwards compatibility with its many callers). Keeps Flatpickr-specific concerns isolated — swapping the picker library later, or dropping the file if pickers ever move to bundled assets, doesn't churn `comman.js`.

### Cross-field cascades

Handled inside `createEvent.js` so orphaned states can't reach the server:

- **`type` → filter `category`** — pre-existing. Choosing `personal` shows only personal categories; choosing `professional` shows only corporate categories.
- **`category` → sync `type`** — new. Picking a personal-flavoured category auto-sets `type=personal`, and vice-versa. Prevents the "orphaned type" bug class where the two dropdowns drift out of sync.
- **`state_id` → clear `cities_id.is-invalid`** — new. Because the cities dropdown reloads via AJAX after a state change, the stale `.is-invalid` class is removed so the user isn't left with a red border on a freshly-repopulated select.
- **Service picker duplicate guard — `refreshServiceOptions()`** — new. Iterates every `.service-select` dropdown and disables options whose value matches another row's current selection. Preserves two always-enabled cases: (1) the empty placeholder `""` ("Select Service") is always enabled, and (2) each dropdown's *own* current selection is always enabled (otherwise a row would disable its own value and appear blank). Called from `document.ready` (for both initial load and resubmit-mode pre-fill), the delegated `change` handler on `.service-select`, the `#addServiceBtn` click handler after row append, and the `.remove-service` click handler after row removal. Defense in depth: the existing submitHandler duplicate check and the server `services.*.id => distinct` rule are unchanged — this is a UX shortcut, not the authoritative gate.
- **Number-input keydown filter** — new. `document`-delegated `keydown` on `#createEventForm input[type=number]` blocks `['e', 'E', '+', '-', '.']` via `e.preventDefault()`. Kills the HTML5-`type=number` gotcha where scientific notation (`1e5`) and signed values pass the browser's own validation but look empty via `.value`, and where a stray `.` on the (now integer-only) budget field would visually fill the input but read as invalid. The filter was previously scoped to `guest_count` only; it now covers all number inputs on the form (guest_count + all `services[i].budget` rows including dynamically-added ones — hence the delegation).
- **Number-input paste guard** — new. Same delegated selector; on the `paste`/`input` event, strips everything non-digit before it lands in the model. Catches the clipboard path (paste `1e5` or `-12`).
- **Time on-input revalidation** — new. `$('#event_start_time').on('input change blur', function () { $(this).valid(); })` re-runs jQuery Validate the moment the field's value changes. Fixes the case where the picker doesn't commit `.value` until blur — the field now clears its red border the instant a valid `HH:MM` is entered, not on the next submit.

### Dynamic service rows

The `#addServiceBtn` click handler appends a new `services[i].id` select + `services[i].budget` input, then immediately calls `.rules('add', {...})` on both — attaching the `valueNotEquals` / `required` / `digits` / `min` / `max` rules so the newly-added row participates in validation on the very next submit. No page refresh, no re-init of the validator instance. After append it also invokes `refreshServiceOptions()` so the newly-added row's dropdown starts out with the correct disabled-options state. The keydown/paste filters need no per-row wiring — they're delegated on `document` against `#createEventForm input[type=number]`.

### UX gotchas fixed

Several form-UX bugs surfaced during testing after the Phase 1–5 baseline landed. All are non-obvious enough to warrant calling out so future hands don't reintroduce them.

| Bug | Root cause | Fix |
|-----|-----------|-----|
| **`event_start_time` reports "Please select an event time" even when the field visibly shows a valid value (e.g. `07:56`)** | Two compounding causes. (1) MySQL `TIME` columns return `HH:MM:SS`; rendering that raw value into `<input type="time" step="60">` (minute granularity) leaves some browsers in an invalid state where `element.value` reads empty and jQuery Validate's `required` false-fires. (2) The `<input type="time">` picker doesn't commit `.value` until blur; a submit while the picker is still focused reads an empty string. | Blade pre-fill trimmed to `HH:MM` via `substr($rawTime, 0, 5)`. JS adds an `input change blur` revalidation trigger that calls `.valid()` on the field so a freshly-entered valid time clears the red border without waiting for submit. |
| **Date errors show ISO or vague "at least tomorrow"** | The static message strings on `futureDate` / `within12Months` had no way to embed the actual boundary date, and defaulted to browser-format ISO which isn't the UAE convention. | Convert both custom methods' messages from strings to functions that compute the boundary at call-time and format it via `formatDDMMYYYY(d)`. The static `event_date.futureDate` / `event_date.within12Months` entries were removed from the `messages: {...}` block so the function-based ones win. The `event_date.date` message was also updated to name the format explicitly: "Please enter a valid date (DD-MM-YYYY)". |
| **HTML5 `type=number` accepts `e`, `E`, `+`, `-` (and `.` on integer-only fields)** | Native browser validation on `type=number` treats scientific notation (`1e5`) and signed values as valid, but jQuery Validate + server rules see them as empty or invalid — the field looks filled but validation fails, confusing the RM. Clipboard paste bypasses the keyboard entirely. | Delegated `keydown` filter blocks `['e', 'E', '+', '-', '.']` on all `#createEventForm input[type=number]`. A paste/input guard on the same selector strips non-digits post-paste. Delegation means dynamically-added service rows are covered without per-row wiring. |
| **Chrome's `<input type="date">` constraint tooltip is locked to `YYYY-MM-DD` — UAE RMs expect `DD-MM-YYYY`** | Chrome renders the `min`/`max` constraint-violation tooltip on `<input type="date">` in the browser locale's ISO format, and this UI cannot be styled or reformatted from author code. The intuitive workaround "just drop `min`/`max`" removes the tooltip but also removes the picker's greying of past/out-of-range dates — degrading the whole picker to appease one tooltip. | Abandon native `type=date` entirely for this field. Replace with **Flatpickr 4.6.13** (`altInput: true`, `altFormat: 'd-m-Y'`, `dateFormat: 'Y-m-d'`) — see [Date Picker (Flatpickr)](#date-picker-flatpickr). Server rules unchanged: the hidden real input still posts `YYYY-MM-DD`. |
| **`novalidate` on the `<form>` does NOT suppress per-input constraint UI** | Key realisation worth pinning. `novalidate` (which jQuery Validate auto-adds to `<form>`) only suppresses submit-time form-level validation ("Please fill out this field" and similar). It does **NOT** suppress the per-input `:invalid` constraint UI that Chrome shows for `min`/`max` violations on `<input type="date">` or the `type` mismatch on `<input type="email">`. An earlier attempt of "add `min` for picker UX, `novalidate` suppresses the tooltip" failed for exactly this reason. | No `novalidate` fix exists at the input level. The only clean way to control the constraint UI on a date input is to not use `<input type="date">` — see the Flatpickr replacement above. |

### Submit flow

```
[Save Event] button (type="button", onclick="submitCreateEvent()")
        │
        ▼
submitCreateEvent()  ── thin wrapper ──▶  $('#createEventForm').trigger('submit')
        │
        ▼
jQuery Validate .validate({...})
        │
        ├── invalid ──▶ mark .is-invalid + .invalid-feedback,
        │                scroll to first error (~120px above), STOP.
        │
        └── valid ────▶ submitHandler(form):
                          1. Post-validation checks
                               • at least one service row
                               • no duplicate service ids across rows
                          2. SweetAlert confirm dialog
                               ("Create this event? …")
                          3. On user confirm ──▶ form.submit()  (native POST)
                                                    │
                                                    ▼
                                       POST /rm/store-event
                                       (or /rm/resubmit-event on edit path)
                                                    │
                                                    ▼
                                       RmController::storeEvent / resubmitEvent
                                                    │
                                                    ▼
                                       validateEventForm($request)  ← authoritative
```

### Defense in depth

Client validation is **UX only**. It exists so the user gets a fast red border and a helpful message without a round-trip. The authoritative gate is the shared `RmController::validateEventForm(Request $request): void` helper, called from both `storeEvent` and `resubmitEvent` — replacing what used to be two duplicated inline `$request->validate([...])` blocks that had already begun to drift. Anyone bypassing the browser (curl, Postman, a stale form tab) hits the same rules. **Never** remove a server rule to "just fix" a client-side complaint — fix the client, or add a matching server rule.

Custom messages are declared in the `validate(rules, messages)` array for the least-obvious rules (combined date+time floor, whitespace-only, HTML block, distinct services). Generic Laravel messages cover the rest.

### Known gaps

- **No async / remote validation** — the form does not re-check that a chosen `category`, `state_id`, `cities_id`, or `services[].id` is still `is_active` mid-form. A master-data row toggled off after the RM opened the page will only be caught by the server (`exists:...`) rule on submit.
- **No i18n on messages** — all validation strings are English-only. Fine for the current RM audience (VisoAdmin is admin-only, English), but flag if RM ever localises. Note that date errors are now DD-MM-YYYY (UAE) hard-coded — if the app ever needs MM-DD-YYYY or ISO, the format lives in one place (`formatDDMMYYYY(d)`).
- ~~**`services[].distinct` is server-only**~~ — **PARTIALLY RESOLVED.** The picker itself now disables already-selected options via `refreshServiceOptions()`, so duplicates can no longer be *chosen*. The submitHandler duplicate check and server `distinct` rule remain as defence in depth. There is still no jQuery Validate rule that paints `.is-invalid` on the offending row(s) if a duplicate somehow slips through — cosmetic gap only, since the picker + server rule make the situation unreachable in normal use.
- **CDN dependency** — `jquery-validation` and now `flatpickr@4.6.13` are loaded from jsdelivr. Same posture as select2 already in this app; if the app ever moves to strict CSP or offline-first, all three need to move to bundled assets. Specifically for Flatpickr: if the CDN is unreachable at page load, the field degrades to a plain `<input type="text">` (no calendar UI, no bounds greying) — the RM can still type a date manually and the three-layer validation (jQuery Validate `futureDate` / `within12Months` + server `after:today` / `before:+1 year`) still catches out-of-range values, but the UX is severely degraded.
- **Date format `DD-MM-YYYY` is hard-coded, not i18n-aware** — the visible `altFormat: 'd-m-Y'` matches UAE convention and is fine for the current UAE-only market. If the app ever launches in a locale that expects `MM-DD-YYYY` or ISO, the `altFormat` (and the `formatDDMMYYYY(d)` helper used by the message functions) need coordinated updates.

---

## API Endpoints

| Method | Path | Auth | Request | Response | Consumer |
|--------|------|------|---------|----------|----------|
| GET | `/rm/create-event/{uuid}` | session + permission 8 | path uuid; chat must exist | HTML or redirect-with-warning | Browser |
| POST | `/rm/store-event` | session + permission 8 | see Inputs above | redirect with flash | Form |
| POST | `/rm/send-confirmation/{id}` | session + permission 8 | path id; event must be draft | redirect with flash | Detail-page button |
| GET | `/rm/edit-event/{id}` | session + permission 8 | path id; event must be draft/rejected/expired | HTML | Detail-page button |
| POST | `/rm/resubmit-event` | session + permission 8 | same shape as store-event + event id | redirect with flash | Form |

### Outbound HTTP (to Viso-Chat)

| Method | Path | Body | Behaviour |
|--------|------|------|-----------|
| POST | `<SOCKET_URL transformed>/notification` | `{ receiverId, title:"Please confirm your event details", body, props: { event_id, event_name } }` | Fire-and-forget. Failures logged. Event still saved. Sent only by sendConfirmation / resubmitEvent (rejected/expired branch). |
| POST | `<SOCKET_URL transformed>/chat-broadcast` | the saved `rm_chat` action row | Fire-and-forget. Pushes the action card to any already-open chat panel so it appears live. |

---

## Upstream Impact

- **Feature 08 (RM Requests)** — provides the parent `client_rm_requests` row.
- **Feature 12 (Master Data — Locations)** — must have UAE in `countries`, plus active states + cities under it.
- **Feature 13 (Master Data — Services)** — list of active services for the multi-select.
- **`categories` table** — populates the category dropdown.
- **`.env :: SOCKET_URL`** — controls realtime push destination.

---

## Downstream Impact

- **Feature 06 (Event Oversight)** — new event appears in the events list.
- **Feature 10 (Critical Events)** — until vendor quotes arrive, this event appears in critical-events table.
- **Public Vendor App** — matched vendors get this event as a lead (via OotboAPI lead-matching pipeline).
- **Public Client App** — client sees an in-app notification + (via socket) a realtime push.
- **Feature 16 (Notifications — Admin Inbox)** — the new notification row also shows in the admin inbox if `type='admin'` is met by future filters.

---

## Impact of Changes

| If you change... | Risk to... | Level | Type |
|-----------------|------------|-------|------|
| Removing `events.uuid` or changing its generation | RM Create Event breaks; OotboAPI events still use integer id | Critical | Data |
| Changing `event_services.event_id` from string-UUID to int | RM Create Event writes string but list/detail queries cast to int → orphans | Critical | Data |
| Changing the `notifications.payload` column name (it may actually be `data_props`) | Feature 16 inbox & this controller diverge → notifications display nothing | High | Data |
| Removing UAE from `countries` master | `createEventForm` aborts with flash error | High | Data |
| Adding required columns to `events` | INSERT in `storeEvent` fails | High | Data |
| Changing `SOCKET_URL` scheme | Realtime push silently drops | High | Service |
| Renaming `client_rm_requests.record_id` | UPDATE inside `storeEvent` writes nothing → request not linked to its event | High | Data |
| Renaming `events.confirmation_status` or `confirmation_expires_at` | Two-step flow guards become no-ops; drafts auto-send or sends silently no-op | Critical | Data |
| Removing/renaming `rm_chat.message_type` or `metadata` | Chat panel can't render the action card; client never sees the confirmation prompt | Critical | Data |
| Bypassing `dispatchConfirmationFlow` and inlining the post+push | `sendConfirmation` and `resubmitEvent` drift; one path stops broadcasting | High | Code |
| Loosening the `rm_conversation` lookup in `postEventConfirmationMessage` (e.g. dropping the JOIN on `event_id`) | Action card posted to the wrong conversation when an RM has multiple requests with the same client | Critical | Data |
| Forgetting to extend `GlobalMethods::permissionGroup` when adding a new `rm/*` route | `UserPermission` middleware rejects with 403 | Medium | Code |
| Removing `mt_rand(1, 999999999)` collision retry | Birthday collisions in human_usable_id at scale | Medium | Data |
| Removing the scoped `jquery-validation` `<script>` block from `dashboard.blade.php` when `$js_file == 'createEvent.js'` | `createEvent.js` throws `$(...).validate is not a function` on form load — submit falls through to native POST, server still rejects but user gets no inline errors | High | Code |
| Editing `validateEventForm()` in only one of `storeEvent` / `resubmitEvent` call sites | Impossible now — the helper is shared. Deleting the helper and re-inlining the rules risks the previous drift. | High | Code |
| Changing the combined-date+time floor from `+4h` to any other value | Must be changed in three places (server closure, client `combinedDateTimeFuture`, and `confirmation_expires_at = event_date − 4h` in `sendConfirmation`). Missing one lets drafts be created that can never be sent. | High | Code |
| Bumping `jquery-validation` past `1.19.5` from the CDN | Rule-registration API surface may shift; the dynamic `.rules('add')` calls on new service rows are the most fragile path. Pin version in `dashboard.blade.php`. | Medium | Code |
| Removing an `exists:<table>,id` rule from the server helper | Silent orphan writes if a category/state/city is deleted between page load and submit | High | Data |
| Reverting the blade time pre-fill from `substr($rawTime, 0, 5)` back to raw `HH:MM:SS` | `event_start_time` re-enters the false-empty state on browsers that reject seconds under `step=60`; RM sees "Please select an event time" on a visibly-filled field. Only re-fires on edit paths where a `TIME` column round-trips through blade. | High | Code |
| Changing `services.*.budget` back to `numeric` / decimals (or the blade cast, or the HTML `step`, or the client `digits` rule) without changing all four in lockstep | Divergence between HTML5, jQuery Validate, and server rules. **Data migration deferred** — existing rows may carry legacy decimal budgets; blade casts to `int` on pre-fill, so display is safe, but new saves are integer-only. If decimals ever need to return, coordinate all four layers + backfill/normalise historical rows. | High | Data |
| Rewiring `refreshServiceOptions` from event delegation to direct binding | New rows added via `#addServiceBtn` won't participate — direct binds only attach to elements present at bind time. Dynamic rows would then let duplicates slip past the picker, falling back to the submit-time SweetAlert. Keep the delegated `$(document).on('change', '.service-select', ...)` shape. | Medium | Code |
| Removing the `formatDDMMYYYY(d)` helper or reverting the `futureDate` / `within12Months` message functions to strings | Date errors regress to browser-default ISO or the old vague "at least tomorrow" wording — UAE users lose the DD-MM-YYYY convention they expect. | Medium | Code |
| Removing the `keydown` / paste filter on `#createEventForm input[type=number]` | `type=number` inputs accept `e`/`E`/`+`/`-` again; users can submit values that look filled but read empty via `.value`, hitting the confusing "required" error on a visibly-populated field. | Medium | Code |
| Reverting Flatpickr and going back to `<input type="date">` | Multi-file rollback: revert `#event_date` input `type=text` → `type=date`, remove the `placeholder="DD-MM-YYYY"`, restore HTML5 `min`/`max`; delete `initDatePicker()` and the CDN loader block in `dashboard.blade.php`; roll back the jQuery Validate `ignore` selector to `':hidden:not([name="country_id"])'`, drop the altInput `highlight`/`unhighlight`/`errorPlacement` branches. Chrome's ISO tooltip returns — UAE RMs see `YYYY-MM-DD` again. | High | Code |
| Bumping `flatpickr` past `4.6.13` from the CDN | `altInput` / `altFormat` are the most fragile surfaces; also any change to the DOM structure that Flatpickr produces (currently the visible sibling is `#event_date.next('.form-control')`) would break `highlight` / `errorPlacement`. Pin version in `dashboard.blade.php` and re-verify the altInput sibling selector after any bump. | Medium | Code |
| Changing the event-date allowed window (e.g. `+1 year` → `+6 months`, or moving off "tomorrow" as the floor) | Must be changed in three places in lockstep: Flatpickr `minDate` / `maxDate` math in `initDatePicker()`, jQuery Validate `futureDate` / `within12Months` methods (name and boundary math), and the server rules `after:today` / `before:+1 year` in `validateEventForm()`. Missing one lets the picker offer dates that the server rejects, or vice versa. | High | Code |
| Removing or renaming any helper in `datepicker-utils.js` (`ootboDatePicker` / `ootboFutureDatePicker` / `ootboFlatpickrValidateGlue`) | Every caller across the admin app breaks silently at page load — pickers degrade to plain text inputs and validate configs lose their altInput awareness. Grep all `$js_file` pages included in the `@if` block before renaming. | High | Code |
| Adding a new preset (e.g. `ootboPastDatePicker`, `ootboDateRangePicker`) | Trivial — add another function to `datepicker-utils.js` alongside the existing three. No caller changes needed, no `dashboard.blade.php` change needed (the file is already loaded in the scoped `@if`). | Low | Code |

---

## Known Issues

- **`notifications.payload` vs `data_props`**: this controller writes `payload`. Feature 16 (admin inbox) reads `data_props`. **Verify column names with ops** — one of these is the schema, the other may be a typo or aliased.
- **No retry on socket POST failure** — purely best-effort. If the socket bridge is down at the moment of event creation, the realtime push is lost; only the DB notification row persists.
- ~~**`event_start_time` has no format validator** in `storeEvent`~~ — **RESOLVED.** Server now validates `regex:/^\d{2}:\d{2}(:\d{2})?$/` via `validateEventForm()`, and the client enforces `timeFormat` + `step=60`. Feature 06's `updateEvent` still uses its own `H:i:s` variant — the two are compatible (server accepts optional seconds) but worth keeping aligned.
- **Hard-pinned to UAE** — there is no way for an RM in India to create an event from this surface. If/when India launches, this controller branch needs widening.
- **The `default-password = phone` pattern** is NOT triggered here because the client already exists (the RM request guarantees it). Safe.
- **`guest_count` is required and now bounded to 1–9999** at every layer (HTML5 `min/max`, client `min/max`, server `min:1|max:9999`). The public Client app still allows null guest counts — there's a UX mismatch for RM if the original request had no guest estimate.
- ~~**`event_start_time` reports false-empty on visibly-filled fields**~~ — **RESOLVED.** Two-part fix: blade pre-fill trimmed to `HH:MM` (avoids the `HH:MM:SS`-vs-`step=60` browser-invalid state) and JS `on('input change blur', ... .valid())` revalidation trigger (clears the red border the moment a valid time is committed, without waiting for submit).
- **`services.*.budget` is now integer-only** across all four layers (HTML5 `step=1` / `inputmode=numeric`, client `digits`, server `integer`, keydown/paste filter blocks `.` / `-` / `e`). **No data migration performed** — historical rows with decimal budgets read fine because blade casts to `int` on pre-fill (`(int)($row->amount ?? 0)`), but any new save from this surface is integer. If the display of legacy decimal budgets elsewhere in the app needs consistency, backfill would need to happen separately.
- **Send Confirmation is gated only by `confirmation_status='draft'`** — there is no idempotency token. A double-click could theoretically fire two pushes; mitigated by the immediate status flip to `pending` inside the handler, but not race-proof under heavy load.
- **`resubmitEvent` wipes `event_services` and re-inserts** — any external table that referenced `event_services.uuid` (not `event_id`) would be orphaned. Currently nothing does, but worth knowing.
