# Admin Organizer — Reset Customization: Laravel Implementation Plan

**Service:** Agency Pulse Laravel  
**Feature:** Proxy endpoint that signs and forwards a reset-customization request to a
connected WordPress site's `apwp-ao/v1/reset-customization` REST endpoint.  
**Created:** 2026-06-26  
**Depends on WP plugin plans:**
- `agency-pulse-admin-menu/build-docs/RESET_CUSTOMIZATION_PLAN.md` (phases R-1 – R-5)
- `agency-pulse-admin-menu/build-docs/RESET_EXTENSION_PLAN.md` (phases E-1 – E-4)

---

## Context

The WordPress plugin exposes a signed REST endpoint that resets admin customization settings
to defaults. The Laravel server acts as the trusted caller — it derives the HMAC signing key
from the user's `ao_plugin_key`, signs the request body, and POSTs to the WP site.

This is the same signing pattern used by:
- `AoApplyTemplateController` → `/wp-json/apwp-ao/v1/apply-template`
- `AoDashboardMessageController` → `/wp-json/apwp-ao/v1/dashboard-message`

No new signing logic is needed. The only new work is a controller and a route.

---

## WP Endpoint Contract (as defined by the plugin plans)

### Route

```
POST /wp-json/apwp-ao/v1/reset-customization
```

### Auth

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-APWP-Timestamp` | Unix timestamp (integer, seconds) |
| `X-APWP-Signature` | `sha256=` + HMAC-SHA256(raw body bytes, pk) |

- **pk** = `HMAC-SHA256(user->ao_plugin_key, PLUGIN_JWT_SECRET)` — same derivation as all
  other `apwp-ao` routes, already in `computePk()` on `AoApplyTemplateController` and
  `AoDashboardMessageController`.
- **canonical** for signing = the raw request body exactly as sent.
- **Replay window**: ±5 minutes. WP returns `400 timestamp_out_of_range` outside this window.

### Request Body (Phase L-1)

```json
{ "reset_menu": false }
```

### Request Body (Phase L-2, after WP Extension phases are deployed)

```json
{
  "reset_menu":   false,
  "reset_labels": false,
  "nuke_menus":   false
}
```

### WP Success Response — 200

```json
{
  "success": true,
  "reset": ["branding_icon", "admin_footer", "color_scheme", "dashboard_message"]
}
```

With optional flags set, `reset` also includes `"menu_configs"`, `"menu_labels"`,
and/or `"role_assignments"`.

### WP Error Responses

| Status | `code` | Cause |
|---|---|---|
| `400` | `missing_timestamp` | `X-APWP-Timestamp` absent or non-integer |
| `400` | `timestamp_out_of_range` | Timestamp >5 min from WP server time |
| `400` | `missing_signature` | `X-APWP-Signature` absent |
| `401` | `not_connected` | WP site has no stored JWT / pk |
| `403` | `bad_signature` | HMAC comparison failed |

---

## Laravel Route

```
POST /account/ao-slots/{slot}/reset-customization
```

Follows the existing slot-action naming convention:

```
POST /account/ao-slots/{slot}/apply-template       → AoApplyTemplateController
POST /account/ao-slots/{slot}/dashboard-message    → AoDashboardMessageController
POST /account/ao-slots/{slot}/reset-customization  → AoResetCustomizationController  ← NEW
```

Authenticated via the standard `auth` + Breeze session middleware (same as the other two).
The `{slot}` model is `PluginAoDomain`, resolved by route model binding.

---

## Phases

### Phase L-1 — Controller + Route (basic reset)

**Deliverable:** `AoResetCustomizationController` supporting `reset_menu` only, matched
to WP plugin phases R-1 – R-5.

#### File: `app/Http/Controllers/AoResetCustomizationController.php`

```php
<?php

namespace App\Http\Controllers;

