monorepo sepration
This commit is contained in:
parent
c29badf6b7
commit
f990dc1bd3
115 changed files with 17014 additions and 618 deletions
76
.env.example
76
.env.example
|
|
@ -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=
|
|
||||||
62
.github/package-workflows/README.md
vendored
62
.github/package-workflows/README.md
vendored
|
|
@ -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
|
|
||||||
[](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
|
|
||||||
[](https://codecov.io/gh/host-uk/{package})
|
|
||||||
[](https://packagist.org/packages/host-uk/{package})
|
|
||||||
[](https://packagist.org/packages/host-uk/{package})
|
|
||||||
[](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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
55
.github/package-workflows/ci.yml
vendored
55
.github/package-workflows/ci.yml
vendored
|
|
@ -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 }}
|
|
||||||
40
.github/package-workflows/release.yml
vendored
40
.github/package-workflows/release.yml
vendored
|
|
@ -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
181
Boot.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
Concerns/SearchableContent.php
Normal file
150
Concerns/SearchableContent.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
242
Console/Commands/ContentBatch.php
Normal file
242
Console/Commands/ContentBatch.php
Normal 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}%";
|
||||||
|
}
|
||||||
|
}
|
||||||
247
Console/Commands/ContentGenerate.php
Normal file
247
Console/Commands/ContentGenerate.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
957
Console/Commands/ContentImportWordPress.php
Normal file
957
Console/Commands/ContentImportWordPress.php
Normal 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 ’)
|
||||||
|
$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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
343
Console/Commands/ContentValidate.php
Normal file
343
Console/Commands/ContentValidate.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Console/Commands/ProcessPendingWebhooks.php
Normal file
90
Console/Commands/ProcessPendingWebhooks.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
Console/Commands/PruneContentRevisions.php
Normal file
116
Console/Commands/PruneContentRevisions.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
Console/Commands/PublishScheduledContent.php
Normal file
96
Console/Commands/PublishScheduledContent.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
262
Controllers/Api/ContentBriefController.php
Normal file
262
Controllers/Api/ContentBriefController.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
Controllers/Api/ContentMediaController.php
Normal file
238
Controllers/Api/ContentMediaController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
186
Controllers/Api/ContentRevisionController.php
Normal file
186
Controllers/Api/ContentRevisionController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
195
Controllers/Api/ContentSearchController.php
Normal file
195
Controllers/Api/ContentSearchController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
323
Controllers/Api/ContentWebhookController.php
Normal file
323
Controllers/Api/ContentWebhookController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
402
Controllers/Api/GenerationController.php
Normal file
402
Controllers/Api/GenerationController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Controllers/ContentPreviewController.php
Normal file
102
Controllers/ContentPreviewController.php
Normal 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
115
Enums/BriefContentType.php
Normal 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
121
Enums/ContentType.php
Normal 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
201
Jobs/GenerateContentJob.php
Normal 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()}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
546
Jobs/ProcessContentWebhook.php
Normal file
546
Jobs/ProcessContentWebhook.php
Normal 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()}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
281
Mcp/Handlers/ContentCreateHandler.php
Normal file
281
Mcp/Handlers/ContentCreateHandler.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Mcp/Handlers/ContentDeleteHandler.php
Normal file
100
Mcp/Handlers/ContentDeleteHandler.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
145
Mcp/Handlers/ContentListHandler.php
Normal file
145
Mcp/Handlers/ContentListHandler.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
177
Mcp/Handlers/ContentReadHandler.php
Normal file
177
Mcp/Handlers/ContentReadHandler.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
Mcp/Handlers/ContentSearchHandler.php
Normal file
139
Mcp/Handlers/ContentSearchHandler.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
88
Mcp/Handlers/ContentTaxonomiesHandler.php
Normal file
88
Mcp/Handlers/ContentTaxonomiesHandler.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
260
Mcp/Handlers/ContentUpdateHandler.php
Normal file
260
Mcp/Handlers/ContentUpdateHandler.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Middleware/WorkspaceRouter.php
Normal file
68
Middleware/WorkspaceRouter.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
278
Migrations/0001_01_01_000001_create_content_tables.php
Normal file
278
Migrations/0001_01_01_000001_create_content_tables.php
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
226
Models/AIUsage.php
Normal 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
68
Models/ContentAuthor.php
Normal 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
316
Models/ContentBrief.php
Normal 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
713
Models/ContentItem.php
Normal 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
105
Models/ContentMedia.php
Normal 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
611
Models/ContentRevision.php
Normal 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
162
Models/ContentTask.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Models/ContentTaxonomy.php
Normal file
94
Models/ContentTaxonomy.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
404
Models/ContentWebhookEndpoint.php
Normal file
404
Models/ContentWebhookEndpoint.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
262
Models/ContentWebhookLog.php
Normal file
262
Models/ContentWebhookLog.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Observers/ContentItemObserver.php
Normal file
109
Observers/ContentItemObserver.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
617
Services/AIGatewayService.php
Normal file
617
Services/AIGatewayService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
Services/CdnPurgeService.php
Normal file
138
Services/CdnPurgeService.php
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
586
Services/ContentProcessingService.php
Normal file
586
Services/ContentProcessingService.php
Normal 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
376
Services/ContentRender.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
494
Services/ContentSearchService.php
Normal file
494
Services/ContentSearchService.php
Normal 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(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
339
Services/WebhookRetryService.php
Normal file
339
Services/WebhookRetryService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
261
View/Blade/admin/content-search.blade.php
Normal file
261
View/Blade/admin/content-search.blade.php
Normal 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>
|
||||||
392
View/Blade/admin/webhook-manager.blade.php
Normal file
392
View/Blade/admin/webhook-manager.blade.php
Normal 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
|
||||||
73
View/Blade/web/blog.blade.php
Normal file
73
View/Blade/web/blog.blade.php
Normal 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>
|
||||||
50
View/Blade/web/help-article.blade.php
Normal file
50
View/Blade/web/help-article.blade.php
Normal 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>
|
||||||
41
View/Blade/web/help.blade.php
Normal file
41
View/Blade/web/help.blade.php
Normal 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>
|
||||||
62
View/Blade/web/post.blade.php
Normal file
62
View/Blade/web/post.blade.php
Normal 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>
|
||||||
22
View/Blade/web/preview-invalid.blade.php
Normal file
22
View/Blade/web/preview-invalid.blade.php
Normal 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>
|
||||||
108
View/Blade/web/preview.blade.php
Normal file
108
View/Blade/web/preview.blade.php
Normal 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>
|
||||||
236
View/Modal/Admin/ContentSearch.php
Normal file
236
View/Modal/Admin/ContentSearch.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
396
View/Modal/Admin/WebhookManager.php
Normal file
396
View/Modal/Admin/WebhookManager.php
Normal 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
62
View/Modal/Web/Blog.php
Normal 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
86
View/Modal/Web/Help.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
View/Modal/Web/HelpArticle.php
Normal file
68
View/Modal/Web/HelpArticle.php
Normal 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
68
View/Modal/Web/Post.php
Normal 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
115
View/Modal/Web/Preview.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
15
artisan
|
|
@ -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);
|
|
||||||
|
|
@ -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();
|
|
||||||
2
bootstrap/cache/.gitignore
vendored
2
bootstrap/cache/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
App\Providers\AppServiceProvider::class,
|
|
||||||
];
|
|
||||||
|
|
@ -1,78 +1,44 @@
|
||||||
{
|
{
|
||||||
"name": "host-uk/core-template",
|
"name": "host-uk/core-content",
|
||||||
"type": "project",
|
"description": "Content management and headless CMS for Laravel",
|
||||||
"description": "Core PHP Framework - Project Template",
|
"keywords": ["laravel", "content", "cms", "headless"],
|
||||||
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
|
|
||||||
"license": "EUPL-1.2",
|
"license": "EUPL-1.2",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^12.0",
|
"host-uk/core": "dev-main"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
|
||||||
"laravel/pail": "^1.2",
|
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"laravel/sail": "^1.41",
|
"orchestra/testbench": "^9.0|^10.0",
|
||||||
"mockery/mockery": "^1.6",
|
"pestphp/pest": "^3.0"
|
||||||
"nunomaduro/collision": "^8.6",
|
|
||||||
"pestphp/pest": "^3.0",
|
|
||||||
"pestphp/pest-plugin-laravel": "^3.0"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"Core\\Content\\": ""
|
||||||
"Database\\Factories\\": "database/factories/",
|
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"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": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"dont-discover": []
|
"providers": [
|
||||||
|
"Core\\Content\\Boot"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "pint",
|
||||||
|
"test": "pest"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
|
||||||
"preferred-install": "dist",
|
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"php-http/discovery": true
|
"pestphp/pest-plugin": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true
|
"prefer-stable": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
config.php
Normal file
106
config.php
Normal 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),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
package.json
16
package.json
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
phpunit.xml
33
phpunit.xml
|
|
@ -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>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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());
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
import './bootstrap';
|
|
||||||
3
resources/js/bootstrap.js
vendored
3
resources/js/bootstrap.js
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
import axios from 'axios';
|
|
||||||
window.axios = axios;
|
|
||||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
|
||||||
|
|
@ -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>
|
|
||||||
202
routes/api.php
202
routes/api.php
|
|
@ -1,5 +1,203 @@
|
||||||
<?php
|
<?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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,35 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
declare(strict_types=1);
|
||||||
|
|
||||||
Route::get('/', function () {
|
use Illuminate\Support\Facades\Route;
|
||||||
return view('welcome');
|
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');
|
||||||
|
|
|
||||||
3
storage/app/.gitignore
vendored
3
storage/app/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
*
|
|
||||||
!public/
|
|
||||||
!.gitignore
|
|
||||||
2
storage/app/public/.gitignore
vendored
2
storage/app/public/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
9
storage/framework/.gitignore
vendored
9
storage/framework/.gitignore
vendored
|
|
@ -1,9 +0,0 @@
|
||||||
compiled.php
|
|
||||||
config.php
|
|
||||||
down
|
|
||||||
events.scanned.php
|
|
||||||
maintenance.php
|
|
||||||
routes.php
|
|
||||||
routes.scanned.php
|
|
||||||
schedule-*
|
|
||||||
services.json
|
|
||||||
3
storage/framework/cache/.gitignore
vendored
3
storage/framework/cache/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
*
|
|
||||||
!data/
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/cache/data/.gitignore
vendored
2
storage/framework/cache/data/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue