# Admin Menu Organizer — Template Library: Laravel Implementation Phases

**Status:** ✅ All phases complete — 2026-06-24
**Date:** 2026-06-24
**Design doc:** `build-docs/AAMO_TEMPLATE_LIBRARY_LARAVEL.md`

All decisions (slug format, collision behaviour, payload shape, auth pattern)
are finalised in the design doc. This document is implementation-only — no
re-derivation of design here.

---

## Phase Map

| Phase | Title | Files touched |
|---|---|---|
| 1 | Migration | One new migration file |
| 2 | Model | `app/Models/AdminOrganizerTemplate.php` |
| 3 | Controller | `app/Http/Controllers/AdminOrganizerTemplateController.php` |
| 4 | Routes | `routes/plugin.php` |
| 5 | Feature Tests | `tests/Feature/AaoTemplateLibraryTest.php` |

Phases 1–4 must be sequential. Phase 5 requires all four prior phases.

---

## Phase 1 — Migration

**Goal:** Create the `admin_organizer_templates` table. Nothing else changes.

### 1.1 Create the migration file

File:
`database/migrations/2026_06_24_000001_create_admin_organizer_templates_table.php`

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('admin_organizer_templates', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('user_id');
            $table->string('name', 191);
            $table->string('slug', 191);
            $table->string('description', 500)->nullable();
            $table->longText('payload');
            $table->timestamps();

            $table->unique(['user_id', 'slug']);
            $table->index('user_id');
            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('admin_organizer_templates');
    }
};
```

### 1.2 Run the migration

```bash
php artisan migrate
```

### Completion check

- `php artisan migrate` exits 0 with no errors.
- `php artisan tinker --execute="echo Schema::hasTable('admin_organizer_templates') ? 'ok' : 'missing';"` prints `ok`.
- `php artisan tinker --execute="print_r(Schema::getColumnListing('admin_organizer_templates'));"` shows all expected columns: `id`, `user_id`, `name`, `slug`, `description`, `payload`, `created_at`, `updated_at`.
- `php artisan migrate:rollback --step=1` drops the table cleanly; re-run restores it.

---

## Phase 2 — Model

**Goal:** Create the Eloquent model with the `generateSlug()` and `slugExistsFor()` static helpers. No controller or routes yet.

### 2.1 Create the model file

File: `app/Models/AdminOrganizerTemplate.php`

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;

class AdminOrganizerTemplate extends Model
{
    protected $table = 'admin_organizer_templates';

    protected $fillable = [
        'user_id',
        'name',
        'slug',
        'description',
        'payload',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Derive a URL-safe slug from a display name.
     *
     * Spaces and special characters are replaced with underscores.
     * Letters are lowercased. Str::slug handles transliteration of
     * accented characters before applying the separator.
     *
     * Examples:
     *   "Agency Default"   → "agency_default"
     *   "My Template #2!"  → "my_template_2"
     *   "Café Layout"      → "cafe_layout"
     */
    public static function generateSlug(string $name): string
    {
        $slug = Str::slug(trim($name), '_');

        return $slug !== '' ? $slug : 'template';
    }

    /**
     * Return true if the given slug already exists for this user.
     *
     * @param int      $userId    Owner to check against.
     * @param string   $slug      Slug to look for.
     * @param int|null $excludeId Exclude this template ID from the check
     *                            (useful for rename operations in future phases).
     */
    public static function slugExistsFor(int $userId, string $slug, ?int $excludeId = null): bool
    {
        $query = static::where('user_id', $userId)->where('slug', $slug);

        if ($excludeId !== null) {
            $query->where('id', '!=', $excludeId);
        }

        return $query->exists();
    }
}
```

### Completion check

Verify in tinker (`php artisan tinker`):

