# Admin Menu Organizer — Template Library API Design

> **Project:** Agency Pulse Laravel (`agency_pulse`)
> **Document type:** API design and implementation plan for the
> Admin Menu Organizer template library feature.
> **Companion document:** `AAMO_TEMPLATE_LIBRARY_WP.md` in the WordPress plugin
> build-docs — defines the plugin-side implementation that calls these endpoints.
> **Created:** 2026-06-24

---

## Overview

The template library allows admin menu organizer users to save their full
plugin configuration as a named, reusable template on the external server,
then browse and load those templates onto any of their connected sites.

This is intentionally different from the per-site cloud backup
(`/api/plugin/ao-backup`):

| Feature | Path | Scope | Purpose |
|---|---|---|---|
| Site backup | `/api/plugin/ao-backup` | Per site (keyed by `backup_key`) | Restore this site's config if something goes wrong |
| Template library | `/api/plugin/ao-templates` | Per user (shared across sites) | Reuse a config layout across multiple client sites |

Both are gated behind the same `apwp-ao` middleware group. The authenticated
user is resolved from the JWT by the existing `auth.plugin` middleware —
no new auth logic is needed.

---

## Database

### Migration

New file:
`database/migrations/YYYY_MM_DD_000001_create_admin_organizer_templates_table.php`

```php
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');   // full JSON blob
    $table->timestamps();

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

**Decisions:**
- `slug` is unique per user, not globally. Two different users can each have a
  template named "Default Layout" — they do not collide.
- `payload` is `longText` to accommodate arbitrarily large menu configurations.
  The existing `admin_organizer_backups.payload` uses `text`; templates may be
  richer (many named configs) so `longText` is safer.
- `description` is optional (nullable), capped at 500 characters.
- No `is_public` column in initial build. The library is private per user.

### Model

File: `app/Models/AdminOrganizerTemplate.php`

```php
<?php

