Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/Models/Performance/ContentPerformanceEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Models\Performance;

use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class ContentPerformanceEvent extends Model
{
use HasFactory;
use HasUlids;

protected $fillable = [
'space_id',
'content_id',
'event_type',
'source',
'value',
'metadata',
'session_id',
'visitor_id',
'occurred_at',
];

protected function casts(): array
{
return [
'value' => 'float',
'metadata' => 'array',
'occurred_at' => 'datetime',
];
}
}
15 changes: 15 additions & 0 deletions app/Services/Migration/Connectors/CmsConnectorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Services\Migration\Connectors;

interface CmsConnectorInterface
{
/**
* Retrieve content types from the external CMS.
*
* @return array<array-key, mixed>
*/
public function getContentTypes(): array;
}
33 changes: 24 additions & 9 deletions app/Services/Migration/SchemaInspectorService.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Services\Migration;

use App\Services\Migration\Connectors\CmsConnectorInterface;

/** Normalises CMS schema into a standard format. */
class SchemaInspectorService
{
Expand Down Expand Up @@ -39,11 +43,12 @@ public function inspectSchema(CmsConnectorInterface $connector): array
if (empty($raw)) {
return [];
}

return $this->normalise($raw);
}

/**
* @param array<string, mixed> $raw
* @param array<array-key, mixed> $raw
* @return array<int, array{key: string, label: string, fields: array<int, array{name: string, type: string, required: bool}>}>
*/
public function normalise(array $raw): array
Expand All @@ -66,6 +71,7 @@ public function normalise(array $raw): array
if ($this->looksLikeWordPressTypes($raw)) {
return $this->normaliseWordPress($raw);
}

return [];
}

Expand All @@ -74,7 +80,7 @@ private function normaliseStrapi(array $items): array
{
$result = [];
foreach ($items as $item) {
if (!is_array($item)) {
if (! is_array($item)) {
continue;
}
$key = $item['apiID'] ?? (isset($item['uid']) ? (string) $item['uid'] : null);
Expand All @@ -89,6 +95,7 @@ private function normaliseStrapi(array $items): array
'fields' => $this->normaliseFields($schema['attributes'] ?? [], 'strapi'),
];
}

return $result;
}

Expand All @@ -97,7 +104,7 @@ private function normaliseWordPress(array $raw): array
{
$result = [];
foreach ($raw as $slug => $type) {
if (!is_array($type)) {
if (! is_array($type)) {
continue;
}
$result[] = [
Expand All @@ -106,6 +113,7 @@ private function normaliseWordPress(array $raw): array
'fields' => $this->wordPressDefaultFields((string) $slug),
];
}

return $result;
}

Expand All @@ -114,7 +122,7 @@ private function normaliseContentful(array $items): array
{
$result = [];
foreach ($items as $ct) {
if (!is_array($ct)) {
if (! is_array($ct)) {
continue;
}
$key = $ct['sys']['id'] ?? null;
Expand All @@ -127,6 +135,7 @@ private function normaliseContentful(array $items): array
'fields' => $this->normaliseFields($ct['fields'] ?? [], 'contentful'),
];
}

return $result;
}

Expand All @@ -135,7 +144,7 @@ private function normaliseDirectus(array $collections): array
{
$result = [];
foreach ($collections as $col) {
if (!is_array($col)) {
if (! is_array($col)) {
continue;
}
$key = $col['collection'] ?? null;
Expand All @@ -148,6 +157,7 @@ private function normaliseDirectus(array $collections): array
'fields' => $this->normaliseFields($col['fields'] ?? [], 'directus'),
];
}

return $result;
}

Expand All @@ -156,7 +166,7 @@ private function normalisePayload(array $collections): array
{
$result = [];
foreach ($collections as $col) {
if (!is_array($col)) {
if (! is_array($col)) {
continue;
}
$key = $col['slug'] ?? null;
Expand All @@ -169,6 +179,7 @@ private function normalisePayload(array $collections): array
'fields' => $this->normaliseFields($col['fields'] ?? [], 'payload'),
];
}

return $result;
}

Expand All @@ -186,6 +197,7 @@ private function normaliseGhost(array $raw): array
];
}
}

return $result;
}