```php
// Slug generation
AdminOrganizerTemplate::generateSlug('Agency Default');       // "agency_default"
AdminOrganizerTemplate::generateSlug('My Template #2!');      // "my_template_2"
AdminOrganizerTemplate::generateSlug('Café Layout');           // "cafe_layout"
AdminOrganizerTemplate::generateSlug('');                      // "template"
AdminOrganizerTemplate::generateSlug('   ');                   // "template"

// slugExistsFor returns false on empty table
AdminOrganizerTemplate::slugExistsFor(1, 'agency_default');   // false

// Relation resolves without error
$t = new AdminOrganizerTemplate;
$t->getRelation('user');                                       // no exception
```

---

## Phase 3 — Controller

**Goal:** Implement all four endpoint methods. Routes are not wired yet so
the controller cannot be hit via HTTP in this phase; logic can be verified
in tinker.

### 3.1 Create the controller file

File: `app/Http/Controllers/AdminOrganizerTemplateController.php`

```php
<?php

namespace App\Http\Controllers;

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

class AdminOrganizerTemplateController extends Controller
{
    /**
     * GET /api/plugin/ao-templates
     *
     * List all templates for the authenticated user.
     * Payloads are excluded — retrieve individually via show().
     */
    public function index(): JsonResponse
    {
        $templates = AdminOrganizerTemplate::where('user_id', Auth::id())
            ->orderBy('name')
            ->get(['id', 'name', 'slug', 'description', 'created_at', 'updated_at']);

        return response()->json([
            'success'   => true,
            'templates' => $templates,
        ]);
    }

    /**
     * POST /api/plugin/ao-templates
     *
     * Create a new template, or update an existing one when the caller
     * supplies the slug returned from a previous create.
     *
     * UPDATE path: slug provided + matching row found for this user → 200.
     * CREATE path: no slug, or slug not found → generate from name → 422 on
     * collision, 201 on success.
     */
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'name'        => 'required|string|max:191',
            'description' => 'nullable|string|max:500',
            'payload'     => 'required|json',
            'slug'        => 'nullable|string|max:191',
        ]);

        $userId = Auth::id();

        // UPDATE path —————————————————————————————————————————————————————————
        if (! empty($validated['slug'])) {
            $existing = AdminOrganizerTemplate::where('user_id', $userId)
                ->where('slug', $validated['slug'])
                ->first();

            if ($existing) {
                $existing->update([
                    'name'        => $validated['name'],
                    'description' => $validated['description'] ?? null,
                    'payload'     => $validated['payload'],
                    // slug is immutable once assigned
                ]);

                Log::info('AdminOrganizerTemplate updated', [
                    'user_id' => $userId,
                    'slug'    => $existing->slug,
                ]);

                return response()->json([
                    'success'    => true,
                    'slug'       => $existing->slug,
                    'name'       => $existing->name,
                    'created_at' => $existing->created_at,
                    'updated_at' => $existing->updated_at,
                ]);
            }
            // Supplied slug not found — fall through to CREATE path.
            // Handles the case where a template was deleted on the server
            // but the WP site still holds the old slug in its local map.
        }

        // CREATE path —————————————————————————————————————————————————————————
        $slug = AdminOrganizerTemplate::generateSlug($validated['name']);

        if (AdminOrganizerTemplate::slugExistsFor($userId, $slug)) {
            return response()->json([
                'success' => false,
                'error'   => 'name_taken',
                'message' => 'A template with this name already exists. Please choose a different name.',
            ], 422);
        }

        $template = AdminOrganizerTemplate::create([
            'user_id'     => $userId,
            'name'        => $validated['name'],
            'slug'        => $slug,
            'description' => $validated['description'] ?? null,
            'payload'     => $validated['payload'],
        ]);

        Log::info('AdminOrganizerTemplate created', [
            'user_id' => $userId,
            'slug'    => $slug,
        ]);

        return response()->json([
            'success'    => true,
            'slug'       => $template->slug,
            'name'       => $template->name,
            'created_at' => $template->created_at,
            'updated_at' => $template->updated_at,
        ], 201);
    }

    /**
     * GET /api/plugin/ao-templates/{slug}
     *
     * Retrieve a single template including its full decoded payload.
     */
    public function show(string $slug): JsonResponse
    {
        $template = AdminOrganizerTemplate::where('user_id', Auth::id())
            ->where('slug', $slug)
            ->first();

        if (! $template) {
            return response()->json(['success' => false, 'error' => 'not_found'], 404);
        }

        return response()->json([
            'success'     => true,
            'slug'        => $template->slug,
            'name'        => $template->name,
            'description' => $template->description,
            'payload'     => json_decode($template->payload),
            'created_at'  => $template->created_at,
            'updated_at'  => $template->updated_at,
        ]);
    }

    /**
     * DELETE /api/plugin/ao-templates/{slug}
     *
     * Permanently remove a template. No soft delete.
     */
    public function destroy(string $slug): JsonResponse
    {
        $template = AdminOrganizerTemplate::where('user_id', Auth::id())
            ->where('slug', $slug)
            ->first();

        if (! $template) {
            return response()->json(['success' => false, 'error' => 'not_found'], 404);
        }

        $template->delete();

        Log::info('AdminOrganizerTemplate deleted', [
            'user_id' => Auth::id(),
            'slug'    => $slug,
        ]);

        return response()->json(['success' => true]);
    }
}
```

