# Dashboard Widget Webhook — Laravel Side Plan

**Status:** COMPLETE — all three phases implemented and validated
**Date:** 2026-06-23
**Counterpart:** `src/wp-content/plugins/agency-pulse-admin-menu/build-docs/DASHBOARD_WIDGET_WEBHOOK_WP_PLAN.md`

---

## Overview

Adds UI and server-side plumbing to the Laravel site so an authenticated user can
read and update the Dashboard Message widget on any of their connected apwp-ao
WordPress sites — directly from the Downloads page.

Each active slot row in the Connected Sites list gains a **"Set Message"** button.
Clicking it opens a modal that (a) fetches the current title/content from the
target WordPress site and (b) saves new content back via a signed webhook call.

The browser never talks to WordPress directly. All WordPress communication happens
server-side (Laravel → WordPress) using HMAC-signed requests so the signing
secret (`pk`) is never exposed to the client.

---

## Architecture: Request Flow

```
Browser (Downloads page)
   │
   │ 1. GET /account/ao-slots/{slot}/dashboard-message
   │    (Breeze session auth)
   ▼
Laravel AoDashboardMessageController
   │
   │ 2. GET {domain_url}/wp-json/apwp-ao/v1/dashboard-message
   │    (HMAC-SHA256 signed with pk, see Authentication section)
   ▼
WordPress REST endpoint  →  returns { title, content, enabled }
   │
   ▼
Laravel returns JSON to browser
   │
   ▼
Modal renders title + content fields

Browser edits, clicks Save
   │
   │ 3. POST /account/ao-slots/{slot}/dashboard-message
   │    body: { title, content }
   ▼
Laravel AoDashboardMessageController
   │
   │ 4. POST {domain_url}/wp-json/apwp-ao/v1/dashboard-message
   │    (HMAC-SHA256 signed)
   ▼
WordPress REST endpoint  →  updates aamo_dashboard_message option
   │
   ▼
Laravel returns success/error to browser
   │
   ▼
Modal shows confirmation or error
```

---

## Authentication Between Laravel and WordPress

### Shared secret derivation

The JWT issued to each apwp-ao WordPress site contains a `pk` claim:

```php
$pk = hash_hmac('sha256', $user->ao_plugin_key, env('PLUGIN_JWT_SECRET'));
```

This value is:
- **Known to Laravel** — computable at any time from `users.ao_plugin_key` + `PLUGIN_JWT_SECRET`
- **Known to WordPress** — stored inside the JWT payload in `aamo_ao_connector_state`
- **Unique per user** — same across all of a user's connected sites
- **Stable** — does not change unless `ao_plugin_key` rotates

### Signing outbound requests (Laravel → WordPress)

**POST requests** (pushing a new message):
```
body = JSON.encode({ title, content })
signature = HMAC-SHA256(body, pk)
Headers sent:
  Content-Type: application/json
  X-APWP-Signature: sha256=<hex_signature>
  X-APWP-Timestamp: <unix_timestamp>
```

**GET requests** (reading the current message):
```
timestamp = time()
signature = HMAC-SHA256(string(timestamp), pk)
Headers sent:
  X-APWP-Signature: sha256=<hex_signature>
  X-APWP-Timestamp: <unix_timestamp>
```

WordPress verifies the signature on both methods (see WP plan for details).
The timestamp is included to bound replay window to ±5 minutes on the WP side.

---

## Domain URL Storage

### Problem

The `plugin_ao_domains` table stores `domain_hash` (one-way) and `pending_domain`
(full URL, set during authorization). There is no canonical `domain_url` column for
active slots.

`pending_domain` contains the WordPress site URL but the name implies it is
temporary. It is not updated on subsequent JWT refreshes, so it may become stale
if the site URL changes.

### Solution: new `domain_url` column

**New migration:** `add_domain_url_to_plugin_ao_domains`

```php
$table->string('domain_url', 255)->nullable();
```

Population strategy:
1. **On authorization confirmation** — copy `pending_domain` into `domain_url`
   when the slot transitions to `is_active = true`.
2. **On every JWT issue/refresh** — update `domain_url` from `plugin_domain`
   parameter so it stays current if the site URL changes.
3. **Backfill** — set `domain_url = pending_domain` for all existing active slots
   in the migration itself.

---

## New Files

