# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Install dependencies
composer install
npm install

# Run dev server (Vite)
npm run dev

# Build assets for production
npm run build

# Run all tests
php artisan test

# Run a single test file
php artisan test tests/Feature/PluginAuthMiddlewareTest.php

# Run a specific test method
php artisan test --filter=test_method_name

# Database migrations
php artisan migrate

# Sail (Docker) — used for local MySQL + Postgres
./vendor/bin/sail up -d
./vendor/bin/sail artisan migrate

# Laravel Pint (code style fixer)
./vendor/bin/pint
```

## Architecture Overview

**Agency Pulse** is a Laravel 10 e-commerce platform that sells WordPress plugins. It combines the **Vanilo** e-commerce framework with **Konekt AppShell** for the admin panel.

### Two Distinct Databases

- **MySQL** — main app database (users, orders, payments, conversations, chats)
- **PostgreSQL with pgvector** — vector embeddings for AI support search (`sp_vector_dump.sql`). Configured via `VECTOR_DB_*` env vars.

### Plugin License System

Users receive an Ed25519 keypair on registration (`User::generateKeypair()`). The public key (`plugin_key`) is stored base64-encoded. WordPress plugins authenticate API calls via JWTs signed with a per-user HMAC secret derived from `PLUGIN_JWT_SECRET + plugin_key`. The `AuthenticatePlugin` middleware validates these tokens and sets the authenticated user via `Auth::setUser()`.

Plugin access (whether a user owns a SKU) is checked via `PluginAccessService::userHasAccessToSku()`, which inspects completed `orders` joined with `order_items` and `products` by SKU.

### Authentication Flow

Standard Laravel Breeze auth (`routes/auth.php`) extended with:
1. **Social login** via `SocialiteController` (passwords are nullable for OAuth-only accounts)
2. **2FA** via `TwoFactorMiddleware` — enforces TOTP (pragmarx/google2fa) or email OTP after login. Controlled by `google2fa.enabled` config. Routes guarded by `twofactor` middleware must pass the `2fa_verified` session key.

### Affiliate Package

Lives at `packages/agencypulse/affiliate/` as a local Composer path dependency. Registered via `AffiliateServiceProvider`. Tracks referral clicks via `?ref=` (injected into the `web` middleware group automatically), converts on payment/subscription events, and has its own migrations, routes, and views under `packages/agencypulse/affiliate/`.

### AI Support Chat

`AiSupportService` uses Gemini 1.5 Flash for responses. `VectorSearch` generates embeddings via the Gemini Embedding API and queries pgvector. Set `AI_TEST_MODE=true` in `.env` to use canned mock replies locally without a Gemini quota.

There are two chat surfaces:
- **Widget** — embedded into WordPress plugin pages via a signed iframe (`/support/widget.js` → `/ai/chat` or `/support/chat`). Authenticated by `AuthenticatePlugin` middleware or as guest.
- **Docs chat** — `GET /support/docs-chat` validates the requested page against `public/docs/indexed_pages.json`.

### Admin Panel

`/admin` routes use `['auth', 'is_admin']` middleware. The Vanilo/Konekt AppShell panel is at `/admin` (handled by the `vanilo/admin` package). Custom admin controllers in `app/Http/Controllers/Vanilo/Admin/` extend or override AppShell functionality (People, TaxReport, MailingList, LiveAgentChat dashboard).

#### Adding a new admin page

**1. Controller** — place it in `app/Http/Controllers/Vanilo/Admin/MyController.php`.

**2. Routes** — add inside the existing admin group in `routes/web.php`:

```php
Route::middleware(['web', 'auth', 'is_admin'])->prefix('admin')->as('appshell.')->group(function () {
    Route::prefix('my-section')->name('my_section.')->group(function () {
        Route::get('/',         [MyController::class, 'index'] )->name('index');
        Route::get('/create',   [MyController::class, 'create'])->name('create');
        Route::post('/',        [MyController::class, 'store'] )->name('store');
        Route::get('/{id}/edit',[MyController::class, 'edit']  )->name('edit');
        Route::put('/{id}',     [MyController::class, 'update'])->name('update');
        Route::delete('/{id}',  [MyController::class, 'destroy'])->name('destroy');
    });
});
```

Because the group uses `->as('appshell.')`, the resulting route names are
`appshell.my_section.index`, `appshell.my_section.create`, etc. — **always use
the `appshell.` prefix** when calling `route()`.

**3. Views** — place in `resources/views/admin/my-section/`.

Every admin view must follow this template exactly:

```blade
@extends('appshell::layouts.private')