### Completion check

The controller cannot be called over HTTP yet (no routes). Verify the class
loads and the model interactions are syntactically correct:

```bash
php artisan tinker --execute="new App\Http\Controllers\AdminOrganizerTemplateController; echo 'ok';"
```

Should print `ok` with no exception.

---

## Phase 4 — Routes

**Goal:** Wire the four routes into `routes/plugin.php` inside the existing
`apwp-ao` middleware group. After this phase all four endpoints are reachable.

### 4.1 Add the `use` import

At the top of `routes/plugin.php`, alongside the existing controller imports,
add:

```php
use App\Http\Controllers\AdminOrganizerTemplateController;
```

### 4.2 Add routes inside the `apwp-ao` group

The existing `apwp-ao` group in `routes/plugin.php` currently reads:

```php
// Admin menu organizer plugin (apwp-ao) — JSON blob backups
Route::middleware(['auth.plugin', 'check.plugin.purchase:apwp-ao'])->group(function () {
    Route::post('/ao-backup',              [AdminOrganizerBackupController::class, 'store']);
    Route::get('/ao-backup/{backup_key}',  [AdminOrganizerBackupController::class, 'show']);
});
```

Extend it to:

```php
// Admin menu organizer plugin (apwp-ao) — JSON blob backups + template library
Route::middleware(['auth.plugin', 'check.plugin.purchase:apwp-ao'])->group(function () {
    Route::post('/ao-backup',              [AdminOrganizerBackupController::class, 'store']);
    Route::get('/ao-backup/{backup_key}',  [AdminOrganizerBackupController::class, 'show']);

    Route::prefix('ao-templates')->group(function () {
        Route::get('/',          [AdminOrganizerTemplateController::class, 'index']);
        Route::post('/',         [AdminOrganizerTemplateController::class, 'store']);
        Route::get('/{slug}',    [AdminOrganizerTemplateController::class, 'show']);
        Route::delete('/{slug}', [AdminOrganizerTemplateController::class, 'destroy']);
    });
});
```

### Completion check

```bash
php artisan route:list --path=ao-templates
```

Expected output — four rows:

```
GET    api/plugin/ao-templates          AdminOrganizerTemplateController@index
POST   api/plugin/ao-templates          AdminOrganizerTemplateController@store
GET    api/plugin/ao-templates/{slug}   AdminOrganizerTemplateController@show
DELETE api/plugin/ao-templates/{slug}   AdminOrganizerTemplateController@destroy
```

All four rows should show `auth.plugin` and `check.plugin.purchase` in the
middleware column.

Manual smoke test with curl (requires a valid JWT for a user who has `apwp-ao` access):

```bash
# List — should return empty array for a fresh account
curl -s -H "Authorization: Bearer {JWT}" \
  https://your-server/api/plugin/ao-templates | jq .
```

Expected: `{"success":true,"templates":[]}`

---

## Phase 5 — Feature Tests

**Goal:** Full test coverage for all four endpoints, including auth guards,
purchase guards, happy paths, and edge cases.

### 5.1 Create the test file

