# AO Slot — Disable Toggle Plan

**App:** Agency Pulse Laravel
**Feature:** Per-slot disable toggle that makes the connected WP plugin behave
as if a bad or inactive license key is installed, without releasing the slot.
**Created:** 2026-06-26
**Status:** Plan — not yet implemented

---

## Purpose

Testing and support scenarios require simulating the plugin's "license inactive"
state on a live connected site without permanently releasing the slot or changing
the user's subscription. A toggled-off slot returns auth errors identical to what
the WP plugin receives when the subscription has lapsed, causing the plugin to
immediately show its disconnected / inactive UI.

Toggling the slot back on restores normal operation instantly.

---

## Behaviour Contract

| State | `issue` response | `refresh` response | Slot in DB |
|---|---|---|---|
| Normal (`is_disabled = false`) | `200 Token issued` | `200 Token refreshed` | `is_active = true` |
| Disabled (`is_disabled = true`) | `403 access_denied` | `403 access_denied` | `is_active = true` (unchanged) |

The `is_disabled` flag does **not** alter `is_active`. The slot remains in the
connected slots list on the dashboard. Only JWT issuance and refresh are blocked.

The error shape returned when disabled matches what the WP plugin receives when
`PluginAccessService::userHasAccessToSku` returns false:

```json
{ "message": "Plugin subscription inactive or expired", "error": "access_denied" }
```

HTTP status: **403**.

---

## Files Changed

| File | Phase | Change |
|---|---|---|
| `database/migrations/<timestamp>_add_is_disabled_to_plugin_ao_domains.php` | D-1 | New migration |
| `app/Models/PluginAoDomain.php` | D-2 | `$fillable` + `$casts` |
| `app/Http/Controllers/PluginAuthController.php` | D-3 | Disable check in issue + refresh paths |
| `app/Http/Controllers/PluginAoDomainController.php` | D-4 | `toggleDisabled()` method |
| `routes/web.php` | D-5 | New PATCH route |
| `resources/views/dashboard.blade.php` | D-6 | Toggle button, query, JS handler |

---

## Implementation Phases

### Phase D-1 — Migration

Create a new migration file:

```php
Schema::table('plugin_ao_domains', function (Blueprint $table) {
    $table->boolean('is_disabled')->default(false)->after('is_active');
});
```

**Acceptance:** `php artisan migrate` completes without error; column appears in
`plugin_ao_domains` with default `false`.

---

### Phase D-2 — Model

**File:** `app/Models/PluginAoDomain.php`

Add `'is_disabled'` to `$fillable` and `$casts`:

```php
protected $fillable = [
    // ...existing...
    'is_disabled',
];

protected $casts = [
    // ...existing...
    'is_disabled' => 'boolean',
];
```

**Acceptance:** `$slot->is_disabled` returns a bool; `$slot->update(['is_disabled' => true])` persists.

---

### Phase D-3 — Auth controller checks

**File:** `app/Http/Controllers/PluginAuthController.php`

Two insertion points — both return the same `403 access_denied` shape.

#### 3a — `issueAoMultiDomain()` Step C

```php
// After: $activeSlot = $user->pluginAoDomains()->...->first();
if ($activeSlot) {
    if ($activeSlot->is_disabled) {
        Log::info('apwp-ao issue blocked: slot is disabled', [
            'user_id' => $user->id,
            'slot_id' => $activeSlot->id,
        ]);
        return response()->json([
            'message' => 'Plugin subscription inactive or expired',
            'error'   => 'access_denied',
        ], 403);
    }
    return $this->issueAoJwt($request, $user, $activeSlot);
}
```

#### 3b — `refreshAoMultiDomain()` after slot lookup

```php
// After: $slot = PluginAoDomain::where(...)->where('is_active', true)->first();
if (! $slot) { ... }  // existing not-found check

if ($slot->is_disabled) {
    Log::info('apwp-ao refresh blocked: slot is disabled', [
        'user_id' => $user->id,
        'slot_id' => $slot->id,
    ]);
    return response()->json([
        'message' => 'Plugin subscription inactive or expired',
        'error'   => 'access_denied',
    ], 403);
}
```

**Acceptance:** With `is_disabled = true`, a fresh `issue` request returns `403 access_denied`.
A `refresh` request for the same slot returns `403 access_denied`. With `is_disabled = false`,
both return `200` as normal.

---

### Phase D-4 — Domain controller toggle action

**File:** `app/Http/Controllers/PluginAoDomainController.php`