Expand All @@ -197,13 +209,13 @@ public function normaliseFields(array $rawFields, string $cms = 'generic'): arra
{
$fields = [];
foreach ($rawFields as $nameOrIndex => $fieldDef) {
if (!is_array($fieldDef)) {
if (! is_array($fieldDef)) {
continue;
}
$name = match ($cms) {
'contentful' => $fieldDef['apiName'] ?? $fieldDef['id'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null),
'directus' => $fieldDef['field'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null),
default => $fieldDef['name'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null),
'directus' => $fieldDef['field'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null),
default => $fieldDef['name'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null),
};
if ($name === null) {
continue;
Expand All @@ -216,6 +228,7 @@ public function normaliseFields(array $rawFields, string $cms = 'generic'): arra
'required' => $required,
];
}

return $fields;
}

Expand Down Expand Up @@ -243,6 +256,7 @@ private function wordPressDefaultFields(string $typeSlug): array
$base[] = ['name' => 'categories', 'type' => 'relation', 'required' => false];
$base[] = ['name' => 'tags', 'type' => 'relation', 'required' => false];
}

return $base;
}

Expand All @@ -268,6 +282,7 @@ private function ghostDefaultFields(string $typeSlug): array
private function looksLikeWordPressTypes(array $raw): bool
{
$first = reset($raw);

return is_array($first) && (isset($first['slug']) || isset($first['name']));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('content_performance_events')) {
Schema::create('content_performance_events', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('space_id', 26)->index();
$table->string('content_id', 26)->index();
$table->string('event_type', 50)->index();
$table->string('source', 50);
$table->decimal('value', 16, 4)->nullable();
$table->json('metadata')->nullable();
$table->string('session_id')->index();
$table->string('visitor_id')->nullable()->index();
$table->timestamp('occurred_at')->index();
$table->timestamps();

$table->index(['session_id', 'content_id', 'event_type', 'occurred_at'], 'cpe_dedup_idx');
});
}
}

public function down(): void
{
Schema::dropIfExists('content_performance_events');
}
};
1 change: 0 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
<php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_BASE_PATH" value="/tmp/quality-worktree"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="QUEUE_CONNECTION" value="array"/>
Expand Down
23 changes: 13 additions & 10 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,33 @@
use App\Http\Controllers\Api\AuditLogController;
use App\Http\Controllers\Api\BriefController;
use App\Http\Controllers\Api\ChatController;
use App\Http\Controllers\Api\CompetitorController;
use App\Http\Controllers\Api\CompetitorSourceController;
use App\Http\Controllers\Api\ComponentDefinitionController;
use App\Http\Controllers\Api\ContentController;
use App\Http\Controllers\Api\ContentQualityController;
use App\Http\Controllers\Api\ContentTaxonomyController;
use App\Http\Controllers\Api\DifferentiationController;
use App\Http\Controllers\Api\FormatTemplateController;
use App\Http\Controllers\Api\GraphController;
use App\Http\Controllers\Api\LocaleController;
use App\Http\Controllers\Api\MediaCollectionController;
use App\Http\Controllers\Api\MediaController;
use App\Http\Controllers\Api\MediaEditController;
use App\Http\Controllers\Api\MediaFolderController;
use App\Http\Controllers\Api\PageController;
use App\Http\Controllers\Api\PerformanceTrackingController;
use App\Http\Controllers\Api\PermissionController;
use App\Http\Controllers\Api\PluginAdminController;
use App\Http\Controllers\Api\PublicMediaController;
use App\Http\Controllers\Api\RepurposingController;
use App\Http\Controllers\Api\RoleController;
use App\Http\Controllers\Api\TaxonomyController;
use App\Http\Controllers\Api\TaxonomyTermController;
use App\Http\Controllers\Api\Templates\PipelineTemplateController;
use App\Http\Controllers\Api\Templates\PipelineTemplateInstallController;
use App\Http\Controllers\Api\Templates\PipelineTemplateRatingController;
use App\Http\Controllers\Api\Templates\PipelineTemplateVersionController;
use App\Http\Controllers\Api\TranslationController;
use App\Http\Controllers\Api\UserRoleController;
use App\Http\Controllers\Api\V1\Admin\SearchAdminController;
Expand Down Expand Up @@ -322,7 +332,6 @@
});

// Knowledge Graph API
use App\Http\Controllers\Api\GraphController;