| File | Purpose |
|---|---|
| `database/migrations/YYYY_MM_DD_add_domain_url_to_plugin_ao_domains.php` | Add `domain_url` column + backfill |
| `app/Http/Controllers/AoDashboardMessageController.php` | GET and POST handlers |

---

## Modified Files

| File | Change |
|---|---|
| `routes/web.php` (or account sub-group) | Register two new account routes |
| `app/Http/Controllers/PluginAuthController.php` | Populate `domain_url` in `issueAoJwt()` and `refreshAoMultiDomain()` |
| `app/Http/Controllers/PluginAuthorizationController.php` | Populate `domain_url` when slot is confirmed |
| `app/Models/PluginAoDomain.php` | Add `domain_url` to `$fillable` |
| `resources/views/account/downloads.blade.php` | Add "Set Message" button + modal |

---

## New Routes

```php
// Scoped to authenticated users (Breeze session middleware)
Route::middleware(['auth'])->prefix('account')->group(function () {
    Route::get( '/ao-slots/{slot}/dashboard-message', [AoDashboardMessageController::class, 'show']);
    Route::post('/ao-slots/{slot}/dashboard-message', [AoDashboardMessageController::class, 'update']);
});
```

Authorization check: the slot's `user_id` must equal `Auth::id()`.

---

## `AoDashboardMessageController` — Method Summary

### `show(Request $request, PluginAoDomain $slot): JsonResponse`

1. Authorize: `$slot->user_id === Auth::id()` — abort 403 otherwise.
2. Check `$slot->is_active` and `$slot->domain_url` — return 422 if missing.
3. Compute `pk` from `$slot->user->ao_plugin_key`.
4. Build timestamp; sign `(string)$timestamp` with `pk`.
5. GET `{domain_url}/wp-json/apwp-ao/v1/dashboard-message` with headers.
6. Proxy the response body (title, content, enabled) back to the browser as JSON.
7. On HTTP error or network failure, return a descriptive error JSON.

### `update(Request $request, PluginAoDomain $slot): JsonResponse`

1. Authorize: same as above.
2. Validate: `title` (nullable string, max 255), `content` (nullable string).
3. Compute `pk`; sign raw JSON-encoded `{ title, content }` body.
4. POST `{domain_url}/wp-json/apwp-ao/v1/dashboard-message` with signed body.
5. Return success or proxy the WP error message back to the browser.

### Helper: `computePk(User $user): string`

```php
return hash_hmac('sha256', $user->ao_plugin_key, env('PLUGIN_JWT_SECRET'));
```

---

## UI Changes — Downloads Page

### Slot row change

Each active slot `<li>` currently shows a Release button. Append a new button:

```html
<button type="button"
    class="js-ao-set-message text-xs text-blue-600 hover:underline"
    data-slot-id="{{ $aoSlot->id }}"
    data-slot-label="{{ $aoSlot->domain_label ?? 'Site #'.($loop->index+1) }}">
    Set Message
</button>
```

### Modal

A single shared modal appended once to the page (not one per slot). Structure:

```
┌─ Dashboard Message — <site label> ───────────────────────────┐
│                                                               │
│ Widget Title:                                                 │
│ [_________________________________________________________]   │
│                                                               │
│ Message:                                                      │
│ ┌───────────────────────────────────────────────────────┐     │
│ │ [B] [I] [link] [ul] [ol]  (TinyMCE teeny toolbar)    │     │
│ │───────────────────────────────────────────────────────│     │
│ │                                                       │     │
│ │  (WYSIWYG editor body, ~6 rows)                       │     │
│ │                                                       │     │
│ └───────────────────────────────────────────────────────┘     │
│                                                               │
│ <status message area>                                         │
│                                [Cancel]   [Save Message]      │
└───────────────────────────────────────────────────────────────┘
```

### WYSIWYG Editor

The message content field uses **TinyMCE 6** loaded from CDN, matching the
simplified "teeny" toolbar used in the WordPress settings panel so what the
user sees here matches what gets rendered there.

**CDN:** `https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js`
(self-host or swap for a licensed key as needed).

**Toolbar config:**
```javascript
tinymce.init({
    selector:   '#ao-dm-content',
    menubar:    false,
    plugins:    'link lists',
    toolbar:    'bold italic | link | bullist numlist',
    height:     200,
    branding:   false,
    promotion:  false,
});
```