@section('title')
    Page Title Here
@stop

@section('content')

    {{-- content here — NO wrapping <div class="container"> --}}

@stop

@push('scripts')
<script>
(function () {
    // page-specific JS here
})();
</script>
@endpush
```

Key rules:
- `@extends('appshell::layouts.private')` — not `layouts.app` (that is the user-facing Breeze layout)
- Close sections with `@stop` — not `@endsection`
- No `<div class="container">` inside `@section('content')` — the layout wraps content already
- `@push('scripts')` / `@endpush` **after** the final `@stop` for page JS
- Use Bootstrap **4** classes throughout (not Bootstrap 5)

**Bootstrap 4 class reference for admin views:**

| Element | Correct (BS4) | Wrong (BS5) |
|---------|--------------|-------------|
| Form row | `form-group row` | `row mb-3` |
| Label | `col-sm-3 col-form-label font-weight-bold` | `col-form-label col-sm-3 fw-bold` |
| Checkbox | `custom-control custom-checkbox` + `custom-control-input` + `custom-control-label` | `form-check-input` |
| Badges | `badge badge-success` / `badge-secondary` / `badge-info` / `badge-danger` | `badge bg-success` etc. |
| Margins | `ml-2` / `mr-2` / `mt-*` / `mb-*` | `ms-2` / `me-2` |
| Input suffix | `input-group-append` wrapping `<span class="input-group-text">` | `<span class="input-group-text">` directly |
| Text weight | `font-weight-bold` | `fw-bold` |

**4. Sidebar nav link** — add to `resources/views/vendor/appshell/layouts/default/_nav.blade.php`.

This file has two sections: an `@unless(Auth::guest())` block that renders
AppShell's built-in menu items, followed by a block of hardcoded
`<span class="nav-item">` entries for custom pages. Add new links to the
hardcoded block:

```blade
<span class="nav-item">
    <a class="nav-link" href="{{ route('appshell.my_section.index') }}">
        My Section
    </a>
</span>
```

Use `route()` rather than a hardcoded path so the link survives prefix changes.
The current order in the hardcoded block is: People → Product → Chat Admin →
Mailing List → Tax Reporting → Gift Coupons → Support → Affiliates → WEBSITE.

**5. JavaScript in admin views**

Bootstrap 4's jQuery plugins are available (jQuery is loaded by AppShell).

```js
// Modal
$('#myModal').modal('show');

