From f990dc1bd3659d2a5a80b009f9cbdf6c61292d54 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 23:59:46 +0000 Subject: [PATCH] monorepo sepration --- .env.example | 76 -- .github/package-workflows/README.md | 62 -- .github/package-workflows/ci.yml | 55 - .github/package-workflows/release.yml | 40 - Boot.php | 181 ++++ Concerns/SearchableContent.php | 150 +++ Console/Commands/ContentBatch.php | 242 +++++ Console/Commands/ContentGenerate.php | 247 +++++ Console/Commands/ContentImportWordPress.php | 957 ++++++++++++++++++ Console/Commands/ContentValidate.php | 343 +++++++ Console/Commands/ProcessPendingWebhooks.php | 90 ++ Console/Commands/PruneContentRevisions.php | 116 +++ Console/Commands/PublishScheduledContent.php | 96 ++ Controllers/Api/ContentBriefController.php | 262 +++++ Controllers/Api/ContentMediaController.php | 238 +++++ Controllers/Api/ContentRevisionController.php | 186 ++++ Controllers/Api/ContentSearchController.php | 195 ++++ Controllers/Api/ContentWebhookController.php | 323 ++++++ Controllers/Api/GenerationController.php | 402 ++++++++ Controllers/ContentPreviewController.php | 102 ++ Enums/BriefContentType.php | 115 +++ Enums/ContentType.php | 121 +++ Jobs/GenerateContentJob.php | 201 ++++ Jobs/ProcessContentWebhook.php | 546 ++++++++++ Mcp/Handlers/ContentCreateHandler.php | 281 +++++ Mcp/Handlers/ContentDeleteHandler.php | 100 ++ Mcp/Handlers/ContentListHandler.php | 145 +++ Mcp/Handlers/ContentReadHandler.php | 177 ++++ Mcp/Handlers/ContentSearchHandler.php | 139 +++ Mcp/Handlers/ContentTaxonomiesHandler.php | 88 ++ Mcp/Handlers/ContentUpdateHandler.php | 260 +++++ Middleware/WorkspaceRouter.php | 68 ++ ...001_01_01_000001_create_content_tables.php | 278 +++++ ...0001_make_content_media_wp_id_nullable.php | 45 + ...002_add_preview_token_to_content_items.php | 31 + ...d_retry_fields_to_content_webhook_logs.php | 40 + ...create_content_webhook_endpoints_table.php | 53 + Models/AIUsage.php | 226 +++++ Models/ContentAuthor.php | 68 ++ Models/ContentBrief.php | 316 ++++++ Models/ContentItem.php | 713 +++++++++++++ Models/ContentMedia.php | 105 ++ Models/ContentRevision.php | 611 +++++++++++ Models/ContentTask.php | 162 +++ Models/ContentTaxonomy.php | 94 ++ Models/ContentWebhookEndpoint.php | 404 ++++++++ Models/ContentWebhookLog.php | 262 +++++ Observers/ContentItemObserver.php | 109 ++ Services/AIGatewayService.php | 617 +++++++++++ Services/CdnPurgeService.php | 138 +++ Services/ContentProcessingService.php | 586 +++++++++++ Services/ContentRender.php | 376 +++++++ Services/ContentSearchService.php | 494 +++++++++ Services/WebhookRetryService.php | 339 +++++++ View/Blade/admin/content-search.blade.php | 261 +++++ View/Blade/admin/webhook-manager.blade.php | 392 +++++++ View/Blade/web/blog.blade.php | 73 ++ View/Blade/web/help-article.blade.php | 50 + View/Blade/web/help.blade.php | 41 + View/Blade/web/post.blade.php | 62 ++ View/Blade/web/preview-invalid.blade.php | 22 + View/Blade/web/preview.blade.php | 108 ++ View/Modal/Admin/ContentSearch.php | 236 +++++ View/Modal/Admin/WebhookManager.php | 396 ++++++++ View/Modal/Web/Blog.php | 62 ++ View/Modal/Web/Help.php | 86 ++ View/Modal/Web/HelpArticle.php | 68 ++ View/Modal/Web/Post.php | 68 ++ View/Modal/Web/Preview.php | 115 +++ app/Http/Controllers/.gitkeep | 0 app/Mod/.gitkeep | 0 app/Models/.gitkeep | 0 app/Providers/AppServiceProvider.php | 24 - artisan | 15 - bootstrap/app.php | 26 - bootstrap/cache/.gitignore | 2 - bootstrap/providers.php | 5 - composer.json | 68 +- config.php | 106 ++ config/core.php | 24 - database/factories/.gitkeep | 0 database/migrations/.gitkeep | 0 database/seeders/DatabaseSeeder.php | 16 - package.json | 16 - phpunit.xml | 33 - postcss.config.js | 6 - public/.htaccess | 21 - public/index.php | 17 - public/robots.txt | 2 - resources/css/app.css | 3 - resources/js/app.js | 1 - resources/js/bootstrap.js | 3 - resources/views/welcome.blade.php | 65 -- routes/api.php | 202 +++- routes/web.php | 36 +- storage/app/.gitignore | 3 - storage/app/public/.gitignore | 2 - storage/framework/.gitignore | 9 - storage/framework/cache/.gitignore | 3 - storage/framework/cache/data/.gitignore | 2 - storage/framework/sessions/.gitignore | 2 - storage/framework/testing/.gitignore | 2 - storage/framework/views/.gitignore | 2 - storage/logs/.gitignore | 2 - tailwind.config.js | 11 - tests/Feature/ContentManagerTest.php | 385 +++++++ tests/Feature/ContentPreviewTest.php | 223 ++++ tests/Feature/ContentRenderTest.php | 124 +++ tests/Feature/FactoriesTest.php | 231 +++++ tests/Feature/RevisionDiffTest.php | 196 ++++ tests/Unit/ContentSearchServiceTest.php | 357 +++++++ tests/Unit/ContentWebhookEndpointTest.php | 214 ++++ tests/Unit/McpHandlersTest.php | 179 ++++ tests/Unit/ProcessContentWebhookTest.php | 272 +++++ vite.config.js | 11 - 115 files changed, 17014 insertions(+), 618 deletions(-) delete mode 100644 .env.example delete mode 100644 .github/package-workflows/README.md delete mode 100644 .github/package-workflows/ci.yml delete mode 100644 .github/package-workflows/release.yml create mode 100644 Boot.php create mode 100644 Concerns/SearchableContent.php create mode 100644 Console/Commands/ContentBatch.php create mode 100644 Console/Commands/ContentGenerate.php create mode 100644 Console/Commands/ContentImportWordPress.php create mode 100644 Console/Commands/ContentValidate.php create mode 100644 Console/Commands/ProcessPendingWebhooks.php create mode 100644 Console/Commands/PruneContentRevisions.php create mode 100644 Console/Commands/PublishScheduledContent.php create mode 100644 Controllers/Api/ContentBriefController.php create mode 100644 Controllers/Api/ContentMediaController.php create mode 100644 Controllers/Api/ContentRevisionController.php create mode 100644 Controllers/Api/ContentSearchController.php create mode 100644 Controllers/Api/ContentWebhookController.php create mode 100644 Controllers/Api/GenerationController.php create mode 100644 Controllers/ContentPreviewController.php create mode 100644 Enums/BriefContentType.php create mode 100644 Enums/ContentType.php create mode 100644 Jobs/GenerateContentJob.php create mode 100644 Jobs/ProcessContentWebhook.php create mode 100644 Mcp/Handlers/ContentCreateHandler.php create mode 100644 Mcp/Handlers/ContentDeleteHandler.php create mode 100644 Mcp/Handlers/ContentListHandler.php create mode 100644 Mcp/Handlers/ContentReadHandler.php create mode 100644 Mcp/Handlers/ContentSearchHandler.php create mode 100644 Mcp/Handlers/ContentTaxonomiesHandler.php create mode 100644 Mcp/Handlers/ContentUpdateHandler.php create mode 100644 Middleware/WorkspaceRouter.php create mode 100644 Migrations/0001_01_01_000001_create_content_tables.php create mode 100644 Migrations/2026_01_26_000001_make_content_media_wp_id_nullable.php create mode 100644 Migrations/2026_01_26_000002_add_preview_token_to_content_items.php create mode 100644 Migrations/2026_01_26_000003_add_retry_fields_to_content_webhook_logs.php create mode 100644 Migrations/2026_01_26_000003_create_content_webhook_endpoints_table.php create mode 100644 Models/AIUsage.php create mode 100644 Models/ContentAuthor.php create mode 100644 Models/ContentBrief.php create mode 100644 Models/ContentItem.php create mode 100644 Models/ContentMedia.php create mode 100644 Models/ContentRevision.php create mode 100644 Models/ContentTask.php create mode 100644 Models/ContentTaxonomy.php create mode 100644 Models/ContentWebhookEndpoint.php create mode 100644 Models/ContentWebhookLog.php create mode 100644 Observers/ContentItemObserver.php create mode 100644 Services/AIGatewayService.php create mode 100644 Services/CdnPurgeService.php create mode 100644 Services/ContentProcessingService.php create mode 100644 Services/ContentRender.php create mode 100644 Services/ContentSearchService.php create mode 100644 Services/WebhookRetryService.php create mode 100644 View/Blade/admin/content-search.blade.php create mode 100644 View/Blade/admin/webhook-manager.blade.php create mode 100644 View/Blade/web/blog.blade.php create mode 100644 View/Blade/web/help-article.blade.php create mode 100644 View/Blade/web/help.blade.php create mode 100644 View/Blade/web/post.blade.php create mode 100644 View/Blade/web/preview-invalid.blade.php create mode 100644 View/Blade/web/preview.blade.php create mode 100644 View/Modal/Admin/ContentSearch.php create mode 100644 View/Modal/Admin/WebhookManager.php create mode 100644 View/Modal/Web/Blog.php create mode 100644 View/Modal/Web/Help.php create mode 100644 View/Modal/Web/HelpArticle.php create mode 100644 View/Modal/Web/Post.php create mode 100644 View/Modal/Web/Preview.php delete mode 100644 app/Http/Controllers/.gitkeep delete mode 100644 app/Mod/.gitkeep delete mode 100644 app/Models/.gitkeep delete mode 100644 app/Providers/AppServiceProvider.php delete mode 100755 artisan delete mode 100644 bootstrap/app.php delete mode 100644 bootstrap/cache/.gitignore delete mode 100644 bootstrap/providers.php create mode 100644 config.php delete mode 100644 config/core.php delete mode 100644 database/factories/.gitkeep delete mode 100644 database/migrations/.gitkeep delete mode 100644 database/seeders/DatabaseSeeder.php delete mode 100644 package.json delete mode 100644 phpunit.xml delete mode 100644 postcss.config.js delete mode 100644 public/.htaccess delete mode 100644 public/index.php delete mode 100644 public/robots.txt delete mode 100644 resources/css/app.css delete mode 100644 resources/js/app.js delete mode 100644 resources/js/bootstrap.js delete mode 100644 resources/views/welcome.blade.php delete mode 100644 storage/app/.gitignore delete mode 100644 storage/app/public/.gitignore delete mode 100644 storage/framework/.gitignore delete mode 100644 storage/framework/cache/.gitignore delete mode 100644 storage/framework/cache/data/.gitignore delete mode 100644 storage/framework/sessions/.gitignore delete mode 100644 storage/framework/testing/.gitignore delete mode 100644 storage/framework/views/.gitignore delete mode 100644 storage/logs/.gitignore delete mode 100644 tailwind.config.js create mode 100644 tests/Feature/ContentManagerTest.php create mode 100644 tests/Feature/ContentPreviewTest.php create mode 100644 tests/Feature/ContentRenderTest.php create mode 100644 tests/Feature/FactoriesTest.php create mode 100644 tests/Feature/RevisionDiffTest.php create mode 100644 tests/Unit/ContentSearchServiceTest.php create mode 100644 tests/Unit/ContentWebhookEndpointTest.php create mode 100644 tests/Unit/McpHandlersTest.php create mode 100644 tests/Unit/ProcessContentWebhookTest.php delete mode 100644 vite.config.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 01b4da4..0000000 --- a/.env.example +++ /dev/null @@ -1,76 +0,0 @@ -APP_NAME="Core PHP App" -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=http://localhost - -APP_LOCALE=en_GB -APP_FALLBACK_LOCALE=en_GB -APP_FAKER_LOCALE=en_GB - -APP_MAINTENANCE_DRIVER=file - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=core -# DB_USERNAME=root -# DB_PASSWORD= - -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" - -# Core PHP Framework -CORE_CACHE_DISCOVERY=true - -# CDN Configuration (optional) -CDN_ENABLED=false -CDN_DRIVER=bunny -BUNNYCDN_API_KEY= -BUNNYCDN_STORAGE_ZONE= -BUNNYCDN_PULL_ZONE= - -# Flux Pro (optional) -FLUX_LICENSE_KEY= diff --git a/.github/package-workflows/README.md b/.github/package-workflows/README.md deleted file mode 100644 index 999966f..0000000 --- a/.github/package-workflows/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Package Workflows - -These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects. - -## README Badges - -Add these badges to your package README (replace `{package}` with your package name): - -```markdown -[![CI](https://github.com/host-uk/{package}/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/{package}/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/host-uk/{package}/graph/badge.svg)](https://codecov.io/gh/host-uk/{package}) -[![Latest Version](https://img.shields.io/packagist/v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) -[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) -[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) -``` - -## Usage - -Copy the relevant workflows to your library's `.github/workflows/` directory: - -```bash -# In your library repo -mkdir -p .github/workflows -cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/ -cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/ -``` - -## Workflows - -### ci.yml -- Runs on push/PR to main -- Tests against PHP 8.2, 8.3, 8.4 -- Tests against Laravel 11 and 12 -- Runs Pint linting -- Runs Pest tests - -### release.yml -- Triggers on version tags (v*) -- Generates changelog using git-cliff -- Creates GitHub release - -## Requirements - -For these workflows to work, your package needs: - -1. **cliff.toml** - Copy from core-template root -2. **Pest configured** - `composer require pestphp/pest --dev` -3. **Pint configured** - `composer require laravel/pint --dev` -4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads -5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button - -## Recommended composer.json scripts - -```json -{ - "scripts": { - "lint": "pint", - "test": "pest", - "test:coverage": "pest --coverage" - } -} -``` diff --git a/.github/package-workflows/ci.yml b/.github/package-workflows/ci.yml deleted file mode 100644 index 7c5f722..0000000 --- a/.github/package-workflows/ci.yml +++ /dev/null @@ -1,55 +0,0 @@ -# CI workflow for library packages (host-uk/core-*, etc.) -# Copy this to .github/workflows/ci.yml in library repos - -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - tests: - runs-on: ubuntu-latest - - strategy: - fail-fast: true - matrix: - php: [8.2, 8.3, 8.4] - laravel: [11.*, 12.*] - exclude: - - php: 8.2 - laravel: 12.* - - name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite - coverage: pcov - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update - composer update --prefer-dist --no-interaction --no-progress - - - name: Run Pint - run: vendor/bin/pint --test - - - name: Run tests - run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml - - - name: Upload coverage to Codecov - if: matrix.php == '8.3' && matrix.laravel == '12.*' - uses: codecov/codecov-action@v4 - with: - files: coverage.xml - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/package-workflows/release.yml b/.github/package-workflows/release.yml deleted file mode 100644 index 035294e..0000000 --- a/.github/package-workflows/release.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Release workflow for library packages -# Copy this to .github/workflows/release.yml in library repos - -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - name: Create Release - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate changelog - id: changelog - uses: orhun/git-cliff-action@v3 - with: - config: cliff.toml - args: --latest --strip header - env: - OUTPUT: CHANGELOG.md - - - name: Create release - uses: softprops/action-gh-release@v2 - with: - body_path: CHANGELOG.md - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Boot.php b/Boot.php new file mode 100644 index 0000000..a0d54c0 --- /dev/null +++ b/Boot.php @@ -0,0 +1,181 @@ + + */ + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + ApiRoutesRegistering::class => 'onApiRoutes', + ConsoleBooting::class => 'onConsole', + McpToolsRegistering::class => 'onMcpTools', + ]; + + public function register(): void + { + $this->mergeConfigFrom(__DIR__.'/config.php', 'content'); + } + + public function boot(): void + { + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + $this->configureRateLimiting(); + } + + /** + * Configure rate limiters for content generation endpoints. + * + * AI generation is expensive, so we apply strict rate limits: + * - Authenticated users: 10 requests per minute + * - Unauthenticated: 2 requests per minute (should not happen via API auth) + */ + protected function configureRateLimiting(): void + { + // Rate limit for AI content generation: 10 per minute per user/workspace + // AI calls are expensive ($0.01-0.10 per generation), so we limit aggressively + RateLimiter::for('content-generate', function (Request $request) { + $user = $request->user(); + + if ($user) { + // Use workspace_id if available for workspace-level limiting + $workspaceId = $request->input('workspace_id') ?? $request->route('workspace_id'); + + return $workspaceId + ? Limit::perMinute(10)->by('workspace:'.$workspaceId) + : Limit::perMinute(10)->by('user:'.$user->id); + } + + // Unauthenticated - very low limit + return Limit::perMinute(2)->by($request->ip()); + }); + + // Rate limit for brief creation: 30 per minute per user + // Brief creation is less expensive but still rate limited + RateLimiter::for('content-briefs', function (Request $request) { + $user = $request->user(); + + return $user + ? Limit::perMinute(30)->by('user:'.$user->id) + : Limit::perMinute(5)->by($request->ip()); + }); + + // Rate limit for incoming webhooks: 60 per minute per endpoint + // Webhooks from external CMS systems need reasonable limits + RateLimiter::for('content-webhooks', function (Request $request) { + // Use endpoint UUID or IP for rate limiting + $endpoint = $request->route('endpoint'); + + return $endpoint + ? Limit::perMinute(60)->by('webhook-endpoint:'.$endpoint) + : Limit::perMinute(30)->by('webhook-ip:'.$request->ip()); + }); + + // Rate limit for content search: configurable per minute per user + // Search queries can be resource-intensive with full-text matching + RateLimiter::for('content-search', function (Request $request) { + $user = $request->user(); + $limit = config('content.search.rate_limit', 60); + + return $user + ? Limit::perMinute($limit)->by('search-user:'.$user->id) + : Limit::perMinute(20)->by('search-ip:'.$request->ip()); + }); + } + + // ------------------------------------------------------------------------- + // Event-driven handlers (for lazy loading once event system is integrated) + // ------------------------------------------------------------------------- + + /** + * Handle web routes registration event. + */ + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('content', __DIR__.'/View/Blade'); + + // Public web components + $event->livewire('content.blog', View\Modal\Web\Blog::class); + $event->livewire('content.post', View\Modal\Web\Post::class); + $event->livewire('content.help', View\Modal\Web\Help::class); + $event->livewire('content.help-article', View\Modal\Web\HelpArticle::class); + $event->livewire('content.preview', View\Modal\Web\Preview::class); + + // Admin components + $event->livewire('content.admin.webhook-manager', View\Modal\Admin\WebhookManager::class); + $event->livewire('content.admin.content-search', View\Modal\Admin\ContentSearch::class); + + if (file_exists(__DIR__.'/Routes/web.php')) { + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + } + + /** + * Handle API routes registration event. + */ + public function onApiRoutes(ApiRoutesRegistering $event): void + { + if (file_exists(__DIR__.'/Routes/api.php')) { + $event->routes(fn () => require __DIR__.'/Routes/api.php'); + } + } + + /** + * Handle console booting event. + */ + public function onConsole(ConsoleBooting $event): void + { + // Register Content module commands + $event->command(Console\Commands\PruneContentRevisions::class); + $event->command(Console\Commands\PublishScheduledContent::class); + + // Note: Some content commands are in app/Console/Commands as they + // depend on both Content and Agentic modules + } + + /** + * Handle MCP tools registration event. + * + * Registers Content module MCP tools for: + * - Listing content items + * - Reading content by ID/slug + * - Searching content + * - Creating new content + * - Updating existing content + * - Deleting content (soft delete) + * - Listing taxonomies (categories/tags) + */ + public function onMcpTools(McpToolsRegistering $event): void + { + $event->handler(Mcp\Handlers\ContentListHandler::class); + $event->handler(Mcp\Handlers\ContentReadHandler::class); + $event->handler(Mcp\Handlers\ContentSearchHandler::class); + $event->handler(Mcp\Handlers\ContentCreateHandler::class); + $event->handler(Mcp\Handlers\ContentUpdateHandler::class); + $event->handler(Mcp\Handlers\ContentDeleteHandler::class); + $event->handler(Mcp\Handlers\ContentTaxonomiesHandler::class); + } +} diff --git a/Concerns/SearchableContent.php b/Concerns/SearchableContent.php new file mode 100644 index 0000000..964fd7a --- /dev/null +++ b/Concerns/SearchableContent.php @@ -0,0 +1,150 @@ + + */ + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'workspace_id' => $this->workspace_id, + 'title' => $this->title, + 'slug' => $this->slug, + 'excerpt' => $this->excerpt, + 'content' => $this->getSearchableContent(), + 'type' => $this->type, + 'status' => $this->status, + 'content_type' => $this->content_type?->value, + 'author_id' => $this->author_id, + 'author_name' => $this->author?->name, + 'categories' => $this->categories->pluck('slug')->all(), + 'tags' => $this->tags->pluck('slug')->all(), + 'created_at' => $this->created_at?->timestamp, + 'updated_at' => $this->updated_at?->timestamp, + 'publish_at' => $this->publish_at?->timestamp, + ]; + } + + /** + * Get searchable content text (HTML stripped). + */ + protected function getSearchableContent(): string + { + $content = $this->content_markdown + ?? $this->content_html + ?? $this->content_html_clean + ?? ''; + + // Strip HTML tags and normalise whitespace + $text = strip_tags($content); + $text = preg_replace('/\s+/', ' ', $text); + + // Limit content length for indexing (Scout/Meilisearch has limits) + return mb_substr(trim($text), 0, 50000); + } + + /** + * Get the name of the index associated with the model. + * + * Using workspace-prefixed index allows for tenant isolation. + */ + public function searchableAs(): string + { + $prefix = config('scout.prefix', ''); + $workspaceId = $this->workspace_id ?? 'global'; + + return "{$prefix}content_items_{$workspaceId}"; + } + + /** + * Determine if the model should be searchable. + * + * Only index native content types (not WordPress legacy content). + */ + public function shouldBeSearchable(): bool + { + // Only index native content + if ($this->content_type && ! $this->content_type->isNative()) { + return false; + } + + // Don't index trashed content + if ($this->trashed()) { + return false; + } + + return true; + } + + /** + * Get filterable attributes for Meilisearch. + * + * These attributes can be used in filter queries. + * + * @return array + */ + public static function getFilterableAttributes(): array + { + return [ + 'workspace_id', + 'type', + 'status', + 'content_type', + 'author_id', + 'categories', + 'tags', + 'created_at', + 'updated_at', + ]; + } + + /** + * Get sortable attributes for Meilisearch. + * + * @return array + */ + public static function getSortableAttributes(): array + { + return [ + 'created_at', + 'updated_at', + 'publish_at', + 'title', + ]; + } + + /** + * Modify the search query builder before executing. + * + * @param \Laravel\Scout\Builder $query + * @return \Laravel\Scout\Builder + */ + public function modifyScoutQuery($query, string $search) + { + return $query; + } +} diff --git a/Console/Commands/ContentBatch.php b/Console/Commands/ContentBatch.php new file mode 100644 index 0000000..b5a7fc0 --- /dev/null +++ b/Console/Commands/ContentBatch.php @@ -0,0 +1,242 @@ +argument('action'); + $batchId = $this->argument('batch'); + + return match ($action) { + 'list' => $this->listBatches(), + 'status' => $this->showStatus($batchId), + 'schedule' => $this->showSchedule(), + default => $this->showHelp(), + }; + } + + protected function listBatches(): int + { + $this->info('Content Generation Batches'); + $this->newLine(); + + $batches = $this->batchService->listBatches(); + + if (empty($batches)) { + $this->warn('No batch specifications found.'); + + return self::SUCCESS; + } + + $this->table( + ['Batch ID', 'Service', 'Category', 'Articles', 'Priority'], + array_map(fn ($b) => [ + $b['id'], + $b['service'], + $b['category'], + $b['article_count'], + ucfirst($b['priority']), + ], $batches) + ); + + $totalArticles = array_sum(array_column($batches, 'article_count')); + $this->newLine(); + $this->line('Total batches: '.count($batches).''); + $this->line("Total articles: {$totalArticles}"); + + return self::SUCCESS; + } + + protected function showStatus(?string $batchId = null): int + { + if (! $batchId) { + return $this->showAllStatuses(); + } + + $status = $this->batchService->getBatchStatus($batchId); + + if (isset($status['error'])) { + $this->error($status['error']); + + return self::FAILURE; + } + + $this->info("Batch Status: {$batchId}"); + $this->newLine(); + + $this->table( + ['Metric', 'Count', 'Percentage'], + [ + ['Total articles', $status['total'], '100%'], + ['Drafted', $status['drafted'], $this->percentage($status['drafted'], $status['total'])], + ['Generated', $status['generated'], $this->percentage($status['generated'], $status['total'])], + ['Published', $status['published'], $this->percentage($status['published'], $status['total'])], + ['Remaining', $status['remaining'], $this->percentage($status['remaining'], $status['total'])], + ] + ); + + // Progress bar + $progress = $status['total'] > 0 + ? round(($status['drafted'] / $status['total']) * 100) + : 0; + + $this->newLine(); + $this->line('Progress: '.$this->progressBar($progress)); + + return self::SUCCESS; + } + + protected function showAllStatuses(): int + { + $this->info('Batch Status Overview'); + $this->newLine(); + + $batches = $this->batchService->listBatches(); + $rows = []; + $totals = ['total' => 0, 'drafted' => 0, 'published' => 0]; + + foreach ($batches as $batch) { + $status = $this->batchService->getBatchStatus($batch['id']); + + if (isset($status['error'])) { + continue; + } + + $progress = $status['total'] > 0 + ? round(($status['drafted'] / $status['total']) * 100) + : 0; + + $rows[] = [ + $batch['id'], + $status['drafted'].'/'.$status['total'], + $status['published'], + $this->progressBar($progress, 10), + ]; + + $totals['total'] += $status['total']; + $totals['drafted'] += $status['drafted']; + $totals['published'] += $status['published']; + } + + $this->table( + ['Batch', 'Drafted', 'Published', 'Progress'], + $rows + ); + + $this->newLine(); + $overallProgress = $totals['total'] > 0 + ? round(($totals['drafted'] / $totals['total']) * 100) + : 0; + + $this->line("Overall: {$totals['drafted']}/{$totals['total']} articles drafted ({$overallProgress}%)"); + $this->line("Published: {$totals['published']} articles live"); + + return self::SUCCESS; + } + + protected function showSchedule(): int + { + $this->info('Content Generation Schedule (Phase 42)'); + $this->newLine(); + + // Read schedule from task index + $taskIndexPath = base_path('doc/phase42/tasks/00-task-index.md'); + + if (! file_exists($taskIndexPath)) { + $this->warn('Task index not found.'); + + return self::FAILURE; + } + + $content = file_get_contents($taskIndexPath); + + // Extract weekly schedule + if (preg_match('/## Weekly Schedule(.+?)(?=## |$)/s', $content, $match)) { + $schedule = $match[1]; + + // Parse weeks + preg_match_all('/### (Week \d+)\n(.+?)(?=### Week|\Z)/s', $schedule, $weeks); + + foreach ($weeks[1] as $i => $week) { + $this->line("{$week}"); + + // Parse tasks in week + $tasks = $weeks[2][$i]; + preg_match_all('/- \[([ x])\] (.+)/', $tasks, $items); + + foreach ($items[1] as $j => $status) { + $icon = $status === 'x' ? '✓' : '○'; + $this->line(" {$icon} {$items[2][$j]}"); + } + + $this->newLine(); + } + } else { + $this->warn('Could not parse weekly schedule from task index.'); + } + + return self::SUCCESS; + } + + protected function showHelp(): int + { + $this->info('Content Batch Management'); + $this->newLine(); + $this->line('Actions:'); + $this->line(' list - List all available batches'); + $this->line(' status - Show status for all batches or a specific batch'); + $this->line(' schedule - Show the generation schedule'); + $this->newLine(); + $this->line('Examples:'); + $this->line(' php artisan content:batch list'); + $this->line(' php artisan content:batch status batch-001-link-getting-started'); + $this->line(' php artisan content:batch schedule'); + + return self::SUCCESS; + } + + protected function percentage(int $value, int $total): string + { + if ($total === 0) { + return '0%'; + } + + return round(($value / $total) * 100).'%'; + } + + protected function progressBar(int $percent, int $width = 20): string + { + $filled = (int) round($percent / 100 * $width); + $empty = $width - $filled; + + $bar = str_repeat('█', $filled).str_repeat('░', $empty); + + $colour = match (true) { + $percent >= 75 => 'green', + $percent >= 50 => 'yellow', + $percent >= 25 => 'orange', + default => 'red', + }; + + return "{$bar} {$percent}%"; + } +} diff --git a/Console/Commands/ContentGenerate.php b/Console/Commands/ContentGenerate.php new file mode 100644 index 0000000..c125ab9 --- /dev/null +++ b/Console/Commands/ContentGenerate.php @@ -0,0 +1,247 @@ +argument('batch'); + $provider = $this->option('provider'); + $refine = $this->option('refine'); + $dryRun = $this->option('dry-run'); + $articleSlug = $this->option('article'); + + if (! $batchId) { + return $this->listBatches(); + } + + if ($refine) { + return $this->refineBatch($batchId, $dryRun); + } + + return $this->generateBatch($batchId, $provider, $dryRun, $articleSlug); + } + + protected function listBatches(): int + { + $batches = $this->batchService->listBatches(); + + if (empty($batches)) { + $this->error('No batch specifications found in doc/phase42/tasks/'); + + return self::FAILURE; + } + + $this->info('Available content batches:'); + $this->newLine(); + + $this->table( + ['Batch ID', 'Service', 'Category', 'Articles', 'Priority'], + array_map(fn ($b) => [ + $b['id'], + $b['service'], + $b['category'], + $b['article_count'], + $b['priority'], + ], $batches) + ); + + $this->newLine(); + $this->line('Usage: php artisan content:generate batch-001-link-getting-started'); + + return self::SUCCESS; + } + + protected function generateBatch(string $batchId, string $provider, bool $dryRun, ?string $articleSlug): int + { + $this->info("Generating content for batch: {$batchId}"); + $this->line("Provider: {$provider}"); + + if ($dryRun) { + $this->warn('Dry run mode - no files will be created'); + } + + $this->newLine(); + + // Get batch status first + $status = $this->batchService->getBatchStatus($batchId); + + if (isset($status['error'])) { + $this->error($status['error']); + + return self::FAILURE; + } + + $this->table( + ['Metric', 'Count'], + [ + ['Total articles', $status['total']], + ['Already drafted', $status['drafted']], + ['Remaining', $status['remaining']], + ] + ); + + if ($status['remaining'] === 0 && ! $articleSlug) { + $this->info('All articles in this batch have been drafted.'); + + return self::SUCCESS; + } + + $this->newLine(); + + if (! $dryRun && ! $this->confirm('Proceed with generation?', true)) { + $this->line('Cancelled.'); + + return self::SUCCESS; + } + + $this->newLine(); + $results = $this->batchService->generateBatch($batchId, $provider, $dryRun); + + if (isset($results['error'])) { + $this->error($results['error']); + + return self::FAILURE; + } + + // Display results + $this->info('Generation Results:'); + + foreach ($results['articles'] as $slug => $result) { + $statusIcon = match ($result['status']) { + 'generated' => '✓', + 'skipped' => '-', + 'would_generate' => '?', + 'failed' => '✗', + }; + + $message = match ($result['status']) { + 'generated' => "Generated: {$result['path']}", + 'skipped' => "Skipped: {$result['reason']}", + 'would_generate' => "Would generate: {$result['path']}", + 'failed' => "Failed: {$result['error']}", + }; + + $this->line(" {$statusIcon} {$slug} - {$message}"); + } + + $this->newLine(); + $this->table( + ['Generated', 'Skipped', 'Failed'], + [[$results['generated'], $results['skipped'], $results['failed']]] + ); + + return $results['failed'] > 0 ? self::FAILURE : self::SUCCESS; + } + + protected function refineBatch(string $batchId, bool $dryRun): int + { + $this->info("Refining drafts for batch: {$batchId}"); + $this->line('Using: Claude for quality refinement'); + + if ($dryRun) { + $this->warn('Dry run mode - no files will be modified'); + } + + $this->newLine(); + + $spec = $this->batchService->loadBatch($batchId); + + if (! $spec) { + $this->error("Batch not found: {$batchId}"); + + return self::FAILURE; + } + + $refined = 0; + $skipped = 0; + $failed = 0; + + foreach ($spec['articles'] ?? [] as $article) { + $slug = $article['slug'] ?? null; + if (! $slug) { + continue; + } + + // Find draft file + $draftPath = $this->findDraft($slug); + + if (! $draftPath) { + $this->line(" - {$slug} - No draft found"); + $skipped++; + + continue; + } + + if ($dryRun) { + $this->line(" ? {$slug} - Would refine: {$draftPath}"); + + continue; + } + + try { + $refinedContent = $this->batchService->refineDraft($draftPath); + + // Create backup + copy($draftPath, $draftPath.'.backup'); + + // Write refined content + file_put_contents($draftPath, $refinedContent); + + $this->line(" {$slug} - Refined"); + $refined++; + } catch (\Exception $e) { + $this->line(" {$slug} - {$e->getMessage()}"); + $failed++; + } + } + + $this->newLine(); + $this->table( + ['Refined', 'Skipped', 'Failed'], + [[$refined, $skipped, $failed]] + ); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + protected function findDraft(string $slug): ?string + { + $basePath = base_path('doc/phase42/drafts'); + $patterns = [ + "{$basePath}/help/**/{$slug}.md", + "{$basePath}/blog/**/{$slug}.md", + "{$basePath}/**/{$slug}.md", + ]; + + foreach ($patterns as $pattern) { + $matches = glob($pattern); + if (! empty($matches)) { + return $matches[0]; + } + } + + return null; + } +} diff --git a/Console/Commands/ContentImportWordPress.php b/Console/Commands/ContentImportWordPress.php new file mode 100644 index 0000000..696765d --- /dev/null +++ b/Console/Commands/ContentImportWordPress.php @@ -0,0 +1,957 @@ + ['created' => 0, 'updated' => 0, 'skipped' => 0], + 'categories' => ['created' => 0, 'updated' => 0, 'skipped' => 0], + 'tags' => ['created' => 0, 'updated' => 0, 'skipped' => 0], + 'media' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'downloaded' => 0], + 'posts' => ['created' => 0, 'updated' => 0, 'skipped' => 0], + 'pages' => ['created' => 0, 'updated' => 0, 'skipped' => 0], + ]; + + protected array $authorMap = []; // wp_id => local_id + + protected array $categoryMap = []; // wp_id => local_id + + protected array $tagMap = []; // wp_id => local_id + + protected array $mediaMap = []; // wp_id => local_id + + public function handle(): int + { + $this->baseUrl = rtrim($this->argument('url'), '/'); + $this->dryRun = $this->option('dry-run'); + $this->skipMedia = $this->option('skip-media'); + $this->limit = $this->option('limit') ? (int) $this->option('limit') : null; + + if ($this->option('since')) { + try { + $this->since = Carbon::parse($this->option('since')); + } catch (\Exception $e) { + $this->error("Invalid date format for --since: {$this->option('since')}"); + + return self::FAILURE; + } + } + + // Validate WordPress site is accessible + if (! $this->validateWordPressSite()) { + return self::FAILURE; + } + + // Authenticate if credentials provided + if ($this->option('username') && $this->option('password')) { + if (! $this->authenticate()) { + $this->error('Failed to authenticate with WordPress. Check credentials.'); + + return self::FAILURE; + } + $this->info('Authenticated successfully.'); + } + + // Resolve workspace + if (! $this->resolveWorkspace()) { + return self::FAILURE; + } + + $this->info(''); + $this->info("Importing from: {$this->baseUrl}"); + $this->info("Target workspace: {$this->workspace->name} (ID: {$this->workspace->id})"); + if ($this->since) { + $this->info("Modified since: {$this->since->toDateTimeString()}"); + } + if ($this->dryRun) { + $this->warn('DRY RUN - No changes will be made'); + } + $this->info(''); + + $types = explode(',', $this->option('types')); + + // Import in dependency order + if (in_array('authors', $types)) { + $this->importAuthors(); + } + + if (in_array('categories', $types)) { + $this->importCategories(); + } + + if (in_array('tags', $types)) { + $this->importTags(); + } + + if (in_array('media', $types)) { + $this->importMedia(); + } + + if (in_array('posts', $types)) { + $this->importPosts(); + } + + if (in_array('pages', $types)) { + $this->importPages(); + } + + $this->displaySummary(); + + return self::SUCCESS; + } + + /** + * Validate the WordPress site is accessible and has REST API. + */ + protected function validateWordPressSite(): bool + { + $this->info('Validating WordPress site...'); + + try { + $response = Http::timeout(10)->get("{$this->baseUrl}/wp-json"); + + if ($response->failed()) { + $this->error("Cannot access WordPress REST API at {$this->baseUrl}/wp-json"); + $this->error("Status: {$response->status()}"); + + return false; + } + + $info = $response->json(); + $siteName = $info['name'] ?? 'Unknown'; + $this->info("Connected to: {$siteName}"); + + return true; + } catch (\Exception $e) { + $this->error("Failed to connect to WordPress site: {$e->getMessage()}"); + + return false; + } + } + + /** + * Authenticate with WordPress using JWT or application passwords. + */ + protected function authenticate(): bool + { + // Try JWT auth first (if plugin installed) + $response = Http::timeout(10)->post("{$this->baseUrl}/wp-json/jwt-auth/v1/token", [ + 'username' => $this->option('username'), + 'password' => $this->option('password'), + ]); + + if ($response->successful()) { + $this->token = $response->json('token'); + + return true; + } + + // Fall back to Basic Auth with application password + $this->token = base64_encode($this->option('username').':'.$this->option('password')); + + return true; + } + + /** + * Get HTTP client with authentication. + */ + protected function client() + { + $http = Http::timeout(30) + ->acceptJson() + ->baseUrl("{$this->baseUrl}/wp-json/wp/v2"); + + if ($this->token) { + // Check if it's a JWT token or basic auth + if (str_starts_with($this->token, 'eyJ')) { + $http = $http->withToken($this->token); + } else { + $http = $http->withHeaders(['Authorization' => "Basic {$this->token}"]); + } + } + + return $http; + } + + /** + * Resolve target workspace. + */ + protected function resolveWorkspace(): bool + { + $workspaceInput = $this->option('workspace') ?? 'main'; + + $this->workspace = is_numeric($workspaceInput) + ? Workspace::find($workspaceInput) + : Workspace::where('slug', $workspaceInput)->first(); + + if (! $this->workspace) { + $this->error("Workspace not found: {$workspaceInput}"); + + return false; + } + + return true; + } + + /** + * Import authors from WordPress users. + */ + protected function importAuthors(): void + { + $this->info('Importing authors...'); + + $page = 1; + $imported = 0; + $progressStarted = false; + + do { + $response = $this->client()->get('/users', [ + 'page' => $page, + 'per_page' => 100, + ]); + + if ($response->failed()) { + $this->warn("Failed to fetch authors page {$page}"); + break; + } + + $users = $response->json(); + $total = (int) $response->header('X-WP-Total', count($users)); + + if (empty($users)) { + break; + } + + if (! $progressStarted) { + $this->output->progressStart($total); + $progressStarted = true; + } + + foreach ($users as $user) { + $result = $this->importAuthor($user); + $this->stats['authors'][$result]++; + $imported++; + $this->output->progressAdvance(); + + if ($this->limit && $imported >= $this->limit) { + break 2; + } + } + + $page++; + $hasMore = count($users) === 100; + } while ($hasMore); + + if ($progressStarted) { + $this->output->progressFinish(); + } + $this->newLine(); + } + + /** + * Import a single author. + */ + protected function importAuthor(array $user): string + { + $wpId = $user['id']; + + // Check if already exists + $existing = ContentAuthor::forWorkspace($this->workspace->id) + ->byWpId($wpId) + ->first(); + + $data = [ + 'workspace_id' => $this->workspace->id, + 'wp_id' => $wpId, + 'name' => $user['name'] ?? '', + 'slug' => $user['slug'] ?? Str::slug($user['name'] ?? 'author-'.$wpId), + 'avatar_url' => $user['avatar_urls']['96'] ?? null, + 'bio' => $user['description'] ?? null, + ]; + + if ($this->dryRun) { + if ($existing) { + $this->authorMap[$wpId] = $existing->id; + + return 'skipped'; + } + + return 'created'; + } + + if ($existing) { + $existing->update($data); + $this->authorMap[$wpId] = $existing->id; + + return 'updated'; + } + + $author = ContentAuthor::create($data); + $this->authorMap[$wpId] = $author->id; + + return 'created'; + } + + /** + * Import categories. + */ + protected function importCategories(): void + { + $this->info('Importing categories...'); + + $page = 1; + $imported = 0; + $progressStarted = false; + + do { + $response = $this->client()->get('/categories', [ + 'page' => $page, + 'per_page' => 100, + ]); + + if ($response->failed()) { + $this->warn("Failed to fetch categories page {$page}"); + break; + } + + $categories = $response->json(); + $total = (int) $response->header('X-WP-Total', count($categories)); + + if (empty($categories)) { + break; + } + + if (! $progressStarted) { + $this->output->progressStart($total); + $progressStarted = true; + } + + foreach ($categories as $category) { + $result = $this->importTaxonomy($category, 'category'); + $this->stats['categories'][$result]++; + $imported++; + $this->output->progressAdvance(); + + if ($this->limit && $imported >= $this->limit) { + break 2; + } + } + + $page++; + $hasMore = count($categories) === 100; + } while ($hasMore); + + if ($progressStarted) { + $this->output->progressFinish(); + } + $this->newLine(); + } + + /** + * Import tags. + */ + protected function importTags(): void + { + $this->info('Importing tags...'); + + $page = 1; + $imported = 0; + $progressStarted = false; + + do { + $response = $this->client()->get('/tags', [ + 'page' => $page, + 'per_page' => 100, + ]); + + if ($response->failed()) { + $this->warn("Failed to fetch tags page {$page}"); + break; + } + + $tags = $response->json(); + $total = (int) $response->header('X-WP-Total', count($tags)); + + if (empty($tags)) { + break; + } + + if (! $progressStarted) { + $this->output->progressStart($total); + $progressStarted = true; + } + + foreach ($tags as $tag) { + $result = $this->importTaxonomy($tag, 'tag'); + $this->stats['tags'][$result]++; + $imported++; + $this->output->progressAdvance(); + + if ($this->limit && $imported >= $this->limit) { + break 2; + } + } + + $page++; + $hasMore = count($tags) === 100; + } while ($hasMore); + + if ($progressStarted) { + $this->output->progressFinish(); + } + $this->newLine(); + } + + /** + * Import a single taxonomy (category or tag). + */ + protected function importTaxonomy(array $term, string $type): string + { + $wpId = $term['id']; + $map = $type === 'category' ? 'categoryMap' : 'tagMap'; + + // Check if already exists + $existing = ContentTaxonomy::forWorkspace($this->workspace->id) + ->where('type', $type) + ->byWpId($wpId) + ->first(); + + $data = [ + 'workspace_id' => $this->workspace->id, + 'wp_id' => $wpId, + 'type' => $type, + 'name' => $this->decodeText($term['name'] ?? ''), + 'slug' => $term['slug'] ?? Str::slug($term['name'] ?? $type.'-'.$wpId), + 'description' => $term['description'] ?? null, + 'parent_wp_id' => $term['parent'] ?? null, + 'count' => $term['count'] ?? 0, + ]; + + if ($this->dryRun) { + if ($existing) { + $this->$map[$wpId] = $existing->id; + + return 'skipped'; + } + + return 'created'; + } + + if ($existing) { + $existing->update($data); + $this->$map[$wpId] = $existing->id; + + return 'updated'; + } + + $taxonomy = ContentTaxonomy::create($data); + $this->$map[$wpId] = $taxonomy->id; + + return 'created'; + } + + /** + * Import media files. + */ + protected function importMedia(): void + { + $this->info('Importing media...'); + + $page = 1; + $imported = 0; + $progressStarted = false; + $params = [ + 'page' => $page, + 'per_page' => 20, // Smaller batches for media + ]; + + if ($this->since) { + $params['modified_after'] = $this->since->toIso8601String(); + } + + do { + $params['page'] = $page; + $response = $this->client()->get('/media', $params); + + if ($response->failed()) { + $this->warn("Failed to fetch media page {$page}"); + break; + } + + $media = $response->json(); + $total = (int) $response->header('X-WP-Total', count($media)); + + if (empty($media)) { + break; + } + + if (! $progressStarted) { + $this->output->progressStart($total); + $progressStarted = true; + } + + foreach ($media as $item) { + $result = $this->importMediaItem($item); + $this->stats['media'][$result]++; + $imported++; + $this->output->progressAdvance(); + + if ($this->limit && $imported >= $this->limit) { + break 2; + } + } + + $page++; + $hasMore = count($media) === 20; + } while ($hasMore); + + if ($progressStarted) { + $this->output->progressFinish(); + } + $this->newLine(); + } + + /** + * Import a single media item. + */ + protected function importMediaItem(array $item): string + { + $wpId = $item['id']; + + // Check if already exists + $existing = ContentMedia::forWorkspace($this->workspace->id) + ->byWpId($wpId) + ->first(); + + $sourceUrl = $item['source_url'] ?? ($item['guid']['rendered'] ?? null); + $mimeType = $item['mime_type'] ?? 'application/octet-stream'; + $filename = basename(parse_url($sourceUrl, PHP_URL_PATH) ?? "media-{$wpId}"); + + // Parse sizes from media_details + $sizes = []; + if (isset($item['media_details']['sizes'])) { + foreach ($item['media_details']['sizes'] as $sizeName => $sizeData) { + $sizes[$sizeName] = [ + 'source_url' => $sizeData['source_url'] ?? null, + 'width' => $sizeData['width'] ?? null, + 'height' => $sizeData['height'] ?? null, + ]; + } + } + + $data = [ + 'workspace_id' => $this->workspace->id, + 'wp_id' => $wpId, + 'title' => $this->decodeText($item['title']['rendered'] ?? $filename), + 'filename' => $filename, + 'mime_type' => $mimeType, + 'file_size' => $item['media_details']['filesize'] ?? 0, + 'source_url' => $sourceUrl, + 'width' => $item['media_details']['width'] ?? null, + 'height' => $item['media_details']['height'] ?? null, + 'alt_text' => $item['alt_text'] ?? null, + 'caption' => $item['caption']['rendered'] ?? null, + 'sizes' => $sizes, + ]; + + if ($this->dryRun) { + if ($existing) { + $this->mediaMap[$wpId] = $existing->id; + + return 'skipped'; + } + + return 'created'; + } + + // Download media file if not existing and not skipping + $localPath = null; + if ($sourceUrl && ! $this->skipMedia) { + $localPath = $this->downloadMedia($sourceUrl, $filename); + if ($localPath) { + $data['cdn_url'] = Storage::disk('content-media')->url($localPath); + $this->stats['media']['downloaded']++; + } + } + + if ($existing) { + $existing->update($data); + $this->mediaMap[$wpId] = $existing->id; + + return 'updated'; + } + + $media = ContentMedia::create($data); + $this->mediaMap[$wpId] = $media->id; + + return 'created'; + } + + /** + * Download a media file. + */ + protected function downloadMedia(string $url, string $filename): ?string + { + try { + $response = Http::timeout(60)->get($url); + + if ($response->failed()) { + $this->warn("Failed to download: {$url}"); + + return null; + } + + $path = "imports/{$this->workspace->slug}/".date('Y/m')."/{$filename}"; + + Storage::disk('content-media')->put($path, $response->body()); + + return $path; + } catch (\Exception $e) { + $this->warn("Error downloading {$url}: {$e->getMessage()}"); + + return null; + } + } + + /** + * Import posts. + */ + protected function importPosts(): void + { + $this->info('Importing posts...'); + $this->importContentType('posts'); + } + + /** + * Import pages. + */ + protected function importPages(): void + { + $this->info('Importing pages...'); + $this->importContentType('pages'); + } + + /** + * Import content of a specific type (posts or pages). + */ + protected function importContentType(string $type): void + { + $page = 1; + $imported = 0; + $progressStarted = false; + $params = [ + 'page' => $page, + 'per_page' => 50, + 'status' => 'any', + '_embed' => true, + ]; + + if ($this->since) { + $params['modified_after'] = $this->since->toIso8601String(); + } + + do { + $params['page'] = $page; + $response = $this->client()->get("/{$type}", $params); + + if ($response->failed()) { + $this->warn("Failed to fetch {$type} page {$page}"); + break; + } + + $items = $response->json(); + $total = (int) $response->header('X-WP-Total', count($items)); + + if (empty($items)) { + break; + } + + if (! $progressStarted) { + $this->output->progressStart(min($total, $this->limit ?? $total)); + $progressStarted = true; + } + + foreach ($items as $item) { + $result = $this->importContentItem($item, $type === 'posts' ? 'post' : 'page'); + $this->stats[$type][$result]++; + $imported++; + $this->output->progressAdvance(); + + if ($this->limit && $imported >= $this->limit) { + break 2; + } + } + + $page++; + $hasMore = count($items) === 50; + } while ($hasMore); + + if ($progressStarted) { + $this->output->progressFinish(); + } + $this->newLine(); + } + + /** + * Import a single content item (post or page). + */ + protected function importContentItem(array $item, string $type): string + { + $wpId = $item['id']; + + // Check modification date for --since filter + if ($this->since) { + $modified = Carbon::parse($item['modified_gmt'] ?? $item['modified']); + if ($modified->lt($this->since)) { + return 'skipped'; + } + } + + // Check if already exists + $existing = ContentItem::forWorkspace($this->workspace->id) + ->where('wp_id', $wpId) + ->first(); + + // Map WordPress status to our status + $status = match ($item['status']) { + 'publish' => 'publish', + 'draft' => 'draft', + 'pending' => 'pending', + 'future' => 'future', + 'private' => 'private', + default => 'draft', + }; + + // Get author ID from map + $authorId = null; + if (isset($item['author'])) { + $authorId = $this->authorMap[$item['author']] ?? null; + } + + // Get featured media ID from map + $featuredMediaId = null; + if (isset($item['featured_media']) && $item['featured_media'] > 0) { + $featuredMediaId = $item['featured_media']; + } + + $data = [ + 'workspace_id' => $this->workspace->id, + 'content_type' => ContentType::WORDPRESS->value, // Mark as WordPress import + 'wp_id' => $wpId, + 'wp_guid' => $item['guid']['rendered'] ?? null, + 'type' => $type, + 'status' => $status, + 'slug' => $item['slug'] ?? Str::slug($item['title']['rendered'] ?? 'untitled-'.$wpId), + 'title' => $this->decodeText($item['title']['rendered'] ?? ''), + 'excerpt' => strip_tags($item['excerpt']['rendered'] ?? ''), + 'content_html_original' => $item['content']['rendered'] ?? '', + 'content_html_clean' => $this->cleanHtml($item['content']['rendered'] ?? ''), + 'content_html' => $item['content']['rendered'] ?? '', + 'author_id' => $authorId, + 'featured_media_id' => $featuredMediaId, + 'wp_created_at' => Carbon::parse($item['date_gmt'] ?? $item['date']), + 'wp_modified_at' => Carbon::parse($item['modified_gmt'] ?? $item['modified']), + 'sync_status' => 'synced', + 'synced_at' => now(), + ]; + + // Handle scheduled posts + if ($status === 'future' && isset($item['date_gmt'])) { + $data['publish_at'] = Carbon::parse($item['date_gmt']); + } + + // Extract SEO from Yoast or other plugins + $seoMeta = $this->extractSeoMeta($item); + if (! empty($seoMeta)) { + $data['seo_meta'] = $seoMeta; + } + + if ($this->dryRun) { + return $existing ? 'skipped' : 'created'; + } + + if ($existing) { + $existing->update($data); + $contentItem = $existing; + } else { + $contentItem = ContentItem::create($data); + } + + // Sync categories + if ($type === 'post' && isset($item['categories'])) { + $categoryIds = collect($item['categories']) + ->map(fn ($wpId) => $this->categoryMap[$wpId] ?? null) + ->filter() + ->values() + ->all(); + + if (! empty($categoryIds)) { + $contentItem->taxonomies()->syncWithoutDetaching($categoryIds); + } + } + + // Sync tags + if ($type === 'post' && isset($item['tags'])) { + $tagIds = collect($item['tags']) + ->map(fn ($wpId) => $this->tagMap[$wpId] ?? null) + ->filter() + ->values() + ->all(); + + if (! empty($tagIds)) { + $contentItem->taxonomies()->syncWithoutDetaching($tagIds); + } + } + + return $existing ? 'updated' : 'created'; + } + + /** + * Clean HTML content (remove WordPress-specific markup). + */ + protected function cleanHtml(string $html): string + { + // Remove WordPress block comments + $html = preg_replace('//s', '', $html); + + // Remove empty paragraphs + $html = preg_replace('/