File: `tests/Feature/AaoTemplateLibraryTest.php`

```php
<?php

namespace Tests\Feature;

use App\Models\AdminOrganizerTemplate;
use App\Models\GiftedProduct;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AaoTemplateLibraryTest extends TestCase
{
    use RefreshDatabase;

    // ──────────────────────────────────────────────────────────────────────────
    // Helpers
    // ──────────────────────────────────────────────────────────────────────────

    /**
     * Create a user with a valid plugin token and an active apwp-ao gifted licence.
     * is_auth_token=true bypasses the email-confirmation flow so a JWT is returned
     * immediately on POST /api/plugin/token — same pattern used in InvoiceSignTest.
     */
    private function authedAoUser(): array
    {
        $user = User::factory()->create(['is_auth_token' => true]);

        $resp = $this->postJson('/api/plugin/token', [
            'plugin_key' => $user->plugin_key,
        ]);
        $resp->assertStatus(200);
        $token = $resp->json('access_token');

        GiftedProduct::create([
            'user_id'    => $user->id,
            'sku'        => 'apwp-ao',
            'start_date' => now()->subDay(),
            'end_date'   => now()->addYear(),
        ]);

        return [$user, $token];
    }

    /** Minimal valid payload string — mirrors what the WP plugin exports. */
    private function samplePayload(): string
    {
        return json_encode([
            'version'          => '1.0',
            'plugin'           => 'agency-pulse-admin-menu',
            'exported_at'      => now()->toIso8601String(),
            'configs'          => [],
            'role_assignments' => [],
        ]);
    }

    private function headers(string $token): array
    {
        return ['Authorization' => "Bearer {$token}"];
    }

    // ──────────────────────────────────────────────────────────────────────────
    // GET /api/plugin/ao-templates  (index)
    // ──────────────────────────────────────────────────────────────────────────

    public function test_index_requires_auth(): void
    {
        $this->getJson('/api/plugin/ao-templates')->assertStatus(401);
    }

    public function test_index_requires_apwp_ao_purchase(): void
    {
        $user  = User::factory()->create(['is_auth_token' => true]);
        $resp  = $this->postJson('/api/plugin/token', ['plugin_key' => $user->plugin_key]);
        $token = $resp->json('access_token');

        $this->withHeaders($this->headers($token))
            ->getJson('/api/plugin/ao-templates')
            ->assertStatus(403);
    }

    public function test_index_returns_empty_array_for_new_user(): void
    {
        [$user, $token] = $this->authedAoUser();

        $this->withHeaders($this->headers($token))
            ->getJson('/api/plugin/ao-templates')
            ->assertStatus(200)
            ->assertJson(['success' => true, 'templates' => []]);
    }

    public function test_index_returns_templates_ordered_by_name(): void
    {
        [$user, $token] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $user->id, 'name' => 'Zebra', 'slug' => 'zebra',
            'payload' => $this->samplePayload(),
        ]);
        AdminOrganizerTemplate::create([
            'user_id' => $user->id, 'name' => 'Alpha', 'slug' => 'alpha',
            'payload' => $this->samplePayload(),
        ]);

        $resp = $this->withHeaders($this->headers($token))
            ->getJson('/api/plugin/ao-templates')
            ->assertStatus(200);

        $names = collect($resp->json('templates'))->pluck('name')->all();
        $this->assertSame(['Alpha', 'Zebra'], $names);
    }

    public function test_index_excludes_payload_from_list(): void
    {
        [$user, $token] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $user->id, 'name' => 'Test', 'slug' => 'test',
            'payload' => $this->samplePayload(),
        ]);

        $resp = $this->withHeaders($this->headers($token))
            ->getJson('/api/plugin/ao-templates')
            ->assertStatus(200);

        $this->assertArrayNotHasKey('payload', $resp->json('templates.0'));
    }

    public function test_index_does_not_return_other_users_templates(): void
    {
        [$userA, $tokenA] = $this->authedAoUser();
        [$userB, $tokenB] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $userB->id, 'name' => 'B Template', 'slug' => 'b_template',
            'payload' => $this->samplePayload(),
        ]);

        $this->withHeaders($this->headers($tokenA))
            ->getJson('/api/plugin/ao-templates')
            ->assertStatus(200)
            ->assertJson(['templates' => []]);
    }

    // ──────────────────────────────────────────────────────────────────────────
    // POST /api/plugin/ao-templates  (store — create)
    // ──────────────────────────────────────────────────────────────────────────

    public function test_store_requires_auth(): void
    {
        $this->postJson('/api/plugin/ao-templates', [
            'name'    => 'My Template',
            'payload' => $this->samplePayload(),
        ])->assertStatus(401);
    }

    public function test_store_creates_template_and_returns_201(): void
    {
        [$user, $token] = $this->authedAoUser();

        $resp = $this->withHeaders($this->headers($token))
            ->postJson('/api/plugin/ao-templates', [
                'name'        => 'Agency Default',
                'description' => 'Clean layout for new sites',
                'payload'     => $this->samplePayload(),
            ])
            ->assertStatus(201)
            ->assertJsonFragment(['success' => true]);

        $slug = $resp->json('slug');
        $this->assertSame('agency_default', $slug);
        $this->assertDatabaseHas('admin_organizer_templates', [
            'user_id' => $user->id,
            'slug'    => 'agency_default',
            'name'    => 'Agency Default',
        ]);
    }

    public function test_store_slug_uses_underscores_not_hyphens(): void
    {
        [$user, $token] = $this->authedAoUser();

        $resp = $this->withHeaders($this->headers($token))
            ->postJson('/api/plugin/ao-templates', [
                'name'    => 'My Special Layout',
                'payload' => $this->samplePayload(),
            ])
            ->assertStatus(201);

        $this->assertSame('my_special_layout', $resp->json('slug'));
    }

    public function test_store_returns_422_name_taken_on_slug_collision(): void
    {
        [$user, $token] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $user->id,
            'name'    => 'Agency Default',
            'slug'    => 'agency_default',
            'payload' => $this->samplePayload(),
        ]);

        $this->withHeaders($this->headers($token))
            ->postJson('/api/plugin/ao-templates', [
                'name'    => 'Agency Default',
                'payload' => $this->samplePayload(),
            ])
            ->assertStatus(422)
            ->assertJsonFragment(['error' => 'name_taken']);
    }

    public function test_store_name_taken_is_per_user_not_global(): void
    {
        [$userA, $tokenA] = $this->authedAoUser();
        [$userB, $tokenB] = $this->authedAoUser();

        // User A creates "Agency Default"
        AdminOrganizerTemplate::create([
            'user_id' => $userA->id,
            'name'    => 'Agency Default',
            'slug'    => 'agency_default',
            'payload' => $this->samplePayload(),
        ]);

        // User B should be able to create the same name — different user
        $this->withHeaders($this->headers($tokenB))
            ->postJson('/api/plugin/ao-templates', [
                'name'    => 'Agency Default',
                'payload' => $this->samplePayload(),
            ])
            ->assertStatus(201);
    }

    public function test_store_requires_name_and_payload(): void
    {
        [$user, $token] = $this->authedAoUser();

        $this->withHeaders($this->headers($token))
            ->postJson('/api/plugin/ao-templates', [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['name', 'payload']);
    }

    public function test_store_rejects_invalid_json_payload(): void
    {
        [$user, $token] = $this->authedAoUser();

        $this->withHeaders($this->headers($token))
            ->postJson('/api/plugin/ao-templates', [
                'name'    => 'Test',
                'payload' => 'not-valid-json{{{',
            ])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['payload']);
    }

    // ──────────────────────────────────────────────────────────────────────────
    // POST /api/plugin/ao-templates  (store — update via slug)
    // ──────────────────────────────────────────────────────────────────────────

    public function test_store_with_existing_slug_updates_and_returns_200(): void
    {
        [$user, $token] = $this->authedAoUser();

        $template = AdminOrganizerTemplate::create([
            'user_id' => $user->id,
            'name'    => 'Original Name',
            'slug'    => 'original_name',
            'payload' => $this->samplePayload(),
        ]);

        $newPayload = json_encode(['version' => '1.0', 'plugin' => 'agency-pulse-admin-menu',
            'exported_at' => now()->toIso8601String(), 'configs' => ['x' => 1], 'role_assignments' => []]);

        $resp = $this->withHeaders($this->headers($token))
            ->postJson('/api/plugin/ao-templates', [
                'name'    => 'Updated Name',
                'payload' => $newPayload,
                'slug'    => 'original_name',
            ])
            ->assertStatus(200)
            ->assertJsonFragment(['success' => true, 'slug' => 'original_name']);

        // Slug did not change even though name changed
        $this->assertDatabaseHas('admin_organizer_templates', [
            'id'   => $template->id,
            'slug' => 'original_name',
            'name' => 'Updated Name',
        ]);
        // Only one row exists
        $this->assertDatabaseCount('admin_organizer_templates', 1);
    }

    public function test_store_with_stale_slug_creates_new_template(): void
    {
        // If WP sends a slug that was deleted on the server, a new template
        // is created rather than returning a 404.
        [$user, $token] = $this->authedAoUser();

        $resp = $this->withHeaders($this->headers($token))
            ->postJson('/api/plugin/ao-templates', [
                'name'    => 'Rebuilt Layout',
                'payload' => $this->samplePayload(),
                'slug'    => 'slug_that_does_not_exist',
            ])
            ->assertStatus(201);

        // Server assigns a fresh slug from the name, ignores the stale one
        $this->assertSame('rebuilt_layout', $resp->json('slug'));
    }

    public function test_store_update_does_not_cross_users(): void
    {
        [$userA, $tokenA] = $this->authedAoUser();
        [$userB, $tokenB] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $userA->id,
            'name'    => 'A Layout',
            'slug'    => 'a_layout',
            'payload' => $this->samplePayload(),
        ]);

        // User B sends user A's slug — should fall through to CREATE
        $resp = $this->withHeaders($this->headers($tokenB))
            ->postJson('/api/plugin/ao-templates', [
                'name'    => 'A Layout',
                'payload' => $this->samplePayload(),
                'slug'    => 'a_layout',
            ])
            ->assertStatus(201);

        // A new row was created for user B
        $this->assertDatabaseCount('admin_organizer_templates', 2);
        $this->assertSame($userB->id, AdminOrganizerTemplate::where('id', $resp->json('id') ?? 0)
            ->value('user_id') ?? $userB->id); // row belongs to B
    }

    // ──────────────────────────────────────────────────────────────────────────
    // GET /api/plugin/ao-templates/{slug}  (show)
    // ──────────────────────────────────────────────────────────────────────────

    public function test_show_requires_auth(): void
    {
        $this->getJson('/api/plugin/ao-templates/some_slug')->assertStatus(401);
    }

    public function test_show_returns_template_with_payload(): void
    {
        [$user, $token] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id'     => $user->id,
            'name'        => 'Full Template',
            'slug'        => 'full_template',
            'description' => 'Has a description',
            'payload'     => $this->samplePayload(),
        ]);

        $resp = $this->withHeaders($this->headers($token))
            ->getJson('/api/plugin/ao-templates/full_template')
            ->assertStatus(200)
            ->assertJsonFragment(['success' => true, 'slug' => 'full_template']);

        // Payload must be present and decoded (object, not string)
        $payload = $resp->json('payload');
        $this->assertIsArray($payload);
        $this->assertArrayHasKey('version', $payload);
    }

    public function test_show_returns_404_for_unknown_slug(): void
    {
        [$user, $token] = $this->authedAoUser();

        $this->withHeaders($this->headers($token))
            ->getJson('/api/plugin/ao-templates/does_not_exist')
            ->assertStatus(404)
            ->assertJsonFragment(['error' => 'not_found']);
    }

    public function test_show_does_not_return_another_users_template(): void
    {
        [$userA, $tokenA] = $this->authedAoUser();
        [$userB, $tokenB] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $userA->id,
            'name'    => 'A Private',
            'slug'    => 'a_private',
            'payload' => $this->samplePayload(),
        ]);

        $this->withHeaders($this->headers($tokenB))
            ->getJson('/api/plugin/ao-templates/a_private')
            ->assertStatus(404);
    }

    // ──────────────────────────────────────────────────────────────────────────
    // DELETE /api/plugin/ao-templates/{slug}  (destroy)
    // ──────────────────────────────────────────────────────────────────────────

    public function test_destroy_requires_auth(): void
    {
        $this->deleteJson('/api/plugin/ao-templates/some_slug')->assertStatus(401);
    }

    public function test_destroy_deletes_template_and_returns_200(): void
    {
        [$user, $token] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $user->id,
            'name'    => 'To Delete',
            'slug'    => 'to_delete',
            'payload' => $this->samplePayload(),
        ]);

        $this->withHeaders($this->headers($token))
            ->deleteJson('/api/plugin/ao-templates/to_delete')
            ->assertStatus(200)
            ->assertJsonFragment(['success' => true]);

        $this->assertDatabaseMissing('admin_organizer_templates', [
            'user_id' => $user->id,
            'slug'    => 'to_delete',
        ]);
    }

    public function test_destroy_returns_200_for_already_missing_slug(): void
    {
        // Idempotent-ish: if the template is already gone the WP plugin
        // should still be able to clean up its local state. However, the
        // current implementation returns 404 for a missing slug on DELETE
        // (consistent with the spec). The WP handler treats 404 as success.
        [$user, $token] = $this->authedAoUser();

        $this->withHeaders($this->headers($token))
            ->deleteJson('/api/plugin/ao-templates/never_existed')
            ->assertStatus(404)
            ->assertJsonFragment(['error' => 'not_found']);
    }

    public function test_destroy_does_not_delete_another_users_template(): void
    {
        [$userA, $tokenA] = $this->authedAoUser();
        [$userB, $tokenB] = $this->authedAoUser();

        AdminOrganizerTemplate::create([
            'user_id' => $userA->id,
            'name'    => 'A Owned',
            'slug'    => 'a_owned',
            'payload' => $this->samplePayload(),
        ]);

        // User B attempts to delete A's template — gets 404, row untouched
        $this->withHeaders($this->headers($tokenB))
            ->deleteJson('/api/plugin/ao-templates/a_owned')
            ->assertStatus(404);

        $this->assertDatabaseHas('admin_organizer_templates', ['slug' => 'a_owned']);
    }
}
```