Route::prefix('v1/graph')->middleware(['auth:sanctum'])->group(function () {
Route::get('/related/{contentId}', [GraphController::class, 'related']);
Expand Down Expand Up @@ -360,15 +369,6 @@
});

// --- #36 Pipeline Templates API ---
use App\Http\Controllers\Api\CompetitorController;
use App\Http\Controllers\Api\CompetitorSourceController;
use App\Http\Controllers\Api\ContentQualityController;
use App\Http\Controllers\Api\DifferentiationController;
use App\Http\Controllers\Api\Templates\PipelineTemplateController;
use App\Http\Controllers\Api\Templates\PipelineTemplateInstallController;
use App\Http\Controllers\Api\Templates\PipelineTemplateRatingController;
use App\Http\Controllers\Api\Templates\PipelineTemplateVersionController;


Route::prefix('v1/spaces/{space}/pipeline-templates')->middleware(['auth:sanctum'])->group(function () {
Route::get('/', [PipelineTemplateController::class, 'index'])->name('api.pipeline-templates.index');
Expand Down Expand Up @@ -419,3 +419,6 @@
Route::get('/differentiation/summary', [DifferentiationController::class, 'summary']);
Route::get('/differentiation/{id}', [DifferentiationController::class, 'show']);
});

// --- Performance Tracking (public, rate limited) ---
Route::post('v1/track', [PerformanceTrackingController::class, 'track'])->middleware('throttle:120,1');
2 changes: 2 additions & 0 deletions sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.vite/
51 changes: 51 additions & 0 deletions sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Changelog

All notable changes to `@numen/sdk` will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] — 2026-03-17

### Added

- **Core Client** (`NumenClient`) — typed HTTP client with auth middleware and SWR caching
- **16 Resource Modules**
- `ContentResource` — CRUD for content items with status filtering
- `PagesResource` — page management with reordering and tree queries
- `MediaResource` — asset management with upload support
- `SearchResource` — full-text search, suggestions, and AI-powered ask
- `VersionsResource` — content version history and diff
- `TaxonomiesResource` — taxonomy and term management
- `BriefsResource` — content brief generation and management
- `PipelineResource` — AI pipeline run management
- `WebhooksResource` — webhook endpoint CRUD and delivery logs
- `GraphResource` — knowledge graph queries
- `ChatResource` — conversational AI interface
- `AdminResource` — admin operations (settings, users, roles)
- `CompetitorResource` — competitor analysis
- `QualityResource` — content quality scoring
- `RepurposeResource` — content repurposing workflows
- `TranslationsResource` — translation management
- **React Bindings** (`@numen/sdk/react`)
- `NumenProvider` context provider
- Hooks: `useContent`, `useContentList`, `useSearch`, `useMedia`, `usePipeline`, `useRealtime`, `usePage`, `usePageList`
- Built on `useNumenQuery` with SWR caching
- **Vue 3 Bindings** (`@numen/sdk/vue`)
- `NumenPlugin` for `app.use()` installation
- Composables: `useContent`, `useContentList`, `useSearch`, `useMedia`, `usePipeline`, `useRealtime`, `usePage`, `usePageList`
- Reactive refs with automatic cleanup
- **Svelte Bindings** (`@numen/sdk/svelte`)
- `setNumenClient` / `getNumenClient` context
- Store factories: `createContentStore`, `createContentListStore`, `createSearchStore`, `createMediaStore`, `createPipelineStore`, `createPageStore`, `createPageListStore`, `createRealtimeStore`
- **Realtime**
- `RealtimeClient` — SSE connection with auto-reconnect and exponential backoff
- `PollingClient` — HTTP polling fallback
- `RealtimeManager` — unified interface with pattern-based channel subscriptions
- **Error Handling** — typed error classes: `NumenError`, `NumenRateLimitError`, `NumenValidationError`, `NumenAuthError`, `NumenNotFoundError`, `NumenNetworkError`
- **SWR Cache** — stale-while-revalidate caching with TTL and listeners
- **Auth Middleware** — pluggable authentication (API key, Bearer token)
- **TypeScript** — full type coverage with exported types for all resources
- **355 Tests** — comprehensive test suite covering core, resources, framework bindings, and realtime

[0.1.0]: https://github.com/byte5digital/numen/releases/tag/sdk-v0.1.0
Loading
Loading