This mirrors the WordPress `teeny` preset: bold, italic, links, unordered and
ordered lists. No media, no tables, no heading styles — appropriate for a short
admin broadcast message.

### Content extraction for save

TinyMCE must be queried for its current content before the POST is submitted,
with a textarea fallback for when the editor is in source-view mode:

```javascript
function getDmContent() {
    var ed = tinymce.get('ao-dm-content');
    return ed ? ed.getContent() : document.getElementById('ao-dm-content').value;
}
```

### TinyMCE lifecycle with the modal

TinyMCE cannot be initialised against a hidden element. The init call must fire
**after** the modal becomes visible, and the editor must be destroyed when the
modal closes to avoid stale instances on the next open.

```javascript
// On modal open (after display: block / visibility change):
tinymce.init({ selector: '#ao-dm-content', ...config });

// Populate after init completes (use the `init_instance_callback`):
tinymce.init({
    ...config,
    init_instance_callback: function (editor) {
        editor.setContent(currentContent);
    }
});

// On modal close (Cancel or post-save):
tinymce.remove('#ao-dm-content');
```

### Modal JavaScript flow

```javascript
// On "Set Message" click:
//   1. Record active slot ID; set modal heading to site label
//   2. Clear title input; show spinner in editor area
//   3. Show modal (make visible)
//   4. GET /account/ao-slots/{id}/dashboard-message
//   5. On success: init TinyMCE with init_instance_callback to setContent(content);
//      populate title input
//   6. On error: show error, hide spinner
//
// On "Save Message" click:
//   1. content = getDmContent()
//   2. POST /account/ao-slots/{id}/dashboard-message  { title, content }
//   3. Button → "Saving…", disabled
//   4. On success: show "Saved!", close modal after 1.5s (tinymce.remove first)
//   5. On error: show error message, re-enable Save button
//
// On "Cancel" / modal close:
//   tinymce.remove('#ao-dm-content')
//   Hide modal
```

All fetches use `fetch()` with `X-CSRF-TOKEN` from the meta tag.

---

## Error Handling

| Scenario | Behaviour |
|---|---|
| WordPress site unreachable (network error) | Return 502 with message "Could not reach WordPress site." |
| WP returns non-200 | Proxy the WP response body's `message` field |
| Slot has no `domain_url` | Return 422 "Site URL not available. Reconnect the plugin." |
| Slot belongs to different user | 403 Forbidden |
| WP signature verification fails (misconfiguration) | WP returns 403; proxy back to UI |

---

## Migration Detail

```php
// add_domain_url_to_plugin_ao_domains
public function up(): void
{
    Schema::table('plugin_ao_domains', function (Blueprint $table) {
        $table->string('domain_url', 255)->nullable()->after('domain_label');
    });

    // Backfill: copy pending_domain into domain_url for active slots
    DB::table('plugin_ao_domains')
        ->where('is_active', true)
        ->whereNotNull('pending_domain')
        ->whereNull('domain_url')
        ->update(['domain_url' => DB::raw('pending_domain')]);
}
```

---

## Implementation Phases

### Phase 1 — Migration + Model + Auth plumbing

1. New migration: add `domain_url`, backfill active slots.
2. Add `domain_url` to `PluginAoDomain::$fillable`.
3. Populate `domain_url` in `PluginAuthController::issueAoJwt()` (on token issue)
   and `refreshAoMultiDomain()` (on token refresh).
4. Populate `domain_url` in `PluginAuthorizationController` when slot is confirmed.

**Acceptance:** `domain_url` is set on all existing active slots and on every new
authorization.

---

### Phase 2 — Controller + Routes

1. Create `AoDashboardMessageController` with `show()`, `update()`, and `computePk()`.
2. Register routes in web.php under the `auth` middleware group.

**Acceptance:** `GET /account/ao-slots/{slot}/dashboard-message` returns the
current message from the WordPress site. `POST` updates it.
Both return 403 for wrong user, 422 for missing URL, 502 for unreachable WP.

---

### Phase 3 — UI (downloads page modal)

1. Add "Set Message" button to each slot `<li>`.
2. Add modal HTML (once per page, after the slot list).
3. Add modal JavaScript (open, fetch current, save, close).

**Acceptance:** Clicking "Set Message" on a slot opens the modal, TinyMCE
initialises and is populated with the current content, and saving writes the new
title/content back to the WordPress site's dashboard widget. TinyMCE is destroyed
on modal close so re-opening a different slot starts fresh.

