# RankWiz AI

SEO diagnostic and content optimization tool. Connects Google Search Console + WordPress sites, analyzes traffic patterns, generates actionable recommendations, and uses OpenAI to produce content rewrite drafts.

## Tech Stack

| Layer | Technology |
|-------|-----------|
| Backend | Laravel 12, PHP 8.4+ |
| Frontend | React 18, Inertia.js, TypeScript |
| Styling | Tailwind CSS 4 (`@import "tailwindcss"` syntax, NOT `@tailwind`) |
| Auth | Laravel Breeze (React + Inertia variant) |
| Database | MySQL (production), SQLite (development/tests) |
| Queue | Laravel queue (`database` driver local, `redis` production) |
| WP Plugin | Separate PHP 8.1+ codebase in `wp-plugin/rankwiz-ai/` |
| Billing | Laravel Cashier (Stripe) — feature-gated |

## Commands

```bash
# Tests — during development, run ONLY what you changed (fast iteration)
./vendor/bin/pest --dirty --parallel          # Only tests in files with uncommitted changes (~5-15s)
./vendor/bin/pest tests/Feature/SomeTest.php  # Single test file
./vendor/bin/pest --filter="test name here"   # Filter by test name
npx vitest run resources/js/Pages/SomePage.test.tsx  # Single frontend test

# Tests — full suite (pre-flight only, NOT during development)
./vendor/bin/pest --parallel --processes=4   # All PHP tests (parallel, ~60s)
npx vitest run                                # All frontend tests
npm run build                                 # Build frontend assets
npm run lint                                  # Lint JS/TS

# Quality gates (use /v-pre-flight, not manual)
./vendor/bin/pest --parallel --processes=4 && npm run build && npm run lint && npx tsc --noEmit && vendor/bin/phpstan analyse --memory-limit=1G && composer audit && npm audit --audit-level=critical

# Code style / dev / routes
./vendor/bin/pint                             # PHP code formatting
composer run dev                              # Dev environment
php artisan ziggy:generate                    # After route changes
vendor/bin/phpstan analyse --memory-limit=1G  # Static analysis
```

**Note**: `npm audit` blocks on CRITICAL only. HIGH is advisory (`continue-on-error` in CI).

## Key Design Decisions

- **HMAC auth** between Laravel and WordPress plugin (SHA-256 shared secret)
- **OpenAI BYOK** + **DataForSEO BYOK** — user-supplied API keys, encrypted at rest via `encrypted` cast
- **Site-scoped authorization** — all site routes require `SitePolicy` (`can:view,site` middleware)
- **Limit system** — config defaults → per-user DB overrides → per-site DB overrides; checked via `LimitService`
- **Hard deletes** by default. Soft delete exceptions: `Site`, `User`, `BlogPost`, `WebhookEndpoint`, `ChangelogEntry`
- **Lazy loading disabled globally** — eager load ALL relationships before accessing. Violations throw in dev/test
- **Feature flags** — optional modules toggled via `config/features.php` env vars
- **Service layer** — constructor injection, external API calls in Jobs only, typed exceptions
- **RecommendationEngine** — strategy pattern, 8 rules implement `RecommendationRule` interface
- **Opportunity detection** — Run pattern (`KeywordOpportunityService`, `CannibalizationDetectionService`, `FreshnessAnalysisService`)

## Critical Gotchas

| # | Gotcha | Rule |
|---|--------|------|
| 1 | Lazy loading is **globally disabled** in dev/test | Always `->load()` / `->loadMissing()` before accessing relationships. `auth()->user()->aiKey` throws. |
| 2 | SQLite `::where()->delete()` does NOT cascade FKs | Delete leaf records explicitly before parents. |
| 3 | Audit log columns `ip` + `metadata` were dropped, `event` is nullable | Write/read `action`, `site_id`, `context`, `ip_address` only. Never use `$l->action ?? $l->event` fallback. |
| 4 | Laravel 12 providers register in `bootstrap/providers.php` | Not `config/app.php`. |
| 5 | Tailwind CSS 4 syntax | Use `@import "tailwindcss"`, never `@tailwind base/components/utilities`. |
| 6 | `WpApiClient::getContent()` returns array | Extract `$response['content']`, never use directly as string. |
| 7 | `PlanLimitService` ≠ `LimitService` | `PlanLimitService` = Stripe subscription tiers; `LimitService` = RankWiz usage limits. |
| 8 | `dangerouslySetInnerHTML` requires DOMPurify with explicit allowlist | Use `lib/sanitize.ts` `sanitizeHtml()`. No exceptions, even for "trusted" upstream content. |
| 9 | TypeScript `any` is banned (ESLint `--max-warnings=0` pre-commit) | Use `Record<string, unknown>`, `unknown`, or a proper interface. |
| 10 | Inertia page contract enforced by tests | Every `Inertia::render('X/Y', $props)` requires `resources/js/Pages/X/Y.tsx`. Multiple controllers rendering same page must supply identical top-level prop keys. |
| 11 | Inertia `router.*` is fire-and-forget | Never `await router.visit()` / `router.post()`. |
| 12 | Job retry property is `$tries`, not `$retries` | Common typo causes silent infinite retries. |
| 13 | New routes need ziggy mock | Add to `resources/js/test/setup.ts` `mockRoutes` after `php artisan ziggy:generate`. |
| 14 | Carbon `diffInDays()` direction | Use `(int) ceil(now()->floatDiffInDays($futureDate))` for forward intervals. |