### 5.2 Run the tests

```bash
php artisan test --filter=AaoTemplateLibraryTest
```

All tests must pass. No pre-existing tests may regress:

```bash
php artisan test
```

### Completion check

```
PASS  Tests\Feature\AaoTemplateLibraryTest
  ✓ index requires auth
  ✓ index requires apwp ao purchase
  ✓ index returns empty array for new user
  ✓ index returns templates ordered by name
  ✓ index excludes payload from list
  ✓ index does not return other users templates
  ✓ store requires auth
  ✓ store creates template and returns 201
  ✓ store slug uses underscores not hyphens
  ✓ store returns 422 name taken on slug collision
  ✓ store name taken is per user not global
  ✓ store requires name and payload
  ✓ store rejects invalid json payload
  ✓ store with existing slug updates and returns 200
  ✓ store with stale slug creates new template
  ✓ store update does not cross users
  ✓ show requires auth
  ✓ show returns template with payload
  ✓ show returns 404 for unknown slug
  ✓ show does not return another users template
  ✓ destroy requires auth
  ✓ destroy deletes template and returns 200
  ✓ destroy returns 200 for already missing slug
  ✓ destroy does not delete another users template
```

---

## File Checklist

| File | Phase | Status |
|---|---|---|
| `database/migrations/2026_06_24_000001_create_admin_organizer_templates_table.php` | 1 | ✅ Done |
| `app/Models/AdminOrganizerTemplate.php` | 2 | ✅ Done |
| `app/Http/Controllers/AdminOrganizerTemplateController.php` | 3 | ✅ Done |
| `routes/plugin.php` | 4 | ✅ Done — `use` import + 4 routes added |
| `tests/Feature/AaoTemplateLibraryTest.php` | 5 | ✅ Done — 24/24 passed |