---

## Phase 4 — Receive inbound push from WordPress settings save (planned, not yet implemented)

When the admin saves the dashboard message in the WordPress settings panel, the plugin
fires an outbound JWT-authenticated POST to `{APWP_CONNECT_URL}/api/plugin/ao-dashboard-message`.
This phase adds the receiving endpoint and stores the pushed values on the slot record.

### New route

Add inside the existing `auth.plugin` + `check.plugin.purchase:apwp-ao` group in `routes/plugin.php`:

```php
Route::post('/ao-dashboard-message', [AdminOrganizerBackupController::class, 'receiveMessage']);
// Or create a dedicated AoDashboardMessagePushController if preferred.
```

The `auth.plugin` middleware decodes the JWT — for apwp-ao tokens this includes the `sid`
claim identifying the exact slot making the push.

### Handler design

1. Extract `sub` (user ID) and `sid` (slot ID) from the verified JWT (available via
   `$request->jwtPayload` or however `auth.plugin` exposes it).
2. Look up the slot: `PluginAoDomain::where('id', $sid)->where('user_id', $sub)->firstOrFail()`.
3. Validate body: `title` (nullable string, max 255), `content` (nullable string).
4. Persist to new slot columns `last_push_title` and `last_push_content` (see migration below).
5. Return `200 { message: 'Received.' }`.

### New migration — `add_last_push_to_plugin_ao_domains`

```php
$table->string('last_push_title',   255)->nullable()->after('domain_url');
$table->text('last_push_content')->nullable()->after('last_push_title');
$table->timestamp('last_push_at')->nullable()->after('last_push_content');
```

Add `last_push_title`, `last_push_content`, `last_push_at` to `PluginAoDomain::$fillable`.

### How the modal uses the cached values

The modal always fetches live from WordPress first (existing GET flow). The cached
`last_push_*` columns are a fallback: if the GET fails (WP site down), the Laravel
controller can fall back to returning the last pushed values with a `"cached": true`
flag so the modal can display them with a stale-data notice.

Updating `AoDashboardMessageController::show()` to implement this fallback is part
of this phase.

### Acceptance criteria

1. WordPress saving the dashboard message fires `POST /api/plugin/ao-dashboard-message`.
2. Laravel stores title, content, and timestamp on the correct slot record.
3. If the live WordPress GET fails, the modal receives the cached values with
   `"cached": true` instead of a 502 error.
4. If no cached values exist and the live GET fails, the 502 error is returned unchanged.

---

## Implementation Divergences from Plan

1. **Migration backfill uses `domain_label` not `pending_domain`.** `pending_domain` is cleared at slot confirmation time (Phase 10), so the plan's backfill of `pending_domain → domain_url` would produce no rows for already-confirmed slots. The migration instead copies `domain_label` (which holds the full URL per Phase 10) into `domain_url` for active slots.

2. **Routes use `['auth', 'twofactor']` not just `['auth']`.** The plan specifies `Route::middleware(['auth'])` but the existing account routes (downloads, slot release) all use `['auth', 'twofactor']`. The dashboard message routes were added to the same group for consistency.

3. **Private helper renamed from `authorize()` to `checkSlotAccess()`.** Laravel's base `Controller` class exposes a public `authorize()` method via the `AuthorizesRequests` trait. A private method with the same name causes a fatal PHP access-level conflict. Renamed to avoid the collision.

---

## Resolved Design Decisions

1. **TinyMCE 6 from CDN, teeny toolbar.** Matches the editor used in the
   WordPress settings panel so the composer sees formatting the same way it will
   render. Loaded from CDN to avoid adding a build step. TinyMCE is init/destroy
   cycled with the modal to avoid stale-instance bugs.

2. **Server-side proxying, not direct browser-to-WP calls.** The `pk` signing
   secret must not be exposed to the browser.

3. **`domain_url` column instead of reusing `pending_domain`.** Makes intent
   explicit and allows it to be updated independently.

4. **Backfill in migration.** All existing active slots get `domain_url` set
   immediately so the feature works without requiring users to re-authorize.

5. **Slot ownership check on every request.** `$slot->user_id === Auth::id()`
   enforced in the controller, not just in route model binding, to be explicit.