namespace App\Models;

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

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

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

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

    /**
     * Derive a slug from a display name.
     *
     * Spaces and special characters are replaced with underscores.
     * Letters are lowercased. Leading/trailing underscores are trimmed.
     * Consecutive underscores are collapsed to one.
     *
     * Examples:
     *   "Agency Default"   → "agency_default"
     *   "My Template #2!"  → "my_template_2"
     */
    public static function generateSlug(string $name): string
    {
        $slug = Str::slug($name, '_');

        if ($slug === '') {
            $slug = 'template';
        }

        return $slug;
    }

    /**
     * Return true if the given slug is already taken for this user.
     * Optionally exclude a specific template ID (used for update checks).
     */
    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();
    }
}
```

---

## Controller

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 belonging to the authenticated user.
     * Payloads are excluded — load them 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 replace an existing one with the same slug.
     *
     * If the request includes an existing slug owned by this user,
     * the template is updated in place (name, description, payload).
     * If no matching slug exists, a new template is created and the
     * server assigns a slug derived from the provided name.
     */
    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: caller supplies a slug that already exists for this user.
        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
                ]);
                $template = $existing->fresh();

                Log::info('AdminOrganizerTemplateController::store (update)', [
                    'user_id' => $userId,
                    'slug'    => $existing->slug,
                ]);

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

        // CREATE path: derive slug from name; reject if the slug is already taken.
        $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'],
        ]);
        $created = true;

        Log::info('AdminOrganizerTemplateController::store (create)', [
            '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 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 delete a template.
     */
    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('AdminOrganizerTemplateController::destroy', [
            'user_id' => Auth::id(),
            'slug'    => $slug,
        ]);

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

---

## Routes

In `routes/plugin.php`, inside the existing `apwp-ao` middleware group
(immediately after the two `ao-backup` lines):

```php
// Admin menu organizer plugin (apwp-ao) — template library
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']);
});
```

Add the controller import at the top of `routes/plugin.php`:

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

The four routes inherit `auth.plugin` and `check.plugin.purchase:apwp-ao`
from the enclosing group — no additional middleware needed.

---

## API Contract

### `GET /api/plugin/ao-templates`

Lists all templates for the authenticated user. No pagination in the initial
build — template counts are expected to be small (< 50 per user).

**Request:** Bearer JWT, no body.

**Response 200:**

```json
{
  "success": true,
  "templates": [
    {
      "id": 1,
      "name": "Agency Default",
      "slug": "agency_default",
      "description": "Clean layout for new client sites",
      "created_at": "2026-06-24T10:00:00.000000Z",
      "updated_at": "2026-06-24T10:00:00.000000Z"
    },
    {
      "id": 2,
      "name": "Minimal",
      "slug": "minimal",
      "description": null,
      "created_at": "2026-06-24T11:00:00.000000Z",
      "updated_at": "2026-06-24T11:00:00.000000Z"
    }
  ]
}
```

Payloads are not included. The WP plugin fetches individual payloads only
when the admin explicitly applies a template.

---

### `POST /api/plugin/ao-templates`

Save the current site configuration as a named template.

**Request body:**

```json
{
  "name":        "Agency Default",
  "slug":        "agency_default",
  "description": "Clean layout for new client sites",
  "payload":     "{\"version\":\"1.0\",\"plugin\":\"agency-pulse-admin-menu\",...}"
}
```

| Field | Required | Notes |
|---|---|---|
| `name` | Yes | Display name; max 191 chars |
| `slug` | No | If supplied and matches an existing template for this user, the template is **updated**. If supplied but not found, or if omitted, a new template is **created** with a server-assigned slug. |
| `description` | No | Max 500 chars |
| `payload` | Yes | Full JSON string — the same envelope produced by the WP plugin's export pipeline |

**Response 201 (created):**

```json
{
  "success":    true,
  "slug":       "agency_default",
  "name":       "Agency Default",
  "created_at": "2026-06-24T10:00:00.000000Z",
  "updated_at": "2026-06-24T10:00:00.000000Z"
}
```

**Response 200 (updated):**
Same shape as 201, with HTTP status 200.

**Response 422 — required field missing:**

```json
{
  "message": "The name field is required.",
  "errors": {
    "name": ["The name field is required."]
  }
}
```

**Response 422 — name already taken:**

```json
{
  "success": false,
  "error":   "name_taken",
  "message": "A template with this name already exists. Please choose a different name."
}
```

This is returned only on the CREATE path (no `slug` supplied, or supplied slug
not found). An existing template is always updated by slug — name uniqueness is
not re-checked on update, so renaming is not supported in this build.

Slugs use underscores as the word separator. Spaces and special characters
in the template name are replaced with underscores; letters are lowercased.
Examples:
- `"Agency Default"` → `"agency_default"`
- `"My Template #2!"` → `"my_template_2"`

---

### `GET /api/plugin/ao-templates/{slug}`

Retrieve a single template with its full payload. The WP plugin calls this
when the admin clicks "Apply" on a template.

**Response 200:**

```json
{
  "success":     true,
  "slug":        "agency-default",
  "name":        "Agency Default",
  "description": "Clean layout for new client sites",
  "payload": {
    "version":          "1.0",
    "plugin":           "agency-pulse-admin-menu",
    "exported_at":      "2026-06-24T10:00:00+00:00",
    "configs":          { "...": "..." },
    "role_assignments": { "...": "..." }
  },
  "created_at": "2026-06-24T10:00:00.000000Z",
  "updated_at": "2026-06-24T10:00:00.000000Z"
}
```

Note: `payload` is decoded from JSON on return (an object, not a string),
consistent with the existing `ao-backup` `show()` endpoint behaviour.

**Response 404:**

```json
{
  "success": false,
  "error":   "not_found"
}
```

---

### `DELETE /api/plugin/ao-templates/{slug}`

Permanently remove a template. There is no soft delete.

**Response 200:**

```json
{
  "success": true
}
```

**Response 404:**

```json
{
  "success": false,
  "error":   "not_found"
}
```

---

## Implementation Phases

### Phase L-1 — Migration and Model

1. Create the migration file for `admin_organizer_templates`.
2. Create `app/Models/AdminOrganizerTemplate.php` with the `generateSlug()` and `slugExistsFor()`
   helper and the `user()` relation.
3. Run `php artisan migrate`.

**Acceptance:**
- `php artisan migrate` completes without error.
- `AdminOrganizerTemplate::count()` returns 0 with no exceptions.
- `AdminOrganizerTemplate::generateSlug('My Layout')` returns `'my_layout'`.
- `AdminOrganizerTemplate::generateSlug('Agency Default #1!')` returns `'agency_default_1'`.
- `AdminOrganizerTemplate::slugExistsFor($userId, 'my_layout')` returns `false` on a fresh table.

---

### Phase L-2 — Controller and Routes

1. Create `app/Http/Controllers/AdminOrganizerTemplateController.php`.
2. Add the four routes to `routes/plugin.php` inside the `apwp-ao` group.
3. Add the `use` import at the top of `routes/plugin.php`.

**Acceptance:**
- `GET /api/plugin/ao-templates` with a valid JWT returns `{"success":true,"templates":[]}`.
- `POST /api/plugin/ao-templates` with a valid name and JSON payload creates a row,
  returns 201 with a slug using underscores (e.g. `agency_default`).
- `POST` again with the same name (no slug) returns 422 `name_taken`.
- `POST` with the returned slug in the body updates the row, returns 200.
- `GET /api/plugin/ao-templates/{slug}` returns the full payload (as an object).
- `DELETE /api/plugin/ao-templates/{slug}` removes the row, returns 200.
- All four routes return 401 when called without a JWT.
- All four routes return 403 when called with a valid JWT for a user
  without `apwp-ao` access.
- `GET`/`DELETE` on a slug belonging to a different user returns 404
  (user-scoping enforced via `where('user_id', Auth::id())`).

---

## Files Changed

| File | Change |
|---|---|
| `database/migrations/YYYY_MM_DD_create_admin_organizer_templates_table.php` | New — migration |
| `app/Models/AdminOrganizerTemplate.php` | New — Eloquent model + `generateSlug()` + `slugExistsFor()` |
| `app/Http/Controllers/AdminOrganizerTemplateController.php` | New — four-method controller |
| `routes/plugin.php` | Modified — add `use` import + four routes in `apwp-ao` group |

No middleware changes. No other existing files are modified.

---

## Design Decisions

**Payload is stored as a JSON string, returned as a decoded object.**
The WP plugin sends the payload as `wp_json_encode($array)`. Laravel
stores the raw string in `longText`. On retrieval (`show()`), it is decoded
with `json_decode($template->payload)` — matching the existing `ao-backup`
pattern. The WP plugin receives an object and re-encodes it for `import_from_json()`.

**Slugs are assigned server-side from the name using underscores.**
Spaces and special characters in the name are replaced with underscores;
letters are lowercased. `Str::slug($name, '_')` handles transliteration
and normalisation. `"Agency Default"` → `"agency_default"`.

Slugs are immutable once assigned. WP persists the slug returned on
creation and sends it back on subsequent saves to trigger the update path.
The slug is the stable client-side handle for a given template.

**Name collisions are rejected, not silently resolved.**
If a new template's derived slug would collide with an existing one for
the same user, the server returns 422 `name_taken`. The WP plugin surfaces
this as a field-level error so the user can choose a different name. There
is no auto-incrementing suffix (no `agency_default_2`). This keeps slugs
predictable and prevents silent duplicates.

**No per-slug validation on the `slug` request field.**
The incoming `slug` field is treated as an opaque lookup key (must be a
string, max 191). If it does not match any existing user template, the
server ignores it and creates a new template with a server-assigned slug.
This prevents broken states if WP sends a stale slug from a template the
user has since deleted.

**Templates are private per user, not per domain slot.**
The JWT's `sub` claim identifies the user. A single user with three connected
sites all share the same template library — saving a template on site A makes
it available to apply on sites B and C. This is the intended behaviour for an
agency managing multiple client sites.

**No public/shared templates in this build.**
All templates belong to the authenticated user only. A future phase could
add a `is_public` flag and a public browsing endpoint, but that is explicitly
out of scope here.

**No pagination in this build.**
Template lists are fetched in a single request. If a user accumulates enough
templates to make this slow (unlikely in practice), pagination can be added
as a non-breaking enhancement by adding `?page=` parameters the WP plugin
can optionally send.