See `tests/CLAUDE.md` for test-specific gotchas (Queue::fake, factory FK drift, Mockery identity, AssertableInertia, etc.).

## TDD Mandate

- **Services, controllers, jobs**: Write tests FIRST → run (expect fail) → implement → pass
- **UI components (React/JSX)**: Test-after is acceptable
- **Every model MUST have a factory**, every mutation endpoint MUST have a Form Request
- **During TDD iteration**: Use `--dirty` or `--filter`. Never run full suite during red-green cycles.

## Pre-Flight Checklist

Use `/v-pre-flight` to run all gates automatically. After route changes: `php artisan ziggy:generate`. After model changes: verify eager loading. After new `Inertia::render()`: ensure `.tsx` exists first.

## When Adding a New...

### Route
1. Add to `routes/web.php` with correct middleware group
2. Run `php artisan ziggy:generate`
3. Add to `mockRoutes` in `resources/js/test/setup.ts`

### Model
1. Create model in `app/Models/` with `$fillable` whitelist
2. Create factory in `database/factories/`
3. Create migration (nullable columns on existing tables)

### Inertia Page
1. Create `.tsx` file in `resources/js/Pages/` first (contract test enforces existence)
2. Add controller returning `Inertia::render('Path/Name', $props)`
3. Wrap in `DashboardLayout` for authenticated pages
4. If multiple controllers render same page: identical top-level prop keys required

### Recommendation Rule
1. Create class implementing `RecommendationRule` in `app/Services/`
2. Add case to `ActionType` enum
3. Register in `AppServiceProvider` in `RecommendationEngine` singleton binding

### Feature Flag
1. Add to `config/features.php`
2. Add to `phpunit.xml` as `<env name="FEATURE_X" value="true"/>`
3. Tests that need feature-gated routes: use `registerXRoutes()` helper

### Service
1. Constructor injection only (no static methods)
2. External API calls only inside Jobs — never in request lifecycle
3. Throw typed exceptions from `app/Exceptions/`

### Job
1. Create in `app/Jobs/` — implement `ShouldQueue`
2. Set `$tries` (NOT `$retries`) and `$backoff` array
3. External API calls ONLY in jobs

### Observer
1. Create in `app/Observers/`, register in `AppServiceProvider::boot()`
2. If it invalidates limit counts, call `LimitCacheService::invalidateCount()`

## Subdirectory Documentation

Load on demand when working in that subtree:

| Path | Covers |
|------|--------|
| `app/CLAUDE.md` | Service routing, controller/model/job conventions, RunsAnalysis trait, observer/exception/middleware patterns |
| `database/CLAUDE.md` | ER diagram, encrypted columns, enum columns, migration rules, SQLite gotchas, limit-policy cascade |
| `resources/js/CLAUDE.md` | Inertia shared props, hooks catalog, fetch-vs-router, format utilities, styling rules |
| `tests/CLAUDE.md` | Pest helpers, contract tests, critical patterns (Queue::fake, eager-load, FK drift, AssertableInertia) |
| `wp-plugin/CLAUDE.md` | HMAC auth, REST endpoints, classmap autoload, bidirectional publishing |

**Discovery patterns** (use these instead of memorizing lists):

| Need | Command |
|------|---------|
| All services | `find app/Services -maxdepth 2 -name '*.php'` |
| All models | `ls app/Models/` |
| All jobs | `ls app/Jobs/` |
| All factories | `ls database/factories/` |
| All Inertia pages | `find resources/js/Pages -name '*.tsx' -not -name '*.test.*'` |
| All routes | `php artisan route:list --columns=method,uri,name,action` |
| All scheduled commands | `php artisan schedule:list` |
| All enums | `ls app/Enums/` |
| Find observer for model X | `grep -rl "observe(X::class)" app/Providers/` |