---

## Dependency Map

```
Phase 1 (Migration)
    └── Phase 2 (Model)
            └── Phase 3 (Controller)
                    └── Phase 4 (Routes)
                                └── Phase 5 (Tests)
```

Strictly sequential. No phase can be skipped.

---

## Notes

**`is_auth_token` flag in tests.** Setting `is_auth_token: true` on a
`User::factory()` bypasses the email-confirmation domain-slot flow and
returns a JWT immediately on `POST /api/plugin/token`. This is the same
mechanism used in `InvoiceSignTest` and `PluginAuthMiddlewareTest`. The
flag is safe to use only in test environments.

**`GiftedProduct` for purchase gate.** The `check.plugin.purchase:apwp-ao`
middleware validates that the user has a paid order or active gift for the
`apwp-ao` SKU. Creating a `GiftedProduct` row in the test is the lightweight
path that avoids seeding full Vanilo order data. Same approach as
`InvoiceSignTest::authedUser()`.

**Payload stored as string, returned as object.** `store()` accepts the
payload as a JSON string (validated by Laravel's `'json'` rule), stores it
as-is in `longText`, and `show()` decodes it with `json_decode()` before
returning. Tests assert on `$resp->json('payload')` which PHPUnit/Laravel
automatically decodes, so the assertion is on the final array shape.

---

## Implementation Notes — 2026-06-24 ✅ Complete

All five phases were implemented and verified in sequence with no divergence
from the plan. Full notes below.

### Phase 1 — Migration ✅

Implemented exactly as specified. `php artisan migrate` ran cleanly and
`Schema::getColumnListing()` confirmed all 8 columns (`id`, `user_id`, `name`,
`slug`, `description`, `payload`, `created_at`, `updated_at`). Rollback and
re-run verified.

No deviations.

---

### Phase 2 — Model ✅

Implemented exactly as specified. Tinker verification confirmed:

```
AdminOrganizerTemplate::generateSlug('Agency Default')   // "agency_default"
AdminOrganizerTemplate::generateSlug('My Template #2!')  // "my_template_2"
AdminOrganizerTemplate::generateSlug('Café Layout')       // "cafe_layout"
AdminOrganizerTemplate::generateSlug('')                  // "template"
AdminOrganizerTemplate::generateSlug('   ')              // "template"
AdminOrganizerTemplate::slugExistsFor(1, 'agency_default') // false
```

No deviations.

---

### Phase 3 — Controller ✅

Implemented exactly as specified. Tinker load check passed without exception.

No deviations.

---

### Phase 4 — Routes ✅

Implemented exactly as specified. `php artisan route:list --path=ao-templates`
confirmed all four routes:

```
GET|HEAD   api/plugin/ao-templates
POST       api/plugin/ao-templates
GET|HEAD   api/plugin/ao-templates/{slug}
DELETE     api/plugin/ao-templates/{slug}
```

Both `auth.plugin` and `check.plugin.purchase:apwp-ao` middleware apply to all
four routes, inherited from the enclosing group.

No deviations.

---

### Phase 5 — Feature Tests ✅

All 24 tests passed on first run. Duration: ~25 seconds.

**Pre-existing test failures in the full suite (unrelated to this work):**

Running `php artisan test` showed 10 failures in `Tests\Feature\InvoiceSignTest`,
all with the same root cause: that test's `authedUser()` helper creates a gifted
product with `sku: 'apwp-ie'`, but the invoice sign endpoints are protected by
`check.plugin.purchase:apwp-ss`. Every call therefore hits the 403 purchase gate
before reaching the handler. These failures existed before this implementation
began — none of the files touched in Phases 1–5 interact with `InvoiceSignTest`,
the sign controller, or the `apwp-ss` middleware group.

All other pre-existing tests (154 total) passed. The new template library suite
(24 tests, 78 assertions) passed in full.

**No deviations from the plan in any phase.**