use App\Models\PluginAoDomain;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class AoResetCustomizationController extends Controller
{
    /**
     * POST /account/ao-slots/{slot}/reset-customization
     *
     * Signs and forwards a reset request to the connected WordPress site.
     */
    public function update(Request $request, PluginAoDomain $slot): JsonResponse
    {
        if ($error = $this->checkSlotAccess($slot)) {
            return $error;
        }

        $validated = $request->validate([
            'reset_menu' => 'boolean',
        ]);

        $body      = json_encode(['reset_menu' => (bool) ($validated['reset_menu'] ?? false)]);
        $pk        = $this->computePk($slot->user);
        $timestamp = time();
        $signature = hash_hmac('sha256', $body, $pk);

        $resolvedUrl = $this->resolveWpUrl($slot->domain_url);
        $fullUrl     = $resolvedUrl . '/wp-json/apwp-ao/v1/reset-customization';

        Log::info('[ao-reset] request context', [
            'slot_id'          => $slot->id,
            'domain_url_stored'=> $slot->domain_url,
            'resolved_url'     => $resolvedUrl,
            'full_url'         => $fullUrl,
            'reset_menu'       => (bool) ($validated['reset_menu'] ?? false),
            'pk_prefix'        => substr($pk, 0, 8) . '…',
            'signature_prefix' => substr($signature, 0, 12) . '…',
        ]);

        try {
            $response = Http::timeout(15)
                ->withHeaders([
                    'Content-Type'     => 'application/json',
                    'X-APWP-Signature' => 'sha256=' . $signature,
                    'X-APWP-Timestamp' => (string) $timestamp,
                ])
                ->withBody($body, 'application/json')
                ->post($fullUrl);

            Log::info('[ao-reset] WP response', [
                'slot_id'     => $slot->id,
                'http_status' => $response->status(),
                'body'        => $response->body(),
            ]);

        } catch (\Throwable $e) {
            Log::error('[ao-reset] network exception', [
                'slot_id'         => $slot->id,
                'exception_class' => get_class($e),
                'message'         => $e->getMessage(),
                'full_url'        => $fullUrl,
            ]);
            return response()->json(['message' => 'Could not reach WordPress site.'], 502);
        }

        if (! $response->successful()) {
            $wpBody = $response->json();
            Log::warning('[ao-reset] WP returned non-2xx', [
                'slot_id'     => $slot->id,
                'http_status' => $response->status(),
                'wp_message'  => $wpBody['message'] ?? null,
                'wp_code'     => $wpBody['code']    ?? null,
            ]);
            return response()->json([
                'message' => $wpBody['message'] ?? ('WordPress returned status ' . $response->status()),
            ], $response->status() === 403 ? 403 : 502);
        }

        return response()->json([
            'message' => 'Reset applied successfully.',
            'reset'   => $response->json('reset', []),
        ]);
    }

    // ── helpers (identical to AoApplyTemplateController) ────────────────────

    private function checkSlotAccess(PluginAoDomain $slot): ?JsonResponse
    {
        if ($slot->user_id !== auth()->id()) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }
        if (! $slot->is_active) {
            return response()->json(['message' => 'Slot is not active.'], 422);
        }
        if (empty($slot->domain_url)) {
            Log::warning('[ao-reset] domain_url is empty', ['slot_id' => $slot->id]);
            return response()->json(['message' => 'Site URL not available. Reconnect the plugin.'], 422);
        }
        return null;
    }

    private function computePk($user): string
    {
        return hash_hmac('sha256', $user->ao_plugin_key ?? '', env('PLUGIN_JWT_SECRET', config('app.key')));
    }

    private function resolveWpUrl(string $domainUrl): string
    {
        $internalHost = env('APWP_WP_INTERNAL_HOST');
        if (empty($internalHost)) {
            return rtrim($domainUrl, '/');
        }
        $parsed = parse_url($domainUrl);
        $path   = rtrim($parsed['path'] ?? '', '/');
        return 'http://' . $internalHost . $path;
    }
}
```

#### File: `routes/web.php`

Add after the `apply-template` route (around line 171):

```php
Route::post('/account/ao-slots/{slot}/reset-customization', [\App\Http\Controllers\AoResetCustomizationController::class, 'update'])->name('account.ao_slots.reset_customization');
```

#### Acceptance

- `POST /account/ao-slots/{slot}/reset-customization` with `{}` → sends `{"reset_menu":false}`
  to WP, returns `200 {"message":"Reset applied successfully.","reset":[...]}`.
- `reset_menu: true` in the body → WP clears menu item arrays, `reset` response includes
  `"menu_configs"`.
- Invalid slot (wrong user) → `403`.
- Inactive slot → `422`.
- Missing `domain_url` → `422`.
- WP unreachable → `502`.

---

### Phase L-2 — Extend for `reset_labels` and `nuke_menus`

**Deliverable:** Extend `AoResetCustomizationController` to forward the two new body flags
introduced by WP plugin phases E-1 – E-4. Deploy **after** the WP plugin Extension phases
are live on the connected sites — sending unknown fields to an older WP plugin is harmless
(they are ignored), so this can be deployed in advance if needed.

#### Change: `app/Http/Controllers/AoResetCustomizationController.php`

Replace the `update()` method's validation and body-building block:

```php
// Before (Phase L-1)
$validated = $request->validate([
    'reset_menu' => 'boolean',
]);

$body = json_encode(['reset_menu' => (bool) ($validated['reset_menu'] ?? false)]);
```

```php
// After (Phase L-2)
$validated = $request->validate([
    'reset_menu'   => 'boolean',
    'reset_labels' => 'boolean',
    'nuke_menus'   => 'boolean',
]);

$body = json_encode([
    'reset_menu'   => (bool) ($validated['reset_menu']   ?? false),
    'reset_labels' => (bool) ($validated['reset_labels'] ?? false),
    'nuke_menus'   => (bool) ($validated['nuke_menus']   ?? false),
]);
```

Also extend the log line:

```php
// Add to the Log::info('[ao-reset] request context', [...]) call:
'reset_labels' => (bool) ($validated['reset_labels'] ?? false),
'nuke_menus'   => (bool) ($validated['nuke_menus']   ?? false),
```

No other files change — route, signing, HTTP call, and response handling are unchanged.

#### Acceptance

- `nuke_menus: true` → WP response `reset` includes `"menu_configs"` and
  `"role_assignments"`.
- `reset_labels: true` → WP response `reset` includes `"menu_labels"`.
- All three flags `false` (or omitted) → same behaviour as Phase L-1.
- WP Extension phases not yet deployed: sending `reset_labels`/`nuke_menus` is silently
  ignored by WP — no error, existing behaviour unchanged.

---

## Dependency Map

```
WP R-1 – R-5 (plugin phases: basic reset endpoint)
    └── L-1 (Laravel: controller + route for reset_menu)

WP E-1 – E-4 (plugin phases: reset_labels + nuke_menus)
    └── L-2 (Laravel: extend controller for new flags)
```

L-1 can be deployed as soon as the WP plugin R phases are live.  
L-2 can be deployed in advance of WP E phases (unknown fields are ignored by older WP versions).

---

## Files Changed

| File | Phase | Change |
|---|---|---|
| `app/Http/Controllers/AoResetCustomizationController.php` | L-1 | New controller |
| `routes/web.php` | L-1 | 1 new route after `apply-template` |
| `app/Http/Controllers/AoResetCustomizationController.php` | L-2 | Add `reset_labels` + `nuke_menus` to validation + body |

No migrations. No new models. No changes to middleware or service providers.