```php
public function toggleDisabled(Request $request, int $id): JsonResponse
{
    $slot = PluginAoDomain::where('id', $id)
        ->where('user_id', $request->user()->id)
        ->firstOrFail();

    if (! $slot->is_active) {
        return response()->json(['message' => 'Slot is not active', 'error' => 'not_active'], 409);
    }

    $slot->update(['is_disabled' => ! $slot->is_disabled]);

    Log::info('apwp-ao slot disable toggled', [
        'slot_id'    => $slot->id,
        'user_id'    => $slot->user_id,
        'is_disabled'=> $slot->is_disabled,
    ]);

    return response()->json([
        'message'     => $slot->is_disabled ? 'Slot disabled.' : 'Slot enabled.',
        'is_disabled' => $slot->is_disabled,
    ]);
}
```

**Acceptance:** PATCH with `is_disabled = false` slot → returns `200` with
`is_disabled = true`. Second PATCH → returns `200` with `is_disabled = false`.
Wrong user → `404`. Inactive slot → `409`.

---

### Phase D-5 — Route

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

Add after the existing `release` route (line ~168):

```php
Route::patch('/account/plugin-domains/{id}/toggle-disabled', [\App\Http\Controllers\PluginAoDomainController::class, 'toggleDisabled'])->name('account.plugin_domains.toggle_disabled');
```

Uses the same `plugin-domains/{id}` prefix as the existing `release` route for
consistency — both operate on the slot by numeric id, not by route model binding.

**Acceptance:** `PATCH /account/plugin-domains/1/toggle-disabled` reaches the
controller; unauthenticated requests return `302` redirect to login.

---

### Phase D-6 — Dashboard view

**File:** `resources/views/dashboard.blade.php`

#### 6a — Query update

Add `is_disabled` to the `get()` column list (line ~186):

```php
->get(['id', 'domain_label', 'domain_url', 'authorized_at', 'last_connected_at', 'is_disabled'])
```

#### 6b — Toggle button per slot

Inside the `@foreach($aoActiveSlots as $aoSlot)` loop, add a toggle button
before the Release button:

```blade
<button
    type="button"
    class="js-toggle-slot-disabled text-xs hover:underline mr-3
           {{ $aoSlot->is_disabled
               ? 'text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300'
               : 'text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300' }}"
    data-slot-id="{{ $aoSlot->id }}"
    data-disabled="{{ $aoSlot->is_disabled ? '1' : '0' }}"
>{{ $aoSlot->is_disabled ? 'Enable' : 'Disable' }}</button>
```

#### 6c — Visual indicator on disabled slots

Wrap the `<li>` label in a conditional dim/badge when disabled:

```blade
<span class="text-gray-800 dark:text-gray-200 font-medium {{ $aoSlot->is_disabled ? 'opacity-50' : '' }}">
    {{ $aoSlot->domain_label ?? 'Site #' . ($loop->index + 1) }}
    @if($aoSlot->is_disabled)
        <span class="ml-1 text-xs font-normal text-yellow-600 dark:text-yellow-400">(disabled)</span>
    @endif
</span>
```

#### 6d — JavaScript handler

Add alongside the existing `js-release-ao-slot` handler:

```js
document.querySelectorAll('.js-toggle-slot-disabled').forEach(function (btn) {
    btn.addEventListener('click', function () {
        var slotId   = this.getAttribute('data-slot-id');
        var disabled = this.getAttribute('data-disabled') === '1';
        var label    = disabled ? 'enable' : 'disable';
        if (! confirm('Are you sure you want to ' + label + ' this slot?')) return;

        fetch('/account/plugin-domains/' + slotId + '/toggle-disabled', {
            method: 'PATCH',
            headers: {
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
                'Accept': 'application/json',
            },
        })
        .then(function (res) { return res.json(); })
        .then(function (data) {
            if (data.is_disabled !== undefined) {
                location.reload();
            } else {
                alert(data.message || 'Could not toggle slot. Please try again.');
            }
        })
        .catch(function () {
            alert('Could not toggle slot. Please try again.');
        });
    });
});
```

**Acceptance:** Clicking Disable on a slot reloads the page showing the slot
labelled "(disabled)" with an Enable button. Clicking Enable reverses it.
The WP plugin on a disabled slot immediately sees `403 access_denied` on its
next auth attempt and shows its inactive-license UI.

---

## Execution Order

```
D-1  (migration — independent)
  └── D-2  (model — needs column)
        └── D-3  (auth controller — needs model cast)

D-4  (controller method — independent after model)
  └── D-5  (route — needs controller)
        └── D-6  (view — needs route + model column)
```

D-1 through D-5 can be written in a single pass. D-6 follows.

---

## Implementation Notes

*(to be completed after implementation)*