// Event delegation for dynamically-added elements
$(document).on('click', '.my-class', function () { ... });
```

Alternatively, use vanilla JS (`document.querySelectorAll`, `addEventListener`)
inside an IIFE — both work. Avoid `DOMContentLoaded` wrappers; `@push('scripts')`
content runs after the DOM is already ready.

### Live Agent Chat

Real-time handoff from AI to human agents. Customer-facing API at `/api/chat/*`, agent-facing (auth-required) API at `/api/agent/*`. Agents have `is_available` flag on the `users` table. Agent dashboard at `/admin/chat`.

### Key Models

| Model | Notes |
|-------|-------|
| `User` | Implements Konekt `UserContract`. Holds plugin keypair, 2FA fields, Stripe (Cashier Billable), social login fields. |
| `Payment` | Tracks Stripe subscription payments with tax fields. |
| `Conversation` | AI support chat session. Has `escalated` flag for live agent handoff. |
| `Chat` / `ChatMessage` | Live agent chat entities (separate from AI Conversation). |
| `GiftedProduct` | Admin-gifted plugin access with optional expiry. |

### Frontend

Tailwind CSS + Alpine.js for the main UI (`resources/css/app.css`, `resources/js/app.js`). Bootstrap 4 is also present for AppShell/admin pages. Vite handles asset bundling; both the app CSS/JS and AppShell's Sass are compiled as separate entry points.

### Product Routes

Individual product landing pages (e.g. `/products/invoice-enhancements-plugin`) hard-code Vanilo `Product::find($id)` by integer ID. These IDs must match the production database.

### Product Availability Control System

Every product's public visibility is controlled by a single ENV variable. This is the **authoritative system** — use it for all products, including new ones.

**Values:** `open` | `disabled` | `closed` | `auth`

| State | Nav dropdown | Product landing page | Homepage/Products listing | Downloads page |
|-------|:------------:|:--------------------:|:-------------------------:|:--------------:|
| `open` | Shown | Accessible, purchase active | Shown, purchase button active | Shown, download/purchase active |
| `disabled` | Shown | Accessible, no purchase button | Shown, grayed, no purchase button | Shown, no download/purchase buttons |
| `closed` | Hidden | 404 | Hidden | Hidden |
| `auth` | Hidden | 404 | Hidden | Shown, download/purchase active |

**Config file:** `config/products.php` — the single source of truth. Each product entry has `name`, `sku`, `route`, and `availability` (read from ENV). Never read `env()` directly for availability in views or controllers.

**ViewComposer:** `app/View/Composers/ProductAvailabilityComposer.php` — registered in `AppServiceProvider::boot()` and attached to `home`, `products.index`, `account.downloads`, and `layouts.navigation`. Shares:
- `$productAvailability` — assoc array keyed by both catalog slug and Vanilo SKU → availability string
- `$navProducts` — catalog entries where `availability !== 'closed'` and `route !== null`

**Documentation index** is served by the Laravel route `/documentation` → `resources/views/docs/index.blade.php`. It uses `config('products.catalog.*.availability')` directly (not the ViewComposer). Every plugin section and its cross-references are individually guarded — only `closed` hides them.

**Products with no SKU / no route / docs-only** (only the docs index respects their availability; they have no effect on nav, listings, or downloads):
- `gateway-stripe` — Gateway - Stripe plugin
- `pro-connector` — Pro Connector plugin

#### Adding a new product — required steps

**1. Add the ENV var** to `.env` and `.env.example`:
```env
PRODUCT_MY_NEW_PLUGIN=open
```

**2. Add the catalog entry** to `config/products.php`:
```php
'my-new-plugin' => [
    'name'         => 'My New Plugin',
    'sku'          => 'apwp-xx',           // Vanilo SKU; null if free/no SKU
    'route'        => 'products.my-new-plugin',  // null if no landing page
    'availability' => env('PRODUCT_MY_NEW_PLUGIN', 'open'),
],
```

**3. Add the SKU to `DownloadController::$productSkus`** if it should appear on the downloads page.

**4. Add the landing page route** in `routes/web.php` with the standard 404 guard:
```php
Route::get('/products/my-new-plugin', function () {
    if (config('products.catalog.my-new-plugin.availability') === 'closed') {
        abort(404);
    }
    $product = \Vanilo\Product\Models\Product::find($id);
    return view('products.my-new-plugin', compact('product'));
})->name('products.my-new-plugin');
```

**5. Nav dropdown** — the nav reads from `$navProducts` (built by the ViewComposer), so add the new entry to both the desktop dropdown and mobile menu in `layouts/navigation.blade.php`:
```blade
@if(isset($navProducts['my-new-plugin']))
    <a href="{{ route('products.my-new-plugin') }}" class="block px-4 py-2.5 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700">My New Plugin</a>
@endif
```

**6. Homepage and Products page** — products loaded from the DB via `Product::get()` are automatically filtered and styled by the loop logic that reads `$productAvailability[$product->sku]`. No changes needed there as long as the SKU is in `config/products.php`.

**7. Run `php artisan config:clear`** (dev) or `php artisan config:cache` (production) after any ENV or catalog change.

### Documentation Pages & AI Chat

Doc pages live in `public/docs/plugins/{plugin-folder}/pages/*.html`. Each page loads `navigation.js`, which injects `chat-bubble.js`. The bubble builds an iframe pointing to `/support/docs-chat?plugin={folder}&page={filename-without-.html}`. That route validates both values against `public/docs/indexed_pages.json` and returns **403 if the plugin or page is absent** — so a new doc plugin will show a broken/403 chat panel until it is indexed.

#### Adding a new doc plugin — required steps

**1. Create the HTML pages** in `public/docs/plugins/{folder}/pages/`.
- Use the same template as existing pages (see any `agency-pulse-invoice` page as reference).
- Include `<link rel="stylesheet" href="../../assets/modal.css">` and `<link rel="stylesheet" href="../../../assets/navigation.css">`, plus the two `<script>` tags at the bottom.
- All content must be inside `<div class="container">` — the indexer's DOM parser targets this element.

**2. Add the plugin to `public/docs/assets/pages.json`** — insert a new object with `plugin`, `folder`, `productKey`, `title`, `description`, `badge`, and `pages` array (each page needs `filename`, `title`, `emoji`, `description`, `type`). Order in this file determines prev/next navigation order. `productKey` must match the plugin's key in `config/products.php`'s `catalog` array — `navigation.js` fetches `/support/docs-config` (which reports catalog entries with `availability === 'closed'`) and skips that plugin's pages entirely when building the prev/next chain, so hidden plugins never get linked to from adjacent doc pages.

**3. Update `public/docs/index.html`** — manually insert a matching plugin section block between the correct plugins, and add a `.badge-{name}` CSS rule in the `<style>` block at the top. (`generate-docs-index.js` can regenerate this file from `pages.json` but we currently maintain it manually — see note below.)

**4. Run the indexer** (this is what unlocks the chatbot and updates `indexed_pages.json`):

```bash
# Index all pages for a new plugin (runs inside Docker)
docker exec agency_pulse-laravel.test-1 php /var/www/html/artisan ai:index-docs --plugin=agency-pulse-admin-menu

# Index a single page only
docker exec agency_pulse-laravel.test-1 php /var/www/html/artisan ai:index-docs --plugin=agency-pulse-admin-menu --page=admin_menu_overview

# Re-index a page that already exists (clears old vectors first)
docker exec agency_pulse-laravel.test-1 php /var/www/html/artisan ai:index-docs --plugin=agency-pulse-admin-menu --force
```

The indexer: extracts text from `<div class="container">` headings and paragraphs → chunks to ~800 chars → calls Gemini `gemini-embedding-001` → inserts vectors into the pgvector `ai_docs` table → **writes the plugin/page into `indexed_pages.json`**. It sleeps 2 seconds per chunk (Gemini rate limit), so 5 content-heavy pages takes several minutes.

**`indexed_pages.json` is the sole gate** for the chatbot 403. It is written automatically by the indexer — do not hand-edit it.

#### `generate-docs-index.js` — what it actually does

`public/docs/generate-docs-index.js` regenerates `index.html` from `pages.json` (static HTML only). It has **no connection to the vector DB or `indexed_pages.json`**. Run it with `node public/docs/generate-docs-index.js` from the project root if you want to regenerate `index.html` from scratch, but note it will overwrite the manually-maintained `index.html` including custom badge CSS and ordering. Currently we maintain `index.html` by hand.
