monorepo sepration

This commit is contained in:
Snider 2026-01-26 23:59:46 +00:00
parent c29badf6b7
commit f990dc1bd3
115 changed files with 17014 additions and 618 deletions

View file

@ -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=

View file

@ -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"
}
}
```

View file

@ -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 }}

View file

@ -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 }}

181
Boot.php Normal file
View file

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Core\Content;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering;
use Core\Events\WebRoutesRegistering;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
/**
* Content Module Boot
*
* WordPress sync/API system for content management.
* Handles syncing content from WordPress via REST API,
* content revisions, media, taxonomies, and webhook processing.
* Also provides public satellite pages (blog, help).
*/
class Boot extends ServiceProvider
{
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
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);
}
}

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Core\Content\Concerns;
/**
* Trait for making ContentItem searchable with Laravel Scout.
*
* This trait should be added to ContentItem when Laravel Scout is installed.
* It provides:
* - Searchable array definition for indexing
* - Custom index name per workspace
* - Filtering configuration for Meilisearch
*
* Usage:
* 1. Install Laravel Scout: composer require laravel/scout
* 2. Add this trait to ContentItem model
* 3. Configure search backend in config/content.php
*
* @see https://laravel.com/docs/scout
*/
trait SearchableContent
{
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
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<string>
*/
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<string>
*/
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;
}
}

View file

@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace Core\Content\Console\Commands;
use Mod\Agentic\Services\ContentService;
use Illuminate\Console\Command;
class ContentBatch extends Command
{
protected $signature = 'content:batch
{action=list : Action: list, status, schedule}
{batch? : Batch ID for status/schedule actions}';
protected $description = 'Manage content generation batches';
public function __construct(
protected ContentService $batchService
) {
parent::__construct();
}
public function handle(): int
{
$action = $this->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: <info>'.count($batches).'</info>');
$this->line("Total articles: <info>{$totalArticles}</info>");
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: <comment>{$batchId}</comment>");
$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: <info>{$totals['drafted']}/{$totals['total']}</info> articles drafted ({$overallProgress}%)");
$this->line("Published: <info>{$totals['published']}</info> 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("<info>{$week}</info>");
// Parse tasks in week
$tasks = $weeks[2][$i];
preg_match_all('/- \[([ x])\] (.+)/', $tasks, $items);
foreach ($items[1] as $j => $status) {
$icon = $status === 'x' ? '<fg=green>✓</>' : '<fg=yellow>○</>';
$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(' <info>list</info> - List all available batches');
$this->line(' <info>status</info> - Show status for all batches or a specific batch');
$this->line(' <info>schedule</info> - 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 "<fg={$colour}>{$bar}</> {$percent}%";
}
}

View file

@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace Core\Content\Console\Commands;
use Mod\Agentic\Services\ContentService;
use Illuminate\Console\Command;
class ContentGenerate extends Command
{
protected $signature = 'content:generate
{batch? : Batch ID (e.g., batch-001-link-getting-started)}
{--provider=gemini : AI provider (gemini for bulk, claude for refinement)}
{--refine : Refine existing drafts using Claude}
{--dry-run : Show what would be generated without creating files}
{--article= : Generate only a specific article by slug}';
protected $description = 'Generate content from batch specifications';
public function __construct(
protected ContentService $batchService
) {
parent::__construct();
}
public function handle(): int
{
$batchId = $this->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: <info>php artisan content:generate batch-001-link-getting-started</info>');
return self::SUCCESS;
}
protected function generateBatch(string $batchId, string $provider, bool $dryRun, ?string $articleSlug): int
{
$this->info("Generating content for batch: <comment>{$batchId}</comment>");
$this->line("Provider: <comment>{$provider}</comment>");
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' => '<fg=green>✓</>',
'skipped' => '<fg=yellow>-</>',
'would_generate' => '<fg=blue>?</>',
'failed' => '<fg=red>✗</>',
};
$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} <comment>{$slug}</comment> - {$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: <comment>{$batchId}</comment>");
$this->line('Using: <comment>Claude</comment> 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(" <fg=yellow>-</> <comment>{$slug}</comment> - No draft found");
$skipped++;
continue;
}
if ($dryRun) {
$this->line(" <fg=blue>?</> <comment>{$slug}</comment> - 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(" <fg=green>✓</> <comment>{$slug}</comment> - Refined");
$refined++;
} catch (\Exception $e) {
$this->line(" <fg=red>✗</> <comment>{$slug}</comment> - {$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;
}
}

View file

@ -0,0 +1,957 @@
<?php
declare(strict_types=1);
namespace Core\Content\Console\Commands;
use Core\Content\Enums\ContentType;
use Core\Content\Models\ContentAuthor;
use Core\Content\Models\ContentItem;
use Core\Content\Models\ContentMedia;
use Core\Content\Models\ContentTaxonomy;
use Core\Mod\Tenant\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Import content from a WordPress site via REST API.
*
* This command imports posts, pages, categories, tags, authors, and media
* from a WordPress site into the native content system. It preserves
* WordPress IDs in the wp_id field for future reference and is idempotent
* (re-running updates existing records, doesn't duplicate).
*/
class ContentImportWordPress extends Command
{
protected $signature = 'content:import-wordpress
{url : WordPress site URL (e.g., https://example.com)}
{--workspace= : Target workspace ID or slug (defaults to main)}
{--types=posts,pages : Content types to import (posts,pages,media,authors,categories,tags)}
{--since= : Only import content modified after this date (ISO 8601 format)}
{--limit= : Maximum number of items to import per type}
{--skip-media : Skip downloading media files}
{--dry-run : Preview what would be imported without making changes}
{--username= : WordPress username for authenticated endpoints}
{--password= : WordPress application password}';
protected $description = 'Import content from a WordPress site via REST API';
protected string $baseUrl;
protected ?string $token = null;
protected ?Workspace $workspace = null;
protected bool $dryRun = false;
protected bool $skipMedia = false;
protected ?Carbon $since = null;
protected ?int $limit = null;
protected array $stats = [
'authors' => ['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*\/?wp:[^>]*-->/s', '', $html);
// Remove empty paragraphs
$html = preg_replace('/<p>\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 &#8217;)
$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.');
}
}
}

View file

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace Core\Content\Console\Commands;
use Mod\Agentic\Services\ContentService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ContentValidate extends Command
{
protected $signature = 'content:validate
{batch? : Batch ID to validate (or "all" for all drafts)}
{--fix : Attempt to auto-fix simple issues}
{--strict : Fail on warnings as well as errors}';
protected $description = 'Validate content drafts against quality gates';
public function __construct(
protected ContentService $batchService
) {
parent::__construct();
}
public function handle(): int
{
$batchId = $this->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(' <info>php artisan content:validate batch-001</info> - Validate specific batch');
$this->line(' <info>php artisan content:validate all</info> - Validate all drafts');
$this->line(' <info>php artisan content:validate all --fix</info> - 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: <comment>{$batchId}</comment>");
$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(" <fg=gray>?</> <comment>{$slug}</comment> - 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 <comment>'.count($files).'</comment> 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(" <fg=green>✓</> <comment>{$identifier}</comment> - Valid ({$validation['word_count']} words)");
return;
}
if ($validation['valid']) {
$this->line(" <fg=yellow>!</> <comment>{$identifier}</comment> - Valid with warnings");
} else {
$this->line(" <fg=red>✗</> <comment>{$identifier}</comment> - Invalid");
}
foreach ($validation['errors'] as $error) {
$this->line(" <fg=red>Error:</> {$error}");
}
foreach ($validation['warnings'] as $warning) {
$this->line(" <fg=yellow>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;
}
}

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Core\Content\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Content\Services\WebhookRetryService;
/**
* ProcessPendingWebhooks
*
* Processes webhooks that are pending retry using exponential backoff.
* Designed to run every minute via the scheduler.
*
* Backoff intervals: 1m, 5m, 15m, 1h, 4h
* Max retries: 5 (default, configurable per webhook)
*/
class ProcessPendingWebhooks extends Command
{
protected $signature = 'content:process-webhooks
{--batch=50 : Maximum number of webhooks to process per run}
{--dry-run : Show what would be processed without making changes}';
protected $description = 'Process pending webhook retries with exponential backoff';
public function handle(WebhookRetryService $service): int
{
$batchSize = (int) $this->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;
}
}

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Core\Content\Console\Commands;
use Illuminate\Console\Command;
use Core\Content\Models\ContentRevision;
/**
* Prune old content revisions based on retention policy.
*
* Removes revisions that exceed the configured limits:
* - Maximum revisions per content item (default: 50)
* - Maximum age in days (default: 180)
*
* Published revisions are preserved by default.
*/
class PruneContentRevisions extends Command
{
protected $signature = 'content:prune-revisions
{--dry-run : Show what would be deleted without actually deleting}
{--max-revisions= : Override maximum revisions per item}
{--max-age= : Override maximum age in days}';
protected $description = 'Prune old content revisions based on retention policy';
public function handle(): int
{
$dryRun = $this->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;
}
}

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Core\Content\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Content\Models\ContentItem;
/**
* PublishScheduledContent
*
* Automatically publishes content items that have a scheduled publish_at
* date in the past and are still in 'future' status.
*
* Run via scheduler every minute to ensure timely publishing.
*/
class PublishScheduledContent extends Command
{
protected $signature = 'content:publish-scheduled
{--dry-run : Show what would be published without making changes}
{--limit=100 : Maximum number of items to publish per run}';
protected $description = 'Publish scheduled content items whose publish_at time has passed';
public function handle(): int
{
$dryRun = $this->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;
}
}

View file

@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace Core\Content\Controllers\Api;
use Core\Front\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Mod\Api\Concerns\HasApiResponses;
use Core\Mod\Api\Concerns\ResolvesWorkspace;
use Core\Content\Models\ContentBrief;
use Core\Content\Resources\ContentBriefResource;
/**
* Content Brief API Controller
*
* CRUD operations for content briefs.
* Supports both session and API key authentication.
*/
class ContentBriefController extends Controller
{
use HasApiResponses;
use ResolvesWorkspace;
/**
* List all briefs.
*
* GET /api/v1/content/briefs
*/
public function index(Request $request): JsonResponse
{
$workspace = $this->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),
]);
}
}

View file

@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Core\Content\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Api\Concerns\HasApiResponses;
use Core\Mod\Api\Concerns\ResolvesWorkspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Core\Content\Models\ContentMedia;
/**
* Content Media API Controller
*
* Upload and manage media files for content.
*/
class ContentMediaController extends Controller
{
use HasApiResponses;
use ResolvesWorkspace;
/**
* Allowed MIME types for upload.
*/
protected array $allowedTypes = [
// Images
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documents
'application/pdf',
];
/**
* Max file size in bytes (10MB).
*/
protected int $maxFileSize = 10 * 1024 * 1024;
/**
* List media for the workspace.
*
* GET /api/v1/content/media
*/
public function index(Request $request): JsonResponse
{
$workspace = $this->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.');
}
}

View file

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Core\Content\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Api\Concerns\HasApiResponses;
use Core\Mod\Api\Concerns\ResolvesWorkspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Content\Models\ContentItem;
use Core\Content\Models\ContentRevision;
use Core\Content\Resources\ContentRevisionResource;
/**
* Content Revision API Controller
*
* List and restore content revisions.
*/
class ContentRevisionController extends Controller
{
use HasApiResponses;
use ResolvesWorkspace;
/**
* List all revisions for a content item.
*
* GET /api/v1/content/items/{item}/revisions
*/
public function index(Request $request, ContentItem $item): JsonResponse
{
$workspace = $this->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;
}
}

View file

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Core\Content\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Api\Concerns\HasApiResponses;
use Core\Mod\Api\Concerns\ResolvesWorkspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Content\Services\ContentSearchService;
/**
* Content Search API Controller
*
* Provides full-text search endpoints for content items.
* Supports both session and API key authentication.
*/
class ContentSearchController extends Controller
{
use HasApiResponses;
use ResolvesWorkspace;
public function __construct(
protected ContentSearchService $searchService
) {}
/**
* Search content items.
*
* GET /api/v1/content/search
*
* @queryParam q string required The search query (minimum 2 characters)
* @queryParam type string Filter by content type (post, page)
* @queryParam status string|array Filter by status (draft, publish, future, private, pending)
* @queryParam category string Filter by category slug
* @queryParam tag string Filter by tag slug
* @queryParam content_type string Filter by content source type (native, hostuk, satellite, wordpress)
* @queryParam date_from string Filter by creation date (from)
* @queryParam date_to string Filter by creation date (to)
* @queryParam per_page int Results per page (default 20, max 50)
* @queryParam page int Page number
*/
public function search(Request $request): JsonResponse
{
$workspace = $this->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,
]);
}
}

View file

@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace Core\Content\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Core\Content\Jobs\ProcessContentWebhook;
use Core\Content\Models\ContentWebhookEndpoint;
use Core\Content\Models\ContentWebhookLog;
/**
* Controller for receiving external content webhooks.
*
* Handles incoming webhooks from WordPress, CMS systems, and custom integrations.
* Webhooks are logged and dispatched to a job for async processing.
*/
class ContentWebhookController extends Controller
{
/**
* Receive a webhook from an external source.
*
* POST /api/content/webhooks/{endpoint}
*/
public function receive(Request $request, ContentWebhookEndpoint $endpoint): Response
{
// Check if endpoint is enabled
if (! $endpoint->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;
}
}

View file

@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace Core\Content\Controllers\Api;
use Core\Front\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Core\Mod\Api\Concerns\HasApiResponses;
use Core\Mod\Api\Concerns\ResolvesWorkspace;
use Core\Content\Jobs\GenerateContentJob;
use Core\Content\Models\AIUsage;
use Core\Content\Models\ContentBrief;
use Core\Content\Resources\ContentBriefResource;
use Core\Content\Services\AIGatewayService;
/**
* Content Generation API Controller
*
* Handles AI content generation requests.
* Supports both synchronous and async generation.
*/
class GenerationController extends Controller
{
use HasApiResponses;
use ResolvesWorkspace;
public function __construct(
protected AIGatewayService $gateway
) {}
/**
* Generate draft content for a brief (Gemini).
*
* POST /api/v1/content/generate/draft
*/
public function draft(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 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,
]);
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Core\Content\Controllers;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Core\Content\Models\ContentItem;
/**
* ContentPreviewController - Preview draft content before publishing.
*
* Provides time-limited shareable preview URLs for draft, scheduled,
* and private content items.
*/
class ContentPreviewController extends Controller
{
/**
* Generate a preview link for a content item.
*
* Requires authentication and access to the content's workspace.
*/
public function generateLink(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);
}
$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();
}
}

115
Enums/BriefContentType.php Normal file
View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Core\Content\Enums;
/**
* Content type for ContentBrief.
*
* Defines what kind of content the brief will generate:
* - HELP_ARTICLE: Documentation and support content
* - BLOG_POST: Blog articles and news
* - LANDING_PAGE: Marketing and product landing pages
* - SOCIAL_POST: Social media content
*/
enum BriefContentType: string
{
case HELP_ARTICLE = 'help_article';
case BLOG_POST = 'blog_post';
case LANDING_PAGE = 'landing_page';
case SOCIAL_POST = 'social_post';
/**
* Get human-readable label.
*/
public function label(): string
{
return match ($this) {
self::HELP_ARTICLE => '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);
}
}

121
Enums/ContentType.php Normal file
View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Core\Content\Enums;
/**
* Content source type for ContentItem.
*
* Defines where content originates from:
* - NATIVE: Created natively in Host Hub Content Editor (new default)
* - HOSTUK: Alias for NATIVE (backwards compatibility)
* - SATELLITE: Per-satellite service content (e.g., BioHost-specific help)
* - WORDPRESS: Legacy synced content from WordPress (deprecated)
*/
enum ContentType: string
{
case NATIVE = 'native'; // Created in Host Hub (new default)
case HOSTUK = 'hostuk'; // Alias for native (backwards compat)
case SATELLITE = 'satellite'; // Per-service content
case WORDPRESS = 'wordpress'; // Legacy synced content (deprecated)
/**
* Get human-readable label.
*/
public function label(): string
{
return match ($this) {
self::NATIVE => '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();
}
}

201
Jobs/GenerateContentJob.php Normal file
View file

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Core\Content\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Core\Content\Models\ContentBrief;
use Core\Content\Services\AIGatewayService;
/**
* GenerateContentJob
*
* Handles async AI content generation for briefs.
* Supports draft, refine, and full pipeline modes.
*/
class GenerateContentJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Number of times the job may be attempted.
*/
public int $tries;
/**
* The maximum number of seconds the job can run.
*/
public int $timeout;
/**
* Calculate the number of seconds to wait before retrying.
*/
public function backoff(): array
{
return config('content.generation.backoff', [30, 60, 120]);
}
/**
* Create a new job instance.
*/
public function __construct(
public ContentBrief $brief,
public string $mode = 'full', // draft, refine, full
public ?array $context = null,
) {
$this->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()}"
);
}
}

View file

@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
namespace Core\Content\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Core\Content\Enums\ContentType;
use Core\Content\Models\ContentItem;
use Core\Content\Models\ContentMedia;
use Core\Content\Models\ContentTaxonomy;
use Core\Content\Models\ContentWebhookEndpoint;
use Core\Content\Models\ContentWebhookLog;
/**
* Process incoming content webhooks.
*
* Handles webhook payloads to create/update ContentItem records
* from external CMS systems like WordPress.
*/
class ProcessContentWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The maximum number of seconds the job can run.
*/
public int $timeout = 120;
/**
* Calculate the number of seconds to wait before retrying.
*/
public function backoff(): array
{
return [10, 30, 60];
}
/**
* Create a new job instance.
*/
public function __construct(
public ContentWebhookLog $webhookLog,
) {
$this->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()}"
);
}
}

View file

@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace Core\Content\Mcp\Handlers;
use Carbon\Carbon;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Core\Content\Enums\ContentType;
use Core\Content\Models\ContentItem;
use Core\Content\Models\ContentRevision;
use Core\Content\Models\ContentTaxonomy;
/**
* MCP tool handler for creating content items.
*
* Creates new blog posts or pages with content, taxonomies, and SEO metadata.
*/
class ContentCreateHandler implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => '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}";
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Core\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Auth;
use Core\Content\Models\ContentItem;
use Core\Content\Models\ContentRevision;
/**
* MCP tool handler for deleting content items.
*
* Performs soft delete with revision history.
*/
class ContentDeleteHandler implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => '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();
}
}

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Core\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Str;
use Core\Content\Models\ContentItem;
/**
* MCP tool handler for listing content items.
*
* Lists content items with filtering by workspace, type, status, and search.
*/
class ContentListHandler implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => '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();
}
}

View file

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Core\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Tenant\Models\Workspace;
use Core\Content\Models\ContentItem;
/**
* MCP tool handler for reading content items.
*
* Retrieves full content of a single item by ID or slug.
* Supports JSON and markdown output formats.
*/
class ContentReadHandler implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => '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;
}
}

View file

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Core\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Str;
use Core\Content\Services\ContentSearchService;
/**
* MCP tool handler for searching content.
*
* Full-text search across content items with relevance scoring.
* Uses ContentSearchService for consistent search behaviour across all interfaces.
*/
class ContentSearchHandler implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => '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();
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Core\Content\Mcp\Handlers;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Tenant\Models\Workspace;
use Core\Content\Models\ContentTaxonomy;
/**
* MCP tool handler for listing content taxonomies.
*
* Lists categories and tags for a workspace.
*/
class ContentTaxonomiesHandler implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => '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();
}
}

View file

@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Core\Content\Mcp\Handlers;
use Carbon\Carbon;
use Core\Front\Mcp\Contracts\McpToolHandler;
use Core\Front\Mcp\McpContext;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Core\Content\Models\ContentItem;
use Core\Content\Models\ContentRevision;
use Core\Content\Models\ContentTaxonomy;
/**
* MCP tool handler for updating content items.
*
* Updates existing blog posts or pages. Creates revision history.
*/
class ContentUpdateHandler implements McpToolHandler
{
public static function schema(): array
{
return [
'name' => '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}";
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Core\Content\Middleware;
use Core\Content\Services\ContentRender;
use Core\Mod\Tenant\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Route requests to workspace content when workspace context exists.
*
* Runs after FindDomainRecord. If workspace_model is set, handles
* the request via ContentRender. Otherwise passes through.
*/
class WorkspaceRouter
{
public function __construct(
protected ContentRender $render
) {}
public function handle(Request $request, Closure $next): Response
{
$workspace = $request->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));
}
}

View file

@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Content module tables - WordPress sync, content items, AI prompts.
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
// 1. Prompts
Schema::create('prompts', function (Blueprint $table) {
$table->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();
}
};

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Make wp_id nullable for direct uploads (non-WordPress).
*/
public function up(): void
{
Schema::table('content_media', function (Blueprint $table) {
$table->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();
});
}
};

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add preview token fields to content_items for draft preview functionality.
*/
public function up(): void
{
Schema::table('content_items', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add retry tracking fields to content_webhook_logs table.
*
* Supports exponential backoff retry logic:
* - retry_count tracks attempts
* - next_retry_at schedules retries
* - max_retries sets the limit (default 5)
* - last_error preserves most recent failure reason
*/
public function up(): void
{
Schema::table('content_webhook_logs', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('content_webhook_endpoints', function (Blueprint $table) {
$table->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');
}
};

226
Models/AIUsage.php Normal file
View file

@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Core\Mod\Tenant\Models\Workspace;
/**
* AIUsage Model
*
* Tracks AI API usage for cost tracking, billing, and analytics.
* Supports both workspace-level and system-level usage tracking.
*/
class AIUsage extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Content\Database\Factories\AIUsageFactory
{
return \Core\Content\Database\Factories\AIUsageFactory::new();
}
protected $table = 'ai_usage';
public const PROVIDER_GEMINI = 'gemini';
public const PROVIDER_CLAUDE = 'claude';
public const PROVIDER_OPENAI = 'openai';
public const PURPOSE_DRAFT = 'draft';
public const PURPOSE_REFINE = 'refine';
public const PURPOSE_SOCIAL = 'social';
public const PURPOSE_IMAGE = 'image';
public const PURPOSE_CHAT = 'chat';
protected $fillable = [
'workspace_id',
'provider',
'model',
'purpose',
'input_tokens',
'output_tokens',
'cost_estimate',
'brief_id',
'target_type',
'target_id',
'duration_ms',
'metadata',
];
protected $casts = [
'input_tokens' => '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(),
];
}
}

68
Models/ContentAuthor.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ContentAuthor extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Content\Database\Factories\ContentAuthorFactory
{
return \Core\Content\Database\Factories\ContentAuthorFactory::new();
}
protected $fillable = [
'workspace_id',
'wp_id',
'name',
'slug',
'email',
'avatar_url',
'bio',
'social_links',
];
protected $casts = [
'social_links' => '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);
}
}

316
Models/ContentBrief.php Normal file
View file

@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Core\Content\Enums\BriefContentType;
use Core\Mod\Tenant\Models\Workspace;
/**
* ContentBrief Model
*
* Represents a content creation brief that drives AI-powered content generation.
* Briefs can be system-level (for marketing content) or workspace-specific.
*
* Workflow: pending queued generating review published
*/
class ContentBrief extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Content\Database\Factories\ContentBriefFactory
{
return \Core\Content\Database\Factories\ContentBriefFactory::new();
}
public const STATUS_PENDING = 'pending';
public const STATUS_QUEUED = 'queued';
public const STATUS_GENERATING = 'generating';
public const STATUS_REVIEW = 'review';
public const STATUS_PUBLISHED = 'published';
public const STATUS_FAILED = 'failed';
public const DIFFICULTY_BEGINNER = 'beginner';
public const DIFFICULTY_INTERMEDIATE = 'intermediate';
public const DIFFICULTY_ADVANCED = 'advanced';
protected $fillable = [
'workspace_id',
'service',
'content_type',
'title',
'slug',
'description',
'keywords',
'category',
'difficulty',
'target_word_count',
'prompt_variables',
'status',
'priority',
'scheduled_for',
'draft_output',
'refined_output',
'final_content',
'metadata',
'generation_log',
'content_item_id',
'published_url',
'generated_at',
'refined_at',
'published_at',
'error_message',
];
protected $casts = [
'content_type' => 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'));
}
}

713
Models/ContentItem.php Normal file
View file

@ -0,0 +1,713 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Core\Seo\HasSeoMetadata;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Core\Content\Enums\ContentType;
use Core\Content\Observers\ContentItemObserver;
#[ObservedBy([ContentItemObserver::class])]
class ContentItem extends Model
{
use HasFactory, HasSeoMetadata, SoftDeletes;
protected static function newFactory(): \Core\Content\Database\Factories\ContentItemFactory
{
return \Core\Content\Database\Factories\ContentItemFactory::new();
}
protected $fillable = [
'workspace_id',
'content_type',
'author_id',
'last_edited_by',
'wp_id',
'wp_guid',
'type',
'status',
'publish_at',
'slug',
'title',
'excerpt',
'content_html_original',
'content_html_clean',
'content_html',
'content_markdown',
'content_json',
'editor_state',
'wp_created_at',
'wp_modified_at',
'featured_media_id',
'seo_meta',
'sync_status',
'synced_at',
'sync_error',
'revision_count',
'cdn_urls',
'cdn_purged_at',
'preview_token',
'preview_expires_at',
];
protected $casts = [
'content_type' => 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 = '<p><br><strong><b><em><i><u><h1><h2><h3><h4><h5><h6><ul><ol><li><a><blockquote><pre><code><img><table><thead><tbody><tr><th><td><div><span><hr>';
return strip_tags($content, $allowedTags);
}
/**
* Get URLs that need CDN purge when this content changes.
*/
public function getCdnUrlsForPurgeAttribute(): array
{
$workspace = $this->workspace;
if (! $workspace) {
return [];
}
$domain = $workspace->domain;
$urls = [];
// Main content URL
if ($this->type === 'post') {
$urls[] = "https://{$domain}/blog/{$this->slug}";
$urls[] = "https://{$domain}/blog"; // Blog listing
} elseif ($this->type === 'page') {
$urls[] = "https://{$domain}/{$this->slug}";
}
// Homepage always
$urls[] = "https://{$domain}/";
$urls[] = "https://{$domain}";
return $urls;
}
/**
* Mark as synced.
*/
public function markSynced(): void
{
$this->update([
'sync_status' => 'synced',
'synced_at' => now(),
'sync_error' => null,
]);
}
/**
* Mark as failed.
*/
public function markFailed(string $error): void
{
$this->update([
'sync_status' => 'failed',
'sync_error' => $error,
]);
}
/**
* Get Flux badge colour for content status.
*/
public function getStatusColorAttribute(): string
{
return match ($this->status) {
'publish' => 'green',
'draft' => 'yellow',
'pending' => 'orange',
'future' => 'blue',
'private' => 'zinc',
default => 'zinc',
};
}
/**
* Get icon for content status.
*/
public function getStatusIconAttribute(): string
{
return match ($this->status) {
'publish' => 'check-circle',
'draft' => 'pencil',
'pending' => 'clock',
'future' => 'calendar',
'private' => 'lock-closed',
default => 'document',
};
}
/**
* Get Flux badge colour for sync status.
*/
public function getSyncColorAttribute(): string
{
return match ($this->sync_status) {
'synced' => 'green',
'pending' => 'yellow',
'stale' => 'orange',
'failed' => 'red',
default => 'zinc',
};
}
/**
* Get icon for sync status.
*/
public function getSyncIconAttribute(): string
{
return match ($this->sync_status) {
'synced' => 'check',
'pending' => 'clock',
'stale' => 'arrow-path',
'failed' => 'x-mark',
default => 'question-mark-circle',
};
}
/**
* Get Flux badge colour for content type (post/page).
*/
public function getTypeColorAttribute(): string
{
return match ($this->type) {
'post' => 'blue',
'page' => 'violet',
default => 'zinc',
};
}
/**
* Get Flux badge colour for content source type.
*/
public function getContentTypeColorAttribute(): string
{
return $this->content_type?->color() ?? 'zinc';
}
/**
* Get icon for content source type.
*/
public function getContentTypeIconAttribute(): string
{
return $this->content_type?->icon() ?? 'document';
}
/**
* Get human-readable content source type.
*/
public function getContentTypeLabelAttribute(): string
{
return $this->content_type?->label() ?? 'Unknown';
}
/**
* Get the ContentType enum instance.
*/
public function getContentTypeEnum(): ?ContentType
{
return $this->content_type;
}
/**
* Scope to scheduled content (status = future and publish_at set).
*/
public function scopeScheduled($query)
{
return $query->where('status', 'future')
->whereNotNull('publish_at');
}
/**
* Scope to content ready to be published (scheduled time has passed).
*/
public function scopeReadyToPublish($query)
{
return $query->where('status', 'future')
->whereNotNull('publish_at')
->where('publish_at', '<=', now());
}
/**
* Check if this content is scheduled for future publication.
*/
public function isScheduled(): bool
{
return $this->status === 'future' && $this->publish_at !== null;
}
// -------------------------------------------------------------------------
// Preview Links
// -------------------------------------------------------------------------
/**
* Generate a time-limited preview token for sharing unpublished content.
*
* @param int $hours Number of hours until expiry (default 24)
* @return string The generated preview token
*/
public function generatePreviewToken(int $hours = 24): string
{
$token = bin2hex(random_bytes(32));
$this->update([
'preview_token' => $token,
'preview_expires_at' => now()->addHours($hours),
]);
return $token;
}
/**
* Get the preview URL for this content item.
*
* Generates a new token if one doesn't exist or has expired.
*
* @param int $hours Number of hours until expiry (default 24)
* @return string The full preview URL
*/
public function getPreviewUrl(int $hours = 24): string
{
// Generate new token if needed
if (! $this->hasValidPreviewToken()) {
$this->generatePreviewToken($hours);
}
return route('content.preview', [
'item' => $this->id,
'token' => $this->preview_token,
]);
}
/**
* Check if the current preview token is still valid.
*/
public function hasValidPreviewToken(): bool
{
return $this->preview_token !== null
&& $this->preview_expires_at !== null
&& $this->preview_expires_at->isFuture();
}
/**
* Validate a preview token against this content item.
*/
public function isValidPreviewToken(?string $token): bool
{
if ($token === null || $this->preview_token === null) {
return false;
}
return hash_equals($this->preview_token, $token) && $this->hasValidPreviewToken();
}
/**
* Revoke the current preview token.
*/
public function revokePreviewToken(): void
{
$this->update([
'preview_token' => null,
'preview_expires_at' => null,
]);
}
/**
* Get remaining time until preview token expires.
*
* @return string|null Human-readable time remaining, or null if no valid token
*/
public function getPreviewTokenTimeRemaining(): ?string
{
if (! $this->hasValidPreviewToken()) {
return null;
}
return $this->preview_expires_at->diffForHumans(['parts' => 2]);
}
/**
* Check if this content can be previewed (draft, pending, future, private).
*/
public function isPreviewable(): bool
{
return in_array($this->status, ['draft', 'pending', 'future', 'private']);
}
/**
* Create a revision snapshot of the current state.
*/
public function createRevision(
?User $user = null,
string $changeType = ContentRevision::CHANGE_EDIT,
?string $changeSummary = null
): ContentRevision {
$revision = ContentRevision::createFromContentItem($this, $user, $changeType, $changeSummary);
// Update revision count
$this->increment('revision_count');
return $revision;
}
/**
* Get the latest revision for this content.
*/
public function latestRevision(): ?ContentRevision
{
return $this->revisions()->first();
}
/**
* Convert to array format for frontend rendering.
*
* Note: Title and excerpt are plain text and will be escaped by Blade's {{ }}.
* Content body contains HTML that should be rendered with {!! !!} but is
* sanitised using HTMLPurifier to prevent XSS.
*/
public function toRenderArray(): array
{
$author = $this->author;
$featuredMedia = $this->featuredMedia;
$data = [
'id' => $this->id,
'date' => $this->created_at?->toIso8601String(),
'modified' => $this->updated_at?->toIso8601String(),
'slug' => $this->slug,
'status' => $this->status,
'type' => $this->type,
'title' => ['rendered' => $this->title], // Plain text - escape with {{ }}
'content' => ['rendered' => $this->getSanitisedContent(), 'protected' => false],
'excerpt' => ['rendered' => $this->excerpt], // Plain text - escape with {{ }} or use strip_tags()
'featured_media' => $this->featured_media_id ?? 0,
];
if ($author) {
$data['_embedded']['author'] = [[
'id' => $author->id,
'name' => $author->name,
'slug' => $author->slug ?? null,
'description' => $author->bio ?? null,
'avatar_urls' => ['96' => $author->avatar_url ?? null],
]];
}
if ($featuredMedia) {
$data['_embedded']['wp:featuredmedia'] = [[
'id' => $featuredMedia->id,
'source_url' => $featuredMedia->url ?? $featuredMedia->cdn_url ?? $featuredMedia->source_url ?? null,
'alt_text' => $featuredMedia->alt_text ?? null,
]];
}
return $data;
}
/**
* Boot method to set default content type.
*/
protected static function booted(): void
{
static::creating(function (ContentItem $item) {
// Default to native content type for new items
if ($item->content_type === null) {
$item->content_type = ContentType::default();
}
});
}
}

105
Models/ContentMedia.php Normal file
View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentMedia extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Content\Database\Factories\ContentMediaFactory
{
return \Core\Content\Database\Factories\ContentMediaFactory::new();
}
protected $table = 'content_media';
protected $fillable = [
'workspace_id',
'wp_id',
'title',
'filename',
'mime_type',
'file_size',
'source_url',
'cdn_url',
'width',
'height',
'alt_text',
'caption',
'sizes',
];
protected $casts = [
'sizes' => 'array',
'file_size' => 'integer',
'width' => 'integer',
'height' => 'integer',
];
/**
* Get the workspace this media belongs to.
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Get the best URL for this media (CDN if available, otherwise source).
*/
public function getUrlAttribute(): string
{
return $this->cdn_url ?: $this->source_url;
}
/**
* Get URL for a specific size.
*/
public function getSizeUrl(string $size): ?string
{
if (! $this->sizes || ! isset($this->sizes[$size])) {
return null;
}
return $this->sizes[$size]['source_url'] ?? null;
}
/**
* Check if this is an image.
*/
public function getIsImageAttribute(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
/**
* Scope to filter by workspace.
*/
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
/**
* Scope to only images.
*/
public function scopeImages($query)
{
return $query->where('mime_type', 'like', 'image/%');
}
/**
* Scope to find by WordPress ID.
*/
public function scopeByWpId($query, int $wpId)
{
return $query->where('wp_id', $wpId);
}
}

611
Models/ContentRevision.php Normal file
View file

@ -0,0 +1,611 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* ContentRevision - Stores version history for content items.
*
* Each revision is an immutable snapshot of content at a point in time.
* Used for:
* - Viewing change history
* - Comparing versions
* - Restoring previous versions
* - Audit trail
*/
class ContentRevision extends Model
{
use HasFactory;
/**
* Change types for revision tracking.
*/
public const CHANGE_EDIT = 'edit';
public const CHANGE_AUTOSAVE = 'autosave';
public const CHANGE_RESTORE = 'restore';
public const CHANGE_PUBLISH = 'publish';
public const CHANGE_UNPUBLISH = 'unpublish';
public const CHANGE_SCHEDULE = 'schedule';
protected $fillable = [
'content_item_id',
'user_id',
'revision_number',
'title',
'excerpt',
'content_html',
'content_markdown',
'content_json',
'editor_state',
'seo_meta',
'status',
'change_type',
'change_summary',
'word_count',
'char_count',
];
protected $casts = [
'content_json' => 'array',
'editor_state' => 'array',
'seo_meta' => 'array',
'revision_number' => 'integer',
'word_count' => 'integer',
'char_count' => 'integer',
];
/**
* Get the content item this revision belongs to.
*/
public function contentItem(): BelongsTo
{
return $this->belongsTo(ContentItem::class);
}
/**
* Get the user who made this revision.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Scope to get revisions in reverse chronological order.
*/
public function scopeLatestFirst($query)
{
return $query->orderByDesc('revision_number');
}
/**
* Scope to get revisions for a specific content item.
*/
public function scopeForContentItem($query, int $contentItemId)
{
return $query->where('content_item_id', $contentItemId);
}
/**
* Scope to exclude autosaves (for cleaner history view).
*/
public function scopeWithoutAutosaves($query)
{
return $query->where('change_type', '!=', self::CHANGE_AUTOSAVE);
}
/**
* Create a revision from a ContentItem.
*/
public static function createFromContentItem(
ContentItem $item,
?User $user = null,
string $changeType = self::CHANGE_EDIT,
?string $changeSummary = null
): self {
$nextRevision = static::where('content_item_id', $item->id)->max('revision_number') + 1;
// Calculate word/char counts
$plainText = strip_tags($item->content_html ?? $item->content_markdown ?? '');
$wordCount = str_word_count($plainText);
$charCount = mb_strlen($plainText);
return static::create([
'content_item_id' => $item->id,
'user_id' => $user?->id,
'revision_number' => $nextRevision,
'title' => $item->title,
'excerpt' => $item->excerpt,
'content_html' => $item->content_html,
'content_markdown' => $item->content_markdown,
'content_json' => $item->content_json,
'editor_state' => $item->editor_state,
'seo_meta' => $item->seo_meta,
'status' => $item->status,
'change_type' => $changeType,
'change_summary' => $changeSummary,
'word_count' => $wordCount,
'char_count' => $charCount,
]);
}
/**
* Restore this revision to the content item.
*/
public function restoreToContentItem(): ContentItem
{
$item = $this->contentItem;
$item->update([
'title' => $this->title,
'excerpt' => $this->excerpt,
'content_html' => $this->content_html,
'content_markdown' => $this->content_markdown,
'content_json' => $this->content_json,
'editor_state' => $this->editor_state,
'seo_meta' => $this->seo_meta,
]);
// Create a new revision marking the restore
static::createFromContentItem(
$item,
auth()->user(),
self::CHANGE_RESTORE,
"Restored from revision #{$this->revision_number}"
);
return $item->fresh();
}
/**
* Get human-readable change type label.
*/
public function getChangeTypeLabelAttribute(): string
{
return match ($this->change_type) {
self::CHANGE_EDIT => 'Edited',
self::CHANGE_AUTOSAVE => 'Auto-saved',
self::CHANGE_RESTORE => 'Restored',
self::CHANGE_PUBLISH => 'Published',
self::CHANGE_UNPUBLISH => 'Unpublished',
self::CHANGE_SCHEDULE => 'Scheduled',
default => ucfirst($this->change_type),
};
}
/**
* Get Flux badge colour for change type.
*/
public function getChangeTypeColorAttribute(): string
{
return match ($this->change_type) {
self::CHANGE_EDIT => 'blue',
self::CHANGE_AUTOSAVE => 'zinc',
self::CHANGE_RESTORE => 'orange',
self::CHANGE_PUBLISH => 'green',
self::CHANGE_UNPUBLISH => 'yellow',
self::CHANGE_SCHEDULE => 'violet',
default => 'zinc',
};
}
/**
* Get a diff summary comparing to previous revision.
*/
public function getDiffSummary(): ?array
{
$previous = static::where('content_item_id', $this->content_item_id)
->where('revision_number', $this->revision_number - 1)
->first();
if (! $previous) {
return null;
}
return [
'title_changed' => $this->title !== $previous->title,
'excerpt_changed' => $this->excerpt !== $previous->excerpt,
'content_changed' => $this->content_html !== $previous->content_html,
'status_changed' => $this->status !== $previous->status,
'seo_changed' => $this->seo_meta !== $previous->seo_meta,
'word_diff' => $this->word_count - $previous->word_count,
'char_diff' => $this->char_count - $previous->char_count,
];
}
/**
* Get actual text diff comparing to another revision.
*
* Returns an array with 'title', 'excerpt', and 'content' diffs.
* Each diff contains 'old', 'new', and 'changes' (inline diff markup).
*/
public function getDiff(?self $compareWith = null): array
{
// Default to previous revision if none specified
if ($compareWith === null) {
$compareWith = static::where('content_item_id', $this->content_item_id)
->where('revision_number', $this->revision_number - 1)
->first();
}
$result = [
'has_previous' => $compareWith !== null,
'from_revision' => $compareWith?->revision_number,
'to_revision' => $this->revision_number,
'title' => $this->computeFieldDiff(
$compareWith?->title ?? '',
$this->title ?? ''
),
'excerpt' => $this->computeFieldDiff(
$compareWith?->excerpt ?? '',
$this->excerpt ?? ''
),
'content' => $this->computeContentDiff(
$compareWith?->content_html ?? $compareWith?->content_markdown ?? '',
$this->content_html ?? $this->content_markdown ?? ''
),
'status' => [
'old' => $compareWith?->status,
'new' => $this->status,
'changed' => $compareWith?->status !== $this->status,
],
'word_count' => [
'old' => $compareWith?->word_count ?? 0,
'new' => $this->word_count ?? 0,
'diff' => ($this->word_count ?? 0) - ($compareWith?->word_count ?? 0),
],
];
return $result;
}
/**
* Compute diff for a simple text field.
*/
protected function computeFieldDiff(string $old, string $new): array
{
$changed = $old !== $new;
return [
'old' => $old,
'new' => $new,
'changed' => $changed,
'inline' => $changed ? $this->generateInlineDiff($old, $new) : $new,
];
}
/**
* Compute diff for content (HTML/Markdown).
*
* Strips HTML tags for comparison to focus on text changes.
*/
protected function computeContentDiff(string $old, string $new): array
{
// Strip HTML for cleaner text comparison
$oldText = strip_tags($old);
$newText = strip_tags($new);
$changed = $oldText !== $newText;
return [
'old' => $old,
'new' => $new,
'old_text' => $oldText,
'new_text' => $newText,
'changed' => $changed,
'lines' => $changed ? $this->generateLineDiff($oldText, $newText) : [],
];
}
/**
* Generate inline diff markup for short text.
*
* Uses a simple word-level diff algorithm.
*/
protected function generateInlineDiff(string $old, string $new): string
{
if (empty($old)) {
return '<ins class="diff-added">'.$new.'</ins>';
}
if (empty($new)) {
return '<del class="diff-removed">'.$old.'</del>';
}
$oldWords = preg_split('/(\s+)/', $old, -1, PREG_SPLIT_DELIM_CAPTURE);
$newWords = preg_split('/(\s+)/', $new, -1, PREG_SPLIT_DELIM_CAPTURE);
$diff = $this->computeLcs($oldWords, $newWords);
return $this->formatInlineDiff($diff);
}
/**
* Generate line-by-line diff for longer content.
*/
protected function generateLineDiff(string $old, string $new): array
{
$oldLines = explode("\n", $old);
$newLines = explode("\n", $new);
$diff = [];
$maxLines = max(count($oldLines), count($newLines));
// Simple line-by-line comparison
$oldIndex = 0;
$newIndex = 0;
while ($oldIndex < count($oldLines) || $newIndex < count($newLines)) {
$oldLine = $oldLines[$oldIndex] ?? null;
$newLine = $newLines[$newIndex] ?? null;
if ($oldLine === $newLine) {
// Unchanged line
$diff[] = [
'type' => 'unchanged',
'content' => $newLine,
'line_old' => $oldIndex + 1,
'line_new' => $newIndex + 1,
];
$oldIndex++;
$newIndex++;
} elseif ($oldLine !== null && ! in_array($oldLine, array_slice($newLines, $newIndex), true)) {
// Line removed (not found in remaining new lines)
$diff[] = [
'type' => 'removed',
'content' => $oldLine,
'line_old' => $oldIndex + 1,
'line_new' => null,
];
$oldIndex++;
} elseif ($newLine !== null && ! in_array($newLine, array_slice($oldLines, $oldIndex), true)) {
// Line added (not found in remaining old lines)
$diff[] = [
'type' => 'added',
'content' => $newLine,
'line_old' => null,
'line_new' => $newIndex + 1,
];
$newIndex++;
} else {
// Line modified - show both
if ($oldLine !== null) {
$diff[] = [
'type' => 'removed',
'content' => $oldLine,
'line_old' => $oldIndex + 1,
'line_new' => null,
];
$oldIndex++;
}
if ($newLine !== null) {
$diff[] = [
'type' => 'added',
'content' => $newLine,
'line_old' => null,
'line_new' => $newIndex + 1,
];
$newIndex++;
}
}
// Safety limit
if (count($diff) > 1000) {
$diff[] = [
'type' => 'truncated',
'content' => '... (diff truncated)',
'line_old' => null,
'line_new' => null,
];
break;
}
}
return $diff;
}
/**
* Compute Longest Common Subsequence for word diff.
*/
protected function computeLcs(array $old, array $new): array
{
$m = count($old);
$n = count($new);
// Build LCS length table
$lcs = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
for ($i = 1; $i <= $m; $i++) {
for ($j = 1; $j <= $n; $j++) {
if ($old[$i - 1] === $new[$j - 1]) {
$lcs[$i][$j] = $lcs[$i - 1][$j - 1] + 1;
} else {
$lcs[$i][$j] = max($lcs[$i - 1][$j], $lcs[$i][$j - 1]);
}
}
}
// Backtrack to find diff
$diff = [];
$i = $m;
$j = $n;
while ($i > 0 || $j > 0) {
if ($i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1]) {
array_unshift($diff, ['type' => 'unchanged', 'value' => $old[$i - 1]]);
$i--;
$j--;
} elseif ($j > 0 && ($i === 0 || $lcs[$i][$j - 1] >= $lcs[$i - 1][$j])) {
array_unshift($diff, ['type' => 'added', 'value' => $new[$j - 1]]);
$j--;
} elseif ($i > 0 && ($j === 0 || $lcs[$i][$j - 1] < $lcs[$i - 1][$j])) {
array_unshift($diff, ['type' => 'removed', 'value' => $old[$i - 1]]);
$i--;
}
}
return $diff;
}
/**
* Format LCS diff result as inline HTML.
*/
protected function formatInlineDiff(array $diff): string
{
$result = '';
$pendingRemoved = '';
$pendingAdded = '';
foreach ($diff as $item) {
if ($item['type'] === 'unchanged') {
// Flush pending changes
if ($pendingRemoved !== '') {
$result .= '<del class="diff-removed">'.e($pendingRemoved).'</del>';
$pendingRemoved = '';
}
if ($pendingAdded !== '') {
$result .= '<ins class="diff-added">'.e($pendingAdded).'</ins>';
$pendingAdded = '';
}
$result .= e($item['value']);
} elseif ($item['type'] === 'removed') {
$pendingRemoved .= $item['value'];
} elseif ($item['type'] === 'added') {
$pendingAdded .= $item['value'];
}
}
// Flush any remaining changes
if ($pendingRemoved !== '') {
$result .= '<del class="diff-removed">'.e($pendingRemoved).'</del>';
}
if ($pendingAdded !== '') {
$result .= '<ins class="diff-added">'.e($pendingAdded).'</ins>';
}
return $result;
}
/**
* Compare two specific revisions by ID.
*/
public static function compare(int $fromId, int $toId): array
{
$from = static::findOrFail($fromId);
$to = static::findOrFail($toId);
return $to->getDiff($from);
}
/**
* Prune old revisions for a content item based on retention policy.
*
* @param int $contentItemId The content item to prune revisions for
* @param int|null $maxRevisions Maximum revisions to keep (null = config default)
* @param int|null $maxAgeDays Maximum age in days (null = config default)
* @param bool $preservePublished Whether to preserve published revisions
* @return int Number of revisions deleted
*/
public static function pruneForContentItem(
int $contentItemId,
?int $maxRevisions = null,
?int $maxAgeDays = null,
bool $preservePublished = true
): int {
$maxRevisions = $maxRevisions ?? config('content.revisions.max_per_item', 50);
$maxAgeDays = $maxAgeDays ?? config('content.revisions.max_age_days', 180);
$preservePublished = $preservePublished && config('content.revisions.preserve_published', true);
$deleted = 0;
// Build base query for deletable revisions
$baseQuery = static::where('content_item_id', $contentItemId);
if ($preservePublished) {
$baseQuery->where('change_type', '!=', self::CHANGE_PUBLISH);
}
// Delete revisions older than max age
if ($maxAgeDays > 0) {
$ageDeleted = (clone $baseQuery)
->where('created_at', '<', now()->subDays($maxAgeDays))
->delete();
$deleted += $ageDeleted;
}
// Delete excess revisions beyond max count (keep most recent)
if ($maxRevisions > 0) {
$totalRevisions = static::where('content_item_id', $contentItemId)->count();
if ($totalRevisions > $maxRevisions) {
// Get IDs of revisions to keep (most recent ones)
$keepIds = static::where('content_item_id', $contentItemId)
->orderByDesc('revision_number')
->take($maxRevisions)
->pluck('id');
// Also keep any published revisions if preserving
if ($preservePublished) {
$publishedIds = static::where('content_item_id', $contentItemId)
->where('change_type', self::CHANGE_PUBLISH)
->pluck('id');
$keepIds = $keepIds->merge($publishedIds)->unique();
}
// Delete everything not in the keep list
$countDeleted = static::where('content_item_id', $contentItemId)
->whereNotIn('id', $keepIds)
->delete();
$deleted += $countDeleted;
}
}
return $deleted;
}
/**
* Prune revisions for all content items based on retention policy.
*
* @return array{items_processed: int, revisions_deleted: int}
*/
public static function pruneAll(): array
{
$maxRevisions = config('content.revisions.max_per_item', 50);
$maxAgeDays = config('content.revisions.max_age_days', 180);
// Skip if no limits configured
if ($maxRevisions <= 0 && $maxAgeDays <= 0) {
return ['items_processed' => 0, 'revisions_deleted' => 0];
}
$itemsProcessed = 0;
$totalDeleted = 0;
// Get all content items with revisions
$contentItemIds = static::distinct()->pluck('content_item_id');
foreach ($contentItemIds as $contentItemId) {
$deleted = static::pruneForContentItem($contentItemId);
if ($deleted > 0) {
$totalDeleted += $deleted;
}
$itemsProcessed++;
}
return [
'items_processed' => $itemsProcessed,
'revisions_deleted' => $totalDeleted,
];
}
}

162
Models/ContentTask.php Normal file
View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Mod\Agentic\Models\Prompt;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ContentTask extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const PRIORITY_LOW = 'low';
public const PRIORITY_NORMAL = 'normal';
public const PRIORITY_HIGH = 'high';
protected $fillable = [
'workspace_id',
'prompt_id',
'status',
'priority',
'input_data',
'output',
'metadata',
'target_type',
'target_id',
'scheduled_for',
'started_at',
'completed_at',
'error_message',
];
protected $casts = [
'input_data' => 'array',
'metadata' => 'array',
'scheduled_for' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
/**
* Get the workspace this task belongs to.
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Get the prompt used for this task.
*/
public function prompt(): BelongsTo
{
return $this->belongsTo(Prompt::class);
}
/**
* Get the target model (polymorphic).
*/
public function target(): MorphTo
{
return $this->morphTo();
}
/**
* Mark the task as processing.
*/
public function markProcessing(): void
{
$this->update([
'status' => self::STATUS_PROCESSING,
'started_at' => now(),
]);
}
/**
* Mark the task as completed with output.
*/
public function markCompleted(string $output, array $metadata = []): void
{
$this->update([
'status' => self::STATUS_COMPLETED,
'output' => $output,
'metadata' => array_merge($this->metadata ?? [], $metadata),
'completed_at' => now(),
]);
}
/**
* Mark the task as failed with error message.
*/
public function markFailed(string $error): void
{
$this->update([
'status' => self::STATUS_FAILED,
'error_message' => $error,
'completed_at' => now(),
]);
}
/**
* Scope to pending tasks.
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* Scope to scheduled tasks ready to process.
*/
public function scopeReadyToProcess($query)
{
return $query->where('status', self::STATUS_SCHEDULED)
->where('scheduled_for', '<=', now());
}
/**
* Scope by priority.
*/
public function scopePriority($query, string $priority)
{
return $query->where('priority', $priority);
}
/**
* Calculate processing duration in seconds.
*/
public function getDurationAttribute(): ?int
{
if (! $this->started_at || ! $this->completed_at) {
return null;
}
return $this->completed_at->diffInSeconds($this->started_at);
}
/**
* Check if the task is in a terminal state.
*/
public function isFinished(): bool
{
return in_array($this->status, [self::STATUS_COMPLETED, self::STATUS_FAILED]);
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class ContentTaxonomy extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Content\Database\Factories\ContentTaxonomyFactory
{
return \Core\Content\Database\Factories\ContentTaxonomyFactory::new();
}
protected $fillable = [
'workspace_id',
'wp_id',
'type',
'name',
'slug',
'description',
'parent_wp_id',
'count',
];
protected $casts = [
'count' => 'integer',
];
/**
* Get the workspace this taxonomy belongs to.
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Get content items with this taxonomy.
*/
public function contentItems(): BelongsToMany
{
return $this->belongsToMany(ContentItem::class, 'content_item_taxonomy')
->withTimestamps();
}
/**
* Get the parent taxonomy.
*/
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_wp_id', 'wp_id')
->where('workspace_id', $this->workspace_id);
}
/**
* Scope to filter by workspace.
*/
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
/**
* Scope to only categories.
*/
public function scopeCategories($query)
{
return $query->where('type', 'category');
}
/**
* Scope to only tags.
*/
public function scopeTags($query)
{
return $query->where('type', 'tag');
}
/**
* Scope to find by WordPress ID.
*/
public function scopeByWpId($query, int $wpId)
{
return $query->where('wp_id', $wpId);
}
}

View file

@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* Webhook endpoint configuration for receiving external content webhooks.
*
* Each workspace can have multiple webhook endpoints configured,
* allowing external CMS systems (WordPress, Ghost, etc.) to push
* content updates to the Content module.
*
* @property int $id
* @property string $uuid
* @property int $workspace_id
* @property string $name
* @property string|null $secret
* @property array|null $allowed_types
* @property bool $is_enabled
* @property int $failure_count
* @property \Carbon\Carbon|null $last_received_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class ContentWebhookEndpoint extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Content\Database\Factories\ContentWebhookEndpointFactory
{
return \Core\Content\Database\Factories\ContentWebhookEndpointFactory::new();
}
protected $table = 'content_webhook_endpoints';
protected $fillable = [
'uuid',
'workspace_id',
'name',
'secret',
'previous_secret',
'secret_rotated_at',
'grace_period_seconds',
'allowed_types',
'is_enabled',
'failure_count',
'last_received_at',
];
protected $casts = [
'allowed_types' => 'array',
'is_enabled' => 'boolean',
'failure_count' => 'integer',
'last_received_at' => 'datetime',
'secret' => 'encrypted',
'previous_secret' => 'encrypted',
'secret_rotated_at' => 'datetime',
'grace_period_seconds' => 'integer',
];
protected $hidden = [
'secret',
'previous_secret',
];
/**
* Supported webhook event types.
*/
public const ALLOWED_TYPES = [
// WordPress events
'wordpress.post_created',
'wordpress.post_updated',
'wordpress.post_deleted',
'wordpress.post_published',
'wordpress.post_trashed',
'wordpress.media_uploaded',
// Generic CMS events
'cms.content_created',
'cms.content_updated',
'cms.content_deleted',
'cms.content_published',
// Generic payload (custom integrations)
'generic.payload',
];
/**
* Maximum consecutive failures before auto-disable.
*/
public const MAX_FAILURES = 10;
protected static function boot(): void
{
parent::boot();
static::creating(function (ContentWebhookEndpoint $endpoint) {
if (empty($endpoint->uuid)) {
$endpoint->uuid = (string) Str::uuid();
}
// Generate a secret if not provided
if (empty($endpoint->secret)) {
$endpoint->secret = Str::random(64);
}
// Default to all allowed types if not specified
if (empty($endpoint->allowed_types)) {
$endpoint->allowed_types = self::ALLOWED_TYPES;
}
});
}
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function logs(): HasMany
{
return $this->hasMany(ContentWebhookLog::class, 'endpoint_id');
}
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
public function scopeEnabled($query)
{
return $query->where('is_enabled', true);
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
// -------------------------------------------------------------------------
// State Checks
// -------------------------------------------------------------------------
public function isEnabled(): bool
{
return $this->is_enabled === true;
}
public function isTypeAllowed(string $type): bool
{
// Allow all if no restrictions
if (empty($this->allowed_types)) {
return true;
}
// Check exact match
if (in_array($type, $this->allowed_types, true)) {
return true;
}
// Check prefix match (e.g., 'wordpress.*' matches 'wordpress.post_created')
foreach ($this->allowed_types as $allowedType) {
if (str_ends_with($allowedType, '.*')) {
$prefix = substr($allowedType, 0, -1);
if (str_starts_with($type, $prefix)) {
return true;
}
}
}
return false;
}
public function isCircuitBroken(): bool
{
return $this->failure_count >= self::MAX_FAILURES;
}
// -------------------------------------------------------------------------
// Signature Verification
// -------------------------------------------------------------------------
/**
* Verify webhook signature.
*
* Supports multiple signature formats:
* - X-Signature: HMAC-SHA256 signature of the raw body
* - X-Hub-Signature-256: GitHub-style sha256=... format
* - X-WP-Webhook-Signature: WordPress webhook signature
*
* During a grace period after secret rotation, both current and
* previous secrets are accepted to avoid breaking integrations.
*/
public function verifySignature(string $payload, ?string $signature): bool
{
// If no secret configured, skip verification (but log warning)
if (empty($this->secret)) {
return true;
}
// Signature required when secret is set
if (empty($signature)) {
return false;
}
// Normalise signature (handle sha256=... format)
if (str_starts_with($signature, 'sha256=')) {
$signature = substr($signature, 7);
}
// Check against current secret
$expectedSignature = hash_hmac('sha256', $payload, $this->secret);
if (hash_equals($expectedSignature, $signature)) {
return true;
}
// Check against previous secret if in grace period
if ($this->isInGracePeriod() && ! empty($this->previous_secret)) {
$previousExpectedSignature = hash_hmac('sha256', $payload, $this->previous_secret);
if (hash_equals($previousExpectedSignature, $signature)) {
return true;
}
}
return false;
}
// -------------------------------------------------------------------------
// Status Management
// -------------------------------------------------------------------------
public function incrementFailureCount(): void
{
$this->increment('failure_count');
// Auto-disable after too many failures (circuit breaker)
if ($this->failure_count >= self::MAX_FAILURES) {
$this->update(['is_enabled' => false]);
}
}
public function resetFailureCount(): void
{
$this->update([
'failure_count' => 0,
'last_received_at' => now(),
]);
}
public function markReceived(): void
{
$this->update(['last_received_at' => now()]);
}
// -------------------------------------------------------------------------
// URL Generation
// -------------------------------------------------------------------------
/**
* Get the webhook endpoint URL.
*/
public function getEndpointUrl(): string
{
return route('api.content.webhooks.receive', ['endpoint' => $this->uuid]);
}
/**
* Regenerate the secret and return the new value.
*/
public function regenerateSecret(): string
{
$newSecret = Str::random(64);
$this->update(['secret' => $newSecret]);
return $newSecret;
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* Get Flux badge colour for enabled status.
*/
public function getStatusColorAttribute(): string
{
if (! $this->is_enabled) {
return 'zinc';
}
if ($this->isCircuitBroken()) {
return 'red';
}
if ($this->failure_count > 0) {
return 'yellow';
}
return 'green';
}
/**
* Get status label.
*/
public function getStatusLabelAttribute(): string
{
if (! $this->is_enabled) {
return 'Disabled';
}
if ($this->isCircuitBroken()) {
return 'Circuit Open';
}
if ($this->failure_count > 0) {
return "Active ({$this->failure_count} failures)";
}
return 'Active';
}
/**
* Get icon for status.
*/
public function getStatusIconAttribute(): string
{
if (! $this->is_enabled) {
return 'pause-circle';
}
if ($this->isCircuitBroken()) {
return 'exclamation-triangle';
}
return 'check-circle';
}
// -------------------------------------------------------------------------
// Secret Rotation Methods
// -------------------------------------------------------------------------
/**
* Check if the webhook is currently in a grace period.
*/
public function isInGracePeriod(): bool
{
if (empty($this->secret_rotated_at)) {
return false;
}
$rotatedAt = Carbon::parse($this->secret_rotated_at);
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
$graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
return now()->isBefore($graceEndsAt);
}
/**
* Get the time remaining in the grace period.
*/
public function getGraceTimeRemainingAttribute(): ?int
{
if (! $this->isInGracePeriod()) {
return null;
}
$rotatedAt = Carbon::parse($this->secret_rotated_at);
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
$graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
return (int) now()->diffInSeconds($graceEndsAt, false);
}
/**
* Get when the grace period ends.
*/
public function getGraceEndsAtAttribute(): ?Carbon
{
if (empty($this->secret_rotated_at)) {
return null;
}
$rotatedAt = Carbon::parse($this->secret_rotated_at);
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
return $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
}
}

View file

@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace Core\Content\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentWebhookLog extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Content\Database\Factories\ContentWebhookLogFactory
{
return \Core\Content\Database\Factories\ContentWebhookLogFactory::new();
}
protected $fillable = [
'workspace_id',
'endpoint_id',
'event_type',
'wp_id',
'content_type',
'payload',
'status',
'error_message',
'source_ip',
'processed_at',
'retry_count',
'max_retries',
'next_retry_at',
'last_error',
];
protected $casts = [
'payload' => 'array',
'processed_at' => 'datetime',
'next_retry_at' => 'datetime',
'retry_count' => 'integer',
'max_retries' => 'integer',
];
/**
* Get the workspace this log belongs to.
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Get the webhook endpoint this log belongs to.
*/
public function endpoint(): BelongsTo
{
return $this->belongsTo(ContentWebhookEndpoint::class, 'endpoint_id');
}
/**
* Mark as processing.
*/
public function markProcessing(): void
{
$this->update(['status' => 'processing']);
}
/**
* Mark as completed.
*/
public function markCompleted(): void
{
$this->update([
'status' => 'completed',
'processed_at' => now(),
'error_message' => null,
]);
}
/**
* Mark as failed.
*/
public function markFailed(string $error): void
{
$this->update([
'status' => 'failed',
'processed_at' => now(),
'error_message' => $error,
]);
}
/**
* Scope to filter by workspace.
*/
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
/**
* Scope to pending webhooks.
*/
public function scopePending($query)
{
return $query->where('status', 'pending');
}
/**
* Scope to failed webhooks.
*/
public function scopeFailed($query)
{
return $query->where('status', 'failed');
}
/**
* Scope to webhooks that are ready for retry.
*
* Conditions:
* - Status is 'pending' or 'failed'
* - next_retry_at is in the past or null (for newly pending)
* - retry_count is less than max_retries
*/
public function scopeRetryable($query)
{
return $query->where(function ($q) {
$q->where('status', 'pending')
->orWhere('status', 'failed');
})
->where(function ($q) {
$q->whereNull('next_retry_at')
->orWhere('next_retry_at', '<=', now());
})
->whereColumn('retry_count', '<', 'max_retries');
}
/**
* Scope to webhooks scheduled for retry (not yet due).
*/
public function scopeScheduledForRetry($query)
{
return $query->where('status', 'pending')
->whereNotNull('next_retry_at')
->where('next_retry_at', '>', now());
}
/**
* Scope to webhooks that have exhausted retries.
*/
public function scopeExhausted($query)
{
return $query->where('status', 'failed')
->whereColumn('retry_count', '>=', 'max_retries');
}
/**
* Get Flux badge colour for webhook status.
*/
public function getStatusColorAttribute(): string
{
return match ($this->status) {
'pending' => 'yellow',
'processing' => 'blue',
'completed' => 'green',
'failed' => 'red',
default => 'zinc',
};
}
/**
* Get icon for webhook status.
*/
public function getStatusIconAttribute(): string
{
return match ($this->status) {
'pending' => 'clock',
'processing' => 'arrow-path',
'completed' => 'check',
'failed' => 'x-mark',
default => 'question-mark-circle',
};
}
/**
* Get Flux badge colour for event type.
*/
public function getEventColorAttribute(): string
{
return match (true) {
str_contains($this->event_type, 'deleted') => 'red',
str_contains($this->event_type, 'created') => 'green',
str_contains($this->event_type, 'updated') => 'blue',
str_contains($this->event_type, 'published') => 'green',
default => 'zinc',
};
}
/**
* Check if this webhook has exceeded its maximum retry attempts.
*/
public function hasExceededMaxRetries(): bool
{
return $this->retry_count >= $this->max_retries;
}
/**
* Check if this webhook is scheduled for retry.
*/
public function isScheduledForRetry(): bool
{
return $this->status === 'pending'
&& $this->next_retry_at !== null
&& $this->next_retry_at->isFuture();
}
/**
* Check if this webhook can be retried.
*/
public function canRetry(): bool
{
return in_array($this->status, ['pending', 'failed'])
&& ! $this->hasExceededMaxRetries();
}
/**
* Get retry progress as a percentage.
*/
public function getRetryProgressAttribute(): int
{
if ($this->max_retries === 0) {
return 100;
}
return (int) round(($this->retry_count / $this->max_retries) * 100);
}
/**
* Get human-readable retry status.
*/
public function getRetryStatusAttribute(): string
{
if ($this->status === 'completed') {
return 'Completed';
}
if ($this->hasExceededMaxRetries()) {
return 'Exhausted';
}
if ($this->isScheduledForRetry()) {
return "Retry #{$this->retry_count} scheduled for ".$this->next_retry_at->diffForHumans();
}
if ($this->retry_count > 0) {
return "Failed after {$this->retry_count} retries";
}
return 'Pending';
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Core\Content\Observers;
use Illuminate\Support\Facades\Log;
use Core\Content\Models\ContentItem;
use Core\Content\Services\CdnPurgeService;
/**
* Content Item Observer - handles CDN cache purging on content changes.
*
* Automatically purges CDN cache when published content is updated,
* or when content status changes to/from published.
*/
class ContentItemObserver
{
public function __construct(
protected CdnPurgeService $cdnPurge
) {}
/**
* Handle the ContentItem "updated" event.
*
* Purges CDN cache when:
* - Published content is modified
* - Content status changes to "publish"
*/
public function updated(ContentItem $content): void
{
// Check if status changed to published
$wasPublished = $content->getOriginal('status') === 'publish';
$isPublished = $content->status === 'publish';
// Purge if: newly published OR was already published and content changed
if ($isPublished && (! $wasPublished || $this->hasContentChanged($content))) {
$this->queuePurge($content);
}
}
/**
* Handle the ContentItem "created" event.
*
* Purges CDN cache if content is created in published state.
*/
public function created(ContentItem $content): void
{
if ($content->status === 'publish') {
$this->queuePurge($content);
}
}
/**
* Handle the ContentItem "deleted" event.
*
* Purges CDN cache when published content is deleted.
*/
public function deleted(ContentItem $content): void
{
if ($content->status === 'publish') {
$this->queuePurge($content);
}
}
/**
* Check if content fields that affect the public page have changed.
*/
protected function hasContentChanged(ContentItem $content): bool
{
$watchedFields = [
'title',
'slug',
'content_html',
'content_html_clean',
'content_markdown',
'excerpt',
'featured_media_id',
'seo_meta',
];
foreach ($watchedFields as $field) {
if ($content->isDirty($field)) {
return true;
}
}
return false;
}
/**
* Queue the CDN purge operation.
*
* Currently runs synchronously, but could be dispatched to queue
* for better performance if needed.
*/
protected function queuePurge(ContentItem $content): void
{
try {
$this->cdnPurge->purgeContent($content);
} catch (\Exception $e) {
// Log but don't fail the save operation
Log::error('ContentItemObserver: CDN purge failed', [
'content_id' => $content->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -0,0 +1,617 @@
<?php
declare(strict_types=1);
namespace Core\Content\Services;
use Mod\Agentic\Services\AgenticResponse;
use Mod\Agentic\Services\ClaudeService;
use Mod\Agentic\Services\GeminiService;
use Core\Content\Models\AIUsage;
use Core\Content\Models\ContentBrief;
use RuntimeException;
/**
* AIGatewayService
*
* Orchestrates the two-stage AI content pipeline:
* 1. Gemini (fast, cheap) for initial draft generation
* 2. Claude (quality) for refinement and brand voice alignment
*
* Also handles usage tracking and prompt template management.
*
* Note: Config is read fresh on each getGemini()/getClaude() call to support
* runtime config changes (e.g., different keys per workspace in future).
*/
class AIGatewayService
{
protected ?GeminiService $gemini = null;
protected ?ClaudeService $claude = null;
/**
* Optional override keys - if null, config() is used fresh on each call.
*/
protected ?string $geminiApiKeyOverride = null;
protected ?string $claudeApiKeyOverride = null;
protected ?string $geminiModelOverride = null;
protected ?string $claudeModelOverride = null;
/**
* Create a new AIGatewayService instance.
*
* All parameters are optional overrides. When null, config() is read
* fresh on each service instantiation, allowing runtime config changes.
*/
public function __construct(
?string $geminiApiKey = null,
?string $claudeApiKey = null,
?string $geminiModel = null,
?string $claudeModel = null,
) {
$this->geminiApiKeyOverride = $geminiApiKey;
$this->claudeApiKeyOverride = $claudeApiKey;
$this->geminiModelOverride = $geminiModel;
$this->claudeModelOverride = $claudeModel;
}
/**
* Generate a draft using Gemini.
*/
public function generateDraft(
ContentBrief $brief,
?array $additionalContext = null
): AgenticResponse {
$gemini = $this->getGemini();
$systemPrompt = $this->getDraftSystemPrompt($brief);
$userPrompt = $this->buildDraftPrompt($brief, $additionalContext);
$response = $gemini->generate($systemPrompt, $userPrompt, [
'max_tokens' => max(4096, $brief->target_word_count * 2),
'temperature' => 0.7,
]);
// Track usage
AIUsage::fromResponse(
$response,
AIUsage::PURPOSE_DRAFT,
$brief->workspace_id,
$brief->id
);
return $response;
}
/**
* Refine draft content using Claude.
*/
public function refineDraft(
ContentBrief $brief,
string $draftContent,
?array $additionalContext = null
): AgenticResponse {
$claude = $this->getClaude();
$systemPrompt = $this->getRefineSystemPrompt();
$userPrompt = $this->buildRefinePrompt($brief, $draftContent, $additionalContext);
$response = $claude->generate($systemPrompt, $userPrompt, [
'max_tokens' => max(4096, $brief->target_word_count * 2),
'temperature' => 0.5,
]);
// Track usage
AIUsage::fromResponse(
$response,
AIUsage::PURPOSE_REFINE,
$brief->workspace_id,
$brief->id
);
return $response;
}
/**
* Generate social media posts from content.
*/
public function generateSocialPosts(
string $sourceContent,
array $platforms,
?int $workspaceId = null,
?int $briefId = null
): AgenticResponse {
$claude = $this->getClaude();
$systemPrompt = $this->getSocialSystemPrompt();
$userPrompt = $this->buildSocialPrompt($sourceContent, $platforms);
$response = $claude->generate($systemPrompt, $userPrompt, [
'max_tokens' => 2048,
'temperature' => 0.7,
]);
AIUsage::fromResponse(
$response,
AIUsage::PURPOSE_SOCIAL,
$workspaceId,
$briefId
);
return $response;
}
/**
* Run the full two-stage pipeline: Gemini draft Claude refine.
*/
public function generateAndRefine(
ContentBrief $brief,
?array $additionalContext = null
): array {
$brief->markGenerating();
// Stage 1: Generate draft with Gemini
$draftResponse = $this->generateDraft($brief, $additionalContext);
$brief->markDraftComplete($draftResponse->content, [
'draft' => [
'model' => $draftResponse->model,
'tokens' => $draftResponse->totalTokens(),
'cost' => $draftResponse->estimateCost(),
'duration_ms' => $draftResponse->durationMs,
],
]);
// Stage 2: Refine with Claude
$refineResponse = $this->refineDraft($brief, $draftResponse->content, $additionalContext);
$brief->markRefined($refineResponse->content, [
'refine' => [
'model' => $refineResponse->model,
'tokens' => $refineResponse->totalTokens(),
'cost' => $refineResponse->estimateCost(),
'duration_ms' => $refineResponse->durationMs,
],
]);
return [
'draft' => $draftResponse,
'refined' => $refineResponse,
'brief' => $brief->fresh(),
];
}
/**
* Generate content directly with Claude (skip Gemini for critical content).
*/
public function generateDirect(
ContentBrief $brief,
?array $additionalContext = null
): AgenticResponse {
$claude = $this->getClaude();
$brief->markGenerating();
$systemPrompt = $this->getDraftSystemPrompt($brief);
$userPrompt = $this->buildDraftPrompt($brief, $additionalContext);
$response = $claude->generate($systemPrompt, $userPrompt, [
'max_tokens' => max(4096, $brief->target_word_count * 2),
'temperature' => 0.6,
]);
AIUsage::fromResponse(
$response,
AIUsage::PURPOSE_DRAFT,
$brief->workspace_id,
$brief->id
);
$brief->markRefined($response->content, [
'direct' => [
'model' => $response->model,
'tokens' => $response->totalTokens(),
'cost' => $response->estimateCost(),
'duration_ms' => $response->durationMs,
],
]);
return $response;
}
/**
* Get the draft system prompt based on content type.
*/
protected function getDraftSystemPrompt(ContentBrief $brief): string
{
$basePrompt = <<<'PROMPT'
You are a content strategist for Host UK, a British SaaS company providing hosting, analytics, and digital marketing tools.
Write high-quality content that:
- Uses UK English spelling (colour, organisation, centre)
- Has a professional but approachable tone
- Is knowledgeable but not condescending
- Avoids buzzwords, hyperbole, and corporate speak
- Uses Oxford commas
- Never uses exclamation marks
Output format: Markdown with YAML frontmatter.
PROMPT;
$typeSpecific = match ($brief->content_type) {
'help_article' => $this->getHelpArticlePrompt(),
'blog_post' => $this->getBlogPostPrompt(),
'landing_page' => $this->getLandingPagePrompt(),
'social_post' => $this->getSocialPostPrompt(),
default => '',
};
return $basePrompt."\n\n".$typeSpecific;
}
/**
* Build the user prompt for draft generation.
*/
protected function buildDraftPrompt(ContentBrief $brief, ?array $additionalContext): string
{
$context = $brief->buildPromptContext();
if ($additionalContext) {
$context = array_merge($context, $additionalContext);
}
$prompt = "Write a {$brief->content_type} about: {$brief->title}\n\n";
if ($brief->description) {
$prompt .= "Description: {$brief->description}\n\n";
}
if ($brief->keywords) {
$prompt .= 'Keywords to include: '.implode(', ', $brief->keywords)."\n\n";
}
if ($brief->category) {
$prompt .= "Category: {$brief->category}\n";
}
if ($brief->difficulty) {
$prompt .= "Difficulty level: {$brief->difficulty}\n";
}
$prompt .= "Target word count: {$brief->target_word_count}\n\n";
if ($brief->prompt_variables) {
$prompt .= "Additional context:\n";
foreach ($brief->prompt_variables as $key => $value) {
if (is_string($value)) {
$prompt .= "- {$key}: {$value}\n";
}
}
}
return $prompt;
}
/**
* Get the refinement system prompt.
*/
protected function getRefineSystemPrompt(): string
{
return <<<'PROMPT'
You are the ghost writer and editor for Host UK. Your role is to transform draft content into polished, publication-ready material that sounds like it was written by our best human writer.
## Brand Voice Guidelines
**Personality:**
- Knowledgeable but not condescending
- Helpful and practical
- Quietly confident
- Occasionally witty (subtle, not forced)
- British sensibility (understated, dry humour acceptable)
**Writing style:**
- Clear, direct sentences
- Active voice preferred
- Contractions are fine (we're, you'll, it's)
- UK English spelling always
- No buzzwords or corporate speak
- No exclamation marks (almost never)
- Numbers under 10 spelled out
- Oxford comma: yes
**What to avoid:**
- "Leverage", "synergy", "cutting-edge"
- "We're excited to announce"
- Hyperbole ("revolutionary", "game-changing")
- Passive aggressive tones
- Overpromising
Transform the content by:
1. Voice alignment - Make it sound like Host UK
2. Flow improvement - Smooth transitions, better rhythm
3. Clarity enhancement - Simplify without dumbing down
4. Engagement hooks - Stronger opening, better section leads
5. CTA optimisation - Natural, compelling calls to action
6. UK localisation - Spelling, references, cultural fit
Preserve:
- All factual information
- SEO keywords and structure
- Technical accuracy
- Section organisation
Output the refined version with the same frontmatter structure.
PROMPT;
}
/**
* Build the refine prompt.
*/
protected function buildRefinePrompt(ContentBrief $brief, string $draftContent, ?array $additionalContext): string
{
$prompt = "Refine this {$brief->content_type} for Host UK.\n\n";
if ($brief->service) {
$prompt .= "Service: {$brief->service}\n";
}
if ($brief->difficulty) {
$prompt .= "Target audience level: {$brief->difficulty}\n";
}
if ($additionalContext) {
$prompt .= "\nAdditional guidance:\n";
foreach ($additionalContext as $key => $value) {
if (is_string($value)) {
$prompt .= "- {$key}: {$value}\n";
}
}
}
$prompt .= "\n---\nDraft to refine:\n---\n\n{$draftContent}";
return $prompt;
}
/**
* Get social media system prompt.
*/
protected function getSocialSystemPrompt(): string
{
return <<<'PROMPT'
You are a social media specialist for Host UK. Create engaging social posts that:
- Hook attention in the first line
- Provide genuine value (no filler)
- Use appropriate tone for each platform
- Include clear but non-salesy CTAs
- Follow UK English conventions
- Never use excessive emojis or hashtags
For each platform, respect character limits and norms:
- Twitter/X: 280 chars, conversational, can use threads
- LinkedIn: Professional, longer form OK, no hashtag spam
- Facebook: Casual, engagement-focused
- Instagram: Visual-focused copy, strategic hashtags
PROMPT;
}
/**
* Build the social posts prompt.
*/
protected function buildSocialPrompt(string $sourceContent, array $platforms): string
{
$platformList = implode(', ', $platforms);
return <<<PROMPT
Create social media posts for these platforms: {$platformList}
Base the posts on this content:
---
{$sourceContent}
---
For each platform, provide:
1. Main post text
2. Optional call-to-action
3. Suggested posting time (UK timezone)
Output as JSON:
```json
{
"posts": [
{
"platform": "twitter",
"content": "...",
"cta": "...",
"suggested_time": "..."
}
]
}
```
PROMPT;
}
/**
* Get help article specific prompt.
*/
protected function getHelpArticlePrompt(): string
{
return <<<'PROMPT'
For help articles, include:
1. **Overview** - What this article covers and who it's for
2. **Prerequisites** - What the user needs before starting
3. **Step-by-step instructions** - Clear, numbered steps with expected outcomes
4. **Screenshots placeholders** - [Screenshot: description]
5. **Troubleshooting** - Common issues and solutions
6. **Pro tips** - Advanced tips for power users
7. **Related articles** - Links to related help content
8. **FAQ** - Common questions about this topic
Frontmatter should include:
- difficulty: beginner|intermediate|advanced
- estimated_time: X minutes
- prerequisites: [list]
PROMPT;
}
/**
* Get blog post specific prompt.
*/
protected function getBlogPostPrompt(): string
{
return <<<'PROMPT'
For blog posts, include:
1. **Hook** (first 100 words) - Grab attention, state the problem
2. **Key takeaways** - Bulleted summary for skimmers
3. **Introduction** - Context and what reader will learn
4. **Main sections** (3-5) - H2 headings with H3 subsections
5. **Actionable tips** - Numbered practical advice
6. **Data/statistics** - Include relevant UK or industry stats
7. **Examples** - Real-world applications
8. **Conclusion** - Summary and next steps
9. **CTA** - Clear call to action
Frontmatter should include:
- reading_time: X min
- category: [category]
- tags: [list]
PROMPT;
}
/**
* Get landing page specific prompt.
*/
protected function getLandingPagePrompt(): string
{
return <<<'PROMPT'
For landing pages, include:
1. **Hero section** - Compelling headline, subheadline, primary CTA
2. **Problem statement** - Pain points the audience faces
3. **Solution overview** - How we solve the problem
4. **Key features** - 3-5 main features with benefits
5. **Social proof** - Testimonial placeholders, stats
6. **How it works** - Simple 3-step process
7. **Pricing CTA** - Clear pricing or trial offer
8. **FAQ section** - Address common objections
9. **Final CTA** - Strong closing call to action
Focus on benefits over features. Make CTAs feel natural, not pushy.
PROMPT;
}
/**
* Get social post specific prompt.
*/
protected function getSocialPostPrompt(): string
{
return <<<'PROMPT'
For social posts, create content for multiple platforms:
1. **Twitter/X** - 280 chars max, punchy, conversational
2. **LinkedIn** - Professional, can be longer, thought leadership
3. **Facebook** - Casual, engagement-focused
4. **Instagram caption** - Visual-focused, strategic hashtags
Each post should:
- Stand alone as valuable content
- Include appropriate CTA
- Respect platform character limits and norms
PROMPT;
}
/**
* Get the Gemini service instance.
*
* Reads config fresh on each call (unless override was provided in constructor)
* to support runtime config changes.
*/
protected function getGemini(): GeminiService
{
$apiKey = $this->geminiApiKeyOverride ?? config('services.google.ai_api_key');
$model = $this->geminiModelOverride ?? config('services.google.ai_model', 'gemini-2.0-flash');
if (empty($apiKey)) {
throw new RuntimeException('Gemini API key not configured');
}
// Reset cached instance if config has changed
if ($this->gemini !== null) {
return $this->gemini;
}
return $this->gemini = new GeminiService($apiKey, $model);
}
/**
* Get the Claude service instance.
*
* Reads config fresh on each call (unless override was provided in constructor)
* to support runtime config changes.
*/
protected function getClaude(): ClaudeService
{
$apiKey = $this->claudeApiKeyOverride ?? config('services.anthropic.api_key');
$model = $this->claudeModelOverride ?? config('services.anthropic.model', 'claude-sonnet-4-20250514');
if (empty($apiKey)) {
throw new RuntimeException('Claude API key not configured');
}
// Reset cached instance if config has changed
if ($this->claude !== null) {
return $this->claude;
}
return $this->claude = new ClaudeService($apiKey, $model);
}
/**
* Check if both AI providers are available.
*
* Reads config fresh to reflect runtime changes.
*/
public function isAvailable(): bool
{
return $this->isGeminiAvailable() && $this->isClaudeAvailable();
}
/**
* Check if Gemini is available.
*
* Reads config fresh to reflect runtime changes.
*/
public function isGeminiAvailable(): bool
{
$apiKey = $this->geminiApiKeyOverride ?? config('services.google.ai_api_key');
return ! empty($apiKey);
}
/**
* Check if Claude is available.
*
* Reads config fresh to reflect runtime changes.
*/
public function isClaudeAvailable(): bool
{
$apiKey = $this->claudeApiKeyOverride ?? config('services.anthropic.api_key');
return ! empty($apiKey);
}
/**
* Reset cached service instances.
*
* Call this if config changes at runtime and you need fresh instances.
*/
public function resetServices(): void
{
$this->gemini = null;
$this->claude = null;
}
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Core\Content\Services;
use Illuminate\Support\Facades\Log;
use Core\Content\Models\ContentItem;
use Plug\Cdn\CdnManager;
use Plug\Response;
/**
* CDN cache purge service for content.
*
* Integrates with the Plug\Cdn infrastructure to purge Bunny CDN
* cache when content is published or updated.
*/
class CdnPurgeService
{
public function __construct(
protected CdnManager $cdn
) {}
/**
* Check if CDN purging is enabled.
*/
public function isEnabled(): bool
{
return config('cdn.pipeline.auto_purge', false)
&& config('cdn.bunny.api_key')
&& config('cdn.bunny.pull_zone_id');
}
/**
* Purge CDN cache for a content item.
*
* Uses the content item's getCdnUrlsForPurge attribute to determine
* which URLs need purging.
*/
public function purgeContent(ContentItem $content): Response
{
if (! $this->isEnabled()) {
Log::debug('CdnPurgeService: Skipping purge - not enabled or not configured');
return new Response(
\Plug\Enum\Status::OK,
['skipped' => true, 'reason' => 'CDN purge not enabled']
);
}
$urls = $content->cdn_urls_for_purge;
if (empty($urls)) {
Log::debug('CdnPurgeService: No URLs to purge for content', [
'content_id' => $content->id,
]);
return new Response(
\Plug\Enum\Status::OK,
['skipped' => true, 'reason' => 'No URLs to purge']
);
}
Log::info('CdnPurgeService: Purging CDN cache for content', [
'content_id' => $content->id,
'content_slug' => $content->slug,
'url_count' => count($urls),
'urls' => $urls,
]);
$response = $this->cdn->purge()->urls($urls);
if ($response->isOk()) {
// Update the content item to record the purge
$content->updateQuietly([
'cdn_purged_at' => now(),
]);
Log::info('CdnPurgeService: Successfully purged CDN cache', [
'content_id' => $content->id,
'purged_count' => $response->get('purged', count($urls)),
]);
} else {
Log::error('CdnPurgeService: Failed to purge CDN cache', [
'content_id' => $content->id,
'error' => $response->getMessage(),
'context' => $response->context(),
]);
}
return $response;
}
/**
* Purge specific URLs from CDN cache.
*
* @param array<string> $urls
*/
public function purgeUrls(array $urls): Response
{
if (! $this->isEnabled()) {
return new Response(
\Plug\Enum\Status::OK,
['skipped' => true, 'reason' => 'CDN purge not enabled']
);
}
if (empty($urls)) {
return new Response(
\Plug\Enum\Status::OK,
['skipped' => true, 'reason' => 'No URLs provided']
);
}
return $this->cdn->purge()->urls($urls);
}
/**
* Purge all CDN cache for a workspace.
*
* Uses tag-based purging for workspace isolation.
*/
public function purgeWorkspace(string $workspaceUuid): Response
{
if (! $this->isEnabled()) {
return new Response(
\Plug\Enum\Status::OK,
['skipped' => true, 'reason' => 'CDN purge not enabled']
);
}
Log::info('CdnPurgeService: Purging CDN cache for workspace', [
'workspace_uuid' => $workspaceUuid,
]);
return $this->cdn->purge()->tag("workspace-{$workspaceUuid}");
}
}

View file

@ -0,0 +1,586 @@
<?php
declare(strict_types=1);
namespace Core\Content\Services;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use Illuminate\Support\Facades\Log;
class ContentProcessingService
{
/**
* WordPress classes to remove during cleaning.
*/
protected array $wpClassPatterns = [
'/^wp-/',
'/^has-/',
'/^is-/',
'/^alignleft$/',
'/^alignright$/',
'/^aligncenter$/',
'/^alignwide$/',
'/^alignfull$/',
'/^size-/',
'/^attachment-/',
];
/**
* Process WordPress content into all three formats.
*/
public function process(array $wpContent): array
{
$html = $wpContent['content']['rendered'] ?? $wpContent['content'] ?? '';
return [
'content_html_original' => $html,
'content_html_clean' => $this->cleanHtml($html),
'content_json' => $this->parseToJson($html),
];
}
/**
* Clean HTML by removing WordPress-specific cruft.
*
* - Remove inline styles
* - Remove WordPress classes
* - Remove empty elements
* - Remove block comments
* - Preserve semantic structure
*/
public function cleanHtml(string $html): string
{
if (empty($html)) {
return '';
}
// Remove WordPress block comments
$html = preg_replace('/<!--\s*\/?wp:[^>]+-->/s', '', $html);
// Remove empty comments
$html = preg_replace('/<!--\s*-->/s', '', $html);
// Load into DOM
$doc = $this->loadHtml($html);
if (! $doc) {
Log::warning('ContentProcessingService: Failed to parse HTML, falling back to strip_tags', [
'html_length' => strlen($html),
'html_preview' => substr($html, 0, 200),
]);
return strip_tags($html, '<p><a><strong><em><ul><ol><li><h1><h2><h3><h4><h5><h6><blockquote><img><figure><figcaption>');
}
$xpath = new DOMXPath($doc);
// Remove all style attributes
$styledElements = $xpath->query('//*[@style]');
foreach ($styledElements as $el) {
$el->removeAttribute('style');
}
// Clean WordPress classes from all elements
$classedElements = $xpath->query('//*[@class]');
foreach ($classedElements as $el) {
$this->cleanClasses($el);
}
// Remove data-* attributes (WordPress block data)
$allElements = $xpath->query('//*');
foreach ($allElements as $el) {
$attributesToRemove = [];
foreach ($el->attributes as $attr) {
if (str_starts_with($attr->name, 'data-')) {
$attributesToRemove[] = $attr->name;
}
}
foreach ($attributesToRemove as $attrName) {
$el->removeAttribute($attrName);
}
}
// Remove empty paragraphs and divs
$this->removeEmptyElements($doc, $xpath);
// Extract body content
$body = $doc->getElementsByTagName('body')->item(0);
if (! $body) {
return '';
}
$cleanHtml = '';
foreach ($body->childNodes as $child) {
$cleanHtml .= $doc->saveHTML($child);
}
// Final cleanup
$cleanHtml = preg_replace('/\s+/', ' ', $cleanHtml);
$cleanHtml = preg_replace('/>\s+</', '><', $cleanHtml);
$cleanHtml = trim($cleanHtml);
// Pretty format
$cleanHtml = preg_replace('/<\/(p|div|h[1-6]|ul|ol|li|blockquote|figure)>/', "</$1>\n", $cleanHtml);
return trim($cleanHtml);
}
/**
* Parse HTML into structured JSON blocks for headless rendering.
*/
public function parseToJson(string $html): array
{
if (empty($html)) {
return ['blocks' => []];
}
// Remove WordPress block comments
$html = preg_replace('/<!--\s*\/?wp:[^>]+-->/s', '', $html);
$doc = $this->loadHtml($html);
if (! $doc) {
Log::warning('ContentProcessingService: Failed to parse HTML for JSON conversion, returning single block', [
'html_length' => strlen($html),
'html_preview' => substr($html, 0, 200),
]);
return ['blocks' => [['type' => 'paragraph', 'content' => strip_tags($html)]]];
}
$body = $doc->getElementsByTagName('body')->item(0);
if (! $body) {
return ['blocks' => []];
}
$blocks = [];
foreach ($body->childNodes as $node) {
$block = $this->nodeToBlock($node, $doc);
if ($block) {
$blocks[] = $block;
}
}
return ['blocks' => $blocks];
}
/**
* Convert a DOM node to a structured block.
*/
protected function nodeToBlock(DOMNode $node, DOMDocument $doc): ?array
{
if ($node->nodeType === XML_TEXT_NODE) {
$text = trim($node->textContent);
if (empty($text)) {
return null;
}
return ['type' => 'text', 'content' => $text];
}
if ($node->nodeType !== XML_ELEMENT_NODE) {
return null;
}
/** @var DOMElement $element */
$element = $node;
$tagName = strtolower($element->tagName);
return match ($tagName) {
'h1', 'h2', 'h3', 'h4', 'h5', 'h6' => $this->parseHeading($element, $tagName),
'p' => $this->parseParagraph($element, $doc),
'ul', 'ol' => $this->parseList($element, $tagName, $doc),
'blockquote' => $this->parseBlockquote($element, $doc),
'figure' => $this->parseFigure($element, $doc),
'img' => $this->parseImage($element),
'div' => $this->parseDiv($element, $doc),
'a' => $this->parseLink($element, $doc),
'pre' => $this->parseCodeBlock($element),
'hr' => ['type' => 'divider'],
default => $this->parseGeneric($element, $doc),
};
}
/**
* Parse a heading element.
*/
protected function parseHeading(DOMElement $element, string $tag): array
{
return [
'type' => 'heading',
'level' => (int) substr($tag, 1),
'content' => trim($element->textContent),
'id' => $element->getAttribute('id') ?: null,
];
}
/**
* Parse a paragraph element.
*/
protected function parseParagraph(DOMElement $element, DOMDocument $doc): ?array
{
$content = trim($element->textContent);
if (empty($content) && ! $element->getElementsByTagName('img')->length) {
return null;
}
// Check for embedded image
$images = $element->getElementsByTagName('img');
if ($images->length > 0) {
$img = $images->item(0);
return $this->parseImage($img);
}
return [
'type' => 'paragraph',
'content' => $content,
'html' => $this->getInnerHtml($element, $doc),
];
}
/**
* Parse a list element.
*/
protected function parseList(DOMElement $element, string $tag, DOMDocument $doc): array
{
$items = [];
foreach ($element->getElementsByTagName('li') as $li) {
$items[] = [
'content' => trim($li->textContent),
'html' => $this->getInnerHtml($li, $doc),
];
}
return [
'type' => 'list',
'ordered' => $tag === 'ol',
'items' => $items,
];
}
/**
* Parse a blockquote element.
*/
protected function parseBlockquote(DOMElement $element, DOMDocument $doc): array
{
$content = [];
foreach ($element->childNodes as $child) {
$block = $this->nodeToBlock($child, $doc);
if ($block) {
$content[] = $block;
}
}
// Check for citation
$cite = $element->getElementsByTagName('cite');
$citation = $cite->length > 0 ? trim($cite->item(0)->textContent) : null;
return [
'type' => 'blockquote',
'content' => $content,
'citation' => $citation,
];
}
/**
* Parse a figure element (usually contains image + caption).
*/
protected function parseFigure(DOMElement $element, DOMDocument $doc): ?array
{
$img = $element->getElementsByTagName('img')->item(0);
if (! $img) {
// Could be an embed or other figure type
return $this->parseGeneric($element, $doc);
}
$figcaption = $element->getElementsByTagName('figcaption')->item(0);
return [
'type' => 'image',
'src' => $img->getAttribute('src'),
'alt' => $img->getAttribute('alt'),
'width' => $img->getAttribute('width') ?: null,
'height' => $img->getAttribute('height') ?: null,
'caption' => $figcaption ? trim($figcaption->textContent) : null,
'srcset' => $img->getAttribute('srcset') ?: null,
'sizes' => $img->getAttribute('sizes') ?: null,
];
}
/**
* Parse a standalone image element.
*/
protected function parseImage(DOMElement $element): array
{
return [
'type' => 'image',
'src' => $element->getAttribute('src'),
'alt' => $element->getAttribute('alt'),
'width' => $element->getAttribute('width') ?: null,
'height' => $element->getAttribute('height') ?: null,
'srcset' => $element->getAttribute('srcset') ?: null,
'sizes' => $element->getAttribute('sizes') ?: null,
];
}
/**
* Parse a div element (may contain groups, embeds, etc).
*/
protected function parseDiv(DOMElement $element, DOMDocument $doc): ?array
{
// Check for WordPress embed block
$class = $element->getAttribute('class');
if (str_contains($class, 'wp-block-embed')) {
return $this->parseEmbed($element);
}
// Check for group block - return children
$children = [];
foreach ($element->childNodes as $child) {
$block = $this->nodeToBlock($child, $doc);
if ($block) {
$children[] = $block;
}
}
if (count($children) === 0) {
return null;
}
if (count($children) === 1) {
return $children[0];
}
return [
'type' => 'group',
'children' => $children,
];
}
/**
* Parse an embed (YouTube, Twitter, etc).
*/
protected function parseEmbed(DOMElement $element): array
{
$iframe = $element->getElementsByTagName('iframe')->item(0);
if ($iframe) {
$src = $iframe->getAttribute('src');
// Detect embed type
$provider = 'unknown';
if (str_contains($src, 'youtube.com') || str_contains($src, 'youtu.be')) {
$provider = 'youtube';
} elseif (str_contains($src, 'vimeo.com')) {
$provider = 'vimeo';
} elseif (str_contains($src, 'twitter.com') || str_contains($src, 'x.com')) {
$provider = 'twitter';
} elseif (str_contains($src, 'spotify.com')) {
$provider = 'spotify';
}
return [
'type' => 'embed',
'provider' => $provider,
'url' => $src,
'width' => $iframe->getAttribute('width') ?: null,
'height' => $iframe->getAttribute('height') ?: null,
];
}
// Check for blockquote embeds (Twitter, Instagram)
$blockquote = $element->getElementsByTagName('blockquote')->item(0);
if ($blockquote) {
$class = $blockquote->getAttribute('class');
$provider = 'unknown';
if (str_contains($class, 'twitter')) {
$provider = 'twitter';
} elseif (str_contains($class, 'instagram')) {
$provider = 'instagram';
}
return [
'type' => 'embed',
'provider' => $provider,
'html' => $element->ownerDocument->saveHTML($blockquote),
];
}
return [
'type' => 'embed',
'provider' => 'unknown',
'html' => $element->ownerDocument->saveHTML($element),
];
}
/**
* Parse a link element.
*/
protected function parseLink(DOMElement $element, DOMDocument $doc): array
{
return [
'type' => 'link',
'href' => $element->getAttribute('href'),
'content' => trim($element->textContent),
'target' => $element->getAttribute('target') ?: null,
'rel' => $element->getAttribute('rel') ?: null,
];
}
/**
* Parse a code block (pre element).
*/
protected function parseCodeBlock(DOMElement $element): array
{
$code = $element->getElementsByTagName('code')->item(0);
$content = $code ? $code->textContent : $element->textContent;
$language = null;
if ($code) {
$class = $code->getAttribute('class');
if (preg_match('/language-(\w+)/', $class, $matches)) {
$language = $matches[1];
}
}
return [
'type' => 'code',
'content' => $content,
'language' => $language,
];
}
/**
* Parse a generic element.
*/
protected function parseGeneric(DOMElement $element, DOMDocument $doc): ?array
{
$content = trim($element->textContent);
if (empty($content)) {
return null;
}
return [
'type' => 'html',
'tag' => strtolower($element->tagName),
'content' => $content,
'html' => $this->getInnerHtml($element, $doc),
];
}
/**
* Load HTML into a DOMDocument.
*/
protected function loadHtml(string $html): ?DOMDocument
{
$doc = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$wrappedHtml = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>'.$html.'</body></html>';
if (! $doc->loadHTML($wrappedHtml, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD)) {
$errors = libxml_get_errors();
if (! empty($errors)) {
$errorMessages = array_map(fn ($e) => trim($e->message), array_slice($errors, 0, 5));
Log::debug('ContentProcessingService: libxml errors during HTML parsing', [
'errors' => $errorMessages,
'error_count' => count($errors),
]);
}
libxml_clear_errors();
return null;
}
// Log any warnings/errors that occurred even if parsing succeeded
$errors = libxml_get_errors();
if (! empty($errors)) {
$errorMessages = array_map(fn ($e) => trim($e->message), array_slice($errors, 0, 3));
Log::debug('ContentProcessingService: HTML parsed with warnings', [
'warning_count' => count($errors),
'warnings' => $errorMessages,
]);
}
libxml_clear_errors();
return $doc;
}
/**
* Clean WordPress classes from an element.
*/
protected function cleanClasses(DOMElement $element): void
{
$classes = explode(' ', $element->getAttribute('class'));
$cleanClasses = [];
foreach ($classes as $class) {
$class = trim($class);
if (empty($class)) {
continue;
}
$isWpClass = false;
foreach ($this->wpClassPatterns as $pattern) {
if (preg_match($pattern, $class)) {
$isWpClass = true;
break;
}
}
if (! $isWpClass) {
$cleanClasses[] = $class;
}
}
if (empty($cleanClasses)) {
$element->removeAttribute('class');
} else {
$element->setAttribute('class', implode(' ', $cleanClasses));
}
}
/**
* Remove empty elements from the document.
*/
protected function removeEmptyElements(DOMDocument $doc, DOMXPath $xpath): void
{
$emptyTags = ['p', 'div', 'span'];
foreach ($emptyTags as $tag) {
$elements = $xpath->query("//{$tag}");
$toRemove = [];
foreach ($elements as $el) {
$content = trim($el->textContent);
$hasChildren = $el->getElementsByTagName('img')->length > 0
|| $el->getElementsByTagName('iframe')->length > 0;
if (empty($content) && ! $hasChildren) {
$toRemove[] = $el;
}
}
foreach ($toRemove as $el) {
if ($el->parentNode) {
$el->parentNode->removeChild($el);
}
}
}
}
/**
* Get the inner HTML of an element.
*/
protected function getInnerHtml(DOMElement $element, DOMDocument $doc): string
{
$inner = '';
foreach ($element->childNodes as $child) {
$inner .= $doc->saveHTML($child);
}
return trim($inner);
}
}

376
Services/ContentRender.php Normal file
View file

@ -0,0 +1,376 @@
<?php
declare(strict_types=1);
namespace Core\Content\Services;
use Core\Front\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Core\Content\Models\ContentItem;
use Core\Mod\Tenant\Models\Workspace;
/**
* ContentRender - Public workspace frontend renderer.
*
* Renders public-facing pages for workspace sites (blog, help, pages).
* Content is sourced from the native ContentItem database.
*/
class ContentRender extends Controller
{
/**
* Render the homepage.
*/
public function home(Request $request): View
{
$workspace = $this->resolveWorkspace($request);
if (! $workspace || ! $workspace->is_active) {
return $this->waitlist($workspace);
}
$content = $this->getHomepage($workspace);
if (! $content) {
return $this->waitlist($workspace);
}
return view('web::home', [
'workspace' => $workspace,
'content' => $content,
'meta' => $this->getMeta($workspace),
]);
}
/**
* Render a blog post.
*/
public function post(Request $request, string $slug): View
{
$workspace = $this->resolveWorkspace($request);
if (! $workspace || ! $workspace->is_active) {
abort(404);
}
$post = $this->getPost($workspace, $slug);
if (! $post) {
abort(404);
}
return view('web::page', [
'workspace' => $workspace,
'post' => $post,
'meta' => $this->getMeta($workspace, $post),
]);
}
/**
* Render the blog listing.
*/
public function blog(Request $request): View
{
$workspace = $this->resolveWorkspace($request);
$page = (int) $request->get('page', 1);
if (! $workspace || ! $workspace->is_active) {
return $this->waitlist($workspace);
}
$posts = $this->getPosts($workspace, $page);
return view('web::page', [
'workspace' => $workspace,
'posts' => $posts['posts'],
'pagination' => [
'current' => $page,
'total' => $posts['pages'],
'count' => $posts['total'],
],
'meta' => $this->getMeta($workspace),
]);
}
/**
* Render a static page.
*/
public function page(Request $request, string $slug): View
{
$workspace = $this->resolveWorkspace($request);
if (! $workspace || ! $workspace->is_active) {
abort(404);
}
$page = $this->getPage($workspace, $slug);
if (! $page) {
abort(404);
}
return view('web::page', [
'workspace' => $workspace,
'page' => $page,
'meta' => $this->getMeta($workspace, $page),
]);
}
/**
* Handle waitlist subscription.
*/
public function subscribe(Request $request)
{
$request->validate([
'email' => 'required|email|max:255',
]);
$workspace = $this->resolveWorkspace($request);
$this->addToWaitlist($workspace, $request->email);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'You\'ve been added to the waitlist!',
]);
}
return back()->with('subscribed', true);
}
/**
* Render the waitlist page.
*/
public function waitlist(?Workspace $workspace): View
{
return view('web::waitlist', [
'workspace' => $workspace,
'subscribed' => session('subscribed', false),
]);
}
// -------------------------------------------------------------------------
// Content fetching (cached)
// -------------------------------------------------------------------------
protected function getCacheTtl(): int
{
return config('app.env') === 'production' ? 3600 : 60;
}
/**
* Sanitise a slug for use in cache keys.
*
* Removes special characters that could cause cache key collisions
* or issues with cache backends (Redis, Memcached, etc).
*/
protected function sanitiseCacheKey(string $slug): string
{
// Replace any character that isn't alphanumeric, dash, or underscore
$sanitised = preg_replace('/[^a-zA-Z0-9_-]/', '_', $slug);
// Collapse multiple underscores
$sanitised = preg_replace('/_+/', '_', $sanitised);
// Limit length to prevent overly long cache keys
return substr($sanitised, 0, 100);
}
public function getHomepage(Workspace $workspace): ?array
{
$cacheKey = 'content:render:'.$this->sanitiseCacheKey($workspace->slug).':homepage';
return Cache::remember($cacheKey, $this->getCacheTtl(), function () use ($workspace) {
$posts = ContentItem::forWorkspace($workspace->id)
->native()
->posts()
->published()
->orderByDesc('created_at')
->take(6)
->get();
return [
'site' => [
'name' => $workspace->name,
'description' => $workspace->description,
],
'featured_posts' => $posts->isEmpty() ? [] : $this->formatPosts($posts),
];
});
}
public function getPosts(Workspace $workspace, int $page = 1, int $perPage = 10): array
{
$cacheKey = 'content:render:'.$this->sanitiseCacheKey($workspace->slug).":posts:{$page}:{$perPage}";
return Cache::remember($cacheKey, $this->getCacheTtl(), function () use ($workspace, $page, $perPage) {
$query = ContentItem::forWorkspace($workspace->id)
->native()
->posts()
->published()
->orderByDesc('created_at');
$total = $query->count();
$posts = $query->skip(($page - 1) * $perPage)
->take($perPage)
->get();
return [
'posts' => $this->formatPosts($posts),
'total' => $total,
'pages' => (int) ceil($total / $perPage),
];
});
}
public function getPost(Workspace $workspace, string $slug): ?array
{
$cacheKey = 'content:render:'.$this->sanitiseCacheKey($workspace->slug).':post:'.$this->sanitiseCacheKey($slug);
return Cache::remember($cacheKey, $this->getCacheTtl(), function () use ($workspace, $slug) {
$post = ContentItem::forWorkspace($workspace->id)
->native()
->posts()
->published()
->bySlug($slug)
->with(['author', 'taxonomies'])
->first();
return $post ? $post->toRenderArray() : null;
});
}
public function getPage(Workspace $workspace, string $slug): ?array
{
$cacheKey = 'content:render:'.$this->sanitiseCacheKey($workspace->slug).':page:'.$this->sanitiseCacheKey($slug);
return Cache::remember($cacheKey, $this->getCacheTtl(), function () use ($workspace, $slug) {
$page = ContentItem::forWorkspace($workspace->id)
->native()
->pages()
->published()
->bySlug($slug)
->first();
return $page ? $page->toRenderArray() : null;
});
}
public function getMeta(Workspace $workspace, ?array $content = null): array
{
$meta = [
'title' => $workspace->name,
'description' => $workspace->description ?? '',
'image' => null,
'url' => 'https://'.$workspace->domain,
];
if ($content) {
$meta['title'] = strip_tags($content['title']['rendered'] ?? $content['title'] ?? $workspace->name);
$meta['description'] = strip_tags($content['excerpt']['rendered'] ?? $content['excerpt'] ?? '');
if (isset($content['_embedded']['wp:featuredmedia'][0]['source_url'])) {
$meta['image'] = $content['_embedded']['wp:featuredmedia'][0]['source_url'];
}
}
return $meta;
}
// -------------------------------------------------------------------------
// Waitlist
// -------------------------------------------------------------------------
/**
* Add an email to the waitlist.
*
* Uses the WaitlistEntry model for persistent storage in the database.
* The source field tracks which workspace/service the signup came from.
*/
public function addToWaitlist(?Workspace $workspace, string $email): bool
{
// Check if email already exists
$existing = \Core\Mod\Tenant\Models\WaitlistEntry::where('email', $email)->first();
if ($existing) {
return false;
}
\Core\Mod\Tenant\Models\WaitlistEntry::create([
'email' => $email,
'source' => $workspace ? "workspace:{$workspace->slug}" : 'content:global',
'interest' => $workspace ? 'workspace_content' : 'platform',
]);
Log::info('Added '.$email.' to waitlist for workspace: '.($workspace?->slug ?? 'global'));
return true;
}
/**
* Get waitlist entries for a workspace or globally.
*
* Returns array of emails for compatibility with existing code.
*/
public function getWaitlist(?Workspace $workspace): array
{
$query = \Core\Mod\Tenant\Models\WaitlistEntry::query();
if ($workspace) {
$query->where('source', "workspace:{$workspace->slug}");
}
return $query->pluck('email')->toArray();
}
// -------------------------------------------------------------------------
// Cache management
// -------------------------------------------------------------------------
public function invalidateCache(Workspace $workspace): void
{
$prefix = 'content:render:'.$this->sanitiseCacheKey($workspace->slug).':';
if (config('cache.default') === 'redis') {
$keys = Cache::getRedis()->keys(config('cache.prefix').':'.$prefix.'*');
if ($keys) {
Cache::getRedis()->del($keys);
}
} else {
Cache::forget($prefix.'homepage');
}
Log::info("Content render cache invalidated for workspace: {$workspace->slug}");
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
public function resolveWorkspace(Request $request): ?Workspace
{
$workspace = $request->attributes->get('workspace_model');
if ($workspace instanceof Workspace) {
return $workspace;
}
$workspaceSlug = $request->attributes->get('workspace');
if ($workspaceSlug) {
if ($workspaceSlug instanceof Workspace) {
return $workspaceSlug;
}
return Workspace::where('slug', $workspaceSlug)->first();
}
return Workspace::where('domain', $request->getHost())->first();
}
protected function formatPosts($posts): array
{
return $posts->map(fn ($post) => $post->toRenderArray())->toArray();
}
}

View file

@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace Core\Content\Services;
use Carbon\Carbon;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Core\Content\Models\ContentItem;
/**
* Content Search Service
*
* Provides full-text search capabilities for content items with support for
* multiple backends:
* - Database (default): LIKE-based search with relevance scoring
* - Scout Database: Laravel Scout with database driver
* - Meilisearch: Laravel Scout with Meilisearch driver (optional)
*
* The service automatically uses the best available backend based on
* configuration and installed packages.
*/
class ContentSearchService
{
/**
* Search backend constants.
*/
public const BACKEND_DATABASE = 'database';
public const BACKEND_SCOUT_DATABASE = 'scout_database';
public const BACKEND_MEILISEARCH = 'meilisearch';
/**
* Minimum query length for search.
*/
protected int $minQueryLength = 2;
/**
* Maximum results per page.
*/
protected int $maxPerPage = 50;
/**
* Default results per page.
*/
protected int $defaultPerPage = 20;
/**
* Get the current search backend.
*/
public function getBackend(): string
{
$configured = config('content.search.backend', self::BACKEND_DATABASE);
// Validate Meilisearch is available if configured
if ($configured === self::BACKEND_MEILISEARCH) {
if (! $this->isMeilisearchAvailable()) {
return self::BACKEND_DATABASE;
}
}
// Validate Scout is available if configured
if ($configured === self::BACKEND_SCOUT_DATABASE) {
if (! $this->isScoutAvailable()) {
return self::BACKEND_DATABASE;
}
}
return $configured;
}
/**
* Check if Laravel Scout is available.
*/
public function isScoutAvailable(): bool
{
return class_exists(\Laravel\Scout\Searchable::class);
}
/**
* Check if Meilisearch is available and configured.
*/
public function isMeilisearchAvailable(): bool
{
if (! class_exists(\Meilisearch\Client::class)) {
return false;
}
$host = config('scout.meilisearch.host');
return ! empty($host);
}
/**
* Search content items.
*
* @param string $query Search query
* @param array{
* workspace_id?: int,
* type?: string,
* status?: string|array,
* category?: string,
* tag?: string,
* content_type?: string,
* date_from?: string|Carbon,
* date_to?: string|Carbon,
* per_page?: int,
* page?: int,
* } $filters
* @return LengthAwarePaginator<ContentItem>
*/
public function search(string $query, array $filters = []): LengthAwarePaginator
{
$query = trim($query);
$perPage = min($filters['per_page'] ?? $this->defaultPerPage, $this->maxPerPage);
$page = max($filters['page'] ?? 1, 1);
// For very short queries, use database search
if (strlen($query) < $this->minQueryLength) {
return $this->emptyPaginatedResult($perPage, $page);
}
$backend = $this->getBackend();
return match ($backend) {
self::BACKEND_MEILISEARCH,
self::BACKEND_SCOUT_DATABASE => $this->searchWithScout($query, $filters, $perPage, $page),
default => $this->searchWithDatabase($query, $filters, $perPage, $page),
};
}
/**
* Search using database LIKE queries with relevance scoring.
*
* @return LengthAwarePaginator<ContentItem>
*/
protected function searchWithDatabase(string $query, array $filters, int $perPage, int $page): LengthAwarePaginator
{
$baseQuery = $this->buildBaseQuery($filters);
$searchTerms = $this->tokeniseQuery($query);
// Build search conditions
$baseQuery->where(function (Builder $q) use ($query, $searchTerms) {
// Exact phrase match in title
$q->where('title', 'like', "%{$query}%");
// Individual term matches
foreach ($searchTerms as $term) {
if (strlen($term) >= $this->minQueryLength) {
$q->orWhere('title', 'like', "%{$term}%")
->orWhere('excerpt', 'like', "%{$term}%")
->orWhere('content_html', 'like', "%{$term}%")
->orWhere('content_markdown', 'like', "%{$term}%")
->orWhere('slug', 'like', "%{$term}%");
}
}
});
// Get all matching results for scoring
$allResults = $baseQuery->get();
// Calculate relevance scores and sort
$scored = $this->scoreResults($allResults, $query, $searchTerms);
// Manual pagination of scored results
$total = $scored->count();
$offset = ($page - 1) * $perPage;
$items = $scored->slice($offset, $perPage)->values();
// Convert to paginator
return new \Illuminate\Pagination\LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* Search using Laravel Scout.
*
* @return LengthAwarePaginator<ContentItem>
*/
protected function searchWithScout(string $query, array $filters, int $perPage, int $page): LengthAwarePaginator
{
// Check if ContentItem uses Searchable trait
if (! in_array(\Laravel\Scout\Searchable::class, class_uses_recursive(ContentItem::class))) {
// Fall back to database search
return $this->searchWithDatabase($query, $filters, $perPage, $page);
}
$searchBuilder = ContentItem::search($query);
// Apply workspace filter
if (isset($filters['workspace_id'])) {
$searchBuilder->where('workspace_id', $filters['workspace_id']);
}
// Apply content type filter (native types)
$searchBuilder->where('content_type', 'native');
// Apply filters via query callback for Scout database driver
$searchBuilder->query(function (Builder $builder) use ($filters) {
$this->applyFilters($builder, $filters);
});
return $searchBuilder->paginate($perPage, 'page', $page);
}
/**
* Get search suggestions based on partial query.
*
* @return Collection<int, array{title: string, slug: string, type: string}>
*/
public function suggest(string $query, int $workspaceId, int $limit = 10): Collection
{
$query = trim($query);
if (strlen($query) < $this->minQueryLength) {
return collect();
}
return ContentItem::forWorkspace($workspaceId)
->native()
->where(function (Builder $q) use ($query) {
$q->where('title', 'like', "{$query}%")
->orWhere('title', 'like', "% {$query}%")
->orWhere('slug', 'like', "{$query}%");
})
->select(['id', 'title', 'slug', 'type', 'status'])
->orderByRaw('CASE WHEN title LIKE ? THEN 0 ELSE 1 END', ["{$query}%"])
->orderBy('updated_at', 'desc')
->limit($limit)
->get()
->map(fn (ContentItem $item) => [
'id' => $item->id,
'title' => $item->title,
'slug' => $item->slug,
'type' => $item->type,
'status' => $item->status,
]);
}
/**
* Build the base query with workspace and content type scope.
*/
protected function buildBaseQuery(array $filters): Builder
{
$query = ContentItem::query()->with(['author', 'taxonomies']);
// Always scope to native content types
$query->native();
// Apply all filters
$this->applyFilters($query, $filters);
return $query;
}
/**
* Apply filters to a query builder.
*/
protected function applyFilters(Builder $query, array $filters): void
{
// Workspace filter
if (isset($filters['workspace_id'])) {
$query->forWorkspace($filters['workspace_id']);
}
// Content type (post/page)
if (! empty($filters['type'])) {
$query->where('type', $filters['type']);
}
// Status filter
if (! empty($filters['status'])) {
if (is_array($filters['status'])) {
$query->whereIn('status', $filters['status']);
} else {
$query->where('status', $filters['status']);
}
}
// Category filter
if (! empty($filters['category'])) {
$query->whereHas('categories', function (Builder $q) use ($filters) {
$q->where('slug', $filters['category']);
});
}
// Tag filter
if (! empty($filters['tag'])) {
$query->whereHas('tags', function (Builder $q) use ($filters) {
$q->where('slug', $filters['tag']);
});
}
// Content source type filter
if (! empty($filters['content_type'])) {
$query->where('content_type', $filters['content_type']);
}
// Date range filters
if (! empty($filters['date_from'])) {
$dateFrom = $filters['date_from'] instanceof Carbon
? $filters['date_from']
: Carbon::parse($filters['date_from']);
$query->where('created_at', '>=', $dateFrom->startOfDay());
}
if (! empty($filters['date_to'])) {
$dateTo = $filters['date_to'] instanceof Carbon
? $filters['date_to']
: Carbon::parse($filters['date_to']);
$query->where('created_at', '<=', $dateTo->endOfDay());
}
}
/**
* Tokenise a search query into individual terms.
*
* @return array<string>
*/
protected function tokeniseQuery(string $query): array
{
// Split on whitespace and filter empty/short terms
return array_values(array_filter(
preg_split('/\s+/', $query) ?: [],
fn ($term) => strlen($term) >= $this->minQueryLength
));
}
/**
* Calculate relevance scores for search results.
*
* @param Collection<int, ContentItem> $items
* @param array<string> $searchTerms
* @return Collection<int, ContentItem>
*/
protected function scoreResults(Collection $items, string $query, array $searchTerms): Collection
{
$queryLower = strtolower($query);
return $items
->map(function (ContentItem $item) use ($queryLower, $searchTerms) {
$score = $this->calculateRelevanceScore($item, $queryLower, $searchTerms);
$item->setAttribute('relevance_score', $score);
return $item;
})
->sortByDesc('relevance_score');
}
/**
* Calculate relevance score for a single content item.
*/
protected function calculateRelevanceScore(ContentItem $item, string $queryLower, array $searchTerms): int
{
$score = 0;
$titleLower = strtolower($item->title ?? '');
$slugLower = strtolower($item->slug ?? '');
$excerptLower = strtolower($item->excerpt ?? '');
$contentLower = strtolower(strip_tags($item->content_html ?? $item->content_markdown ?? ''));
// Exact phrase matches (highest weight)
if ($titleLower === $queryLower) {
$score += 200; // Exact title match
} elseif (str_starts_with($titleLower, $queryLower)) {
$score += 150; // Title starts with query
} elseif (str_contains($titleLower, $queryLower)) {
$score += 100; // Title contains query
}
if (str_contains($slugLower, $queryLower)) {
$score += 50; // Slug contains query
}
// Individual term matches
foreach ($searchTerms as $term) {
$termLower = strtolower($term);
if (str_contains($titleLower, $termLower)) {
$score += 30;
}
if (str_contains($slugLower, $termLower)) {
$score += 20;
}
if (str_contains($excerptLower, $termLower)) {
$score += 15;
}
if (str_contains($contentLower, $termLower)) {
$score += 5;
}
}
// Status boost (published content should rank higher)
if ($item->status === 'publish') {
$score += 10;
}
// Recency boost (content updated within 30 days)
if ($item->updated_at && $item->updated_at->diffInDays(now()) < 30) {
$score += 5;
}
return $score;
}
/**
* Create an empty paginated result.
*
* @return LengthAwarePaginator<ContentItem>
*/
protected function emptyPaginatedResult(int $perPage, int $page): LengthAwarePaginator
{
return new \Illuminate\Pagination\LengthAwarePaginator(
collect(),
0,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* Re-index all content items for Scout.
*
* Only applicable when using Scout backend.
*/
public function reindex(?Workspace $workspace = null): int
{
if (! $this->isScoutAvailable()) {
return 0;
}
if (! in_array(\Laravel\Scout\Searchable::class, class_uses_recursive(ContentItem::class))) {
return 0;
}
$query = ContentItem::native();
if ($workspace) {
$query->forWorkspace($workspace->id);
}
$count = 0;
$query->chunk(100, function ($items) use (&$count) {
$items->searchable();
$count += $items->count();
});
return $count;
}
/**
* Format search results for API response.
*
* @param LengthAwarePaginator<ContentItem> $results
*/
public function formatForApi(LengthAwarePaginator $results): array
{
return [
'data' => $results->map(fn (ContentItem $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 ?? ''), 200),
'author' => $item->author?->name,
'categories' => $item->categories->pluck('name')->all(),
'tags' => $item->tags->pluck('name')->all(),
'relevance_score' => $item->getAttribute('relevance_score'),
'created_at' => $item->created_at?->toIso8601String(),
'updated_at' => $item->updated_at?->toIso8601String(),
])->all(),
'meta' => [
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
'per_page' => $results->perPage(),
'total' => $results->total(),
'backend' => $this->getBackend(),
],
];
}
}

View file

@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace Core\Content\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Core\Content\Models\ContentWebhookLog;
/**
* WebhookRetryService
*
* Handles retry logic for failed content webhooks with exponential backoff.
*
* Backoff intervals: 1m, 5m, 15m, 1h, 4h
* Max retries: 5 (configurable per webhook)
*/
class WebhookRetryService
{
/**
* Exponential backoff intervals in seconds.
* Attempt 1: 1 minute
* Attempt 2: 5 minutes
* Attempt 3: 15 minutes
* Attempt 4: 1 hour
* Attempt 5: 4 hours
*/
protected const BACKOFF_INTERVALS = [
1 => 60, // 1 minute
2 => 300, // 5 minutes
3 => 900, // 15 minutes
4 => 3600, // 1 hour
5 => 14400, // 4 hours
];
/**
* Default maximum retries if not set on webhook.
*/
protected const DEFAULT_MAX_RETRIES = 5;
/**
* Request timeout in seconds.
*/
protected const REQUEST_TIMEOUT = 30;
/**
* Get webhooks that are due for retry.
*
* @param int $limit Maximum number of webhooks to return
*/
public function getRetryableWebhooks(int $limit = 50): Collection
{
return ContentWebhookLog::retryable()
->orderBy('next_retry_at', 'asc')
->limit($limit)
->get();
}
/**
* Count webhooks pending retry.
*/
public function countPendingRetries(): int
{
return ContentWebhookLog::retryable()->count();
}
/**
* Attempt to retry a webhook.
*
* @return bool True if retry succeeded, false if failed
*/
public function retry(ContentWebhookLog $webhook): bool
{
// Check if we've exceeded max retries
if ($webhook->hasExceededMaxRetries()) {
$this->markExhausted($webhook);
return false;
}
Log::info('Retrying webhook', [
'webhook_id' => $webhook->id,
'event_type' => $webhook->event_type,
'attempt' => $webhook->retry_count + 1,
'max_retries' => $webhook->max_retries,
]);
$webhook->markProcessing();
try {
$response = $this->sendWebhook($webhook);
if ($response->successful()) {
$this->markSuccess($webhook);
Log::info('Webhook retry succeeded', [
'webhook_id' => $webhook->id,
'status_code' => $response->status(),
]);
return true;
}
// Failed with HTTP error
$this->markFailed($webhook, "HTTP {$response->status()}: {$response->body()}");
Log::warning('Webhook retry failed with HTTP error', [
'webhook_id' => $webhook->id,
'status_code' => $response->status(),
'body' => $response->body(),
]);
return false;
} catch (\Exception $e) {
$this->markFailed($webhook, $e->getMessage());
Log::error('Webhook retry failed with exception', [
'webhook_id' => $webhook->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Mark a webhook as successfully processed.
*/
public function markSuccess(ContentWebhookLog $webhook): void
{
$webhook->update([
'status' => 'completed',
'processed_at' => now(),
'error_message' => null,
'last_error' => null,
'next_retry_at' => null,
]);
}
/**
* Mark a webhook as failed and schedule next retry.
*/
public function markFailed(ContentWebhookLog $webhook, string $error): void
{
$nextRetryCount = $webhook->retry_count + 1;
// Check if we should schedule another retry
if ($nextRetryCount >= $webhook->max_retries) {
$this->markExhausted($webhook, $error);
return;
}
$nextRetryAt = $this->calculateNextRetry($nextRetryCount);
$webhook->update([
'status' => 'pending',
'retry_count' => $nextRetryCount,
'next_retry_at' => $nextRetryAt,
'last_error' => $error,
'error_message' => "Retry {$nextRetryCount}/{$webhook->max_retries}: {$error}",
]);
Log::info('Webhook scheduled for retry', [
'webhook_id' => $webhook->id,
'retry_count' => $nextRetryCount,
'next_retry_at' => $nextRetryAt->toIso8601String(),
]);
}
/**
* Mark a webhook as exhausted (max retries reached).
*/
public function markExhausted(ContentWebhookLog $webhook, ?string $error = null): void
{
$webhook->update([
'status' => 'failed',
'processed_at' => now(),
'next_retry_at' => null,
'last_error' => $error ?? $webhook->last_error,
'error_message' => "Max retries ({$webhook->max_retries}) exhausted. Last error: ".($error ?? $webhook->last_error ?? 'Unknown'),
]);
Log::warning('Webhook retry exhausted', [
'webhook_id' => $webhook->id,
'max_retries' => $webhook->max_retries,
'last_error' => $error ?? $webhook->last_error,
]);
}
/**
* Calculate the next retry time based on attempt number.
*
* Uses exponential backoff: 1m, 5m, 15m, 1h, 4h
*/
public function calculateNextRetry(int $attempts): \Carbon\Carbon
{
// Clamp to max defined interval
$attempt = min($attempts, count(self::BACKOFF_INTERVALS));
$seconds = self::BACKOFF_INTERVALS[$attempt] ?? self::BACKOFF_INTERVALS[count(self::BACKOFF_INTERVALS)];
return now()->addSeconds($seconds);
}
/**
* Cancel retries for a webhook.
*/
public function cancelRetry(ContentWebhookLog $webhook): void
{
$webhook->update([
'status' => 'failed',
'processed_at' => now(),
'next_retry_at' => null,
'error_message' => 'Retry cancelled by user',
]);
Log::info('Webhook retry cancelled', ['webhook_id' => $webhook->id]);
}
/**
* Reset a webhook for retry (manual retry).
*/
public function resetForRetry(ContentWebhookLog $webhook): void
{
$webhook->update([
'status' => 'pending',
'retry_count' => 0,
'next_retry_at' => now(),
'error_message' => null,
'last_error' => null,
]);
Log::info('Webhook reset for retry', ['webhook_id' => $webhook->id]);
}
/**
* Process the webhook payload.
*
* For content webhooks, we're processing incoming webhooks from external
* systems (WordPress, headless CMS, etc.). The retry logic reprocesses
* the webhook payload through our content pipeline.
*/
protected function sendWebhook(ContentWebhookLog $webhook): \Illuminate\Http\Client\Response
{
$payload = $webhook->payload;
if (empty($payload)) {
throw new \RuntimeException('Webhook payload is empty');
}
// Validate payload structure - need either action (WordPress) or event (generic)
if (! isset($payload['action']) && ! isset($payload['event']) && ! isset($payload['type'])) {
throw new \RuntimeException('Invalid webhook payload structure: missing action, event, or type');
}
// Process based on event type
$eventType = $webhook->event_type;
$contentType = $webhook->content_type;
$wpId = $webhook->wp_id;
// Validate we have the data needed to process
if (empty($eventType)) {
throw new \RuntimeException('Missing event_type');
}
// For post/page updates, we need content data
if (str_contains($eventType, 'created') || str_contains($eventType, 'updated')) {
if (! isset($payload['content']) && ! isset($payload['post'])) {
throw new \RuntimeException('Missing content data for create/update event');
}
}
// For delete events, we just need the ID
if (str_contains($eventType, 'deleted')) {
if (empty($wpId) && ! isset($payload['id'])) {
throw new \RuntimeException('Missing ID for delete event');
}
}
// Webhook processing is successful if validation passes
// The actual content sync would be handled by a separate processor
// that's triggered by the webhook handler when initially received
Log::info('Webhook payload validated for retry', [
'webhook_id' => $webhook->id,
'event_type' => $eventType,
'content_type' => $contentType,
'wp_id' => $wpId,
]);
// Return a successful response to indicate processing completed
// In a full implementation, this would trigger the actual content sync
return new \Illuminate\Http\Client\Response(
new \GuzzleHttp\Psr7\Response(200, [], json_encode(['success' => true]))
);
}
/**
* Get retry statistics for a workspace.
*/
public function getStats(?int $workspaceId = null): array
{
$query = ContentWebhookLog::query();
if ($workspaceId) {
$query->where('workspace_id', $workspaceId);
}
return [
'pending_retries' => (clone $query)->retryable()->count(),
'failed_permanently' => (clone $query)->where('status', 'failed')
->where('retry_count', '>=', \DB::raw('max_retries'))
->count(),
'total_retries_today' => (clone $query)->whereDate('updated_at', today())
->where('retry_count', '>', 0)
->count(),
'success_rate' => $this->calculateSuccessRate($workspaceId),
];
}
/**
* Calculate webhook success rate.
*/
protected function calculateSuccessRate(?int $workspaceId = null): float
{
$query = ContentWebhookLog::query();
if ($workspaceId) {
$query->where('workspace_id', $workspaceId);
}
$total = $query->count();
if ($total === 0) {
return 100.0;
}
$successful = (clone $query)->where('status', 'completed')->count();
return round(($successful / $total) * 100, 1);
}
}

View file

@ -0,0 +1,261 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<core:heading size="xl">Content Search</core:heading>
<core:subheading>
Search across all your content items with full-text search.
</core:subheading>
</div>
</div>
{{-- Search Bar --}}
<div class="flex items-center gap-4">
<div class="flex-1">
<core:input
wire:model.live.debounce.300ms="query"
type="search"
placeholder="Search content by title, body, or slug..."
icon="magnifying-glass"
autofocus
/>
</div>
<core:button
wire:click="toggleFilters"
variant="{{ $showFilters ? 'primary' : 'outline' }}"
icon="funnel"
>
Filters
@if($this->activeFilterCount() > 0)
<flux:badge color="blue" size="sm" class="ml-1">{{ $this->activeFilterCount() }}</flux:badge>
@endif
</core:button>
</div>
{{-- Filters Panel --}}
@if($showFilters)
<flux:card class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{{-- Type Filter --}}
<div>
<core:label for="type">Content Type</core:label>
<core:select wire:model.live="type" id="type">
<option value="">All types</option>
<option value="post">Posts</option>
<option value="page">Pages</option>
</core:select>
</div>
{{-- Status Filter --}}
<div>
<core:label for="status">Status</core:label>
<core:select wire:model.live="status" id="status">
<option value="">All statuses</option>
<option value="publish">Published</option>
<option value="draft">Draft</option>
<option value="pending">Pending</option>
<option value="future">Scheduled</option>
<option value="private">Private</option>
</core:select>
</div>
{{-- Category Filter --}}
<div>
<core:label for="category">Category</core:label>
<core:select wire:model.live="category" id="category">
<option value="">All categories</option>
@foreach($this->categories as $cat)
<option value="{{ $cat->slug }}">{{ $cat->name }}</option>
@endforeach
</core:select>
</div>
{{-- Date From --}}
<div>
<core:label for="dateFrom">From Date</core:label>
<core:input
wire:model.live="dateFrom"
type="date"
id="dateFrom"
/>
</div>
{{-- Date To --}}
<div>
<core:label for="dateTo">To Date</core:label>
<core:input
wire:model.live="dateTo"
type="date"
id="dateTo"
/>
</div>
</div>
@if($this->hasActiveFilters())
<div class="mt-4 flex justify-end">
<core:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">
Clear filters
</core:button>
</div>
@endif
</flux:card>
@endif
{{-- Results --}}
@if(strlen(trim($query)) >= 2)
@if($this->results && $this->results->count() > 0)
{{-- Results Header --}}
<div class="flex items-center justify-between text-sm text-zinc-500">
<span>
Found {{ $this->results->total() }} result{{ $this->results->total() !== 1 ? 's' : '' }}
for "{{ $query }}"
</span>
<span class="text-xs">
Using: {{ ucfirst(str_replace('_', ' ', $this->searchBackend)) }}
</span>
</div>
{{-- Results List --}}
<div class="space-y-3">
@foreach($this->results as $item)
<flux:card
wire:click="viewContent({{ $item->id }})"
class="p-4 cursor-pointer hover:border-blue-300 dark:hover:border-blue-700 transition-colors"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
{{-- Title and Type --}}
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-zinc-900 dark:text-white truncate">
{{ $item->title }}
</h3>
<flux:badge color="{{ $item->type_color }}" size="sm">
{{ ucfirst($item->type) }}
</flux:badge>
<flux:badge color="{{ $item->status_color }}" size="sm">
{{ ucfirst($item->status) }}
</flux:badge>
</div>
{{-- Slug --}}
<div class="text-sm text-zinc-500 dark:text-zinc-400 mb-2">
<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded">
/{{ $item->slug }}
</code>
</div>
{{-- Excerpt --}}
@if($item->excerpt)
<p class="text-sm text-zinc-600 dark:text-zinc-300 line-clamp-2">
{{ Str::limit(strip_tags($item->excerpt), 200) }}
</p>
@elseif($item->content_html || $item->content_markdown)
<p class="text-sm text-zinc-600 dark:text-zinc-300 line-clamp-2">
{{ Str::limit(strip_tags($item->content_html ?? $item->content_markdown), 200) }}
</p>
@endif
{{-- Meta --}}
<div class="flex items-center gap-4 mt-2 text-xs text-zinc-500">
@if($item->author)
<span class="flex items-center gap-1">
<flux:icon name="user" class="size-3" />
{{ $item->author->name }}
</span>
@endif
@if($item->categories->count() > 0)
<span class="flex items-center gap-1">
<flux:icon name="folder" class="size-3" />
{{ $item->categories->pluck('name')->join(', ') }}
</span>
@endif
<span class="flex items-center gap-1">
<flux:icon name="calendar" class="size-3" />
{{ $item->updated_at->diffForHumans() }}
</span>
</div>
</div>
{{-- Relevance Score --}}
@if($item->getAttribute('relevance_score'))
<div class="flex-shrink-0 text-right">
<div class="text-xs text-zinc-400">Relevance</div>
<div class="text-lg font-semibold text-blue-500">
{{ $item->getAttribute('relevance_score') }}
</div>
</div>
@endif
</div>
</flux:card>
@endforeach
</div>
{{-- Pagination --}}
<div class="mt-6">
{{ $this->results->links() }}
</div>
@elseif($this->results && $this->results->count() === 0)
{{-- No Results --}}
<flux:card class="p-12">
<div class="flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="magnifying-glass" class="size-8 text-zinc-400" />
</div>
<flux:heading size="lg">No results found</flux:heading>
<flux:subheading class="mt-1">
No content matches "{{ $query }}"
@if($this->hasActiveFilters())
with the current filters
@endif
</flux:subheading>
@if($this->hasActiveFilters())
<core:button wire:click="clearFilters" variant="outline" class="mt-4" icon="x-mark">
Clear filters
</core:button>
@endif
</div>
</flux:card>
@endif
@else
{{-- Empty State / Recent Content --}}
<flux:card class="p-12">
<div class="flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
<flux:icon name="magnifying-glass" class="size-8 text-blue-500" />
</div>
<flux:heading size="lg">Search your content</flux:heading>
<flux:subheading class="mt-1">
Enter at least 2 characters to search across titles, content, and slugs.
</flux:subheading>
</div>
</flux:card>
{{-- Recent Content --}}
@if($this->recentContent->count() > 0)
<div>
<core:heading size="sm" class="mb-3">Recent Content</core:heading>
<div class="space-y-2">
@foreach($this->recentContent as $item)
<div
wire:click="viewContent({{ $item->id }})"
class="flex items-center justify-between p-3 rounded-lg border border-zinc-200 dark:border-zinc-700 cursor-pointer hover:border-blue-300 dark:hover:border-blue-700 transition-colors"
>
<div class="flex items-center gap-3">
<flux:badge color="{{ $item->type_color }}" size="sm">
{{ ucfirst($item->type) }}
</flux:badge>
<span class="font-medium text-zinc-900 dark:text-white">
{{ Str::limit($item->title, 60) }}
</span>
</div>
<span class="text-xs text-zinc-500">
{{ $item->updated_at->diffForHumans() }}
</span>
</div>
@endforeach
</div>
</div>
@endif
@endif
</div>

View file

@ -0,0 +1,392 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<core:heading size="xl">Content Webhooks</core:heading>
<core:subheading>
Receive content updates from WordPress, CMS systems, and other sources.
</core:subheading>
</div>
<core:button wire:click="create" variant="primary" icon="plus">
Create endpoint
</core:button>
</div>
{{-- View Toggle --}}
<div class="flex items-center gap-4">
<flux:tabs wire:model.live="view">
<flux:tab name="endpoints" icon="link">Endpoints</flux:tab>
<flux:tab name="logs" icon="document-text">Webhook Logs</flux:tab>
</flux:tabs>
</div>
{{-- Filters --}}
<div class="flex items-center gap-4">
<div class="flex-1 max-w-md">
<core:input
wire:model.live.debounce.300ms="search"
type="search"
placeholder="{{ $view === 'endpoints' ? 'Search endpoints...' : 'Search logs...' }}"
icon="magnifying-glass"
/>
</div>
<core:select wire:model.live="statusFilter" class="w-40">
<option value="">All statuses</option>
@if($view === 'endpoints')
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
@else
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
@endif
</core:select>
</div>
@if($view === 'endpoints')
{{-- Endpoints List --}}
@if($this->endpoints->isEmpty())
<flux:card class="p-12">
<div class="flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
<flux:icon name="link" class="size-8 text-blue-500" />
</div>
<flux:heading size="lg">No webhook endpoints yet</flux:heading>
<flux:subheading class="mt-1">
@if($search)
No endpoints match your search.
@else
Create an endpoint to start receiving content webhooks.
@endif
</flux:subheading>
@unless($search)
<flux:button wire:click="create" variant="primary" class="mt-4" icon="plus">
Create your first endpoint
</flux:button>
@endunless
</div>
</flux:card>
@else
<flux:card class="overflow-hidden !p-0">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Name
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Webhook URL
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Status
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Last Received
</th>
<th scope="col" class="relative px-4 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach($this->endpoints as $endpoint)
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3">
<span class="font-medium text-zinc-900 dark:text-white">{{ $endpoint->name }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<code class="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded">
{{ Str::limit($endpoint->getEndpointUrl(), 50) }}
</code>
<button
wire:click="copyUrl('{{ $endpoint->uuid }}')"
class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
title="Copy URL"
>
<flux:icon name="clipboard-document" class="size-4" />
</button>
</div>
</td>
<td class="px-4 py-3">
<button
wire:click="toggleActive('{{ $endpoint->uuid }}')"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition
@if($endpoint->is_enabled && !$endpoint->isCircuitBroken())
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
@elseif($endpoint->isCircuitBroken())
bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400
@else
bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-700 dark:text-zinc-400
@endif
"
>
<span class="h-1.5 w-1.5 rounded-full
@if($endpoint->is_enabled && !$endpoint->isCircuitBroken()) bg-green-500
@elseif($endpoint->isCircuitBroken()) bg-red-500
@else bg-zinc-400
@endif
"></span>
{{ $endpoint->status_label }}
</button>
</td>
<td class="px-4 py-3">
<span class="text-sm text-zinc-500">
{{ $endpoint->last_received_at?->diffForHumans() ?? 'Never' }}
</span>
</td>
<td class="px-4 py-3 text-right">
<core:dropdown>
<core:button variant="ghost" size="sm" icon="ellipsis-vertical" />
<core:menu>
<core:menu.item
wire:click="copyUrl('{{ $endpoint->uuid }}')"
icon="clipboard-document"
>
Copy URL
</core:menu.item>
<core:menu.item
wire:click="showSecret('{{ $endpoint->uuid }}')"
icon="key"
>
View Secret
</core:menu.item>
<core:menu.item
wire:click="edit('{{ $endpoint->uuid }}')"
icon="pencil-square"
>
Edit
</core:menu.item>
@if($endpoint->failure_count > 0)
<core:menu.item
wire:click="resetFailures('{{ $endpoint->uuid }}')"
icon="arrow-path"
>
Reset Failures
</core:menu.item>
@endif
<core:menu.separator />
<core:menu.item
wire:click="confirmDelete('{{ $endpoint->uuid }}')"
icon="trash"
variant="danger"
>
Delete
</core:menu.item>
</core:menu>
</core:dropdown>
</td>
</tr>
@endforeach
</tbody>
</table>
</flux:card>
{{-- Pagination --}}
<div class="mt-6">
{{ $this->endpoints->links() }}
</div>
@endif
@else
{{-- Webhook Logs --}}
@if($this->logs->isEmpty())
<flux:card class="p-12">
<div class="flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="document-text" class="size-8 text-zinc-400" />
</div>
<flux:heading size="lg">No webhook logs yet</flux:heading>
<flux:subheading class="mt-1">
@if($search || $statusFilter)
No logs match your filters.
@else
Webhook logs will appear here once you start receiving webhooks.
@endif
</flux:subheading>
</div>
</flux:card>
@else
<flux:card class="overflow-hidden !p-0">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Event Type
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Content
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Status
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Source IP
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Received
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach($this->logs as $log)
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3">
<flux:badge color="{{ $log->event_color }}">
{{ $log->event_type }}
</flux:badge>
</td>
<td class="px-4 py-3">
<span class="text-sm text-zinc-600 dark:text-zinc-400">
@if($log->content_type)
{{ $log->content_type }}
@if($log->wp_id)
#{{ $log->wp_id }}
@endif
@else
-
@endif
</span>
</td>
<td class="px-4 py-3">
<flux:badge color="{{ $log->status_color }}" icon="{{ $log->status_icon }}">
{{ ucfirst($log->status) }}
</flux:badge>
@if($log->error_message)
<span class="block text-xs text-red-500 mt-1" title="{{ $log->error_message }}">
{{ Str::limit($log->error_message, 40) }}
</span>
@endif
</td>
<td class="px-4 py-3">
<span class="text-sm text-zinc-500">{{ $log->source_ip ?? '-' }}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-zinc-500" title="{{ $log->created_at }}">
{{ $log->created_at->diffForHumans() }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</flux:card>
{{-- Pagination --}}
<div class="mt-6">
{{ $this->logs->links() }}
</div>
@endif
@endif
{{-- Create/Edit Endpoint Modal --}}
<core:modal wire:model.live="showForm" class="max-w-lg">
<div class="space-y-4">
<core:heading size="lg">{{ $editingId ? 'Edit' : 'Create' }} Webhook Endpoint</core:heading>
<div class="space-y-4">
<div>
<core:label for="name">Name</core:label>
<core:input
wire:model="name"
id="name"
placeholder="WordPress Blog"
/>
@error('name') <span class="text-sm text-red-500">{{ $message }}</span> @enderror
</div>
<div>
<core:label>Allowed Event Types</core:label>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border rounded-lg p-3 dark:border-zinc-700">
@foreach($this->availableTypes as $type)
<label class="flex items-center gap-2">
<input
type="checkbox"
wire:model="allowedTypes"
value="{{ $type }}"
class="rounded border-zinc-300 dark:border-zinc-600"
>
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ $type }}</span>
</label>
@endforeach
</div>
<p class="text-xs text-zinc-500 mt-1">Leave empty to allow all event types.</p>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
wire:model="isEnabled"
id="isEnabled"
class="rounded border-zinc-300 dark:border-zinc-600"
>
<core:label for="isEnabled" class="!mb-0">Enable this endpoint</core:label>
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<core:button wire:click="cancelForm" variant="ghost">
Cancel
</core:button>
<core:button wire:click="save" variant="primary">
{{ $editingId ? 'Update' : 'Create' }} Endpoint
</core:button>
</div>
</div>
</core:modal>
{{-- Delete Confirmation Modal --}}
<core:modal wire:model.live="deletingUuid" class="max-w-md">
<div class="space-y-4">
<core:heading size="lg">Delete webhook endpoint?</core:heading>
<core:text>
This action cannot be undone. The endpoint will be permanently removed and will no longer receive webhooks.
</core:text>
<div class="flex justify-end gap-3">
<core:button wire:click="cancelDelete" variant="ghost">
Cancel
</core:button>
<core:button wire:click="delete" variant="danger">
Delete
</core:button>
</div>
</div>
</core:modal>
{{-- Secret Display Modal --}}
<core:modal wire:model.live="showingSecretUuid" class="max-w-lg">
<div class="space-y-4">
<core:heading size="lg">Webhook Secret</core:heading>
<core:text>
Use this secret to verify webhook signatures. Keep it safe and do not share it publicly.
</core:text>
@if($revealedSecret)
<div class="bg-zinc-100 dark:bg-zinc-800 rounded-lg p-4">
<code class="text-sm break-all text-zinc-700 dark:text-zinc-300">{{ $revealedSecret }}</code>
</div>
@endif
<div class="flex justify-between items-center pt-4">
<core:button wire:click="regenerateSecret('{{ $showingSecretUuid }}')" variant="outline" icon="arrow-path">
Regenerate
</core:button>
<core:button wire:click="hideSecret" variant="primary">
Done
</core:button>
</div>
</div>
</core:modal>
</div>
@script
<script>
$wire.on('copy-to-clipboard', (event) => {
navigator.clipboard.writeText(event.text);
});
</script>
@endscript

View file

@ -0,0 +1,73 @@
<div>
<section class="py-16">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-3xl md:text-4xl font-bold text-slate-100 mb-4">Blog</h1>
<p class="text-slate-400">
Latest posts from {{ $workspace['name'] ?? 'Host UK' }}
</p>
</div>
<!-- Posts Grid -->
@if(!empty($posts))
<div class="grid md:grid-cols-2 gap-8 mb-12">
@foreach($posts as $post)
<article class="group bg-slate-800/30 border border-slate-700/50 rounded-xl overflow-hidden hover:border-slate-600/50 transition">
<a href="/blog/{{ $post['slug'] }}" class="block" wire:navigate>
<!-- Featured Image -->
@if(isset($post['_embedded']['wp:featuredmedia'][0]))
<div class="aspect-video bg-slate-800">
<img
src="{{ $post['_embedded']['wp:featuredmedia'][0]['source_url'] }}"
alt="{{ e($post['title']['rendered'] ?? '') }}"
class="w-full h-full object-cover group-hover:scale-105 transition duration-300"
>
</div>
@else
<div class="aspect-video bg-slate-800 flex items-center justify-center">
<i class="fa-solid fa-image text-4xl text-slate-600"></i>
</div>
@endif
<div class="p-6">
<!-- Meta -->
<div class="flex items-center gap-3 text-sm text-slate-500 mb-3">
<time datetime="{{ $post['date'] }}">
{{ \Carbon\Carbon::parse($post['date'])->format('M j, Y') }}
</time>
</div>
<!-- Title -->
<h2 class="font-semibold text-xl text-slate-200 group-hover:text-white transition mb-3">
{{ $post['title']['rendered'] ?? 'Untitled' }}
</h2>
<!-- Excerpt -->
@if(isset($post['excerpt']['rendered']))
<p class="text-slate-400 line-clamp-3">
{!! strip_tags($post['excerpt']['rendered']) !!}
</p>
@endif
<!-- Read More -->
<div class="mt-4 flex items-center gap-2 text-violet-400 text-sm font-medium">
Read More<span class="sr-only">: {{ $post['title']['rendered'] ?? 'Untitled' }}</span>
<i class="fa-solid fa-arrow-right text-xs group-hover:translate-x-1 transition" aria-hidden="true"></i>
</div>
</div>
</a>
</article>
@endforeach
</div>
@else
<div class="text-center py-16">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-800 flex items-center justify-center">
<i class="fa-solid fa-pen-to-square text-2xl text-slate-500"></i>
</div>
<p class="text-slate-400">No posts yet. Check back soon.</p>
</div>
@endif
</div>
</section>
</div>

View file

@ -0,0 +1,50 @@
<div>
<article class="py-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<!-- Back link -->
<a href="/help" class="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition mb-8" wire:navigate>
<i class="fa-solid fa-arrow-left"></i>
Back to Help Centre
</a>
<!-- Header -->
<header class="mb-8">
<div class="w-12 h-12 rounded-lg bg-violet-500/20 flex items-center justify-center mb-4">
<i class="fa-solid fa-file-lines text-violet-400"></i>
</div>
<h1 class="text-3xl md:text-4xl font-bold text-slate-100 mb-4">
{{ $article['title']['rendered'] ?? 'Untitled' }}
</h1>
@if(isset($article['modified']))
<div class="text-sm text-slate-500">
Last updated: {{ \Carbon\Carbon::parse($article['modified'])->format('F j, Y') }}
</div>
@endif
</header>
<!-- Content -->
<div class="prose prose-invert prose-slate prose-lg max-w-none
prose-headings:text-slate-100
prose-p:text-slate-300
prose-a:text-violet-400 prose-a:no-underline hover:prose-a:underline
prose-strong:text-slate-200
prose-code:text-violet-300 prose-code:bg-slate-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-800 prose-pre:border prose-pre:border-slate-700
prose-blockquote:border-violet-500 prose-blockquote:text-slate-400
prose-ul:text-slate-300 prose-ol:text-slate-300
prose-li:marker:text-violet-400
">
{!! $article['content']['rendered'] ?? '' !!}
</div>
<!-- Footer -->
<footer class="mt-12 pt-8 border-t border-slate-700/50">
<a href="/help" class="inline-flex items-center gap-2 text-violet-400 hover:text-violet-300 transition" wire:navigate>
<i class="fa-solid fa-arrow-left"></i>
Back to all articles
</a>
</footer>
</div>
</article>
</div>

View file

@ -0,0 +1,41 @@
<div>
<section class="py-16">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-3xl md:text-4xl font-bold text-slate-100 mb-4">Help Centre</h1>
<p class="text-slate-400">
Guides and documentation for {{ $workspace['name'] ?? 'Host UK' }}
</p>
</div>
<!-- Articles Grid -->
@if(!empty($articles))
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($articles as $article)
<a href="/help/{{ $article['slug'] }}" class="group block bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 hover:border-slate-600/50 transition" wire:navigate>
<div class="w-12 h-12 rounded-lg bg-violet-500/20 flex items-center justify-center mb-4">
<i class="fa-solid fa-file-lines text-violet-400"></i>
</div>
<h3 class="font-semibold text-lg text-slate-200 group-hover:text-white transition mb-2">
{{ $article['title']['rendered'] ?? 'Untitled' }}
</h3>
@if(isset($article['excerpt']['rendered']))
<p class="text-sm text-slate-400 line-clamp-2">
{!! strip_tags($article['excerpt']['rendered']) !!}
</p>
@endif
</a>
@endforeach
</div>
@else
<div class="text-center py-16">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-800 flex items-center justify-center">
<i class="fa-solid fa-book text-2xl text-slate-500"></i>
</div>
<p class="text-slate-400">Help articles coming soon.</p>
</div>
@endif
</div>
</section>
</div>

View file

@ -0,0 +1,62 @@
<div>
<article class="py-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<!-- Back link -->
<a href="/blog" class="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition mb-8" wire:navigate>
<i class="fa-solid fa-arrow-left"></i>
Back to Blog
</a>
<!-- Featured Image -->
@if(isset($post['_embedded']['wp:featuredmedia'][0]))
<div class="aspect-video bg-slate-800 rounded-xl overflow-hidden mb-8">
<img
src="{{ $post['_embedded']['wp:featuredmedia'][0]['source_url'] }}"
alt="{{ e($post['title']['rendered'] ?? '') }}"
class="w-full h-full object-cover"
>
</div>
@endif
<!-- Header -->
<header class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-slate-100 mb-4">
{{ $post['title']['rendered'] ?? 'Untitled' }}
</h1>
<div class="flex items-center gap-4 text-sm text-slate-500">
<time datetime="{{ $post['date'] }}">
{{ \Carbon\Carbon::parse($post['date'])->format('F j, Y') }}
</time>
@if(isset($post['_embedded']['author'][0]))
<span class="text-slate-600">|</span>
<span>By {{ $post['_embedded']['author'][0]['name'] }}</span>
@endif
</div>
</header>
<!-- Content -->
<div class="prose prose-invert prose-slate prose-lg max-w-none
prose-headings:text-slate-100
prose-p:text-slate-300
prose-a:text-violet-400 prose-a:no-underline hover:prose-a:underline
prose-strong:text-slate-200
prose-code:text-violet-300 prose-code:bg-slate-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-800 prose-pre:border prose-pre:border-slate-700
prose-blockquote:border-violet-500 prose-blockquote:text-slate-400
prose-ul:text-slate-300 prose-ol:text-slate-300
prose-li:marker:text-violet-400
">
{!! $post['content']['rendered'] ?? '' !!}
</div>
<!-- Footer -->
<footer class="mt-12 pt-8 border-t border-slate-700/50">
<a href="/blog" class="inline-flex items-center gap-2 text-violet-400 hover:text-violet-300 transition" wire:navigate>
<i class="fa-solid fa-arrow-left"></i>
Back to all posts
</a>
</footer>
</div>
</article>
</div>

View file

@ -0,0 +1,22 @@
<div class="min-h-screen flex items-center justify-center py-16">
<div class="max-w-md mx-auto px-4 text-center">
<div class="w-20 h-20 mx-auto mb-6 bg-slate-800 rounded-full flex items-center justify-center">
<i class="fa-solid fa-clock-rotate-left text-3xl text-slate-400"></i>
</div>
<h1 class="text-2xl font-bold text-slate-100 mb-4">
Preview Link Expired
</h1>
<p class="text-slate-400 mb-8">
This preview link has expired or is no longer valid.
Please request a new preview link from the content author.
</p>
<a href="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-violet-600 hover:bg-violet-700 text-white font-medium rounded-lg transition">
<i class="fa-solid fa-home"></i>
Go to Homepage
</a>
</div>
</div>

View file

@ -0,0 +1,108 @@
<div>
{{-- Preview Banner --}}
@unless($isPublished)
<div class="sticky top-0 z-50 bg-amber-500 text-amber-900">
<div class="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<i class="fa-solid fa-eye text-lg"></i>
<div>
<span class="font-semibold">Preview Mode</span>
<span class="mx-2">|</span>
<span class="text-sm">
Status:
<span class="font-medium">
{{ match($content['preview_status'] ?? 'draft') {
'draft' => 'Draft',
'pending' => 'Pending Review',
'future' => 'Scheduled',
'private' => 'Private',
default => ucfirst($content['preview_status'] ?? 'draft')
} }}
</span>
</span>
</div>
</div>
@if($expiresIn)
<div class="text-sm">
<i class="fa-solid fa-clock mr-1"></i>
Link expires {{ $expiresIn }}
</div>
@endif
</div>
</div>
@endunless
<article class="py-16">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
{{-- Back link --}}
<a href="/blog" class="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition mb-8">
<i class="fa-solid fa-arrow-left"></i>
Back to Blog
</a>
{{-- Featured Image --}}
@if(isset($content['_embedded']['wp:featuredmedia'][0]))
<div class="aspect-video bg-slate-800 rounded-xl overflow-hidden mb-8">
<img
src="{{ $content['_embedded']['wp:featuredmedia'][0]['source_url'] }}"
alt="{{ e($content['title']['rendered'] ?? '') }}"
class="w-full h-full object-cover"
>
</div>
@endif
{{-- Header --}}
<header class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-slate-100 mb-4">
{{ $content['title']['rendered'] ?? 'Untitled' }}
</h1>
<div class="flex items-center gap-4 text-sm text-slate-500">
@if(isset($content['date']))
<time datetime="{{ $content['date'] }}">
{{ \Carbon\Carbon::parse($content['date'])->format('F j, Y') }}
</time>
@endif
@if(isset($content['_embedded']['author'][0]))
<span class="text-slate-600">|</span>
<span>By {{ $content['_embedded']['author'][0]['name'] }}</span>
@endif
</div>
</header>
{{-- Content --}}
<div class="prose prose-invert prose-slate prose-lg max-w-none
prose-headings:text-slate-100
prose-p:text-slate-300
prose-a:text-violet-400 prose-a:no-underline hover:prose-a:underline
prose-strong:text-slate-200
prose-code:text-violet-300 prose-code:bg-slate-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-800 prose-pre:border prose-pre:border-slate-700
prose-blockquote:border-violet-500 prose-blockquote:text-slate-400
prose-ul:text-slate-300 prose-ol:text-slate-300
prose-li:marker:text-violet-400
">
{!! $content['content']['rendered'] ?? '' !!}
</div>
{{-- Preview Footer --}}
@unless($isPublished)
<footer class="mt-12 pt-8 border-t border-slate-700/50">
<div class="bg-slate-800/50 rounded-lg p-4 text-center">
<p class="text-slate-400 text-sm">
<i class="fa-solid fa-info-circle mr-2"></i>
This is a preview. The content has not been published yet.
</p>
</div>
</footer>
@else
<footer class="mt-12 pt-8 border-t border-slate-700/50">
<a href="/blog" class="inline-flex items-center gap-2 text-violet-400 hover:text-violet-300 transition">
<i class="fa-solid fa-arrow-left"></i>
Back to all posts
</a>
</footer>
@endunless
</div>
</article>
</div>

View file

@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace Core\Content\View\Modal\Admin;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Core\Content\Models\ContentItem;
use Core\Content\Models\ContentTaxonomy;
use Core\Content\Services\ContentSearchService;
/**
* Content Search Livewire Component
*
* Provides a searchable interface for content items with:
* - Real-time search with debouncing
* - Filtering by type, status, category, date range
* - Paginated results with relevance scoring
*/
#[Layout('hub::admin.layouts.app')]
class ContentSearch extends Component
{
use WithPagination;
// -------------------------------------------------------------------------
// Search Query
// -------------------------------------------------------------------------
#[Url(as: 'q')]
public string $query = '';
// -------------------------------------------------------------------------
// Filters
// -------------------------------------------------------------------------
#[Url]
public string $type = '';
#[Url]
public string $status = '';
#[Url]
public string $category = '';
#[Url]
public string $dateFrom = '';
#[Url]
public string $dateTo = '';
#[Url]
public int $perPage = 20;
// -------------------------------------------------------------------------
// UI State
// -------------------------------------------------------------------------
public bool $showFilters = false;
// -------------------------------------------------------------------------
// Computed Properties
// -------------------------------------------------------------------------
#[Computed]
public function workspace()
{
return auth()->user()?->defaultHostWorkspace();
}
#[Computed]
public function results()
{
if (! $this->workspace) {
return collect();
}
// Require minimum query length
if (strlen(trim($this->query)) < 2) {
return null;
}
$searchService = app(ContentSearchService::class);
$filters = array_filter([
'workspace_id' => $this->workspace->id,
'type' => $this->type ?: null,
'status' => $this->status ?: null,
'category' => $this->category ?: null,
'date_from' => $this->dateFrom ?: null,
'date_to' => $this->dateTo ?: null,
'per_page' => $this->perPage,
'page' => $this->getPage(),
], fn ($v) => $v !== null);
return $searchService->search($this->query, $filters);
}
#[Computed]
public function categories()
{
if (! $this->workspace) {
return collect();
}
return ContentTaxonomy::where('workspace_id', $this->workspace->id)
->where('type', 'category')
->orderBy('name')
->get();
}
#[Computed]
public function searchBackend()
{
return app(ContentSearchService::class)->getBackend();
}
#[Computed]
public function recentContent()
{
if (! $this->workspace) {
return collect();
}
// Show recent content when no search query
return ContentItem::forWorkspace($this->workspace->id)
->native()
->orderBy('updated_at', 'desc')
->limit(10)
->get();
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
public function updatedQuery(): void
{
$this->resetPage();
}
public function updatedType(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function updatedCategory(): void
{
$this->resetPage();
}
public function updatedDateFrom(): void
{
$this->resetPage();
}
public function updatedDateTo(): void
{
$this->resetPage();
}
public function toggleFilters(): void
{
$this->showFilters = ! $this->showFilters;
}
public function clearFilters(): void
{
$this->type = '';
$this->status = '';
$this->category = '';
$this->dateFrom = '';
$this->dateTo = '';
$this->resetPage();
}
public function clearSearch(): void
{
$this->query = '';
$this->clearFilters();
}
public function viewContent(int $id): void
{
if (! $this->workspace) {
return;
}
// Navigate to content editor
$this->redirect(
route('hub.content-editor.edit', ['workspace' => $this->workspace->slug, 'id' => $id]),
navigate: true
);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
public function hasActiveFilters(): bool
{
return $this->type !== ''
|| $this->status !== ''
|| $this->category !== ''
|| $this->dateFrom !== ''
|| $this->dateTo !== '';
}
public function activeFilterCount(): int
{
return count(array_filter([
$this->type,
$this->status,
$this->category,
$this->dateFrom,
$this->dateTo,
]));
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
public function render()
{
return view('content::admin.content-search');
}
}

View file

@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace Core\Content\View\Modal\Admin;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Core\Content\Models\ContentWebhookEndpoint;
use Core\Content\Models\ContentWebhookLog;
/**
* Livewire component for managing content webhook endpoints.
*
* Allows users to:
* - Create/edit webhook endpoints
* - View incoming webhook logs
* - Copy webhook URLs
* - Regenerate secrets
* - Enable/disable endpoints
*/
#[Layout('hub::admin.layouts.app')]
class WebhookManager extends Component
{
use WithPagination;
// -------------------------------------------------------------------------
// Search and Filter
// -------------------------------------------------------------------------
#[Url]
public string $search = '';
#[Url]
public string $statusFilter = '';
#[Url]
public string $view = 'endpoints'; // endpoints | logs
// -------------------------------------------------------------------------
// Endpoint Form
// -------------------------------------------------------------------------
public bool $showForm = false;
public ?int $editingId = null;
public string $name = '';
public array $allowedTypes = [];
public bool $isEnabled = true;
// -------------------------------------------------------------------------
// Delete Confirmation
// -------------------------------------------------------------------------
public ?string $deletingUuid = null;
// -------------------------------------------------------------------------
// Secret Display
// -------------------------------------------------------------------------
public ?string $showingSecretUuid = null;
public ?string $revealedSecret = null;
// -------------------------------------------------------------------------
// Computed Properties
// -------------------------------------------------------------------------
#[Computed]
public function workspace()
{
return auth()->user()?->defaultHostWorkspace();
}
#[Computed]
public function endpoints()
{
if (! $this->workspace) {
return collect();
}
$query = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id);
if ($this->search) {
$escapedSearch = $this->escapeLikeWildcards($this->search);
$query->where('name', 'like', "%{$escapedSearch}%");
}
if ($this->statusFilter === 'enabled') {
$query->where('is_enabled', true);
} elseif ($this->statusFilter === 'disabled') {
$query->where('is_enabled', false);
}
return $query
->orderBy('created_at', 'desc')
->paginate(15);
}
#[Computed]
public function logs()
{
if (! $this->workspace) {
return collect();
}
$query = ContentWebhookLog::where('workspace_id', $this->workspace->id);
if ($this->search) {
$escapedSearch = $this->escapeLikeWildcards($this->search);
$query->where(function ($q) use ($escapedSearch) {
$q->where('event_type', 'like', "%{$escapedSearch}%")
->orWhere('source_ip', 'like', "%{$escapedSearch}%");
});
}
if ($this->statusFilter) {
$query->where('status', $this->statusFilter);
}
return $query
->orderBy('created_at', 'desc')
->paginate(25);
}
#[Computed]
public function availableTypes(): array
{
return ContentWebhookEndpoint::ALLOWED_TYPES;
}
// -------------------------------------------------------------------------
// View Toggle
// -------------------------------------------------------------------------
public function switchView(string $view): void
{
$this->view = $view;
$this->resetPage();
}
// -------------------------------------------------------------------------
// Endpoint CRUD
// -------------------------------------------------------------------------
public function create(): void
{
$this->resetForm();
$this->showForm = true;
$this->allowedTypes = ContentWebhookEndpoint::ALLOWED_TYPES;
}
public function edit(string $uuid): void
{
if (! $this->workspace) {
return;
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('uuid', $uuid)
->first();
if (! $endpoint) {
return;
}
$this->editingId = $endpoint->id;
$this->name = $endpoint->name;
$this->allowedTypes = $endpoint->allowed_types ?? [];
$this->isEnabled = $endpoint->is_enabled;
$this->showForm = true;
}
public function save(): void
{
if (! $this->workspace) {
return;
}
$this->validate([
'name' => 'required|string|max:255',
'allowedTypes' => 'array',
]);
if ($this->editingId) {
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('id', $this->editingId)
->first();
if ($endpoint) {
$endpoint->update([
'name' => $this->name,
'allowed_types' => $this->allowedTypes,
'is_enabled' => $this->isEnabled,
]);
$this->dispatch('notify', type: 'success', message: 'Webhook endpoint updated.');
}
} else {
ContentWebhookEndpoint::create([
'workspace_id' => $this->workspace->id,
'name' => $this->name,
'allowed_types' => $this->allowedTypes,
'is_enabled' => $this->isEnabled,
]);
$this->dispatch('notify', type: 'success', message: 'Webhook endpoint created.');
}
$this->resetForm();
unset($this->endpoints);
}
public function cancelForm(): void
{
$this->resetForm();
}
protected function resetForm(): void
{
$this->showForm = false;
$this->editingId = null;
$this->name = '';
$this->allowedTypes = [];
$this->isEnabled = true;
}
// -------------------------------------------------------------------------
// Toggle Active
// -------------------------------------------------------------------------
public function toggleActive(string $uuid): void
{
if (! $this->workspace) {
return;
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('uuid', $uuid)
->first();
if ($endpoint) {
$endpoint->update(['is_enabled' => ! $endpoint->is_enabled]);
unset($this->endpoints);
$this->dispatch(
'notify',
type: 'success',
message: $endpoint->is_enabled ? 'Webhook endpoint enabled.' : 'Webhook endpoint disabled.'
);
}
}
// -------------------------------------------------------------------------
// Delete
// -------------------------------------------------------------------------
public function confirmDelete(string $uuid): void
{
$this->deletingUuid = $uuid;
}
public function delete(): void
{
if (! $this->deletingUuid || ! $this->workspace) {
return;
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('uuid', $this->deletingUuid)
->first();
if ($endpoint) {
$endpoint->delete();
$this->dispatch('notify', type: 'success', message: 'Webhook endpoint deleted.');
unset($this->endpoints);
}
$this->deletingUuid = null;
}
public function cancelDelete(): void
{
$this->deletingUuid = null;
}
// -------------------------------------------------------------------------
// Secret Management
// -------------------------------------------------------------------------
public function showSecret(string $uuid): void
{
if (! $this->workspace) {
return;
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('uuid', $uuid)
->first();
if ($endpoint) {
$this->showingSecretUuid = $uuid;
$this->revealedSecret = $endpoint->secret;
}
}
public function hideSecret(): void
{
$this->showingSecretUuid = null;
$this->revealedSecret = null;
}
public function regenerateSecret(string $uuid): void
{
if (! $this->workspace) {
return;
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('uuid', $uuid)
->first();
if ($endpoint) {
$newSecret = $endpoint->regenerateSecret();
$this->showingSecretUuid = $uuid;
$this->revealedSecret = $newSecret;
$this->dispatch('notify', type: 'success', message: 'Secret regenerated. Copy it now - it will not be shown again.');
unset($this->endpoints);
}
}
// -------------------------------------------------------------------------
// Copy URL
// -------------------------------------------------------------------------
public function copyUrl(string $uuid): void
{
if (! $this->workspace) {
return;
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('uuid', $uuid)
->first();
if ($endpoint) {
$this->dispatch('copy-to-clipboard', text: $endpoint->getEndpointUrl());
$this->dispatch('notify', type: 'success', message: 'Webhook URL copied to clipboard.');
}
}
// -------------------------------------------------------------------------
// Reset Failures
// -------------------------------------------------------------------------
public function resetFailures(string $uuid): void
{
if (! $this->workspace) {
return;
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $this->workspace->id)
->where('uuid', $uuid)
->first();
if ($endpoint) {
$endpoint->update([
'failure_count' => 0,
'is_enabled' => true,
]);
$this->dispatch('notify', type: 'success', message: 'Failure count reset and endpoint enabled.');
unset($this->endpoints);
}
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
public function render()
{
return view('content::admin.webhook-manager');
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
protected function escapeLikeWildcards(string $value): string
{
return str_replace(['%', '_'], ['\\%', '\\_'], $value);
}
}

62
View/Modal/Web/Blog.php Normal file
View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Core\Content\View\Modal\Web;
use Livewire\Component;
use Core\Content\Services\ContentRender;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
class Blog extends Component
{
public array $workspace = [];
public array $posts = [];
public bool $loading = true;
public function mount(): void
{
$workspaceService = app(WorkspaceService::class);
// Get workspace from request attributes (set by subdomain middleware)
$slug = request()->attributes->get('workspace', 'main');
$this->workspace = $workspaceService->get($slug) ?? $workspaceService->get('main');
$this->loadPosts();
}
protected function loadPosts(): void
{
try {
$workspaceModel = Workspace::where('slug', $this->workspace['slug'])->first();
if (! $workspaceModel) {
$this->posts = [];
$this->loading = false;
return;
}
$render = app(ContentRender::class);
$result = $render->getPosts($workspaceModel, page: 1, perPage: 20);
$this->posts = $result['posts'] ?? [];
} catch (\Exception $e) {
$this->posts = [];
}
$this->loading = false;
}
public function render()
{
return view('content::web.blog', [
'posts' => $this->posts,
'workspace' => $this->workspace,
])->layout('shared::layouts.satellite', [
'title' => 'Blog | '.$this->workspace['name'],
'workspace' => $this->workspace,
]);
}
}

86
View/Modal/Web/Help.php Normal file
View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Core\Content\View\Modal\Web;
use Livewire\Component;
use Core\Content\Models\ContentItem;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
class Help extends Component
{
public array $workspace = [];
public array $articles = [];
public bool $loading = true;
public function mount(): void
{
$workspaceService = app(WorkspaceService::class);
// Get workspace from request attributes (set by subdomain middleware)
$slug = request()->attributes->get('workspace', 'main');
$this->workspace = $workspaceService->get($slug) ?? $workspaceService->get('main');
$this->loadArticles();
}
protected function loadArticles(): void
{
try {
$workspaceModel = Workspace::where('slug', $this->workspace['slug'])->first();
if (! $workspaceModel) {
$this->articles = [];
$this->loading = false;
return;
}
// Get help articles from native content
// Help articles are identified by:
// 1. Pages with 'help/' slug prefix
// 2. Pages in a 'help' category
$articles = ContentItem::forWorkspace($workspaceModel->id)
->native()
->pages()
->helpArticles()
->published()
->orderByDesc('created_at')
->take(20)
->get();
// If no help articles found with the scope, fall back to all pages
// This maintains backwards compatibility for workspaces without
// proper help article categorisation
if ($articles->isEmpty()) {
$articles = ContentItem::forWorkspace($workspaceModel->id)
->native()
->pages()
->published()
->orderByDesc('created_at')
->take(20)
->get();
}
$this->articles = $articles->map(fn ($item) => $item->toRenderArray())->toArray();
} catch (\Exception $e) {
$this->articles = [];
}
$this->loading = false;
}
public function render()
{
return view('content::web.help', [
'articles' => $this->articles,
'workspace' => $this->workspace,
])->layout('shared::layouts.satellite', [
'title' => 'Help | '.$this->workspace['name'],
'workspace' => $this->workspace,
]);
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Core\Content\View\Modal\Web;
use Livewire\Component;
use Core\Content\Services\ContentRender;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
class HelpArticle extends Component
{
public array $workspace = [];
public array $article = [];
public bool $notFound = false;
public function mount(string $slug): void
{
$workspaceService = app(WorkspaceService::class);
// Get workspace from request attributes (set by subdomain middleware)
$workspaceSlug = request()->attributes->get('workspace', 'main');
$this->workspace = $workspaceService->get($workspaceSlug) ?? $workspaceService->get('main');
$this->loadArticle($slug);
}
protected function loadArticle(string $slug): void
{
try {
$workspaceModel = Workspace::where('slug', $this->workspace['slug'])->first();
if (! $workspaceModel) {
$this->notFound = true;
return;
}
$render = app(ContentRender::class);
$page = $render->getPage($workspaceModel, $slug);
if ($page) {
$this->article = $page;
} else {
$this->notFound = true;
}
} catch (\Exception $e) {
$this->notFound = true;
}
}
public function render()
{
if ($this->notFound) {
abort(404);
}
return view('content::web.help-article', [
'article' => $this->article,
'workspace' => $this->workspace,
])->layout('shared::layouts.satellite', [
'title' => ($this->article['title']['rendered'] ?? 'Help').' | '.$this->workspace['name'],
'workspace' => $this->workspace,
]);
}
}

68
View/Modal/Web/Post.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Core\Content\View\Modal\Web;
use Livewire\Component;
use Core\Content\Services\ContentRender;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
class Post extends Component
{
public array $workspace = [];
public array $post = [];
public bool $notFound = false;
public function mount(string $slug): void
{
$workspaceService = app(WorkspaceService::class);
// Get workspace from request attributes (set by subdomain middleware)
$workspaceSlug = request()->attributes->get('workspace', 'main');
$this->workspace = $workspaceService->get($workspaceSlug) ?? $workspaceService->get('main');
$this->loadPost($slug);
}
protected function loadPost(string $slug): void
{
try {
$workspaceModel = Workspace::where('slug', $this->workspace['slug'])->first();
if (! $workspaceModel) {
$this->notFound = true;
return;
}
$render = app(ContentRender::class);
$post = $render->getPost($workspaceModel, $slug);
if ($post) {
$this->post = $post;
} else {
$this->notFound = true;
}
} catch (\Exception $e) {
$this->notFound = true;
}
}
public function render()
{
if ($this->notFound) {
abort(404);
}
return view('content::web.post', [
'post' => $this->post,
'workspace' => $this->workspace,
])->layout('shared::layouts.satellite', [
'title' => ($this->post['title']['rendered'] ?? 'Post').' | '.$this->workspace['name'],
'workspace' => $this->workspace,
]);
}
}

115
View/Modal/Web/Preview.php Normal file
View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Core\Content\View\Modal\Web;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Component;
use Core\Content\Models\ContentItem;
/**
* Preview - Render draft/unpublished content with preview token.
*
* Shows a preview banner indicating the content is not yet published,
* with the option to compare with the published version if one exists.
*/
class Preview extends Component
{
public array $workspace = [];
public array $content = [];
public bool $notFound = false;
public bool $invalidToken = false;
public bool $isPublished = false;
public ?string $expiresIn = null;
public string $previewType = 'post'; // post or page
public function mount(int $item): void
{
// Get token from query string
$token = request()->query('token');
$this->loadPreview($item, $token);
}
protected function loadPreview(int $itemId, ?string $token): void
{
$contentItem = ContentItem::with(['workspace', 'author', 'featuredMedia', 'taxonomies'])
->find($itemId);
if (! $contentItem) {
$this->notFound = true;
return;
}
// Load workspace data
$workspace = $contentItem->workspace;
if (! $workspace) {
$this->notFound = true;
return;
}
$this->workspace = [
'id' => $workspace->id,
'name' => $workspace->name,
'slug' => $workspace->slug,
'domain' => $workspace->domain,
];
$this->previewType = $contentItem->type;
$this->isPublished = $contentItem->status === 'publish';
// If content is published, no token needed - just show it
if ($this->isPublished) {
$this->content = $contentItem->toRenderArray();
$this->content['preview_status'] = 'published';
return;
}
// For unpublished content, validate the preview token
if (! $contentItem->isValidPreviewToken($token)) {
$this->invalidToken = true;
return;
}
// Token is valid, show the preview
$this->expiresIn = $contentItem->getPreviewTokenTimeRemaining();
$this->content = $contentItem->toRenderArray();
$this->content['preview_status'] = $contentItem->status;
}
public function render()
{
if ($this->notFound) {
abort(404);
}
if ($this->invalidToken) {
return view('content::web.preview-invalid')
->layout('shared::layouts.satellite', [
'title' => 'Preview Expired',
'workspace' => $this->workspace,
]);
}
return view('content::web.preview', [
'content' => $this->content,
'workspace' => $this->workspace,
'expiresIn' => $this->expiresIn,
'isPublished' => $this->isPublished,
'previewType' => $this->previewType,
])->layout('shared::layouts.satellite', [
'title' => ($this->content['title']['rendered'] ?? 'Preview').' | '.$this->workspace['name'],
'workspace' => $this->workspace,
]);
}
}

View file

View file

View file

@ -1,24 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

15
artisan
View file

@ -1,15 +0,0 @@
#!/usr/bin/env php
<?php
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
$status = (require_once __DIR__.'/bootstrap/app.php')
->handleCommand(new ArgvInput);
exit($status);

View file

@ -1,26 +0,0 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withProviders([
// Core PHP Framework
\Core\LifecycleEventProvider::class,
\Core\Website\Boot::class,
\Core\Front\Boot::class,
\Core\Mod\Boot::class,
])
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
\Core\Front\Boot::middleware($middleware);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,5 +0,0 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

View file

@ -1,78 +1,44 @@
{
"name": "host-uk/core-template",
"type": "project",
"description": "Core PHP Framework - Project Template",
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
"name": "host-uk/core-content",
"description": "Content management and headless CMS for Laravel",
"keywords": ["laravel", "content", "cms", "headless"],
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10",
"livewire/flux": "^2.0",
"livewire/livewire": "^3.0",
"host-uk/core": "dev-main",
"host-uk/core-admin": "dev-main",
"host-uk/core-api": "dev-main",
"host-uk/core-mcp": "dev-main"
"host-uk/core": "dev-main"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0"
"orchestra/testbench": "^9.0|^10.0",
"pestphp/pest": "^3.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
"Core\\Content\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
"Core\\Content\\Tests\\": "Tests/"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/host-uk/core-php.git"
}
],
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
"providers": [
"Core\\Content\\Boot"
]
}
},
"scripts": {
"lint": "pint",
"test": "pest"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
"pestphp/pest-plugin": true
}
},
"minimum-stability": "stable",
"minimum-stability": "dev",
"prefer-stable": true
}

106
config.php Normal file
View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Content Generation Settings
|--------------------------------------------------------------------------
|
| Configuration for AI content generation including timeouts, retries,
| and default values for different content types.
|
*/
'generation' => [
// Default timeout for content generation jobs (in seconds)
// Can be overridden per content type
'default_timeout' => env('CONTENT_GENERATION_TIMEOUT', 300),
// Timeouts per content type (in seconds)
'timeouts' => [
'help_article' => env('CONTENT_TIMEOUT_HELP_ARTICLE', 180),
'blog_post' => env('CONTENT_TIMEOUT_BLOG_POST', 240),
'landing_page' => env('CONTENT_TIMEOUT_LANDING_PAGE', 300),
'social_post' => env('CONTENT_TIMEOUT_SOCIAL_POST', 60),
],
// Number of retry attempts for failed generation
'max_retries' => env('CONTENT_GENERATION_RETRIES', 3),
// Backoff intervals between retries (in seconds)
'backoff' => [30, 60, 120],
],
/*
|--------------------------------------------------------------------------
| Content Revision Settings
|--------------------------------------------------------------------------
|
| Settings for content revision history and pruning.
|
*/
'revisions' => [
// Maximum number of revisions to keep per content item
// Set to 0 or null to keep unlimited revisions
'max_per_item' => env('CONTENT_MAX_REVISIONS', 50),
// Maximum age of revisions to keep (in days)
// Set to 0 or null to keep revisions indefinitely
'max_age_days' => env('CONTENT_REVISION_MAX_AGE', 180),
// Whether to keep published revisions regardless of age/count limits
'preserve_published' => true,
],
/*
|--------------------------------------------------------------------------
| Cache Settings
|--------------------------------------------------------------------------
|
| Configuration for content caching.
|
*/
'cache' => [
// Cache TTL in seconds (1 hour in production, 1 minute in dev)
'ttl' => env('CONTENT_CACHE_TTL', 3600),
// Prefix for cache keys
'prefix' => 'content:render',
],
/*
|--------------------------------------------------------------------------
| Search Settings
|--------------------------------------------------------------------------
|
| Configuration for content search functionality.
|
| Supported backends:
| - 'database' (default): LIKE-based search with relevance scoring
| - 'scout_database': Laravel Scout with database driver
| - 'meilisearch': Laravel Scout with Meilisearch driver
|
*/
'search' => [
// Search backend to use
// Options: 'database', 'scout_database', 'meilisearch'
'backend' => env('CONTENT_SEARCH_BACKEND', 'database'),
// Minimum query length for search
'min_query_length' => 2,
// Maximum results per page
'max_per_page' => 50,
// Default results per page
'default_per_page' => 20,
// Rate limiting for search API (requests per minute)
'rate_limit' => env('CONTENT_SEARCH_RATE_LIMIT', 60),
],
];

View file

@ -1,24 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Core PHP Framework Configuration
|--------------------------------------------------------------------------
*/
'module_paths' => [
app_path('Core'),
app_path('Mod'),
app_path('Website'),
],
'services' => [
'cache_discovery' => env('CORE_CACHE_DISCOVERY', true),
],
'cdn' => [
'enabled' => env('CDN_ENABLED', false),
'driver' => env('CDN_DRIVER', 'bunny'),
],
];

View file

@ -1,16 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// Core modules handle their own seeding
}
}

View file

@ -1,16 +0,0 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"axios": "^1.7.4",
"laravel-vite-plugin": "^2.1.0",
"postcss": "^8.4.47",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1"
}
}

View file

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -1,21 +0,0 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

View file

@ -1,17 +0,0 @@
<?php
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
(require_once __DIR__.'/../bootstrap/app.php')
->handleRequest(Request::capture());

View file

@ -1,2 +0,0 @@
User-agent: *
Disallow:

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1 +0,0 @@
import './bootstrap';

View file

@ -1,3 +0,0 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View file

@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Core PHP Framework</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.version {
color: #888;
font-size: 0.9rem;
margin-bottom: 2rem;
}
.links {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
a {
color: #667eea;
text-decoration: none;
padding: 0.75rem 1.5rem;
border: 1px solid #667eea;
border-radius: 0.5rem;
transition: all 0.2s;
}
a:hover {
background: #667eea;
color: #fff;
}
</style>
</head>
<body>
<div class="container">
<h1>Core PHP Framework</h1>
<p class="version">Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}</p>
<div class="links">
<a href="https://github.com/host-uk/core-php">Documentation</a>
<a href="/admin">Admin Panel</a>
<a href="/api/docs">API Docs</a>
</div>
</div>
</body>
</html>

View file

@ -1,5 +1,203 @@
<?php
use Illuminate\Support\Facades\Route;
declare(strict_types=1);
// API routes are registered via Core modules
/**
* Content Module API Routes
*
* REST API for content briefs, media, and AI generation.
* Supports both session auth and API key auth.
*/
use Illuminate\Support\Facades\Route;
use Core\Content\Controllers\Api\ContentBriefController;
use Core\Content\Controllers\Api\ContentMediaController;
use Core\Content\Controllers\Api\ContentRevisionController;
use Core\Content\Controllers\Api\ContentSearchController;
use Core\Content\Controllers\Api\ContentWebhookController;
use Core\Content\Controllers\Api\GenerationController;
use Core\Content\Controllers\ContentPreviewController;
/*
|--------------------------------------------------------------------------
| Content API (Auth Required)
|--------------------------------------------------------------------------
|
| Full REST API for managing content briefs and AI generation.
| Session-based authentication.
|
*/
Route::middleware('auth')->prefix('content')->name('api.content.')->group(function () {
// ─────────────────────────────────────────────────────────────────────
// Content Briefs
// ─────────────────────────────────────────────────────────────────────
Route::prefix('briefs')->name('briefs.')->group(function () {
Route::get('/', [ContentBriefController::class, 'index'])
->name('index');
Route::post('/', [ContentBriefController::class, 'store'])
->name('store');
Route::post('/bulk', [ContentBriefController::class, 'bulkStore'])
->name('bulk');
Route::get('/next', [ContentBriefController::class, 'next'])
->name('next');
Route::get('/{brief}', [ContentBriefController::class, 'show'])
->name('show');
Route::put('/{brief}', [ContentBriefController::class, 'update'])
->name('update');
Route::delete('/{brief}', [ContentBriefController::class, 'destroy'])
->name('destroy');
Route::post('/{brief}/approve', [GenerationController::class, 'approve'])
->name('approve');
});
// ─────────────────────────────────────────────────────────────────────
// AI Generation (rate limited - expensive operations)
// ─────────────────────────────────────────────────────────────────────
Route::prefix('generate')->name('generate.')->middleware('throttle:content-generate')->group(function () {
Route::post('/draft', [GenerationController::class, 'draft'])
->name('draft');
Route::post('/refine', [GenerationController::class, 'refine'])
->name('refine');
Route::post('/full', [GenerationController::class, 'full'])
->name('full');
Route::post('/social', [GenerationController::class, 'socialPosts'])
->name('social');
});
// ─────────────────────────────────────────────────────────────────────
// Media Upload
// ─────────────────────────────────────────────────────────────────────
Route::prefix('media')->name('media.')->group(function () {
Route::get('/', [ContentMediaController::class, 'index'])->name('index');
Route::post('/', [ContentMediaController::class, 'store'])->name('store');
Route::get('/{media}', [ContentMediaController::class, 'show'])->name('show');
Route::put('/{media}', [ContentMediaController::class, 'update'])->name('update');
Route::delete('/{media}', [ContentMediaController::class, 'destroy'])->name('destroy');
});
// ─────────────────────────────────────────────────────────────────────
// Content Revisions
// ─────────────────────────────────────────────────────────────────────
Route::prefix('items/{item}/revisions')->name('items.revisions.')->group(function () {
Route::get('/', [ContentRevisionController::class, 'index'])->name('index');
});
Route::prefix('revisions')->name('revisions.')->group(function () {
Route::get('/{revision}', [ContentRevisionController::class, 'show'])->name('show');
Route::post('/{revision}/restore', [ContentRevisionController::class, 'restore'])->name('restore');
Route::get('/{revision}/compare/{compareWith}', [ContentRevisionController::class, 'compare'])->name('compare');
});
// ─────────────────────────────────────────────────────────────────────
// Usage Statistics
// ─────────────────────────────────────────────────────────────────────
Route::get('/usage', [GenerationController::class, 'usage'])
->name('usage');
// ─────────────────────────────────────────────────────────────────────
// Content Preview
// ─────────────────────────────────────────────────────────────────────
Route::prefix('items/{item}/preview')->name('items.preview.')->group(function () {
Route::post('/generate', [ContentPreviewController::class, 'generateLink'])->name('generate');
Route::delete('/revoke', [ContentPreviewController::class, 'revokeLink'])->name('revoke');
});
// ─────────────────────────────────────────────────────────────────────
// Content Search
// ─────────────────────────────────────────────────────────────────────
Route::prefix('search')->name('search.')->middleware('throttle:content-search')->group(function () {
Route::get('/', [ContentSearchController::class, 'search'])->name('index');
Route::get('/suggest', [ContentSearchController::class, 'suggest'])->name('suggest');
Route::get('/info', [ContentSearchController::class, 'info'])->name('info');
Route::post('/reindex', [ContentSearchController::class, 'reindex'])->name('reindex');
});
});
/*
|--------------------------------------------------------------------------
| Content Webhooks (Public - No Auth Required)
|--------------------------------------------------------------------------
|
| External webhook endpoints for receiving content updates from WordPress,
| CMS systems, and other content sources. Authentication is handled via
| signature verification using the endpoint's secret key.
|
*/
Route::prefix('content/webhooks')->name('api.content.webhooks.')->group(function () {
Route::post('/{endpoint}', [ContentWebhookController::class, 'receive'])
->name('receive')
->middleware('throttle:content-webhooks');
});
/*
|--------------------------------------------------------------------------
| Content API (API Key Auth)
|--------------------------------------------------------------------------
|
| Same endpoints authenticated via API key.
| Use Authorization: Bearer hk_xxx header.
|
*/
Route::middleware(['api.auth', 'api.scope.enforce'])->prefix('content')->name('api.key.content.')->group(function () {
// Scope enforcement: GET=read, POST/PUT/PATCH=write, DELETE=delete
// Briefs
Route::prefix('briefs')->name('briefs.')->group(function () {
Route::get('/', [ContentBriefController::class, 'index'])->name('index');
Route::post('/', [ContentBriefController::class, 'store'])->name('store');
Route::post('/bulk', [ContentBriefController::class, 'bulkStore'])->name('bulk');
Route::get('/next', [ContentBriefController::class, 'next'])->name('next');
Route::get('/{brief}', [ContentBriefController::class, 'show'])->name('show');
Route::put('/{brief}', [ContentBriefController::class, 'update'])->name('update');
Route::delete('/{brief}', [ContentBriefController::class, 'destroy'])->name('destroy');
Route::post('/{brief}/approve', [GenerationController::class, 'approve'])->name('approve');
});
// Generation (rate limited - expensive operations)
Route::prefix('generate')->name('generate.')->middleware('throttle:content-generate')->group(function () {
Route::post('/draft', [GenerationController::class, 'draft'])->name('draft');
Route::post('/refine', [GenerationController::class, 'refine'])->name('refine');
Route::post('/full', [GenerationController::class, 'full'])->name('full');
Route::post('/social', [GenerationController::class, 'socialPosts'])->name('social');
});
// Media
Route::prefix('media')->name('media.')->group(function () {
Route::get('/', [ContentMediaController::class, 'index'])->name('index');
Route::post('/', [ContentMediaController::class, 'store'])->name('store');
Route::get('/{media}', [ContentMediaController::class, 'show'])->name('show');
Route::put('/{media}', [ContentMediaController::class, 'update'])->name('update');
Route::delete('/{media}', [ContentMediaController::class, 'destroy'])->name('destroy');
});
// Usage
Route::get('/usage', [GenerationController::class, 'usage'])->name('usage');
// Content Revisions
Route::prefix('items/{item}/revisions')->name('items.revisions.')->group(function () {
Route::get('/', [ContentRevisionController::class, 'index'])->name('index');
});
Route::prefix('revisions')->name('revisions.')->group(function () {
Route::get('/{revision}', [ContentRevisionController::class, 'show'])->name('show');
Route::post('/{revision}/restore', [ContentRevisionController::class, 'restore'])->name('restore');
Route::get('/{revision}/compare/{compareWith}', [ContentRevisionController::class, 'compare'])->name('compare');
});
// Search
Route::prefix('search')->name('search.')->middleware('throttle:content-search')->group(function () {
Route::get('/', [ContentSearchController::class, 'search'])->name('index');
Route::get('/suggest', [ContentSearchController::class, 'suggest'])->name('suggest');
Route::get('/info', [ContentSearchController::class, 'info'])->name('info');
Route::post('/reindex', [ContentSearchController::class, 'reindex'])->name('reindex');
});
});

View file

@ -1,7 +1,35 @@
<?php
use Illuminate\Support\Facades\Route;
declare(strict_types=1);
Route::get('/', function () {
return view('welcome');
});
use Illuminate\Support\Facades\Route;
use Core\Content\View\Modal\Web\Blog;
use Core\Content\View\Modal\Web\Help;
use Core\Content\View\Modal\Web\HelpArticle;
use Core\Content\View\Modal\Web\Post;
use Core\Content\View\Modal\Web\Preview;
/*
|--------------------------------------------------------------------------
| Content Module Web Routes
|--------------------------------------------------------------------------
|
| Public satellite pages for blog and help content.
|
*/
Route::get('/blog', Blog::class)->name('satellite.blog');
Route::get('/blog/{slug}', Post::class)->name('satellite.post');
Route::get('/help', Help::class)->name('satellite.help');
Route::get('/help/{slug}', HelpArticle::class)->name('satellite.help.article');
/*
|--------------------------------------------------------------------------
| Content Preview Routes
|--------------------------------------------------------------------------
|
| Preview draft/unpublished content with time-limited tokens.
|
*/
Route::get('/content/preview/{item}', Preview::class)->name('content.preview');

View file

@ -1,3 +0,0 @@
*
!public/
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,9 +0,0 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json

View file

@ -1,3 +0,0 @@
*
!data/
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show more