\s*<\/p>/i', '', $html); + + // Clean up multiple newlines + $html = preg_replace('/\n{3,}/', "\n\n", $html); + + return trim($html); + } + + /** + * Decode HTML entities and normalize smart quotes to ASCII. + */ + protected function decodeText(string $text): string + { + // Decode HTML entities (including numeric like ’) + $decoded = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Normalize smart quotes and other typographic characters to ASCII + $search = [ + "\u{2019}", // RIGHT SINGLE QUOTATION MARK + "\u{2018}", // LEFT SINGLE QUOTATION MARK + "\u{201C}", // LEFT DOUBLE QUOTATION MARK + "\u{201D}", // RIGHT DOUBLE QUOTATION MARK + "\u{2013}", // EN DASH + "\u{2014}", // EM DASH + "\u{2026}", // HORIZONTAL ELLIPSIS + ]; + $replace = ["'", "'", '"', '"', '-', '-', '...']; + + return str_replace($search, $replace, $decoded); + } + + /** + * Extract SEO metadata from WordPress item. + */ + protected function extractSeoMeta(array $item): array + { + $seo = []; + + // Check for Yoast SEO data in _yoast_wpseo meta + if (isset($item['yoast_head_json'])) { + $yoast = $item['yoast_head_json']; + $seo['title'] = $yoast['title'] ?? null; + $seo['description'] = $yoast['description'] ?? null; + $seo['og_image'] = $yoast['og_image'][0]['url'] ?? null; + $seo['canonical'] = $yoast['canonical'] ?? null; + $seo['robots'] = $yoast['robots'] ?? null; + } + + // Check for RankMath + if (isset($item['rank_math_seo'])) { + $rm = $item['rank_math_seo']; + $seo['title'] = $rm['title'] ?? $seo['title'] ?? null; + $seo['description'] = $rm['description'] ?? $seo['description'] ?? null; + } + + // Filter out null values + return array_filter($seo); + } + + /** + * Display import summary. + */ + protected function displaySummary(): void + { + $this->newLine(); + $this->info('Import Summary'); + $this->info('=============='); + + $rows = []; + foreach ($this->stats as $type => $counts) { + $rows[] = [ + ucfirst($type), + $counts['created'], + $counts['updated'], + $counts['skipped'], + ($counts['downloaded'] ?? 0) ?: '-', + ]; + } + + $this->table( + ['Type', 'Created', 'Updated', 'Skipped', 'Downloaded'], + $rows + ); + + if ($this->dryRun) { + $this->newLine(); + $this->warn('This was a dry run. No changes were made.'); + } + } +} diff --git a/Console/Commands/ContentValidate.php b/Console/Commands/ContentValidate.php new file mode 100644 index 0000000..90cc1fd --- /dev/null +++ b/Console/Commands/ContentValidate.php @@ -0,0 +1,343 @@ +argument('batch'); + $fix = $this->option('fix'); + $strict = $this->option('strict'); + + if (! $batchId) { + return $this->showUsage(); + } + + if ($batchId === 'all') { + return $this->validateAllDrafts($fix, $strict); + } + + return $this->validateBatch($batchId, $fix, $strict); + } + + protected function showUsage(): int + { + $this->info('Content Validation Tool'); + $this->newLine(); + $this->line('Usage:'); + $this->line(' php artisan content:validate batch-001 - Validate specific batch'); + $this->line(' php artisan content:validate all - Validate all drafts'); + $this->line(' php artisan content:validate all --fix - Auto-fix simple issues'); + $this->newLine(); + + // Show available batches + $batches = $this->batchService->listBatches(); + if (! empty($batches)) { + $this->info('Available batches:'); + foreach ($batches as $batch) { + $this->line(" - {$batch['id']}"); + } + } + + return self::SUCCESS; + } + + protected function validateBatch(string $batchId, bool $fix, bool $strict): int + { + $this->info("Validating batch: {$batchId}"); + $this->newLine(); + + $spec = $this->batchService->loadBatch($batchId); + + if (! $spec) { + $this->error("Batch not found: {$batchId}"); + + return self::FAILURE; + } + + $results = [ + 'valid' => 0, + 'errors' => 0, + 'warnings' => 0, + 'missing' => 0, + 'fixed' => 0, + ]; + + foreach ($spec['articles'] ?? [] as $article) { + $slug = $article['slug'] ?? null; + if (! $slug) { + continue; + } + + $draftPath = $this->findDraft($slug); + + if (! $draftPath) { + $this->line(" ? {$slug} - No draft found"); + $results['missing']++; + + continue; + } + + $validation = $this->batchService->validateDraft($draftPath); + + if ($fix && ! empty($validation['errors'])) { + $fixedCount = $this->attemptFixes($draftPath, $validation); + $results['fixed'] += $fixedCount; + + if ($fixedCount > 0) { + // Re-validate after fixes + $validation = $this->batchService->validateDraft($draftPath); + } + } + + $this->displayValidationResult($slug, $validation); + + if ($validation['valid'] && empty($validation['warnings'])) { + $results['valid']++; + } elseif ($validation['valid']) { + $results['warnings']++; + } else { + $results['errors']++; + } + } + + $this->newLine(); + $this->displaySummary($results); + + if ($results['errors'] > 0) { + return self::FAILURE; + } + + if ($strict && $results['warnings'] > 0) { + return self::FAILURE; + } + + return self::SUCCESS; + } + + protected function validateAllDrafts(bool $fix, bool $strict): int + { + $this->info('Validating all content drafts'); + $this->newLine(); + + $draftsPath = base_path('doc/phase42/drafts'); + $files = $this->findAllDrafts($draftsPath); + + if (empty($files)) { + $this->warn('No draft files found'); + + return self::SUCCESS; + } + + $this->line('Found '.count($files).' draft files'); + $this->newLine(); + + $results = [ + 'valid' => 0, + 'errors' => 0, + 'warnings' => 0, + 'fixed' => 0, + ]; + + foreach ($files as $file) { + $slug = pathinfo($file, PATHINFO_FILENAME); + $relativePath = str_replace(base_path().'/', '', $file); + + $validation = $this->batchService->validateDraft($file); + + if ($fix && ! empty($validation['errors'])) { + $fixedCount = $this->attemptFixes($file, $validation); + $results['fixed'] += $fixedCount; + + if ($fixedCount > 0) { + $validation = $this->batchService->validateDraft($file); + } + } + + $this->displayValidationResult($relativePath, $validation); + + if ($validation['valid'] && empty($validation['warnings'])) { + $results['valid']++; + } elseif ($validation['valid']) { + $results['warnings']++; + } else { + $results['errors']++; + } + } + + $this->newLine(); + $this->displaySummary($results); + + if ($results['errors'] > 0) { + return self::FAILURE; + } + + if ($strict && $results['warnings'] > 0) { + return self::FAILURE; + } + + return self::SUCCESS; + } + + protected function displayValidationResult(string $identifier, array $validation): void + { + if ($validation['valid'] && empty($validation['warnings'])) { + $this->line(" {$identifier} - Valid ({$validation['word_count']} words)"); + + return; + } + + if ($validation['valid']) { + $this->line(" ! {$identifier} - Valid with warnings"); + } else { + $this->line(" {$identifier} - Invalid"); + } + + foreach ($validation['errors'] as $error) { + $this->line(" Error: {$error}"); + } + + foreach ($validation['warnings'] as $warning) { + $this->line(" Warning: {$warning}"); + } + } + + protected function displaySummary(array $results): void + { + $this->info('Validation Summary:'); + $this->table( + ['Valid', 'Errors', 'Warnings', 'Missing', 'Fixed'], + [[ + $results['valid'], + $results['errors'], + $results['warnings'], + $results['missing'] ?? 0, + $results['fixed'], + ]] + ); + } + + protected function attemptFixes(string $path, array $validation): int + { + $content = File::get($path); + $fixed = 0; + + // Fix US to UK spellings + $spellingFixes = [ + 'color' => 'colour', + 'customize' => 'customise', + 'customization' => 'customisation', + 'organize' => 'organise', + 'organization' => 'organisation', + 'optimize' => 'optimise', + 'optimization' => 'optimisation', + 'analyze' => 'analyse', + 'analyzing' => 'analysing', + 'behavior' => 'behaviour', + 'favor' => 'favour', + 'favorite' => 'favourite', + 'center' => 'centre', + 'theater' => 'theatre', + 'catalog' => 'catalogue', + 'dialog' => 'dialogue', + 'fulfill' => 'fulfil', + 'license' => 'licence', // noun form + 'practice' => 'practise', // verb form - careful with this one + ]; + + foreach ($spellingFixes as $us => $uk) { + $count = substr_count(strtolower($content), $us); + if ($count > 0) { + $content = preg_replace('/\b'.preg_quote($us, '/').'\b/i', $uk, $content); + $fixed += $count; + } + } + + // Replace banned words with alternatives + $bannedReplacements = [ + 'leverage' => 'use', + 'leveraging' => 'using', + 'utilize' => 'use', + 'utilizing' => 'using', + 'utilization' => 'use', + 'synergy' => 'collaboration', + 'synergies' => 'efficiencies', + 'cutting-edge' => 'modern', + 'revolutionary' => 'new', + 'seamless' => 'smooth', + 'seamlessly' => 'smoothly', + 'robust' => 'reliable', + ]; + + foreach ($bannedReplacements as $banned => $replacement) { + $count = substr_count(strtolower($content), $banned); + if ($count > 0) { + $content = preg_replace('/\b'.preg_quote($banned, '/').'\b/i', $replacement, $content); + $fixed += $count; + } + } + + if ($fixed > 0) { + File::put($path, $content); + } + + return $fixed; + } + + protected function findDraft(string $slug): ?string + { + $basePath = base_path('doc/phase42/drafts'); + $patterns = [ + "{$basePath}/help/**/{$slug}.md", + "{$basePath}/blog/**/{$slug}.md", + "{$basePath}/**/{$slug}.md", + ]; + + foreach ($patterns as $pattern) { + $matches = glob($pattern); + if (! empty($matches)) { + return $matches[0]; + } + } + + return null; + } + + protected function findAllDrafts(string $path): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'md') { + $files[] = $file->getPathname(); + } + } + + sort($files); + + return $files; + } +} diff --git a/Console/Commands/ProcessPendingWebhooks.php b/Console/Commands/ProcessPendingWebhooks.php new file mode 100644 index 0000000..36713e5 --- /dev/null +++ b/Console/Commands/ProcessPendingWebhooks.php @@ -0,0 +1,90 @@ +option('batch'); + $dryRun = $this->option('dry-run'); + + $webhooks = $service->getRetryableWebhooks($batchSize); + $count = $webhooks->count(); + + if ($count === 0) { + $this->info('No pending webhooks to process.'); + + return self::SUCCESS; + } + + $this->info(sprintf( + '%s %d webhook(s)...', + $dryRun ? 'Would process' : 'Processing', + $count + )); + + if ($dryRun) { + $this->table( + ['ID', 'Event', 'Workspace', 'Retry #', 'Scheduled For', 'Last Error'], + $webhooks->map(fn ($wh) => [ + $wh->id, + $wh->event_type, + $wh->workspace_id ?? 'N/A', + $wh->retry_count + 1, + $wh->next_retry_at?->format('Y-m-d H:i:s'), + mb_substr($wh->last_error ?? '-', 0, 40), + ]) + ); + + return self::SUCCESS; + } + + $succeeded = 0; + $failed = 0; + + $this->withProgressBar($webhooks, function ($webhook) use ($service, &$succeeded, &$failed) { + $result = $service->retry($webhook); + + if ($result) { + $succeeded++; + } else { + $failed++; + } + }); + + $this->newLine(2); + $this->info("Processed: {$count}, Succeeded: {$succeeded}, Failed: {$failed}"); + + // Log summary + Log::info('Webhook retry batch completed', [ + 'processed' => $count, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'pending_remaining' => $service->countPendingRetries(), + ]); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/Console/Commands/PruneContentRevisions.php b/Console/Commands/PruneContentRevisions.php new file mode 100644 index 0000000..b9514e4 --- /dev/null +++ b/Console/Commands/PruneContentRevisions.php @@ -0,0 +1,116 @@ +option('dry-run'); + $maxRevisions = $this->option('max-revisions') + ? (int) $this->option('max-revisions') + : config('content.revisions.max_per_item', 50); + $maxAgeDays = $this->option('max-age') + ? (int) $this->option('max-age') + : config('content.revisions.max_age_days', 180); + + $this->info('Content Revision Pruning'); + $this->info('========================'); + $this->newLine(); + $this->line("Max revisions per item: {$maxRevisions}"); + $this->line("Max age (days): {$maxAgeDays}"); + + if ($dryRun) { + $this->warn('DRY RUN - No changes will be made'); + $this->newLine(); + } + + // Get statistics before pruning + $totalRevisions = ContentRevision::count(); + $contentItemIds = ContentRevision::distinct()->pluck('content_item_id'); + + $this->line("Total revisions: {$totalRevisions}"); + $this->line("Content items with revisions: {$contentItemIds->count()}"); + $this->newLine(); + + if ($dryRun) { + // Calculate what would be deleted + $wouldDelete = 0; + + foreach ($contentItemIds as $contentItemId) { + $count = $this->countPrunableRevisions($contentItemId, $maxRevisions, $maxAgeDays); + $wouldDelete += $count; + } + + $this->info("Would delete: {$wouldDelete} revisions"); + + return self::SUCCESS; + } + + // Perform the pruning + $this->output->write('Pruning revisions... '); + + $result = ContentRevision::pruneAll(); + + $this->info('Done'); + $this->newLine(); + $this->table( + ['Metric', 'Value'], + [ + ['Content items processed', $result['items_processed']], + ['Revisions deleted', $result['revisions_deleted']], + ['Revisions remaining', ContentRevision::count()], + ] + ); + + return self::SUCCESS; + } + + /** + * Count revisions that would be pruned for a content item. + */ + protected function countPrunableRevisions(int $contentItemId, int $maxRevisions, int $maxAgeDays): int + { + $count = 0; + + // Count revisions older than max age + if ($maxAgeDays > 0) { + $count += ContentRevision::where('content_item_id', $contentItemId) + ->where('change_type', '!=', ContentRevision::CHANGE_PUBLISH) + ->where('created_at', '<', now()->subDays($maxAgeDays)) + ->count(); + } + + // Count excess revisions + if ($maxRevisions > 0) { + $total = ContentRevision::where('content_item_id', $contentItemId)->count(); + if ($total > $maxRevisions) { + // This is approximate - actual count depends on overlap with age-based deletions + $count += max(0, $total - $maxRevisions); + } + } + + return $count; + } +} diff --git a/Console/Commands/PublishScheduledContent.php b/Console/Commands/PublishScheduledContent.php new file mode 100644 index 0000000..cf5eee0 --- /dev/null +++ b/Console/Commands/PublishScheduledContent.php @@ -0,0 +1,96 @@ +option('dry-run'); + $limit = (int) $this->option('limit'); + + $query = ContentItem::readyToPublish()->limit($limit); + + $count = $query->count(); + + if ($count === 0) { + $this->info('No scheduled content ready to publish.'); + + return self::SUCCESS; + } + + $this->info(sprintf( + '%s %d scheduled content item(s)...', + $dryRun ? 'Would publish' : 'Publishing', + $count + )); + + if ($dryRun) { + $items = $query->get(); + $this->table( + ['ID', 'Title', 'Workspace', 'Scheduled For'], + $items->map(fn ($item) => [ + $item->id, + mb_substr($item->title, 0, 50), + $item->workspace_id, + $item->publish_at->format('Y-m-d H:i:s'), + ]) + ); + + return self::SUCCESS; + } + + $published = 0; + $failed = 0; + + $query->each(function (ContentItem $item) use (&$published, &$failed) { + try { + $item->update([ + 'status' => 'publish', + ]); + + Log::info('Auto-published scheduled content', [ + 'content_item_id' => $item->id, + 'title' => $item->title, + 'workspace_id' => $item->workspace_id, + 'scheduled_for' => $item->publish_at?->toIso8601String(), + ]); + + $published++; + $this->line(" Published: {$item->title}"); + } catch (\Exception $e) { + $failed++; + Log::error('Failed to auto-publish scheduled content', [ + 'content_item_id' => $item->id, + 'error' => $e->getMessage(), + ]); + $this->error(" Failed: {$item->title} - {$e->getMessage()}"); + } + }); + + $this->newLine(); + $this->info("Published: {$published}, Failed: {$failed}"); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/Controllers/Api/ContentBriefController.php b/Controllers/Api/ContentBriefController.php new file mode 100644 index 0000000..26334c0 --- /dev/null +++ b/Controllers/Api/ContentBriefController.php @@ -0,0 +1,262 @@ +resolveWorkspace($request); + + $query = ContentBrief::query(); + + // Scope to workspace if provided + if ($workspace) { + $query->where('workspace_id', $workspace->id); + } elseif (! $request->user()?->is_admin) { + // Non-admin users must have a workspace + return $this->noWorkspaceResponse(); + } + + // Filter by status + if ($request->has('status')) { + $query->where('status', $request->input('status')); + } + + // Filter by content type + if ($request->has('content_type')) { + $query->where('content_type', $request->input('content_type')); + } + + // Filter by service + if ($request->has('service')) { + $query->where('service', $request->input('service')); + } + + // Sorting + $sortBy = in_array($request->input('sort_by'), ['created_at', 'updated_at', 'priority', 'title'], true) + ? $request->input('sort_by') + : 'created_at'; + $sortDir = strtolower($request->input('sort_dir', 'desc')) === 'asc' ? 'asc' : 'desc'; + + $briefs = $query->orderBy($sortBy, $sortDir) + ->paginate($request->input('per_page', 20)); + + return response()->json([ + 'data' => ContentBriefResource::collection($briefs->items()), + 'meta' => [ + 'current_page' => $briefs->currentPage(), + 'last_page' => $briefs->lastPage(), + 'per_page' => $briefs->perPage(), + 'total' => $briefs->total(), + ], + ]); + } + + /** + * Create a new brief. + * + * POST /api/v1/content/briefs + */ + public function store(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + $validated = $request->validate([ + 'content_type' => 'required|string|in:help_article,blog_post,landing_page,social_post', + 'title' => 'required|string|max:255', + 'slug' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'keywords' => 'nullable|array', + 'keywords.*' => 'string', + 'category' => 'nullable|string|max:100', + 'difficulty' => 'nullable|string|in:beginner,intermediate,advanced', + 'target_word_count' => 'nullable|integer|min:100|max:10000', + 'service' => 'nullable|string', + 'priority' => 'nullable|integer|min:1|max:100', + 'prompt_variables' => 'nullable|array', + 'scheduled_for' => 'nullable|date', + ]); + + $brief = ContentBrief::create([ + ...$validated, + 'workspace_id' => $workspace?->id, + 'target_word_count' => $validated['target_word_count'] ?? 1000, + 'priority' => $validated['priority'] ?? 50, + ]); + + return $this->createdResponse( + new ContentBriefResource($brief), + 'Content brief created successfully.' + ); + } + + /** + * Get a specific brief. + * + * GET /api/v1/content/briefs/{brief} + */ + public function show(Request $request, ContentBrief $brief): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + return response()->json([ + 'data' => new ContentBriefResource($brief->load('aiUsage')), + ]); + } + + /** + * Update a brief. + * + * PUT /api/v1/content/briefs/{brief} + */ + public function update(Request $request, ContentBrief $brief): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'slug' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'keywords' => 'nullable|array', + 'keywords.*' => 'string', + 'category' => 'nullable|string|max:100', + 'difficulty' => 'nullable|string|in:beginner,intermediate,advanced', + 'target_word_count' => 'nullable|integer|min:100|max:10000', + 'service' => 'nullable|string', + 'priority' => 'nullable|integer|min:1|max:100', + 'prompt_variables' => 'nullable|array', + 'scheduled_for' => 'nullable|date', + 'status' => 'sometimes|string|in:pending,queued,review,published', + 'final_content' => 'nullable|string', + ]); + + $brief->update($validated); + + return response()->json([ + 'message' => 'Content brief updated successfully.', + 'data' => new ContentBriefResource($brief), + ]); + } + + /** + * Delete a brief. + * + * DELETE /api/v1/content/briefs/{brief} + */ + public function destroy(Request $request, ContentBrief $brief): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + $brief->delete(); + + return $this->successResponse('Content brief deleted successfully.'); + } + + /** + * Create multiple briefs in bulk. + * + * POST /api/v1/content/briefs/bulk + */ + public function bulkStore(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + $validated = $request->validate([ + 'briefs' => 'required|array|min:1|max:50', + 'briefs.*.content_type' => 'required|string|in:help_article,blog_post,landing_page,social_post', + 'briefs.*.title' => 'required|string|max:255', + 'briefs.*.slug' => 'nullable|string|max:255', + 'briefs.*.description' => 'nullable|string', + 'briefs.*.keywords' => 'nullable|array', + 'briefs.*.category' => 'nullable|string|max:100', + 'briefs.*.difficulty' => 'nullable|string|in:beginner,intermediate,advanced', + 'briefs.*.target_word_count' => 'nullable|integer|min:100|max:10000', + 'briefs.*.service' => 'nullable|string', + 'briefs.*.priority' => 'nullable|integer|min:1|max:100', + ]); + + $created = []; + foreach ($validated['briefs'] as $briefData) { + $created[] = ContentBrief::create([ + ...$briefData, + 'workspace_id' => $workspace?->id, + 'target_word_count' => $briefData['target_word_count'] ?? 1000, + 'priority' => $briefData['priority'] ?? 50, + ]); + } + + return $this->createdResponse([ + 'briefs' => ContentBriefResource::collection($created), + 'count' => count($created), + ], count($created).' briefs created successfully.'); + } + + /** + * Get the next brief ready for processing. + * + * GET /api/v1/content/briefs/next + */ + public function next(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + $query = ContentBrief::readyToProcess(); + + if ($workspace) { + $query->where('workspace_id', $workspace->id); + } + + $brief = $query->first(); + + if (! $brief) { + return response()->json([ + 'data' => null, + 'message' => 'No briefs ready for processing.', + ]); + } + + return response()->json([ + 'data' => new ContentBriefResource($brief), + ]); + } +} diff --git a/Controllers/Api/ContentMediaController.php b/Controllers/Api/ContentMediaController.php new file mode 100644 index 0000000..c03adb9 --- /dev/null +++ b/Controllers/Api/ContentMediaController.php @@ -0,0 +1,238 @@ +resolveWorkspace($request); + + if (! $workspace) { + return $this->noWorkspaceResponse(); + } + + $query = ContentMedia::forWorkspace($workspace->id); + + // Filter by type + if ($request->has('type')) { + $type = $request->input('type'); + if ($type === 'image') { + $query->images(); + } elseif ($type === 'document') { + $query->where('mime_type', 'application/pdf'); + } + } + + $media = $query->orderBy('created_at', 'desc') + ->paginate($request->input('per_page', 20)); + + return response()->json([ + 'data' => $media->items(), + 'meta' => [ + 'current_page' => $media->currentPage(), + 'last_page' => $media->lastPage(), + 'per_page' => $media->perPage(), + 'total' => $media->total(), + ], + ]); + } + + /** + * Upload a media file. + * + * POST /api/v1/content/media + */ + public function store(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if (! $workspace) { + return $this->noWorkspaceResponse(); + } + + $validated = $request->validate([ + 'file' => 'required|file|max:10240', // 10MB + 'title' => 'nullable|string|max:255', + 'alt_text' => 'nullable|string|max:500', + 'caption' => 'nullable|string|max:1000', + ]); + + $file = $request->file('file'); + $mimeType = $file->getMimeType(); + + // Validate MIME type + if (! in_array($mimeType, $this->allowedTypes, true)) { + return $this->validationErrorResponse([ + 'file' => ['File type not allowed. Allowed types: JPEG, PNG, GIF, WebP, SVG, PDF.'], + ]); + } + + // Generate unique filename + $extension = $file->getClientOriginalExtension(); + $filename = Str::uuid().'.'.$extension; + + // Store in workspace-specific path + $path = sprintf( + 'content/%d/%s/%s', + $workspace->id, + now()->format('Y/m'), + $filename + ); + + // Store file + Storage::disk('public')->put($path, file_get_contents($file->getRealPath())); + + // Get image dimensions if applicable + $width = null; + $height = null; + + if (str_starts_with($mimeType, 'image/') && $mimeType !== 'image/svg+xml') { + $imageInfo = @getimagesize($file->getRealPath()); + if ($imageInfo !== false) { + [$width, $height] = $imageInfo; + } + } + + // Create media record + $media = ContentMedia::create([ + 'workspace_id' => $workspace->id, + 'wp_id' => null, + 'title' => $validated['title'] ?? $file->getClientOriginalName(), + 'filename' => $filename, + 'mime_type' => $mimeType, + 'file_size' => $file->getSize(), + 'source_url' => Storage::disk('public')->url($path), + 'cdn_url' => null, + 'width' => $width, + 'height' => $height, + 'alt_text' => $validated['alt_text'] ?? null, + 'caption' => $validated['caption'] ?? null, + 'sizes' => null, + ]); + + return $this->createdResponse([ + 'id' => $media->id, + 'url' => $media->url, + 'filename' => $media->filename, + 'mime_type' => $media->mime_type, + 'file_size' => $media->file_size, + 'width' => $media->width, + 'height' => $media->height, + 'title' => $media->title, + 'alt_text' => $media->alt_text, + ], 'Media uploaded successfully.'); + } + + /** + * Get a specific media item. + * + * GET /api/v1/content/media/{media} + */ + public function show(Request $request, ContentMedia $media): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if ($media->workspace_id !== $workspace?->id) { + return $this->accessDeniedResponse(); + } + + return response()->json([ + 'data' => $media, + ]); + } + + /** + * Update media metadata. + * + * PUT /api/v1/content/media/{media} + */ + public function update(Request $request, ContentMedia $media): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if ($media->workspace_id !== $workspace?->id) { + return $this->accessDeniedResponse(); + } + + $validated = $request->validate([ + 'title' => 'nullable|string|max:255', + 'alt_text' => 'nullable|string|max:500', + 'caption' => 'nullable|string|max:1000', + ]); + + $media->update($validated); + + return response()->json([ + 'message' => 'Media updated successfully.', + 'data' => $media, + ]); + } + + /** + * Delete a media item. + * + * DELETE /api/v1/content/media/{media} + */ + public function destroy(Request $request, ContentMedia $media): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if ($media->workspace_id !== $workspace?->id) { + return $this->accessDeniedResponse(); + } + + // Delete file from storage if it's a local upload (not WordPress) + if ($media->wp_id === null && $media->source_url) { + $path = str_replace(Storage::disk('public')->url(''), '', $media->source_url); + Storage::disk('public')->delete($path); + } + + $media->delete(); + + return $this->successResponse('Media deleted successfully.'); + } +} diff --git a/Controllers/Api/ContentRevisionController.php b/Controllers/Api/ContentRevisionController.php new file mode 100644 index 0000000..840c2f1 --- /dev/null +++ b/Controllers/Api/ContentRevisionController.php @@ -0,0 +1,186 @@ +resolveWorkspace($request); + + // Check user has access to this content item + if (! $this->canAccessContentItem($item, $workspace, $request)) { + return $this->accessDeniedResponse(); + } + + $query = $item->revisions(); + + // Filter by change type + if ($request->has('change_type')) { + $query->where('change_type', $request->input('change_type')); + } + + // Exclude autosaves by default (can be overridden) + if (! $request->boolean('include_autosaves')) { + $query->withoutAutosaves(); + } + + // Pagination + $perPage = min((int) $request->input('per_page', 20), 100); + $revisions = $query->with('user')->paginate($perPage); + + return response()->json([ + 'data' => ContentRevisionResource::collection($revisions->items()), + 'meta' => [ + 'current_page' => $revisions->currentPage(), + 'last_page' => $revisions->lastPage(), + 'per_page' => $revisions->perPage(), + 'total' => $revisions->total(), + ], + ]); + } + + /** + * Get a specific revision with diff summary. + * + * GET /api/v1/content/revisions/{revision} + */ + public function show(Request $request, ContentRevision $revision): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + // Load the content item to check access + $revision->load('contentItem', 'user'); + + if (! $this->canAccessContentItem($revision->contentItem, $workspace, $request)) { + return $this->accessDeniedResponse(); + } + + // Always include diff for show endpoint + $request->merge(['include_diff' => true, 'include_content' => true]); + + // Get full diff data + $diffData = $revision->getDiff(); + + return response()->json([ + 'data' => new ContentRevisionResource($revision), + 'diff' => $diffData, + ]); + } + + /** + * Restore a content item to a specific revision. + * + * POST /api/v1/content/revisions/{revision}/restore + */ + public function restore(Request $request, ContentRevision $revision): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + // Load the content item to check access + $revision->load('contentItem'); + + if (! $this->canAccessContentItem($revision->contentItem, $workspace, $request)) { + return $this->accessDeniedResponse(); + } + + // Restore the content item to this revision's state + $restoredItem = $revision->restoreToContentItem(); + + // Get the new revision that was created during restore + $newRevision = $restoredItem->latestRevision(); + + return response()->json([ + 'message' => "Content restored to revision #{$revision->revision_number}.", + 'data' => [ + 'content_item_id' => $restoredItem->id, + 'restored_from_revision' => $revision->revision_number, + 'new_revision' => $newRevision ? new ContentRevisionResource($newRevision) : null, + ], + ]); + } + + /** + * Compare two revisions. + * + * GET /api/v1/content/revisions/{revision}/compare/{compareWith} + */ + public function compare(Request $request, ContentRevision $revision, ContentRevision $compareWith): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + // Load content items for both revisions + $revision->load('contentItem'); + $compareWith->load('contentItem'); + + // Ensure both revisions belong to the same content item + if ($revision->content_item_id !== $compareWith->content_item_id) { + return response()->json([ + 'error' => 'invalid_comparison', + 'message' => 'Cannot compare revisions from different content items.', + ], 400); + } + + if (! $this->canAccessContentItem($revision->contentItem, $workspace, $request)) { + return $this->accessDeniedResponse(); + } + + // Get diff between the two specified revisions + $diffData = $revision->getDiff($compareWith); + + return response()->json([ + 'data' => [ + 'from' => new ContentRevisionResource($compareWith), + 'to' => new ContentRevisionResource($revision), + ], + 'diff' => $diffData, + ]); + } + + /** + * Check if user can access a content item. + */ + protected function canAccessContentItem(ContentItem $item, $workspace, Request $request): bool + { + // Admin users can access any content + if ($request->user()?->is_admin) { + return true; + } + + // Check workspace ownership + if ($item->workspace_id && $workspace?->id !== $item->workspace_id) { + return false; + } + + // No workspace on item and no workspace context = allow (system content) + if (! $item->workspace_id && ! $workspace) { + return true; + } + + return true; + } +} diff --git a/Controllers/Api/ContentSearchController.php b/Controllers/Api/ContentSearchController.php new file mode 100644 index 0000000..70b2c5b --- /dev/null +++ b/Controllers/Api/ContentSearchController.php @@ -0,0 +1,195 @@ +resolveWorkspace($request); + + if (! $workspace && ! $request->user()?->is_admin) { + return $this->noWorkspaceResponse(); + } + + $validated = $request->validate([ + 'q' => 'required|string|min:2|max:500', + 'type' => 'nullable|string|in:post,page', + 'status' => 'nullable', + 'category' => 'nullable|string|max:100', + 'tag' => 'nullable|string|max:100', + 'content_type' => 'nullable|string|in:native,hostuk,satellite,wordpress', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date|after_or_equal:date_from', + 'per_page' => 'nullable|integer|min:1|max:50', + 'page' => 'nullable|integer|min:1', + ]); + + // Normalise status to array if provided + $status = $validated['status'] ?? null; + if (is_string($status) && str_contains($status, ',')) { + $status = array_map('trim', explode(',', $status)); + } + + $filters = [ + 'workspace_id' => $workspace?->id, + 'type' => $validated['type'] ?? null, + 'status' => $status, + 'category' => $validated['category'] ?? null, + 'tag' => $validated['tag'] ?? null, + 'content_type' => $validated['content_type'] ?? null, + 'date_from' => $validated['date_from'] ?? null, + 'date_to' => $validated['date_to'] ?? null, + 'per_page' => $validated['per_page'] ?? 20, + 'page' => $validated['page'] ?? 1, + ]; + + // Remove null filters + $filters = array_filter($filters, fn ($v) => $v !== null); + + $results = $this->searchService->search($validated['q'], $filters); + + return response()->json( + $this->searchService->formatForApi($results) + ); + } + + /** + * Get search suggestions for autocomplete. + * + * GET /api/v1/content/search/suggest + * + * @queryParam q string required The partial search query (minimum 2 characters) + * @queryParam limit int Maximum suggestions to return (default 10, max 20) + */ + public function suggest(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if (! $workspace) { + return $this->noWorkspaceResponse(); + } + + $validated = $request->validate([ + 'q' => 'required|string|min:2|max:100', + 'limit' => 'nullable|integer|min:1|max:20', + ]); + + $suggestions = $this->searchService->suggest( + $validated['q'], + $workspace->id, + $validated['limit'] ?? 10 + ); + + return response()->json([ + 'data' => $suggestions->all(), + 'meta' => [ + 'query' => $validated['q'], + 'count' => $suggestions->count(), + ], + ]); + } + + /** + * Get search backend information. + * + * GET /api/v1/content/search/info + * + * Returns information about the current search backend and capabilities. + */ + public function info(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if (! $workspace && ! $request->user()?->is_admin) { + return $this->noWorkspaceResponse(); + } + + return response()->json([ + 'data' => [ + 'backend' => $this->searchService->getBackend(), + 'scout_available' => $this->searchService->isScoutAvailable(), + 'meilisearch_available' => $this->searchService->isMeilisearchAvailable(), + 'min_query_length' => 2, + 'max_per_page' => 50, + 'filterable_fields' => [ + 'type' => ['post', 'page'], + 'status' => ['draft', 'publish', 'future', 'private', 'pending'], + 'content_type' => ['native', 'hostuk', 'satellite', 'wordpress'], + 'category' => 'string (slug)', + 'tag' => 'string (slug)', + 'date_from' => 'date (Y-m-d)', + 'date_to' => 'date (Y-m-d)', + ], + ], + ]); + } + + /** + * Trigger re-indexing of content (admin only). + * + * POST /api/v1/content/search/reindex + * + * Re-indexes all content items for the workspace. + * Only available when using Scout backend. + */ + public function reindex(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if (! $request->user()?->is_admin && ! $workspace) { + return $this->accessDeniedResponse(); + } + + if (! $this->searchService->isScoutAvailable()) { + return response()->json([ + 'error' => 'Scout is not available. Re-indexing is only supported with Scout backend.', + ], 400); + } + + $count = $this->searchService->reindex($workspace); + + return response()->json([ + 'message' => "Re-indexed {$count} content items.", + 'count' => $count, + ]); + } +} diff --git a/Controllers/Api/ContentWebhookController.php b/Controllers/Api/ContentWebhookController.php new file mode 100644 index 0000000..74e8f07 --- /dev/null +++ b/Controllers/Api/ContentWebhookController.php @@ -0,0 +1,323 @@ +isEnabled()) { + Log::warning('Content webhook received for disabled endpoint', [ + 'endpoint_id' => $endpoint->id, + 'endpoint_uuid' => $endpoint->uuid, + ]); + + return response('Endpoint disabled', 403); + } + + // Check circuit breaker + if ($endpoint->isCircuitBroken()) { + Log::warning('Content webhook endpoint circuit breaker open', [ + 'endpoint_id' => $endpoint->id, + 'failure_count' => $endpoint->failure_count, + ]); + + return response('Service unavailable', 503); + } + + // Get raw payload + $payload = $request->getContent(); + + // Verify signature if secret is configured + $signature = $this->extractSignature($request); + if (! $endpoint->verifySignature($payload, $signature)) { + Log::warning('Content webhook signature verification failed', [ + 'endpoint_id' => $endpoint->id, + 'source_ip' => $request->ip(), + ]); + + return response('Invalid signature', 401); + } + + // Parse payload + $data = json_decode($payload, true); + if (json_last_error() !== JSON_ERROR_NONE) { + Log::warning('Content webhook invalid JSON payload', [ + 'endpoint_id' => $endpoint->id, + 'error' => json_last_error_msg(), + ]); + + return response('Invalid JSON payload', 400); + } + + // Determine event type + $eventType = $this->determineEventType($request, $data); + + // Check if event type is allowed + if (! $endpoint->isTypeAllowed($eventType)) { + Log::info('Content webhook event type not allowed', [ + 'endpoint_id' => $endpoint->id, + 'event_type' => $eventType, + 'allowed_types' => $endpoint->allowed_types, + ]); + + return response('Event type not allowed', 403); + } + + // Create webhook log entry + $log = ContentWebhookLog::create([ + 'workspace_id' => $endpoint->workspace_id, + 'endpoint_id' => $endpoint->id, + 'event_type' => $eventType, + 'wp_id' => $this->extractContentId($data), + 'content_type' => $this->extractContentType($data), + 'payload' => $data, + 'status' => 'pending', + 'source_ip' => $request->ip(), + ]); + + Log::info('Content webhook received', [ + 'log_id' => $log->id, + 'endpoint_id' => $endpoint->id, + 'event_type' => $eventType, + 'workspace_id' => $endpoint->workspace_id, + ]); + + // Update endpoint last received timestamp + $endpoint->markReceived(); + + // Dispatch job for async processing + ProcessContentWebhook::dispatch($log); + + return response('Accepted', 202); + } + + /** + * Extract signature from request headers. + * + * Supports multiple header formats. + */ + protected function extractSignature(Request $request): ?string + { + // Try various signature header formats + $signatureHeaders = [ + 'X-Signature', + 'X-Hub-Signature-256', + 'X-WP-Webhook-Signature', + 'X-Content-Signature', + 'Signature', + ]; + + foreach ($signatureHeaders as $header) { + $value = $request->header($header); + if ($value) { + return $value; + } + } + + return null; + } + + /** + * Determine the event type from the request and payload. + */ + protected function determineEventType(Request $request, array $data): string + { + // Check explicit event type in headers + $headerEventType = $request->header('X-Event-Type') + ?? $request->header('X-WP-Webhook-Event') + ?? $request->header('X-Content-Event'); + + if ($headerEventType) { + return $this->normaliseEventType($headerEventType); + } + + // Check event type in payload + if (isset($data['event'])) { + return $this->normaliseEventType($data['event']); + } + + if (isset($data['event_type'])) { + return $this->normaliseEventType($data['event_type']); + } + + if (isset($data['action'])) { + return $this->normaliseEventType($data['action']); + } + + // WordPress-style hook name + if (isset($data['hook'])) { + return $this->mapWordPressHook($data['hook']); + } + + // Detect WordPress payload structure + if ($this->isWordPressPayload($data)) { + return $this->inferWordPressEventType($data); + } + + // Fallback to generic payload + return 'generic.payload'; + } + + /** + * Normalise event type to standard format. + */ + protected function normaliseEventType(string $eventType): string + { + // Convert underscores to dots for consistency + $normalised = str_replace('_', '.', strtolower($eventType)); + + // Map common variations + $mappings = [ + 'post.created' => 'wordpress.post_created', + 'post.updated' => 'wordpress.post_updated', + 'post.deleted' => 'wordpress.post_deleted', + 'post.published' => 'wordpress.post_published', + 'post.trashed' => 'wordpress.post_trashed', + 'content.created' => 'cms.content_created', + 'content.updated' => 'cms.content_updated', + 'content.deleted' => 'cms.content_deleted', + 'content.published' => 'cms.content_published', + ]; + + // Check if already has a namespace prefix + if (str_contains($normalised, '.')) { + $parts = explode('.', $normalised); + if (in_array($parts[0], ['wordpress', 'cms', 'generic'])) { + // Convert dots back to underscores for action part + $namespace = $parts[0]; + $action = implode('_', array_slice($parts, 1)); + + return $namespace.'.'.$action; + } + } + + return $mappings[$normalised] ?? 'generic.payload'; + } + + /** + * Map WordPress hook names to event types. + */ + protected function mapWordPressHook(string $hook): string + { + $hookMappings = [ + 'save_post' => 'wordpress.post_updated', + 'publish_post' => 'wordpress.post_published', + 'wp_insert_post' => 'wordpress.post_created', + 'before_delete_post' => 'wordpress.post_deleted', + 'wp_trash_post' => 'wordpress.post_trashed', + 'add_attachment' => 'wordpress.media_uploaded', + 'edit_attachment' => 'wordpress.media_uploaded', + ]; + + return $hookMappings[$hook] ?? 'wordpress.post_updated'; + } + + /** + * Check if payload appears to be from WordPress. + */ + protected function isWordPressPayload(array $data): bool + { + // Check for WordPress-specific fields + return isset($data['post_id']) + || isset($data['ID']) + || isset($data['post_type']) + || isset($data['post_status']) + || isset($data['guid']) + || (isset($data['data']) && isset($data['data']['post_id'])); + } + + /** + * Infer WordPress event type from payload content. + */ + protected function inferWordPressEventType(array $data): string + { + $status = $data['post_status'] + ?? $data['data']['post_status'] + ?? null; + + if ($status === 'publish') { + return 'wordpress.post_published'; + } + + if ($status === 'trash') { + return 'wordpress.post_trashed'; + } + + // Check if this looks like a new post (no modified date or same as created) + $created = $data['post_date'] ?? $data['data']['post_date'] ?? null; + $modified = $data['post_modified'] ?? $data['data']['post_modified'] ?? null; + + if ($created && $modified && $created === $modified) { + return 'wordpress.post_created'; + } + + return 'wordpress.post_updated'; + } + + /** + * Extract content ID from payload. + */ + protected function extractContentId(array $data): ?int + { + // Try various ID field names + $idFields = ['post_id', 'ID', 'id', 'content_id', 'item_id']; + + foreach ($idFields as $field) { + if (isset($data[$field])) { + return (int) $data[$field]; + } + + // Check nested data + if (isset($data['data'][$field])) { + return (int) $data['data'][$field]; + } + } + + return null; + } + + /** + * Extract content type from payload. + */ + protected function extractContentType(array $data): ?string + { + // Try various type field names + $typeFields = ['post_type', 'content_type', 'type']; + + foreach ($typeFields as $field) { + if (isset($data[$field])) { + return (string) $data[$field]; + } + + // Check nested data + if (isset($data['data'][$field])) { + return (string) $data['data'][$field]; + } + } + + return null; + } +} diff --git a/Controllers/Api/GenerationController.php b/Controllers/Api/GenerationController.php new file mode 100644 index 0000000..381276b --- /dev/null +++ b/Controllers/Api/GenerationController.php @@ -0,0 +1,402 @@ +validate([ + 'brief_id' => 'required|exists:content_briefs,id', + 'async' => 'boolean', + 'context' => 'nullable|array', + ]); + + $brief = ContentBrief::findOrFail($validated['brief_id']); + $workspace = $this->resolveWorkspace($request); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + // Check if already generated + if ($brief->isGenerated()) { + return response()->json([ + 'message' => 'Draft already generated.', + 'data' => new ContentBriefResource($brief), + ]); + } + + // Async generation + if ($validated['async'] ?? false) { + GenerateContentJob::dispatch($brief, 'draft', $validated['context'] ?? null); + + return response()->json([ + 'message' => 'Draft generation queued.', + 'data' => new ContentBriefResource($brief->fresh()), + ], 202); + } + + // Sync generation + try { + if (! $this->gateway->isGeminiAvailable()) { + return response()->json([ + 'error' => 'service_unavailable', + 'message' => 'Gemini API is not configured.', + ], 503); + } + + $response = $this->gateway->generateDraft($brief, $validated['context'] ?? null); + + $brief->markDraftComplete($response->content, [ + 'draft' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + ], + ]); + + return response()->json([ + 'message' => 'Draft generated successfully.', + 'data' => new ContentBriefResource($brief->fresh()), + 'usage' => [ + 'model' => $response->model, + 'input_tokens' => $response->inputTokens, + 'output_tokens' => $response->outputTokens, + 'cost_estimate' => $response->estimateCost(), + 'duration_ms' => $response->durationMs, + ], + ]); + } catch (\Exception $e) { + $brief->markFailed($e->getMessage()); + + return response()->json([ + 'error' => 'generation_failed', + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * Refine draft content (Claude). + * + * POST /api/v1/content/generate/refine + */ + public function refine(Request $request): JsonResponse + { + $validated = $request->validate([ + 'brief_id' => 'required|exists:content_briefs,id', + 'async' => 'boolean', + 'context' => 'nullable|array', + ]); + + $brief = ContentBrief::findOrFail($validated['brief_id']); + $workspace = $this->resolveWorkspace($request); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + // Check if draft exists + if (! $brief->isGenerated()) { + return response()->json([ + 'error' => 'no_draft', + 'message' => 'No draft to refine. Generate a draft first.', + ], 400); + } + + // Check if already refined + if ($brief->isRefined()) { + return response()->json([ + 'message' => 'Draft already refined.', + 'data' => new ContentBriefResource($brief), + ]); + } + + // Async refinement + if ($validated['async'] ?? false) { + GenerateContentJob::dispatch($brief, 'refine', $validated['context'] ?? null); + + return response()->json([ + 'message' => 'Refinement queued.', + 'data' => new ContentBriefResource($brief->fresh()), + ], 202); + } + + // Sync refinement + try { + if (! $this->gateway->isClaudeAvailable()) { + return response()->json([ + 'error' => 'service_unavailable', + 'message' => 'Claude API is not configured.', + ], 503); + } + + $response = $this->gateway->refineDraft( + $brief, + $brief->draft_output, + $validated['context'] ?? null + ); + + $brief->markRefined($response->content, [ + 'refine' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + ], + ]); + + return response()->json([ + 'message' => 'Draft refined successfully.', + 'data' => new ContentBriefResource($brief->fresh()), + 'usage' => [ + 'model' => $response->model, + 'input_tokens' => $response->inputTokens, + 'output_tokens' => $response->outputTokens, + 'cost_estimate' => $response->estimateCost(), + 'duration_ms' => $response->durationMs, + ], + ]); + } catch (\Exception $e) { + $brief->markFailed($e->getMessage()); + + return response()->json([ + 'error' => 'refinement_failed', + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * Run the full pipeline: draft + refine. + * + * POST /api/v1/content/generate/full + */ + public function full(Request $request): JsonResponse + { + $validated = $request->validate([ + 'brief_id' => 'required|exists:content_briefs,id', + 'async' => 'boolean', + 'context' => 'nullable|array', + ]); + + $brief = ContentBrief::findOrFail($validated['brief_id']); + $workspace = $this->resolveWorkspace($request); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + // Async full generation + if ($validated['async'] ?? false) { + GenerateContentJob::dispatch($brief, 'full', $validated['context'] ?? null); + + return response()->json([ + 'message' => 'Full generation pipeline queued.', + 'data' => new ContentBriefResource($brief->fresh()), + ], 202); + } + + // Sync full generation + try { + if (! $this->gateway->isAvailable()) { + return response()->json([ + 'error' => 'service_unavailable', + 'message' => 'AI services are not fully configured.', + ], 503); + } + + $result = $this->gateway->generateAndRefine($brief, $validated['context'] ?? null); + + return response()->json([ + 'message' => 'Content generated and refined successfully.', + 'data' => new ContentBriefResource($result['brief']), + 'usage' => [ + 'draft' => [ + 'model' => $result['draft']->model, + 'tokens' => $result['draft']->totalTokens(), + 'cost' => $result['draft']->estimateCost(), + ], + 'refine' => [ + 'model' => $result['refined']->model, + 'tokens' => $result['refined']->totalTokens(), + 'cost' => $result['refined']->estimateCost(), + ], + 'total_cost' => $result['draft']->estimateCost() + $result['refined']->estimateCost(), + ], + ]); + } catch (\Exception $e) { + $brief->markFailed($e->getMessage()); + + return response()->json([ + 'error' => 'generation_failed', + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * Generate social media posts from content. + * + * POST /api/v1/content/generate/social + */ + public function socialPosts(Request $request): JsonResponse + { + $validated = $request->validate([ + 'content' => 'required_without:brief_id|string', + 'brief_id' => 'required_without:content|exists:content_briefs,id', + 'platforms' => 'required|array|min:1', + 'platforms.*' => 'string|in:twitter,linkedin,facebook,instagram', + ]); + + $workspace = $this->resolveWorkspace($request); + $briefId = null; + $content = $validated['content'] ?? null; + + // Get content from brief if provided + if (isset($validated['brief_id'])) { + $brief = ContentBrief::findOrFail($validated['brief_id']); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + $content = $brief->best_content; + $briefId = $brief->id; + + if (! $content) { + return response()->json([ + 'error' => 'no_content', + 'message' => 'Brief has no generated content.', + ], 400); + } + } + + try { + if (! $this->gateway->isClaudeAvailable()) { + return response()->json([ + 'error' => 'service_unavailable', + 'message' => 'Claude API is not configured.', + ], 503); + } + + $response = $this->gateway->generateSocialPosts( + $content, + $validated['platforms'], + $workspace?->id, + $briefId + ); + + // Parse JSON response + $posts = []; + if (preg_match('/```json\s*(.*?)\s*```/s', $response->content, $matches)) { + $parsed = json_decode($matches[1], true); + $posts = $parsed['posts'] ?? []; + } + + return response()->json([ + 'message' => 'Social posts generated successfully.', + 'data' => [ + 'posts' => $posts, + 'raw' => $response->content, + ], + 'usage' => [ + 'model' => $response->model, + 'input_tokens' => $response->inputTokens, + 'output_tokens' => $response->outputTokens, + 'cost_estimate' => $response->estimateCost(), + ], + ]); + } catch (\Exception $e) { + return response()->json([ + 'error' => 'generation_failed', + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * Approve a brief's refined content and mark for publishing. + * + * POST /api/v1/content/briefs/{brief}/approve + */ + public function approve(Request $request, ContentBrief $brief): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + // Check access + if ($brief->workspace_id && $workspace?->id !== $brief->workspace_id) { + return $this->accessDeniedResponse(); + } + + if ($brief->status !== ContentBrief::STATUS_REVIEW) { + return response()->json([ + 'error' => 'invalid_status', + 'message' => 'Brief must be in review status to approve.', + ], 400); + } + + $brief->markPublished( + $brief->refined_output ?? $brief->draft_output + ); + + return response()->json([ + 'message' => 'Content approved and ready for publishing.', + 'data' => new ContentBriefResource($brief), + ]); + } + + /** + * Get AI usage statistics. + * + * GET /api/v1/content/usage + */ + public function usage(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + $period = $request->input('period', 'month'); + + $stats = AIUsage::statsForWorkspace($workspace?->id, $period); + + return response()->json([ + 'data' => $stats, + 'period' => $period, + 'workspace_id' => $workspace?->id, + ]); + } +} diff --git a/Controllers/ContentPreviewController.php b/Controllers/ContentPreviewController.php new file mode 100644 index 0000000..2dc201d --- /dev/null +++ b/Controllers/ContentPreviewController.php @@ -0,0 +1,102 @@ +user(); + if (! $user || ! $this->userCanAccessWorkspace($user, $item->workspace_id)) { + return response()->json([ + 'error' => 'Unauthorised access to this content item.', + ], 403); + } + + $hours = (int) $request->input('hours', 24); + $hours = min(max($hours, 1), 168); // Between 1 hour and 7 days + + $token = $item->generatePreviewToken($hours); + $previewUrl = route('content.preview', [ + 'item' => $item->id, + 'token' => $token, + ]); + + return response()->json([ + 'preview_url' => $previewUrl, + 'expires_at' => $item->preview_expires_at->toIso8601String(), + 'expires_in' => $item->getPreviewTokenTimeRemaining(), + ]); + } + + /** + * Revoke the preview token for a content item. + */ + public function revokeLink(Request $request, ContentItem $item): JsonResponse + { + // Verify user has access to this workspace + $user = $request->user(); + if (! $user || ! $this->userCanAccessWorkspace($user, $item->workspace_id)) { + return response()->json([ + 'error' => 'Unauthorised access to this content item.', + ], 403); + } + + $item->revokePreviewToken(); + + return response()->json([ + 'message' => 'Preview link revoked successfully.', + ]); + } + + /** + * Check if a user can access a workspace. + */ + protected function userCanAccessWorkspace($user, int $workspaceId): bool + { + // Check if user owns the workspace or is a member + $workspace = Workspace::find($workspaceId); + + if (! $workspace) { + return false; + } + + // User owns workspace + if ($workspace->user_id === $user->id) { + return true; + } + + // User is workspace member (if membership system exists) + if (method_exists($user, 'workspaces')) { + return $user->workspaces()->where('workspaces.id', $workspaceId)->exists(); + } + + // Fallback: check if user has any content in this workspace + return ContentItem::where('workspace_id', $workspaceId) + ->where(function ($query) use ($user) { + $query->where('author_id', $user->id) + ->orWhere('last_edited_by', $user->id); + }) + ->exists(); + } +} diff --git a/Enums/BriefContentType.php b/Enums/BriefContentType.php new file mode 100644 index 0000000..f2e6729 --- /dev/null +++ b/Enums/BriefContentType.php @@ -0,0 +1,115 @@ + 'Help Article', + self::BLOG_POST => 'Blog Post', + self::LANDING_PAGE => 'Landing Page', + self::SOCIAL_POST => 'Social Post', + }; + } + + /** + * Get Flux badge colour. + */ + public function color(): string + { + return match ($this) { + self::HELP_ARTICLE => 'blue', + self::BLOG_POST => 'green', + self::LANDING_PAGE => 'violet', + self::SOCIAL_POST => 'orange', + }; + } + + /** + * Get icon name for UI. + */ + public function icon(): string + { + return match ($this) { + self::HELP_ARTICLE => 'question-mark-circle', + self::BLOG_POST => 'newspaper', + self::LANDING_PAGE => 'document-text', + self::SOCIAL_POST => 'share', + }; + } + + /** + * Get default word count target for this content type. + */ + public function defaultWordCount(): int + { + return match ($this) { + self::HELP_ARTICLE => 800, + self::BLOG_POST => 1200, + self::LANDING_PAGE => 500, + self::SOCIAL_POST => 100, + }; + } + + /** + * Get recommended timeout in seconds for AI generation. + */ + public function recommendedTimeout(): int + { + return match ($this) { + self::HELP_ARTICLE => 180, + self::BLOG_POST => 240, + self::LANDING_PAGE => 300, + self::SOCIAL_POST => 60, + }; + } + + /** + * Check if this type requires long-form content. + */ + public function isLongForm(): bool + { + return in_array($this, [self::HELP_ARTICLE, self::BLOG_POST, self::LANDING_PAGE]); + } + + /** + * Get all available values as an array (for validation rules). + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Create from string with null fallback. + */ + public static function tryFromString(?string $value): ?self + { + if ($value === null) { + return null; + } + + return self::tryFrom($value); + } +} diff --git a/Enums/ContentType.php b/Enums/ContentType.php new file mode 100644 index 0000000..38db96d --- /dev/null +++ b/Enums/ContentType.php @@ -0,0 +1,121 @@ + 'Native', + self::HOSTUK => 'Host UK', + self::SATELLITE => 'Satellite', + self::WORDPRESS => 'WordPress (Legacy)', + }; + } + + /** + * Get Flux badge colour. + */ + public function color(): string + { + return match ($this) { + self::NATIVE => 'green', + self::HOSTUK => 'violet', + self::SATELLITE => 'blue', + self::WORDPRESS => 'zinc', + }; + } + + /** + * Get icon name for UI. + */ + public function icon(): string + { + return match ($this) { + self::NATIVE => 'document-text', + self::HOSTUK => 'home', + self::SATELLITE => 'signal', + self::WORDPRESS => 'globe-alt', + }; + } + + /** + * Check if this is native content (not WordPress). + */ + public function isNative(): bool + { + return in_array($this, [self::NATIVE, self::HOSTUK, self::SATELLITE]); + } + + /** + * Check if this is legacy WordPress content. + */ + public function isLegacy(): bool + { + return $this === self::WORDPRESS; + } + + /** + * Check if content uses Flux Editor. + */ + public function usesFluxEditor(): bool + { + return $this->isNative(); + } + + /** + * Get all native content types. + */ + public static function nativeTypes(): array + { + return [self::NATIVE, self::HOSTUK, self::SATELLITE]; + } + + /** + * Get string values for native types (for database queries). + */ + public static function nativeTypeValues(): array + { + return array_map(fn ($type) => $type->value, self::nativeTypes()); + } + + /** + * Get the default content type for new content. + */ + public static function default(): self + { + return self::NATIVE; + } + + /** + * Create from string with fallback to default. + */ + public static function fromString(?string $value): self + { + if ($value === null) { + return self::default(); + } + + return self::tryFrom($value) ?? self::default(); + } +} diff --git a/Jobs/GenerateContentJob.php b/Jobs/GenerateContentJob.php new file mode 100644 index 0000000..dad1fdf --- /dev/null +++ b/Jobs/GenerateContentJob.php @@ -0,0 +1,201 @@ +onQueue('content-generation'); + + // Set configurable retries and timeout + $this->tries = config('content.generation.max_retries', 3); + $this->timeout = $this->resolveTimeout(); + } + + /** + * Resolve the timeout based on content type or config. + */ + protected function resolveTimeout(): int + { + // Try to get content-type-specific timeout + $contentType = $this->brief->content_type; + $contentTypeKey = is_string($contentType) ? $contentType : $contentType?->value; + + if ($contentTypeKey) { + $configuredTimeout = config("content.generation.timeouts.{$contentTypeKey}"); + if ($configuredTimeout) { + return (int) $configuredTimeout; + } + } + + // Fall back to brief's recommended timeout if using enum + if (method_exists($this->brief, 'getRecommendedTimeout')) { + return $this->brief->getRecommendedTimeout(); + } + + // Final fallback to default config + return (int) config('content.generation.default_timeout', 300); + } + + /** + * Execute the job. + */ + public function handle(AIGatewayService $gateway): void + { + Log::info('Starting content generation', [ + 'brief_id' => $this->brief->id, + 'mode' => $this->mode, + 'title' => $this->brief->title, + ]); + + try { + match ($this->mode) { + 'draft' => $this->generateDraft($gateway), + 'refine' => $this->refineDraft($gateway), + 'full' => $this->generateFull($gateway), + default => throw new \InvalidArgumentException("Invalid mode: {$this->mode}"), + }; + + Log::info('Content generation completed', [ + 'brief_id' => $this->brief->id, + 'mode' => $this->mode, + 'status' => $this->brief->fresh()->status, + ]); + } catch (\Exception $e) { + Log::error('Content generation failed', [ + 'brief_id' => $this->brief->id, + 'mode' => $this->mode, + 'error' => $e->getMessage(), + ]); + + $this->brief->markFailed($e->getMessage()); + + throw $e; + } + } + + /** + * Generate draft using Gemini. + */ + protected function generateDraft(AIGatewayService $gateway): void + { + if ($this->brief->isGenerated()) { + Log::info('Draft already exists, skipping', ['brief_id' => $this->brief->id]); + + return; + } + + $response = $gateway->generateDraft($this->brief, $this->context); + + $this->brief->markDraftComplete($response->content, [ + 'draft' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + 'duration_ms' => $response->durationMs, + 'generated_at' => now()->toIso8601String(), + ], + ]); + } + + /** + * Refine draft using Claude. + */ + protected function refineDraft(AIGatewayService $gateway): void + { + if (! $this->brief->isGenerated()) { + throw new \RuntimeException('No draft to refine. Generate draft first.'); + } + + if ($this->brief->isRefined()) { + Log::info('Draft already refined, skipping', ['brief_id' => $this->brief->id]); + + return; + } + + $response = $gateway->refineDraft( + $this->brief, + $this->brief->draft_output, + $this->context + ); + + $this->brief->markRefined($response->content, [ + 'refine' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + 'duration_ms' => $response->durationMs, + 'refined_at' => now()->toIso8601String(), + ], + ]); + } + + /** + * Run the full pipeline: draft + refine. + */ + protected function generateFull(AIGatewayService $gateway): void + { + $gateway->generateAndRefine($this->brief, $this->context); + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('Content generation job failed permanently', [ + 'brief_id' => $this->brief->id, + 'mode' => $this->mode, + 'error' => $exception->getMessage(), + 'attempts' => $this->attempts(), + ]); + + $this->brief->markFailed( + "Generation failed after {$this->attempts()} attempts: {$exception->getMessage()}" + ); + } +} diff --git a/Jobs/ProcessContentWebhook.php b/Jobs/ProcessContentWebhook.php new file mode 100644 index 0000000..c70b3a0 --- /dev/null +++ b/Jobs/ProcessContentWebhook.php @@ -0,0 +1,546 @@ +onQueue('content-webhooks'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $this->webhookLog->markProcessing(); + + Log::info('Processing content webhook', [ + 'log_id' => $this->webhookLog->id, + 'event_type' => $this->webhookLog->event_type, + 'workspace_id' => $this->webhookLog->workspace_id, + ]); + + try { + $result = match (true) { + str_starts_with($this->webhookLog->event_type, 'wordpress.') => $this->processWordPress(), + str_starts_with($this->webhookLog->event_type, 'cms.') => $this->processCms(), + default => $this->processGeneric(), + }; + + $this->webhookLog->markCompleted(); + + // Reset failure count on endpoint + if ($endpoint = $this->getEndpoint()) { + $endpoint->resetFailureCount(); + } + + Log::info('Content webhook processed successfully', [ + 'log_id' => $this->webhookLog->id, + 'event_type' => $this->webhookLog->event_type, + 'result' => $result, + ]); + } catch (\Exception $e) { + $this->handleFailure($e); + throw $e; + } + } + + /** + * Process WordPress webhook payload. + */ + protected function processWordPress(): array + { + $payload = $this->webhookLog->payload; + $eventType = $this->webhookLog->event_type; + + return match ($eventType) { + 'wordpress.post_created', 'wordpress.post_updated', 'wordpress.post_published' => $this->upsertWordPressPost($payload), + 'wordpress.post_deleted', 'wordpress.post_trashed' => $this->deleteWordPressPost($payload), + 'wordpress.media_uploaded' => $this->processWordPressMedia($payload), + default => ['action' => 'skipped', 'reason' => 'Unhandled WordPress event type'], + }; + } + + /** + * Create or update a ContentItem from WordPress data. + */ + protected function upsertWordPressPost(array $payload): array + { + // Extract post data from various payload formats + $postData = $payload['data'] ?? $payload['post'] ?? $payload; + + $wpId = $postData['ID'] ?? $postData['post_id'] ?? $postData['id'] ?? null; + + if (! $wpId) { + return ['action' => 'skipped', 'reason' => 'No post ID found in payload']; + } + + $workspaceId = $this->webhookLog->workspace_id; + + // Find existing or create new + $contentItem = ContentItem::where('workspace_id', $workspaceId) + ->where('wp_id', $wpId) + ->first(); + + $isNew = ! $contentItem; + + if ($isNew) { + $contentItem = new ContentItem; + $contentItem->workspace_id = $workspaceId; + $contentItem->wp_id = $wpId; + $contentItem->content_type = ContentType::WORDPRESS; + } + + // Update fields from payload + $contentItem->fill([ + 'title' => $postData['post_title'] ?? $postData['title'] ?? $contentItem->title ?? 'Untitled', + 'slug' => $postData['post_name'] ?? $postData['slug'] ?? $contentItem->slug ?? 'untitled-'.$wpId, + 'type' => $this->mapWordPressPostType($postData['post_type'] ?? 'post'), + 'status' => $this->mapWordPressStatus($postData['post_status'] ?? 'draft'), + 'excerpt' => $postData['post_excerpt'] ?? $postData['excerpt'] ?? $contentItem->excerpt, + 'content_html_original' => $postData['post_content'] ?? $postData['content'] ?? $contentItem->content_html_original, + 'wp_guid' => $postData['guid'] ?? $contentItem->wp_guid, + 'wp_created_at' => isset($postData['post_date']) ? $this->parseDate($postData['post_date']) : $contentItem->wp_created_at, + 'wp_modified_at' => isset($postData['post_modified']) ? $this->parseDate($postData['post_modified']) : now(), + 'featured_media_id' => $postData['featured_media'] ?? $postData['_thumbnail_id'] ?? $contentItem->featured_media_id, + 'sync_status' => 'synced', + 'synced_at' => now(), + 'sync_error' => null, + ]); + + $contentItem->save(); + + // Process taxonomies if provided + if (isset($postData['categories']) || isset($postData['tags'])) { + $this->syncTaxonomies($contentItem, $postData); + } + + return [ + 'action' => $isNew ? 'created' : 'updated', + 'content_item_id' => $contentItem->id, + 'wp_id' => $wpId, + ]; + } + + /** + * Delete/trash a WordPress post. + */ + protected function deleteWordPressPost(array $payload): array + { + $postData = $payload['data'] ?? $payload['post'] ?? $payload; + $wpId = $postData['ID'] ?? $postData['post_id'] ?? $postData['id'] ?? null; + + if (! $wpId) { + return ['action' => 'skipped', 'reason' => 'No post ID found in payload']; + } + + $contentItem = ContentItem::where('workspace_id', $this->webhookLog->workspace_id) + ->where('wp_id', $wpId) + ->first(); + + if (! $contentItem) { + return ['action' => 'skipped', 'reason' => 'Content item not found']; + } + + // Soft delete for trashed, hard delete for deleted + if ($this->webhookLog->event_type === 'wordpress.post_trashed') { + $contentItem->update(['status' => 'trash']); + + return ['action' => 'trashed', 'content_item_id' => $contentItem->id]; + } + + $contentItem->delete(); + + return ['action' => 'deleted', 'wp_id' => $wpId]; + } + + /** + * Process WordPress media upload. + */ + protected function processWordPressMedia(array $payload): array + { + $mediaData = $payload['data'] ?? $payload['attachment'] ?? $payload; + $wpId = $mediaData['ID'] ?? $mediaData['attachment_id'] ?? $mediaData['id'] ?? null; + + if (! $wpId) { + return ['action' => 'skipped', 'reason' => 'No media ID found in payload']; + } + + $workspaceId = $this->webhookLog->workspace_id; + + // Upsert media record + $media = ContentMedia::updateOrCreate( + [ + 'workspace_id' => $workspaceId, + 'wp_id' => $wpId, + ], + [ + 'title' => $mediaData['title'] ?? $mediaData['post_title'] ?? null, + 'filename' => basename($mediaData['url'] ?? $mediaData['guid'] ?? 'unknown'), + 'mime_type' => $mediaData['mime_type'] ?? $mediaData['post_mime_type'] ?? 'application/octet-stream', + 'file_size' => $mediaData['filesize'] ?? 0, + 'source_url' => $mediaData['url'] ?? $mediaData['guid'] ?? $mediaData['source_url'] ?? null, + 'width' => $mediaData['width'] ?? null, + 'height' => $mediaData['height'] ?? null, + 'alt_text' => $mediaData['alt'] ?? $mediaData['alt_text'] ?? null, + 'caption' => $mediaData['caption'] ?? null, + 'sizes' => $mediaData['sizes'] ?? null, + ] + ); + + return [ + 'action' => $media->wasRecentlyCreated ? 'created' : 'updated', + 'media_id' => $media->id, + 'wp_id' => $wpId, + ]; + } + + /** + * Process generic CMS webhook. + */ + protected function processCms(): array + { + $payload = $this->webhookLog->payload; + $eventType = $this->webhookLog->event_type; + + // CMS events follow similar pattern to WordPress + return match ($eventType) { + 'cms.content_created', 'cms.content_updated', 'cms.content_published' => $this->upsertCmsContent($payload), + 'cms.content_deleted' => $this->deleteCmsContent($payload), + default => ['action' => 'skipped', 'reason' => 'Unhandled CMS event type'], + }; + } + + /** + * Upsert content from generic CMS payload. + */ + protected function upsertCmsContent(array $payload): array + { + $contentData = $payload['content'] ?? $payload['data'] ?? $payload; + + // Require an external ID for deduplication + $externalId = $contentData['id'] ?? $contentData['external_id'] ?? $contentData['content_id'] ?? null; + + if (! $externalId) { + return ['action' => 'skipped', 'reason' => 'No content ID found in payload']; + } + + $workspaceId = $this->webhookLog->workspace_id; + + // Find existing by wp_id (used for all external IDs) or create new + $contentItem = ContentItem::where('workspace_id', $workspaceId) + ->where('wp_id', $externalId) + ->first(); + + $isNew = ! $contentItem; + + if ($isNew) { + $contentItem = new ContentItem; + $contentItem->workspace_id = $workspaceId; + $contentItem->wp_id = $externalId; + $contentItem->content_type = ContentType::NATIVE; + } + + $contentItem->fill([ + 'title' => $contentData['title'] ?? $contentItem->title ?? 'Untitled', + 'slug' => $contentData['slug'] ?? $contentItem->slug ?? 'content-'.$externalId, + 'type' => $contentData['type'] ?? 'post', + 'status' => $contentData['status'] ?? 'draft', + 'excerpt' => $contentData['excerpt'] ?? $contentData['summary'] ?? $contentItem->excerpt, + 'content_html' => $contentData['content'] ?? $contentData['body'] ?? $contentData['html'] ?? $contentItem->content_html, + 'content_markdown' => $contentData['markdown'] ?? $contentItem->content_markdown, + 'sync_status' => 'synced', + 'synced_at' => now(), + ]); + + $contentItem->save(); + + return [ + 'action' => $isNew ? 'created' : 'updated', + 'content_item_id' => $contentItem->id, + 'external_id' => $externalId, + ]; + } + + /** + * Delete content from generic CMS. + */ + protected function deleteCmsContent(array $payload): array + { + $contentData = $payload['content'] ?? $payload['data'] ?? $payload; + $externalId = $contentData['id'] ?? $contentData['external_id'] ?? $contentData['content_id'] ?? null; + + if (! $externalId) { + return ['action' => 'skipped', 'reason' => 'No content ID found in payload']; + } + + $contentItem = ContentItem::where('workspace_id', $this->webhookLog->workspace_id) + ->where('wp_id', $externalId) + ->first(); + + if (! $contentItem) { + return ['action' => 'skipped', 'reason' => 'Content item not found']; + } + + $contentItem->delete(); + + return ['action' => 'deleted', 'external_id' => $externalId]; + } + + /** + * Process generic webhook payload. + */ + protected function processGeneric(): array + { + $payload = $this->webhookLog->payload; + + // Generic payloads are logged but require custom handling + // Check if there's enough data to create/update content + if (isset($payload['title']) || isset($payload['content'])) { + return $this->upsertCmsContent($payload); + } + + return [ + 'action' => 'logged', + 'reason' => 'Generic payload stored for manual processing', + 'payload_keys' => array_keys($payload), + ]; + } + + // ------------------------------------------------------------------------- + // Helper Methods + // ------------------------------------------------------------------------- + + /** + * Get the webhook endpoint if linked. + */ + protected function getEndpoint(): ?ContentWebhookEndpoint + { + if ($this->webhookLog->endpoint_id) { + return ContentWebhookEndpoint::find($this->webhookLog->endpoint_id); + } + + return null; + } + + /** + * Map WordPress post type to ContentItem type. + */ + protected function mapWordPressPostType(string $wpType): string + { + return match ($wpType) { + 'post' => 'post', + 'page' => 'page', + 'attachment' => 'attachment', + default => 'post', + }; + } + + /** + * Map WordPress status to ContentItem status. + */ + protected function mapWordPressStatus(string $wpStatus): string + { + return match ($wpStatus) { + 'publish' => 'publish', + 'draft' => 'draft', + 'pending' => 'pending', + 'private' => 'private', + 'future' => 'future', + 'trash' => 'trash', + default => 'draft', + }; + } + + /** + * Parse date string to Carbon instance. + */ + protected function parseDate(string $date): ?\Carbon\Carbon + { + try { + return \Carbon\Carbon::parse($date); + } catch (\Exception) { + return null; + } + } + + /** + * Sync taxonomies from payload. + */ + protected function syncTaxonomies(ContentItem $contentItem, array $payload): void + { + $taxonomyIds = []; + + // Process categories + if (isset($payload['categories'])) { + foreach ((array) $payload['categories'] as $category) { + $taxonomy = $this->findOrCreateTaxonomy($contentItem->workspace_id, $category, 'category'); + if ($taxonomy) { + $taxonomyIds[] = $taxonomy->id; + } + } + } + + // Process tags + if (isset($payload['tags'])) { + foreach ((array) $payload['tags'] as $tag) { + $taxonomy = $this->findOrCreateTaxonomy($contentItem->workspace_id, $tag, 'tag'); + if ($taxonomy) { + $taxonomyIds[] = $taxonomy->id; + } + } + } + + if (! empty($taxonomyIds)) { + $contentItem->taxonomies()->sync($taxonomyIds); + } + } + + /** + * Find or create a taxonomy record. + */ + protected function findOrCreateTaxonomy(int $workspaceId, array|int|string $data, string $type): ?ContentTaxonomy + { + // Handle array with ID/name + if (is_array($data)) { + $wpId = $data['term_id'] ?? $data['id'] ?? null; + $name = $data['name'] ?? null; + $slug = $data['slug'] ?? null; + } elseif (is_numeric($data)) { + // Just an ID + $wpId = (int) $data; + $name = null; + $slug = null; + } else { + // Just a name/slug + $wpId = null; + $name = $data; + $slug = \Illuminate\Support\Str::slug($data); + } + + if (! $wpId && ! $name) { + return null; + } + + // Try to find by wp_id first + if ($wpId) { + $taxonomy = ContentTaxonomy::where('workspace_id', $workspaceId) + ->where('wp_id', $wpId) + ->where('type', $type) + ->first(); + + if ($taxonomy) { + return $taxonomy; + } + } + + // Try to find by slug + if ($slug) { + $taxonomy = ContentTaxonomy::where('workspace_id', $workspaceId) + ->where('slug', $slug) + ->where('type', $type) + ->first(); + + if ($taxonomy) { + return $taxonomy; + } + } + + // Create new taxonomy if we have enough info + if ($name || $slug) { + return ContentTaxonomy::create([ + 'workspace_id' => $workspaceId, + 'wp_id' => $wpId, + 'type' => $type, + 'name' => $name ?? $slug, + 'slug' => $slug ?? \Illuminate\Support\Str::slug($name), + ]); + } + + return null; + } + + /** + * Handle job failure. + */ + protected function handleFailure(\Exception $e): void + { + $this->webhookLog->markFailed($e->getMessage()); + + // Increment failure count on endpoint + if ($endpoint = $this->getEndpoint()) { + $endpoint->incrementFailureCount(); + } + + Log::error('Content webhook processing failed', [ + 'log_id' => $this->webhookLog->id, + 'event_type' => $this->webhookLog->event_type, + 'error' => $e->getMessage(), + 'attempts' => $this->attempts(), + ]); + } + + /** + * Handle a job failure (called by Laravel). + */ + public function failed(\Throwable $exception): void + { + Log::error('Content webhook job failed permanently', [ + 'log_id' => $this->webhookLog->id, + 'event_type' => $this->webhookLog->event_type, + 'error' => $exception->getMessage(), + 'attempts' => $this->attempts(), + ]); + + $this->webhookLog->markFailed( + "Processing failed after {$this->attempts()} attempts: {$exception->getMessage()}" + ); + } +} diff --git a/Mcp/Handlers/ContentCreateHandler.php b/Mcp/Handlers/ContentCreateHandler.php new file mode 100644 index 0000000..6340104 --- /dev/null +++ b/Mcp/Handlers/ContentCreateHandler.php @@ -0,0 +1,281 @@ + 'content_create', + 'description' => 'Create a new blog post or page. Supports markdown content, categories, tags, and SEO metadata.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'workspace' => [ + 'type' => 'string', + 'description' => 'Workspace slug or ID (required)', + ], + 'title' => [ + 'type' => 'string', + 'description' => 'Content title (required)', + ], + 'type' => [ + 'type' => 'string', + 'enum' => ['post', 'page'], + 'description' => 'Content type: post (default) or page', + 'default' => 'post', + ], + 'status' => [ + 'type' => 'string', + 'enum' => ['draft', 'publish', 'future', 'private'], + 'description' => 'Publication status (default: draft)', + 'default' => 'draft', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'URL slug (auto-generated from title if not provided)', + ], + 'excerpt' => [ + 'type' => 'string', + 'description' => 'Content summary/excerpt', + ], + 'content' => [ + 'type' => 'string', + 'description' => 'Content body in markdown format', + ], + 'content_html' => [ + 'type' => 'string', + 'description' => 'Content body in HTML (optional, auto-generated from markdown)', + ], + 'categories' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Array of category slugs or names (creates if not exists)', + ], + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Array of tag strings (creates if not exists)', + ], + 'seo_meta' => [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'keywords' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'description' => 'SEO metadata object', + ], + 'publish_at' => [ + 'type' => 'string', + 'description' => 'ISO datetime for scheduled publishing (required if status=future)', + ], + ], + 'required' => ['workspace', 'title'], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $workspace = $this->resolveWorkspace($args['workspace'] ?? null); + + if (! $workspace) { + return ['error' => 'Workspace not found. Provide a valid workspace slug or ID.']; + } + + // Check entitlements + $entitlementError = $this->checkEntitlement($workspace, 'create'); + if ($entitlementError) { + return $entitlementError; + } + + // Validate required fields + $title = $args['title'] ?? null; + if (! $title) { + return ['error' => 'title is required']; + } + + $type = $args['type'] ?? 'post'; + if (! in_array($type, ['post', 'page'])) { + return ['error' => 'type must be post or page']; + } + + $status = $args['status'] ?? 'draft'; + if (! in_array($status, ['draft', 'publish', 'future', 'private'])) { + return ['error' => 'status must be draft, publish, future, or private']; + } + + // Generate slug + $slug = $args['slug'] ?? Str::slug($title); + $baseSlug = $slug; + $counter = 1; + + // Ensure unique slug within workspace + while (ContentItem::forWorkspace($workspace->id)->where('slug', $slug)->exists()) { + $slug = $baseSlug.'-'.$counter++; + } + + // Parse markdown content if provided + $content = $args['content'] ?? ''; + $contentHtml = $args['content_html'] ?? null; + $contentMarkdown = $content; + + // Convert markdown to HTML if only markdown provided + if ($contentMarkdown && ! $contentHtml) { + $contentHtml = Str::markdown($contentMarkdown); + } + + // Handle scheduling + $publishAt = null; + if ($status === 'future') { + $publishAtArg = $args['publish_at'] ?? null; + if (! $publishAtArg) { + return ['error' => 'publish_at is required for scheduled content']; + } + $publishAt = Carbon::parse($publishAtArg); + } + + // Create content item + $item = ContentItem::create([ + 'workspace_id' => $workspace->id, + 'content_type' => ContentType::NATIVE, + 'type' => $type, + 'status' => $status, + 'slug' => $slug, + 'title' => $title, + 'excerpt' => $args['excerpt'] ?? null, + 'content_html' => $contentHtml, + 'content_markdown' => $contentMarkdown, + 'seo_meta' => $args['seo_meta'] ?? null, + 'publish_at' => $publishAt, + 'last_edited_by' => Auth::id(), + ]); + + // Handle categories + if (! empty($args['categories'])) { + $categoryIds = $this->resolveOrCreateTaxonomies($workspace, $args['categories'], 'category'); + $item->taxonomies()->attach($categoryIds); + } + + // Handle tags + if (! empty($args['tags'])) { + $tagIds = $this->resolveOrCreateTaxonomies($workspace, $args['tags'], 'tag'); + $item->taxonomies()->attach($tagIds); + } + + // Create initial revision + $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Created via MCP'); + + // Record usage + $entitlements = app(EntitlementService::class); + $entitlements->recordUsage($workspace, 'content.items', 1, Auth::user(), [ + 'source' => 'mcp', + 'content_id' => $item->id, + ]); + + $context->logToSession("Created content item: {$item->title} (ID: {$item->id})"); + + return [ + 'ok' => true, + 'item' => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'url' => $this->getContentUrl($workspace, $item), + ], + ]; + } + + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } + + protected function checkEntitlement(Workspace $workspace, string $action): ?array + { + $entitlements = app(EntitlementService::class); + + // Check if workspace has content MCP access + $result = $entitlements->can($workspace, 'content.mcp_access'); + + if ($result->isDenied()) { + return ['error' => $result->reason ?? 'Content MCP access not available in your plan.']; + } + + // For create operations, check content limits + if ($action === 'create') { + $limitResult = $entitlements->can($workspace, 'content.items'); + if ($limitResult->isDenied()) { + return ['error' => $limitResult->reason ?? 'Content item limit reached.']; + } + } + + return null; + } + + protected function resolveOrCreateTaxonomies(Workspace $workspace, array $items, string $type): array + { + $ids = []; + + foreach ($items as $item) { + $taxonomy = ContentTaxonomy::where('workspace_id', $workspace->id) + ->where('type', $type) + ->where(function ($q) use ($item) { + $q->where('slug', $item) + ->orWhere('name', $item); + }) + ->first(); + + if (! $taxonomy) { + // Create new taxonomy + $taxonomy = ContentTaxonomy::create([ + 'workspace_id' => $workspace->id, + 'type' => $type, + 'slug' => Str::slug($item), + 'name' => $item, + ]); + } + + $ids[] = $taxonomy->id; + } + + return $ids; + } + + protected function getContentUrl(Workspace $workspace, ContentItem $item): string + { + $domain = $workspace->domain ?? config('app.url'); + $path = $item->type === 'post' ? "/blog/{$item->slug}" : "/{$item->slug}"; + + return "https://{$domain}{$path}"; + } +} diff --git a/Mcp/Handlers/ContentDeleteHandler.php b/Mcp/Handlers/ContentDeleteHandler.php new file mode 100644 index 0000000..39cf7f6 --- /dev/null +++ b/Mcp/Handlers/ContentDeleteHandler.php @@ -0,0 +1,100 @@ + 'content_delete', + 'description' => 'Delete a blog post or page (soft delete). Content can be restored by admins.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'workspace' => [ + 'type' => 'string', + 'description' => 'Workspace slug or ID (required)', + ], + 'identifier' => [ + 'type' => 'string', + 'description' => 'Content slug or ID to delete (required)', + ], + ], + 'required' => ['workspace', 'identifier'], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $workspace = $this->resolveWorkspace($args['workspace'] ?? null); + + if (! $workspace) { + return ['error' => 'Workspace not found. Provide a valid workspace slug or ID.']; + } + + $identifier = $args['identifier'] ?? null; + + if (! $identifier) { + return ['error' => 'identifier (slug or ID) is required']; + } + + $query = ContentItem::forWorkspace($workspace->id)->native(); + + if (is_numeric($identifier)) { + $item = $query->find($identifier); + } else { + $item = $query->where('slug', $identifier)->first(); + } + + if (! $item) { + return ['error' => 'Content not found']; + } + + // Store info before delete + $deletedInfo = [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + ]; + + // Create final revision before delete + $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Deleted via MCP'); + + // Soft delete + $item->delete(); + + $context->logToSession("Deleted content item: {$deletedInfo['title']} (ID: {$deletedInfo['id']})"); + + return [ + 'ok' => true, + 'deleted' => $deletedInfo, + ]; + } + + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } +} diff --git a/Mcp/Handlers/ContentListHandler.php b/Mcp/Handlers/ContentListHandler.php new file mode 100644 index 0000000..dabcbfd --- /dev/null +++ b/Mcp/Handlers/ContentListHandler.php @@ -0,0 +1,145 @@ + 'content_list', + 'description' => 'List content items (blog posts and pages) for a workspace. Supports filtering by type, status, and search.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'workspace' => [ + 'type' => 'string', + 'description' => 'Workspace slug or ID (required)', + ], + 'type' => [ + 'type' => 'string', + 'enum' => ['post', 'page'], + 'description' => 'Filter by content type: post or page', + ], + 'status' => [ + 'type' => 'string', + 'enum' => ['draft', 'publish', 'future', 'private', 'pending', 'scheduled', 'published'], + 'description' => 'Filter by status. Use "published" or "scheduled" as aliases.', + ], + 'search' => [ + 'type' => 'string', + 'description' => 'Search term to filter by title, content, or excerpt', + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum items to return (default 20, max 100)', + 'default' => 20, + ], + 'offset' => [ + 'type' => 'integer', + 'description' => 'Offset for pagination', + 'default' => 0, + ], + ], + 'required' => ['workspace'], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $workspace = $this->resolveWorkspace($args['workspace'] ?? null); + + if (! $workspace) { + return ['error' => 'Workspace not found. Provide a valid workspace slug or ID.']; + } + + $query = ContentItem::forWorkspace($workspace->id) + ->native() + ->with(['author', 'taxonomies']); + + // Filter by type + if (! empty($args['type'])) { + $query->where('type', $args['type']); + } + + // Filter by status + if (! empty($args['status'])) { + $status = $args['status']; + if ($status === 'published') { + $query->published(); + } elseif ($status === 'scheduled') { + $query->scheduled(); + } else { + $query->where('status', $status); + } + } + + // Search + if (! empty($args['search'])) { + $search = $args['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('content_html', 'like', "%{$search}%") + ->orWhere('excerpt', 'like', "%{$search}%"); + }); + } + + // Pagination + $limit = min($args['limit'] ?? 20, 100); + $offset = $args['offset'] ?? 0; + + $total = $query->count(); + $items = $query->orderByDesc('updated_at') + ->skip($offset) + ->take($limit) + ->get(); + + $context->logToSession("Listed {$items->count()} content items for workspace {$workspace->slug}"); + + return [ + 'items' => $items->map(fn (ContentItem $item) => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'excerpt' => Str::limit($item->excerpt, 200), + 'author' => $item->author?->name, + 'categories' => $item->categories->pluck('name')->all(), + 'tags' => $item->tags->pluck('name')->all(), + 'word_count' => str_word_count(strip_tags($item->content_html ?? '')), + 'publish_at' => $item->publish_at?->toIso8601String(), + 'created_at' => $item->created_at->toIso8601String(), + 'updated_at' => $item->updated_at->toIso8601String(), + ])->all(), + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]; + } + + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } +} diff --git a/Mcp/Handlers/ContentReadHandler.php b/Mcp/Handlers/ContentReadHandler.php new file mode 100644 index 0000000..e769114 --- /dev/null +++ b/Mcp/Handlers/ContentReadHandler.php @@ -0,0 +1,177 @@ + 'content_read', + 'description' => 'Read full content of a blog post or page by ID or slug. Returns content with metadata, categories, tags, and revision history.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'workspace' => [ + 'type' => 'string', + 'description' => 'Workspace slug or ID (required)', + ], + 'identifier' => [ + 'type' => 'string', + 'description' => 'Content slug, ID, or WordPress ID', + ], + 'format' => [ + 'type' => 'string', + 'enum' => ['json', 'markdown'], + 'description' => 'Output format: json (default) or markdown with frontmatter', + 'default' => 'json', + ], + ], + 'required' => ['workspace', 'identifier'], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $workspace = $this->resolveWorkspace($args['workspace'] ?? null); + + if (! $workspace) { + return ['error' => 'Workspace not found. Provide a valid workspace slug or ID.']; + } + + $identifier = $args['identifier'] ?? null; + + if (! $identifier) { + return ['error' => 'identifier (slug or ID) is required']; + } + + $query = ContentItem::forWorkspace($workspace->id)->native(); + + // Find by ID, slug, or wp_id + if (is_numeric($identifier)) { + $item = $query->where('id', $identifier) + ->orWhere('wp_id', $identifier) + ->first(); + } else { + $item = $query->where('slug', $identifier)->first(); + } + + if (! $item) { + return ['error' => 'Content not found']; + } + + // Load relationships + $item->load(['author', 'taxonomies', 'revisions' => fn ($q) => $q->latest()->limit(5)]); + + $context->logToSession("Read content item: {$item->title} (ID: {$item->id})"); + + // Return as markdown with frontmatter for AI context + $format = $args['format'] ?? 'json'; + + if ($format === 'markdown') { + return [ + 'format' => 'markdown', + 'content' => $this->contentToMarkdown($item), + ]; + } + + return [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'excerpt' => $item->excerpt, + 'content_html' => $item->content_html, + 'content_markdown' => $item->content_markdown, + 'author' => [ + 'id' => $item->author?->id, + 'name' => $item->author?->name, + ], + 'categories' => $item->categories->map(fn ($t) => [ + 'id' => $t->id, + 'slug' => $t->slug, + 'name' => $t->name, + ])->all(), + 'tags' => $item->tags->map(fn ($t) => [ + 'id' => $t->id, + 'slug' => $t->slug, + 'name' => $t->name, + ])->all(), + 'seo_meta' => $item->seo_meta, + 'publish_at' => $item->publish_at?->toIso8601String(), + 'revision_count' => $item->revision_count, + 'recent_revisions' => $item->revisions->map(fn ($r) => [ + 'id' => $r->id, + 'revision_number' => $r->revision_number, + 'change_type' => $r->change_type, + 'created_at' => $r->created_at->toIso8601String(), + ])->all(), + 'created_at' => $item->created_at->toIso8601String(), + 'updated_at' => $item->updated_at->toIso8601String(), + ]; + } + + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } + + protected function contentToMarkdown(ContentItem $item): string + { + $frontmatter = [ + 'title' => $item->title, + 'slug' => $item->slug, + 'type' => $item->type, + 'status' => $item->status, + 'author' => $item->author?->name, + 'categories' => $item->categories->pluck('name')->all(), + 'tags' => $item->tags->pluck('name')->all(), + 'created_at' => $item->created_at->toIso8601String(), + 'updated_at' => $item->updated_at->toIso8601String(), + ]; + + if ($item->publish_at) { + $frontmatter['publish_at'] = $item->publish_at->toIso8601String(); + } + + if ($item->seo_meta) { + $frontmatter['seo'] = $item->seo_meta; + } + + $yaml = "---\n"; + foreach ($frontmatter as $key => $value) { + if (is_array($value)) { + $yaml .= "{$key}: ".json_encode($value)."\n"; + } else { + $yaml .= "{$key}: {$value}\n"; + } + } + $yaml .= "---\n\n"; + + // Prefer markdown content, fall back to stripping HTML + $content = $item->content_markdown ?? strip_tags($item->content_html ?? ''); + + return $yaml.$content; + } +} diff --git a/Mcp/Handlers/ContentSearchHandler.php b/Mcp/Handlers/ContentSearchHandler.php new file mode 100644 index 0000000..07adc19 --- /dev/null +++ b/Mcp/Handlers/ContentSearchHandler.php @@ -0,0 +1,139 @@ + 'content_search', + 'description' => 'Search content items by keywords. Searches titles, body content, excerpts, and slugs. Returns results sorted by relevance.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'workspace' => [ + 'type' => 'string', + 'description' => 'Workspace slug or ID (required)', + ], + 'query' => [ + 'type' => 'string', + 'description' => 'Search query - keywords to search for in content (minimum 2 characters)', + ], + 'type' => [ + 'type' => 'string', + 'enum' => ['post', 'page'], + 'description' => 'Limit search to specific content type', + ], + 'status' => [ + 'type' => 'string', + 'enum' => ['draft', 'publish', 'future', 'private', 'pending'], + 'description' => 'Limit search to specific status (default: all statuses)', + ], + 'category' => [ + 'type' => 'string', + 'description' => 'Filter by category slug', + ], + 'tag' => [ + 'type' => 'string', + 'description' => 'Filter by tag slug', + ], + 'date_from' => [ + 'type' => 'string', + 'description' => 'Filter by creation date from (Y-m-d format)', + ], + 'date_to' => [ + 'type' => 'string', + 'description' => 'Filter by creation date to (Y-m-d format)', + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum results to return (default 20, max 50)', + 'default' => 20, + ], + ], + 'required' => ['workspace', 'query'], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $workspace = $this->resolveWorkspace($args['workspace'] ?? null); + + if (! $workspace) { + return ['error' => 'Workspace not found. Provide a valid workspace slug or ID.']; + } + + $query = trim($args['query'] ?? ''); + + if (strlen($query) < 2) { + return ['error' => 'Search query must be at least 2 characters']; + } + + $searchService = app(ContentSearchService::class); + + // Build filters from args + $filters = array_filter([ + 'workspace_id' => $workspace->id, + 'type' => $args['type'] ?? null, + 'status' => $args['status'] ?? null, + 'category' => $args['category'] ?? null, + 'tag' => $args['tag'] ?? null, + 'date_from' => $args['date_from'] ?? null, + 'date_to' => $args['date_to'] ?? null, + 'per_page' => min($args['limit'] ?? 20, 50), + ], fn ($v) => $v !== null); + + $results = $searchService->search($query, $filters); + + $context->logToSession( + "Searched for '{$query}' in workspace {$workspace->slug}, found {$results->total()} results (backend: {$searchService->getBackend()})" + ); + + return [ + 'query' => $query, + 'results' => $results->map(fn ($item) => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'content_type' => $item->content_type?->value, + 'excerpt' => Str::limit($item->excerpt ?? strip_tags($item->content_html ?? $item->content_markdown ?? ''), 200), + 'author' => $item->author?->name, + 'categories' => $item->categories->pluck('name')->all(), + 'tags' => $item->tags->pluck('name')->all(), + 'relevance_score' => $item->getAttribute('relevance_score'), + 'updated_at' => $item->updated_at?->toIso8601String(), + ])->all(), + 'total' => $results->total(), + 'backend' => $searchService->getBackend(), + ]; + } + + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } +} diff --git a/Mcp/Handlers/ContentTaxonomiesHandler.php b/Mcp/Handlers/ContentTaxonomiesHandler.php new file mode 100644 index 0000000..9f27772 --- /dev/null +++ b/Mcp/Handlers/ContentTaxonomiesHandler.php @@ -0,0 +1,88 @@ + 'content_taxonomies', + 'description' => 'List categories and tags available for content. Use this to see what categories/tags exist before creating content.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'workspace' => [ + 'type' => 'string', + 'description' => 'Workspace slug or ID (required)', + ], + 'type' => [ + 'type' => 'string', + 'enum' => ['category', 'tag'], + 'description' => 'Filter by taxonomy type (optional, returns both if not specified)', + ], + ], + 'required' => ['workspace'], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $workspace = $this->resolveWorkspace($args['workspace'] ?? null); + + if (! $workspace) { + return ['error' => 'Workspace not found. Provide a valid workspace slug or ID.']; + } + + $type = $args['type'] ?? null; + + $query = ContentTaxonomy::where('workspace_id', $workspace->id); + + if ($type) { + $query->where('type', $type); + } + + $taxonomies = $query->orderBy('type')->orderBy('name')->get(); + + $context->logToSession("Listed taxonomies for workspace {$workspace->slug}"); + + return [ + 'taxonomies' => $taxonomies->map(fn ($t) => [ + 'id' => $t->id, + 'type' => $t->type, + 'slug' => $t->slug, + 'name' => $t->name, + 'description' => $t->description, + ])->all(), + 'total' => $taxonomies->count(), + 'counts' => [ + 'categories' => $taxonomies->where('type', 'category')->count(), + 'tags' => $taxonomies->where('type', 'tag')->count(), + ], + ]; + } + + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } +} diff --git a/Mcp/Handlers/ContentUpdateHandler.php b/Mcp/Handlers/ContentUpdateHandler.php new file mode 100644 index 0000000..2f221ff --- /dev/null +++ b/Mcp/Handlers/ContentUpdateHandler.php @@ -0,0 +1,260 @@ + 'content_update', + 'description' => 'Update an existing blog post or page. Creates a revision in the history. Only provided fields are updated.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'workspace' => [ + 'type' => 'string', + 'description' => 'Workspace slug or ID (required)', + ], + 'identifier' => [ + 'type' => 'string', + 'description' => 'Content slug or ID to update (required)', + ], + 'title' => [ + 'type' => 'string', + 'description' => 'New title', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'New URL slug', + ], + 'status' => [ + 'type' => 'string', + 'enum' => ['draft', 'publish', 'future', 'private'], + 'description' => 'New publication status', + ], + 'excerpt' => [ + 'type' => 'string', + 'description' => 'New excerpt/summary', + ], + 'content' => [ + 'type' => 'string', + 'description' => 'New content body in markdown', + ], + 'content_html' => [ + 'type' => 'string', + 'description' => 'New content body in HTML (optional)', + ], + 'categories' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Replace categories with this list', + ], + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Replace tags with this list', + ], + 'seo_meta' => [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'keywords' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'description' => 'New SEO metadata', + ], + 'publish_at' => [ + 'type' => 'string', + 'description' => 'New scheduled publish date (ISO format)', + ], + 'change_summary' => [ + 'type' => 'string', + 'description' => 'Summary of changes for revision history', + ], + ], + 'required' => ['workspace', 'identifier'], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $workspace = $this->resolveWorkspace($args['workspace'] ?? null); + + if (! $workspace) { + return ['error' => 'Workspace not found. Provide a valid workspace slug or ID.']; + } + + $identifier = $args['identifier'] ?? null; + + if (! $identifier) { + return ['error' => 'identifier (slug or ID) is required']; + } + + $query = ContentItem::forWorkspace($workspace->id)->native(); + + if (is_numeric($identifier)) { + $item = $query->find($identifier); + } else { + $item = $query->where('slug', $identifier)->first(); + } + + if (! $item) { + return ['error' => 'Content not found']; + } + + // Build update data + $updateData = []; + + if (array_key_exists('title', $args)) { + $updateData['title'] = $args['title']; + } + + if (array_key_exists('excerpt', $args)) { + $updateData['excerpt'] = $args['excerpt']; + } + + if (array_key_exists('content', $args) || array_key_exists('content_markdown', $args)) { + $contentMarkdown = $args['content_markdown'] ?? $args['content'] ?? null; + if ($contentMarkdown !== null) { + $updateData['content_markdown'] = $contentMarkdown; + $updateData['content_html'] = $args['content_html'] ?? Str::markdown($contentMarkdown); + } + } + + if (array_key_exists('content_html', $args) && ! array_key_exists('content', $args)) { + $updateData['content_html'] = $args['content_html']; + } + + if (array_key_exists('status', $args)) { + $status = $args['status']; + if (! in_array($status, ['draft', 'publish', 'future', 'private'])) { + return ['error' => 'status must be draft, publish, future, or private']; + } + $updateData['status'] = $status; + + if ($status === 'future' && array_key_exists('publish_at', $args)) { + $updateData['publish_at'] = Carbon::parse($args['publish_at']); + } + } + + if (array_key_exists('seo_meta', $args)) { + $updateData['seo_meta'] = $args['seo_meta']; + } + + if (array_key_exists('slug', $args)) { + $newSlug = $args['slug']; + if ($newSlug !== $item->slug) { + // Check uniqueness + if (ContentItem::forWorkspace($workspace->id)->where('slug', $newSlug)->where('id', '!=', $item->id)->exists()) { + return ['error' => 'Slug already exists']; + } + $updateData['slug'] = $newSlug; + } + } + + $updateData['last_edited_by'] = Auth::id(); + + // Update item + $item->update($updateData); + + // Handle categories + if (array_key_exists('categories', $args)) { + $categoryIds = $this->resolveOrCreateTaxonomies($workspace, $args['categories'] ?? [], 'category'); + $item->categories()->sync($categoryIds); + } + + // Handle tags + if (array_key_exists('tags', $args)) { + $tagIds = $this->resolveOrCreateTaxonomies($workspace, $args['tags'] ?? [], 'tag'); + $item->tags()->sync($tagIds); + } + + // Create revision + $changeSummary = $args['change_summary'] ?? 'Updated via MCP'; + $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, $changeSummary); + + $item->refresh()->load(['author', 'taxonomies']); + + $context->logToSession("Updated content item: {$item->title} (ID: {$item->id})"); + + return [ + 'ok' => true, + 'item' => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'revision_count' => $item->revision_count, + 'url' => $this->getContentUrl($workspace, $item), + ], + ]; + } + + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } + + protected function resolveOrCreateTaxonomies(Workspace $workspace, array $items, string $type): array + { + $ids = []; + + foreach ($items as $item) { + $taxonomy = ContentTaxonomy::where('workspace_id', $workspace->id) + ->where('type', $type) + ->where(function ($q) use ($item) { + $q->where('slug', $item) + ->orWhere('name', $item); + }) + ->first(); + + if (! $taxonomy) { + // Create new taxonomy + $taxonomy = ContentTaxonomy::create([ + 'workspace_id' => $workspace->id, + 'type' => $type, + 'slug' => Str::slug($item), + 'name' => $item, + ]); + } + + $ids[] = $taxonomy->id; + } + + return $ids; + } + + protected function getContentUrl(Workspace $workspace, ContentItem $item): string + { + $domain = $workspace->domain ?? config('app.url'); + $path = $item->type === 'post' ? "/blog/{$item->slug}" : "/{$item->slug}"; + + return "https://{$domain}{$path}"; + } +} diff --git a/Middleware/WorkspaceRouter.php b/Middleware/WorkspaceRouter.php new file mode 100644 index 0000000..648fe2d --- /dev/null +++ b/Middleware/WorkspaceRouter.php @@ -0,0 +1,68 @@ +attributes->get('workspace_model'); + + if (! $workspace instanceof Workspace) { + return $next($request); + } + + return $this->routeWorkspaceRequest($request, $workspace); + } + + protected function routeWorkspaceRequest(Request $request, Workspace $workspace): Response + { + $path = trim($request->path(), '/'); + $method = $request->method(); + + // Home + if ($path === '' || $path === '/') { + return response($this->render->home($request)); + } + + // Blog listing + if ($path === 'blog') { + return response($this->render->blog($request)); + } + + // Blog post + if (str_starts_with($path, 'blog/')) { + $slug = substr($path, 5); + + return response($this->render->post($request, $slug)); + } + + // Subscribe (waitlist) + if ($path === 'subscribe' && $method === 'POST') { + $result = $this->render->subscribe($request); + + return $result instanceof Response ? $result : response($result); + } + + // Static page (catch-all) + return response($this->render->page($request, $path)); + } +} diff --git a/Migrations/0001_01_01_000001_create_content_tables.php b/Migrations/0001_01_01_000001_create_content_tables.php new file mode 100644 index 0000000..27a1736 --- /dev/null +++ b/Migrations/0001_01_01_000001_create_content_tables.php @@ -0,0 +1,278 @@ +id(); + $table->string('name')->unique(); + $table->string('category'); + $table->text('description')->nullable(); + $table->longText('system_prompt'); + $table->longText('user_template'); + $table->json('variables')->nullable(); + $table->string('model')->default('claude'); + $table->json('model_config')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index('category'); + $table->index('model'); + $table->index('is_active'); + }); + + // 2. Prompt Versions + Schema::create('prompt_versions', function (Blueprint $table) { + $table->id(); + $table->foreignId('prompt_id')->constrained('prompts')->cascadeOnDelete(); + $table->unsignedInteger('version'); + $table->longText('system_prompt'); + $table->longText('user_template'); + $table->json('variables')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['prompt_id', 'version']); + }); + + // 3. Content Items + Schema::create('content_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('last_edited_by')->nullable()->constrained('users')->nullOnDelete(); + $table->unsignedBigInteger('wp_id')->nullable(); + $table->string('wp_guid', 512)->nullable(); + $table->enum('type', ['post', 'page', 'attachment'])->default('post'); + $table->string('content_type')->default('wordpress'); + $table->string('status', 20)->default('draft'); + $table->timestamp('publish_at')->nullable(); + $table->string('slug', 200); + $table->string('title', 500); + $table->text('excerpt')->nullable(); + $table->longText('content_html_original')->nullable(); + $table->longText('content_html_clean')->nullable(); + $table->json('content_json')->nullable(); + $table->longText('content_html')->nullable(); + $table->longText('content_markdown')->nullable(); + $table->json('editor_state')->nullable(); + $table->timestamp('wp_created_at')->nullable(); + $table->timestamp('wp_modified_at')->nullable(); + $table->unsignedBigInteger('featured_media_id')->nullable(); + $table->json('seo_meta')->nullable(); + $table->string('sync_status')->nullable(); + $table->timestamp('synced_at')->nullable(); + $table->text('sync_error')->nullable(); + $table->unsignedInteger('revision_count')->default(0); + $table->json('cdn_urls')->nullable(); + $table->timestamp('cdn_purged_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['workspace_id', 'wp_id', 'type']); + $table->index(['workspace_id', 'slug', 'type']); + $table->index(['workspace_id', 'status', 'type']); + $table->index(['workspace_id', 'sync_status']); + $table->index(['workspace_id', 'status', 'content_type']); + $table->index('author_id'); + $table->index('wp_id'); + $table->index('slug'); + $table->index('content_type'); + $table->index(['status', 'publish_at']); + }); + + // 4. Content Taxonomies + Schema::create('content_taxonomies', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->unsignedBigInteger('wp_id')->nullable(); + $table->enum('type', ['category', 'tag'])->default('category'); + $table->string('name', 200); + $table->string('slug', 200); + $table->text('description')->nullable(); + $table->unsignedBigInteger('parent_wp_id')->nullable(); + $table->unsignedInteger('count')->default(0); + $table->timestamps(); + + $table->unique(['workspace_id', 'wp_id', 'type']); + $table->index(['workspace_id', 'type']); + $table->index(['workspace_id', 'slug']); + }); + + // 5. Content Item Taxonomy + Schema::create('content_item_taxonomy', function (Blueprint $table) { + $table->id(); + $table->foreignId('content_item_id')->constrained('content_items')->cascadeOnDelete(); + $table->foreignId('content_taxonomy_id')->constrained('content_taxonomies')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['content_item_id', 'content_taxonomy_id'], 'content_taxonomy_unique'); + }); + + // 6. Content Media + Schema::create('content_media', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->unsignedBigInteger('wp_id'); + $table->string('title', 500)->nullable(); + $table->string('filename'); + $table->string('mime_type', 100); + $table->unsignedBigInteger('file_size')->default(0); + $table->string('source_url', 1000); + $table->string('cdn_url', 1000)->nullable(); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->string('alt_text', 500)->nullable(); + $table->text('caption')->nullable(); + $table->json('sizes')->nullable(); + $table->timestamps(); + + $table->unique(['workspace_id', 'wp_id']); + $table->index(['workspace_id', 'mime_type']); + }); + + // 7. Content Revisions + Schema::create('content_revisions', function (Blueprint $table) { + $table->id(); + $table->foreignId('content_item_id')->constrained('content_items')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->unsignedInteger('revision_number'); + $table->string('title', 500); + $table->text('excerpt')->nullable(); + $table->longText('content_html')->nullable(); + $table->longText('content_markdown')->nullable(); + $table->json('content_json')->nullable(); + $table->json('editor_state')->nullable(); + $table->json('seo_meta')->nullable(); + $table->string('status', 20); + $table->string('change_type', 50)->default('edit'); + $table->text('change_summary')->nullable(); + $table->unsignedInteger('word_count')->nullable(); + $table->unsignedInteger('char_count')->nullable(); + $table->timestamps(); + + $table->index(['content_item_id', 'revision_number']); + $table->index(['content_item_id', 'created_at']); + $table->index(['user_id', 'created_at']); + }); + + // 8. Content Webhook Logs + Schema::create('content_webhook_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->nullable()->constrained('workspaces')->cascadeOnDelete(); + $table->string('event_type', 50); + $table->unsignedBigInteger('wp_id')->nullable(); + $table->string('content_type', 20)->nullable(); + $table->json('payload')->nullable(); + $table->enum('status', ['pending', 'processing', 'completed', 'failed'])->default('pending'); + $table->text('error_message')->nullable(); + $table->string('source_ip', 45)->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + $table->index(['status', 'created_at']); + }); + + // 9. Content Tasks + Schema::create('content_tasks', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('prompt_id')->constrained('prompts')->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->string('priority')->default('normal'); + $table->json('input_data'); + $table->longText('output')->nullable(); + $table->json('metadata')->nullable(); + $table->string('target_type')->nullable(); + $table->unsignedBigInteger('target_id')->nullable(); + $table->timestamp('scheduled_for')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->index('status'); + $table->index('priority'); + $table->index('scheduled_for'); + $table->index(['target_type', 'target_id']); + }); + + // 10. Content Briefs + Schema::create('content_briefs', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('content_item_id')->nullable()->constrained('content_items')->nullOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status', 32)->default('draft'); + $table->string('type', 32)->default('article'); + $table->json('target_audience')->nullable(); + $table->json('keywords')->nullable(); + $table->json('outline')->nullable(); + $table->json('tone_style')->nullable(); + $table->json('references')->nullable(); + $table->unsignedInteger('target_word_count')->nullable(); + $table->date('target_publish_date')->nullable(); + $table->timestamp('scheduled_for')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['workspace_id', 'status']); + $table->index('scheduled_for'); + }); + + // 11. AI Usage + Schema::create('ai_usage', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('model'); + $table->string('provider')->default('anthropic'); + $table->string('feature'); + $table->unsignedInteger('input_tokens'); + $table->unsignedInteger('output_tokens'); + $table->decimal('cost', 10, 6)->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'created_at']); + $table->index(['model', 'created_at']); + $table->index(['feature', 'created_at']); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('ai_usage'); + Schema::dropIfExists('content_briefs'); + Schema::dropIfExists('content_tasks'); + Schema::dropIfExists('content_webhook_logs'); + Schema::dropIfExists('content_revisions'); + Schema::dropIfExists('content_media'); + Schema::dropIfExists('content_item_taxonomy'); + Schema::dropIfExists('content_taxonomies'); + Schema::dropIfExists('content_items'); + Schema::dropIfExists('prompt_versions'); + Schema::dropIfExists('prompts'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/Migrations/2026_01_26_000001_make_content_media_wp_id_nullable.php b/Migrations/2026_01_26_000001_make_content_media_wp_id_nullable.php new file mode 100644 index 0000000..1a16b4c --- /dev/null +++ b/Migrations/2026_01_26_000001_make_content_media_wp_id_nullable.php @@ -0,0 +1,45 @@ +unsignedBigInteger('wp_id')->nullable()->change(); + }); + + // Drop the unique constraint that includes wp_id + Schema::table('content_media', function (Blueprint $table) { + $table->dropUnique(['workspace_id', 'wp_id']); + }); + + // Add a unique constraint that allows multiple null wp_ids + Schema::table('content_media', function (Blueprint $table) { + $table->unique(['workspace_id', 'wp_id'], 'content_media_workspace_wp_unique'); + }); + } + + public function down(): void + { + Schema::table('content_media', function (Blueprint $table) { + $table->dropUnique('content_media_workspace_wp_unique'); + }); + + Schema::table('content_media', function (Blueprint $table) { + $table->unique(['workspace_id', 'wp_id']); + }); + + Schema::table('content_media', function (Blueprint $table) { + $table->unsignedBigInteger('wp_id')->nullable(false)->change(); + }); + } +}; diff --git a/Migrations/2026_01_26_000002_add_preview_token_to_content_items.php b/Migrations/2026_01_26_000002_add_preview_token_to_content_items.php new file mode 100644 index 0000000..c96a890 --- /dev/null +++ b/Migrations/2026_01_26_000002_add_preview_token_to_content_items.php @@ -0,0 +1,31 @@ +string('preview_token', 64)->nullable()->after('cdn_purged_at'); + $table->timestamp('preview_expires_at')->nullable()->after('preview_token'); + + $table->index('preview_token'); + }); + } + + public function down(): void + { + Schema::table('content_items', function (Blueprint $table) { + $table->dropIndex(['preview_token']); + $table->dropColumn(['preview_token', 'preview_expires_at']); + }); + } +}; diff --git a/Migrations/2026_01_26_000003_add_retry_fields_to_content_webhook_logs.php b/Migrations/2026_01_26_000003_add_retry_fields_to_content_webhook_logs.php new file mode 100644 index 0000000..4506ffd --- /dev/null +++ b/Migrations/2026_01_26_000003_add_retry_fields_to_content_webhook_logs.php @@ -0,0 +1,40 @@ +unsignedTinyInteger('retry_count')->default(0)->after('error_message'); + $table->unsignedTinyInteger('max_retries')->default(5)->after('retry_count'); + $table->timestamp('next_retry_at')->nullable()->after('max_retries'); + $table->text('last_error')->nullable()->after('next_retry_at'); + + // Index for efficient querying of retryable webhooks + $table->index(['status', 'next_retry_at', 'retry_count'], 'webhook_retry_queue_idx'); + }); + } + + public function down(): void + { + Schema::table('content_webhook_logs', function (Blueprint $table) { + $table->dropIndex('webhook_retry_queue_idx'); + $table->dropColumn(['retry_count', 'max_retries', 'next_retry_at', 'last_error']); + }); + } +}; diff --git a/Migrations/2026_01_26_000003_create_content_webhook_endpoints_table.php b/Migrations/2026_01_26_000003_create_content_webhook_endpoints_table.php new file mode 100644 index 0000000..095a8d2 --- /dev/null +++ b/Migrations/2026_01_26_000003_create_content_webhook_endpoints_table.php @@ -0,0 +1,53 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->string('name'); + $table->text('secret')->nullable(); + $table->json('allowed_types')->nullable(); + $table->boolean('is_enabled')->default(true); + $table->unsignedInteger('failure_count')->default(0); + $table->timestamp('last_received_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'is_enabled']); + $table->index('uuid'); + }); + + // Add endpoint_id to webhook logs + Schema::table('content_webhook_logs', function (Blueprint $table) { + $table->foreignId('endpoint_id') + ->nullable() + ->after('workspace_id') + ->constrained('content_webhook_endpoints') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('content_webhook_logs', function (Blueprint $table) { + $table->dropConstrainedForeignId('endpoint_id'); + }); + + Schema::dropIfExists('content_webhook_endpoints'); + } +}; diff --git a/Models/AIUsage.php b/Models/AIUsage.php new file mode 100644 index 0000000..2d3558c --- /dev/null +++ b/Models/AIUsage.php @@ -0,0 +1,226 @@ + 'integer', + 'output_tokens' => 'integer', + 'cost_estimate' => 'decimal:6', + 'duration_ms' => 'integer', + 'metadata' => 'array', + ]; + + /** + * Model pricing per 1M tokens. + */ + protected static array $pricing = [ + 'claude-sonnet-4-20250514' => ['input' => 3.00, 'output' => 15.00], + 'claude-opus-4-20250514' => ['input' => 15.00, 'output' => 75.00], + 'gemini-2.0-flash' => ['input' => 0.075, 'output' => 0.30], + 'gemini-2.0-flash-thinking' => ['input' => 0.70, 'output' => 3.50], + 'gpt-4o' => ['input' => 2.50, 'output' => 10.00], + 'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60], + ]; + + /** + * Get the workspace this usage belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the brief this usage is associated with. + */ + public function brief(): BelongsTo + { + return $this->belongsTo(ContentBrief::class, 'brief_id'); + } + + /** + * Get the target model (polymorphic). + */ + public function target(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get total tokens used. + */ + public function getTotalTokensAttribute(): int + { + return $this->input_tokens + $this->output_tokens; + } + + /** + * Calculate cost estimate based on model pricing. + */ + public static function calculateCost(string $model, int $inputTokens, int $outputTokens): float + { + $pricing = static::$pricing[$model] ?? ['input' => 0, 'output' => 0]; + + return ($inputTokens * $pricing['input'] / 1_000_000) + + ($outputTokens * $pricing['output'] / 1_000_000); + } + + /** + * Create a usage record from an AgenticResponse. + */ + public static function fromResponse( + \Mod\Agentic\Services\AgenticResponse $response, + string $purpose, + ?int $workspaceId = null, + ?int $briefId = null, + ?Model $target = null + ): self { + $provider = str_contains($response->model, 'gemini') ? self::PROVIDER_GEMINI : + (str_contains($response->model, 'claude') ? self::PROVIDER_CLAUDE : self::PROVIDER_OPENAI); + + return self::create([ + 'workspace_id' => $workspaceId, + 'provider' => $provider, + 'model' => $response->model, + 'purpose' => $purpose, + 'input_tokens' => $response->inputTokens, + 'output_tokens' => $response->outputTokens, + 'cost_estimate' => $response->estimateCost(), + 'brief_id' => $briefId, + 'target_type' => $target ? get_class($target) : null, + 'target_id' => $target?->id, + 'duration_ms' => $response->durationMs, + 'metadata' => [ + 'stop_reason' => $response->stopReason, + ], + ]); + } + + /** + * Scope by provider. + */ + public function scopeProvider($query, string $provider) + { + return $query->where('provider', $provider); + } + + /** + * Scope by purpose. + */ + public function scopePurpose($query, string $purpose) + { + return $query->where('purpose', $purpose); + } + + /** + * Scope to a date range. + */ + public function scopeDateRange($query, $start, $end) + { + return $query->whereBetween('created_at', [$start, $end]); + } + + /** + * Scope for current month. + */ + public function scopeThisMonth($query) + { + return $query->whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year); + } + + /** + * Get aggregated stats for a workspace. + */ + public static function statsForWorkspace(?int $workspaceId, ?string $period = 'month'): array + { + $query = static::query(); + + if ($workspaceId) { + $query->where('workspace_id', $workspaceId); + } + + match ($period) { + 'day' => $query->whereDate('created_at', today()), + 'week' => $query->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()]), + 'month' => $query->thisMonth(), + 'year' => $query->whereYear('created_at', now()->year), + default => null, + }; + + return [ + 'total_requests' => $query->count(), + 'total_input_tokens' => $query->sum('input_tokens'), + 'total_output_tokens' => $query->sum('output_tokens'), + 'total_cost' => (float) $query->sum('cost_estimate'), + 'by_provider' => $query->clone() + ->selectRaw('provider, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, SUM(cost_estimate) as cost') + ->groupBy('provider') + ->get() + ->keyBy('provider') + ->toArray(), + 'by_purpose' => $query->clone() + ->selectRaw('purpose, COUNT(*) as count, SUM(cost_estimate) as cost') + ->groupBy('purpose') + ->get() + ->keyBy('purpose') + ->toArray(), + ]; + } +} diff --git a/Models/ContentAuthor.php b/Models/ContentAuthor.php new file mode 100644 index 0000000..a881792 --- /dev/null +++ b/Models/ContentAuthor.php @@ -0,0 +1,68 @@ + 'array', + ]; + + /** + * Get the workspace this author belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get all content items by this author. + */ + public function contentItems(): HasMany + { + return $this->hasMany(ContentItem::class, 'author_id'); + } + + /** + * Scope to filter by workspace. + */ + public function scopeForWorkspace($query, int $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + /** + * Scope to find by WordPress ID. + */ + public function scopeByWpId($query, int $wpId) + { + return $query->where('wp_id', $wpId); + } +} diff --git a/Models/ContentBrief.php b/Models/ContentBrief.php new file mode 100644 index 0000000..2117334 --- /dev/null +++ b/Models/ContentBrief.php @@ -0,0 +1,316 @@ + BriefContentType::class, + 'keywords' => 'array', + 'prompt_variables' => 'array', + 'metadata' => 'array', + 'generation_log' => 'array', + 'scheduled_for' => 'datetime', + 'generated_at' => 'datetime', + 'refined_at' => 'datetime', + 'published_at' => 'datetime', + ]; + + /** + * Get the workspace this brief belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the published ContentItem if any. + */ + public function contentItem(): BelongsTo + { + return $this->belongsTo(ContentItem::class); + } + + /** + * Get AI usage records for this brief. + */ + public function aiUsage(): HasMany + { + return $this->hasMany(AIUsage::class, 'brief_id'); + } + + /** + * Mark the brief as queued for generation. + */ + public function markQueued(): void + { + $this->update(['status' => self::STATUS_QUEUED]); + } + + /** + * Mark the brief as currently generating. + */ + public function markGenerating(): void + { + $this->update(['status' => self::STATUS_GENERATING]); + } + + /** + * Mark the brief as ready for review with draft output. + */ + public function markDraftComplete(string $draftOutput, array $log = []): void + { + $this->update([ + 'draft_output' => $draftOutput, + 'generated_at' => now(), + 'generation_log' => array_merge($this->generation_log ?? [], $log), + ]); + } + + /** + * Mark the brief as refined with Claude output. + */ + public function markRefined(string $refinedOutput, array $log = []): void + { + $this->update([ + 'refined_output' => $refinedOutput, + 'refined_at' => now(), + 'status' => self::STATUS_REVIEW, + 'generation_log' => array_merge($this->generation_log ?? [], $log), + ]); + } + + /** + * Mark the brief as published. + */ + public function markPublished(string $finalContent, ?string $publishedUrl = null, ?int $contentItemId = null): void + { + $this->update([ + 'final_content' => $finalContent, + 'published_url' => $publishedUrl, + 'content_item_id' => $contentItemId, + 'published_at' => now(), + 'status' => self::STATUS_PUBLISHED, + ]); + } + + /** + * Mark the brief as failed. + */ + public function markFailed(string $error): void + { + $this->update([ + 'status' => self::STATUS_FAILED, + 'error_message' => $error, + ]); + } + + /** + * Scope to pending briefs. + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * Scope to queued briefs ready for processing. + */ + public function scopeReadyToProcess($query) + { + return $query->where('status', self::STATUS_QUEUED) + ->where(function ($q) { + $q->whereNull('scheduled_for') + ->orWhere('scheduled_for', '<=', now()); + }) + ->orderByDesc('priority') + ->orderBy('created_at'); + } + + /** + * Scope to briefs needing review. + */ + public function scopeNeedsReview($query) + { + return $query->where('status', self::STATUS_REVIEW); + } + + /** + * Scope by service. + */ + public function scopeForService($query, string $service) + { + return $query->where('service', $service); + } + + /** + * Get the total estimated cost for this brief. + */ + public function getTotalCostAttribute(): float + { + return $this->aiUsage()->sum('cost_estimate'); + } + + /** + * Get the best available content (refined > draft). + */ + public function getBestContentAttribute(): ?string + { + return $this->final_content ?? $this->refined_output ?? $this->draft_output; + } + + /** + * Check if the brief has been generated. + */ + public function isGenerated(): bool + { + return $this->draft_output !== null; + } + + /** + * Check if the brief has been refined. + */ + public function isRefined(): bool + { + return $this->refined_output !== null; + } + + /** + * Check if the brief is in a terminal state. + */ + public function isFinished(): bool + { + return in_array($this->status, [self::STATUS_PUBLISHED, self::STATUS_FAILED]); + } + + /** + * Build the prompt context for AI generation. + */ + public function buildPromptContext(): array + { + return [ + 'title' => $this->title, + 'description' => $this->description, + 'keywords' => $this->keywords ?? [], + 'category' => $this->category, + 'difficulty' => $this->difficulty, + 'target_word_count' => $this->target_word_count, + 'content_type' => $this->content_type instanceof BriefContentType + ? $this->content_type->value + : $this->content_type, + 'service' => $this->service, + ...$this->prompt_variables ?? [], + ]; + } + + /** + * Get the content type enum instance. + */ + public function getContentTypeEnum(): ?BriefContentType + { + return $this->content_type instanceof BriefContentType + ? $this->content_type + : BriefContentType::tryFromString($this->content_type); + } + + /** + * Get the recommended timeout for AI generation based on content type. + */ + public function getRecommendedTimeout(): int + { + $enum = $this->getContentTypeEnum(); + + return $enum?->recommendedTimeout() ?? 180; + } + + /** + * Get Flux badge colour for content type. + */ + public function getContentTypeColorAttribute(): string + { + $enum = $this->getContentTypeEnum(); + + return $enum?->color() ?? 'zinc'; + } + + /** + * Get human-readable content type label. + */ + public function getContentTypeLabelAttribute(): string + { + $enum = $this->getContentTypeEnum(); + + return $enum?->label() ?? ucfirst(str_replace('_', ' ', $this->content_type ?? 'unknown')); + } +} diff --git a/Models/ContentItem.php b/Models/ContentItem.php new file mode 100644 index 0000000..fe501a2 --- /dev/null +++ b/Models/ContentItem.php @@ -0,0 +1,713 @@ + ContentType::class, + 'content_json' => 'array', + 'editor_state' => 'array', + 'seo_meta' => 'array', + 'cdn_urls' => 'array', + 'wp_created_at' => 'datetime', + 'wp_modified_at' => 'datetime', + 'publish_at' => 'datetime', + 'synced_at' => 'datetime', + 'cdn_purged_at' => 'datetime', + 'preview_expires_at' => 'datetime', + 'revision_count' => 'integer', + ]; + + /** + * Get the workspace this content belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the author of this content. + */ + public function author(): BelongsTo + { + return $this->belongsTo(ContentAuthor::class, 'author_id'); + } + + /** + * Get the user who last edited this content. + */ + public function lastEditedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'last_edited_by'); + } + + /** + * Get the revision history for this content. + */ + public function revisions(): HasMany + { + return $this->hasMany(ContentRevision::class)->orderByDesc('revision_number'); + } + + /** + * Get the featured media for this content. + */ + public function featuredMedia(): BelongsTo + { + return $this->belongsTo(ContentMedia::class, 'featured_media_id', 'wp_id') + ->where('workspace_id', $this->workspace_id); + } + + /** + * Get the taxonomies (categories and tags) for this content. + */ + public function taxonomies(): BelongsToMany + { + return $this->belongsToMany(ContentTaxonomy::class, 'content_item_taxonomy') + ->withTimestamps(); + } + + /** + * Get only categories. + */ + public function categories(): BelongsToMany + { + return $this->taxonomies()->where('type', 'category'); + } + + /** + * Get only tags. + */ + public function tags(): BelongsToMany + { + return $this->taxonomies()->where('type', 'tag'); + } + + /** + * Scope to filter by workspace. + */ + public function scopeForWorkspace($query, int $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + /** + * Scope to only published content. + */ + public function scopePublished($query) + { + return $query->where('status', 'publish'); + } + + /** + * Scope to only posts. + */ + public function scopePosts($query) + { + return $query->where('type', 'post'); + } + + /** + * Scope to only pages. + */ + public function scopePages($query) + { + return $query->where('type', 'page'); + } + + /** + * Scope to items needing sync. + */ + public function scopeNeedsSync($query) + { + return $query->whereIn('sync_status', ['pending', 'failed', 'stale']); + } + + /** + * Scope to find by slug. + */ + public function scopeBySlug($query, string $slug) + { + return $query->where('slug', $slug); + } + + /** + * Scope to filter by slug prefix (e.g., 'help/' for help articles). + */ + public function scopeWithSlugPrefix($query, string $prefix) + { + return $query->where('slug', 'like', $prefix.'%'); + } + + /** + * Scope to help articles (pages with 'help' category or 'help/' slug prefix). + */ + public function scopeHelpArticles($query) + { + return $query->where(function ($q) { + // Match pages with 'help/' slug prefix + $q->where('slug', 'like', 'help/%') + // Or pages in a 'help' category + ->orWhereHas('categories', function ($catQuery) { + $catQuery->where('slug', 'help') + ->orWhere('slug', 'help-articles') + ->orWhere('name', 'like', '%help%'); + }); + }); + } + + /** + * Scope to filter by content type. + */ + public function scopeOfContentType($query, ContentType|string $contentType) + { + $value = $contentType instanceof ContentType ? $contentType->value : $contentType; + + return $query->where('content_type', $value); + } + + /** + * Scope to only WordPress content (legacy). + */ + public function scopeWordpress($query) + { + return $query->where('content_type', ContentType::WORDPRESS->value); + } + + /** + * Scope to only native Host UK content. + */ + public function scopeHostuk($query) + { + return $query->where('content_type', ContentType::HOSTUK->value); + } + + /** + * Scope to only satellite content. + */ + public function scopeSatellite($query) + { + return $query->where('content_type', ContentType::SATELLITE->value); + } + + /** + * Scope to only native content (non-WordPress). + * Includes: native, hostuk, satellite + */ + public function scopeNative($query) + { + return $query->whereIn('content_type', ContentType::nativeTypeValues()); + } + + /** + * Scope to only strictly native content (new default type). + */ + public function scopeStrictlyNative($query) + { + return $query->where('content_type', ContentType::NATIVE->value); + } + + /** + * Check if this is WordPress content (legacy). + */ + public function isWordpress(): bool + { + return $this->content_type === ContentType::WORDPRESS; + } + + /** + * Check if this is native Host UK content. + */ + public function isHostuk(): bool + { + return $this->content_type === ContentType::HOSTUK; + } + + /** + * Check if this is satellite content. + */ + public function isSatellite(): bool + { + return $this->content_type === ContentType::SATELLITE; + } + + /** + * Check if this is strictly native content (new default type). + */ + public function isNative(): bool + { + return $this->content_type === ContentType::NATIVE; + } + + /** + * Check if this is any native content type (non-WordPress). + */ + public function isAnyNative(): bool + { + return $this->content_type?->isNative() ?? false; + } + + /** + * Check if this content uses the Flux editor (non-WordPress). + */ + public function usesFluxEditor(): bool + { + return $this->content_type?->usesFluxEditor() ?? false; + } + + /** + * Get the display content (prefers clean HTML, falls back to markdown). + */ + public function getDisplayContentAttribute(): string + { + if ($this->usesFluxEditor()) { + return $this->content_html ?? $this->content_markdown ?? ''; + } + + return $this->content_html_clean ?? $this->content_html_original ?? ''; + } + + /** + * Get sanitised HTML content for safe rendering. + * + * Uses HTMLPurifier to remove XSS vectors while preserving + * safe HTML elements like paragraphs, headings, lists, etc. + */ + public function getSanitisedContent(): string + { + $content = $this->display_content; + + if (empty($content)) { + return ''; + } + + // Use the StaticPageSanitiser if available + if (class_exists(\Mod\Bio\Services\StaticPageSanitiser::class)) { + return app(\Mod\Bio\Services\StaticPageSanitiser::class)->sanitiseHtml($content); + } + + // Fallback: basic sanitisation using strip_tags with allowed tags + $allowedTags = '