diff --git a/.env.example b/.env.example index 7dca353..3e59ad5 100644 --- a/.env.example +++ b/.env.example @@ -92,3 +92,16 @@ MEDIA_AI_TAGGING=false # CDN_ENABLED: Enable public API endpoints for CDN delivery (default: true) # Provides /v1/public/media/* endpoints for headless frontends CDN_ENABLED=true + +# ── GraphQL API (Lighthouse) ────────────────────── +# Cache store for Lighthouse schema/query caching +LIGHTHOUSE_CACHE_STORE=file + +# Subscription broadcaster: 'log' for dev, 'pusher' for production +LIGHTHOUSE_SUBSCRIPTION_BROADCASTER=log + +# Query complexity limit (default: 500) +GRAPHQL_MAX_COMPLEXITY=500 + +# Query depth limit (default: 10) +GRAPHQL_MAX_DEPTH=10 diff --git a/CHANGELOG.md b/CHANGELOG.md index f55bb65..eed81a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [0.9.0] — 2026-03-15 + +### Added + +**GraphQL API Layer** ([Discussion #13](https://github.com/byte5digital/numen/discussions/13)) + +Full-featured GraphQL API powered by Lighthouse PHP, covering all Numen resources with real-time subscriptions, persisted queries, and fine-grained complexity controls. + +**Features:** +- **Lighthouse PHP** — production-grade GraphQL server with SDL-first schema definition +- **20+ GraphQL types** — Content, Space, Brief, PipelineRun, PipelineStage, MediaAsset, MediaFolder, MediaCollection, User, Role, Permission, Tag, Persona, RepurposedContent, FormatTemplate, ContentRevision, Setting, AuditLog, and more +- **Cursor pagination** — Relay-spec connection types on all list fields (edges/node/pageInfo) +- **Mutations** — createBrief, createContent, updateContent, publishContent, unpublishContent, deleteContent, triggerPipeline, uploadMedia, deleteMedia, and more +- **Real-time subscriptions** — contentUpdated, contentPublished, pipelineStageCompleted via WebSocket (Pusher in production, log driver in dev) +- **Automatic Persisted Queries (APQ)** — SHA256 hash-based query caching to reduce bandwidth +- **Complexity scoring** — per-field cost weights with configurable max (GRAPHQL_MAX_COMPLEXITY=500) +- **Depth limiting** — configurable max nesting depth (GRAPHQL_MAX_DEPTH=10) +- **N+1 prevention** — Dataloader batching via Lighthouse's built-in batch loading +- **Field-level caching** — @cache directive with automatic invalidation on model events +- **Auth directives** — @auth, @can, @guest guards on fields and mutations +- **GraphiQL explorer** — interactive IDE at /graphiql (dev only) +- **22 tests** — feature and unit test coverage for all major operations + +**Endpoint:** +**Docs:** + +--- + + ## [0.8.0] — 2026-03-15 diff --git a/README.md b/README.md index a39efe6..526d52a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,16 @@ Role-based access control (Admin, Editor, Author, Viewer) with space-scoped perm ### CLI for Automation 8 commands for content, briefs, and pipeline management — perfect for CI/CD hooks and server-side workflows. +### GraphQL API Layer +**New in v0.9.0.** A full-featured GraphQL API powered by Lighthouse PHP. +- **Endpoint:** `POST /graphql` — all Numen resources in one schema +- **20+ types** with cursor-based pagination (Relay-spec) +- **Mutations** for content, briefs, media, and pipeline operations +- **Real-time subscriptions** for content events and pipeline progress +- **Automatic Persisted Queries (APQ)**, complexity scoring, field-level caching +- **GraphiQL explorer** at `/graphiql` for interactive development +- See [docs/graphql-api.md](docs/graphql-api.md) for the full guide + ## Architecture diff --git a/app/GraphQL/Complexity/PaginatedComplexity.php b/app/GraphQL/Complexity/PaginatedComplexity.php new file mode 100644 index 0000000..c0a5032 --- /dev/null +++ b/app/GraphQL/Complexity/PaginatedComplexity.php @@ -0,0 +1,31 @@ + $args The field arguments. + */ + public function __invoke(int $childComplexity, array $args): int + { + $first = (int) ($args['first'] ?? 20); + + // Clamp to a safe maximum to avoid integer overflow in complexity calculations + $first = min($first, 1000); + + return $childComplexity * $first; + } +} diff --git a/app/GraphQL/Events/ContentPublishedEvent.php b/app/GraphQL/Events/ContentPublishedEvent.php new file mode 100644 index 0000000..e42a891 --- /dev/null +++ b/app/GraphQL/Events/ContentPublishedEvent.php @@ -0,0 +1,15 @@ +wrapResolver( + fn (callable $resolver): \Closure => function ( + mixed $root, + array $args, + GraphQLContext $context, + ResolveInfo $resolveInfo, + ) use ($resolver): mixed { + $startTime = microtime(true); + + $result = $resolver($root, $args, $context, $resolveInfo); + + $executionTimeMs = (int) round((microtime(true) - $startTime) * 1000); + + $request = request(); + $rawQuery = (string) ($request->input('query', '')); + $queryHash = $rawQuery !== '' ? hash('sha256', $rawQuery) : 'unknown'; + + /** @var \Illuminate\Contracts\Auth\Authenticatable|null $user */ + $user = $context->user(); + $userId = $user?->getAuthIdentifier(); + + // Estimate complexity score from requested subfields count + $complexityScore = count($resolveInfo->getFieldSelection(1)); + + Log::debug('graphql.cost_tracking', [ + 'field' => $resolveInfo->parentType->name.'.'.$resolveInfo->fieldName, + 'query_hash' => $queryHash, + 'complexity_score' => $complexityScore, + 'execution_time_ms' => $executionTimeMs, + 'user_id' => $userId, + ]); + + return $result; + } + ); + } +} diff --git a/app/GraphQL/Mutations/ApprovePipelineRun.php b/app/GraphQL/Mutations/ApprovePipelineRun.php new file mode 100644 index 0000000..79c6152 --- /dev/null +++ b/app/GraphQL/Mutations/ApprovePipelineRun.php @@ -0,0 +1,49 @@ +findOrFail($args['id']); + $this->authz->authorize($user, 'pipeline.approve', $run->pipeline->space_id); + + if ($run->status !== 'paused_for_review') { + throw ValidationException::withMessages([ + 'id' => ['This pipeline run is not awaiting review (current status: '.$run->status.').'], + ]); + } + + // Publish associated content (mirrors PipelineAdminController::approveRun) + $content = $run->content; + if ($content) { + $content->publish(); + ContentPublishedEvent::dispatch($content->fresh(['currentVersion', 'contentType', 'space'])); + } + + $run->markCompleted(); + + $this->authz->log($user, 'pipeline.approve', $run); + + $fresh = $run->fresh(['pipeline', 'content']); + + // Fire subscription broadcast for pipeline run update + PipelineRunUpdatedEvent::dispatch($fresh); + + return $fresh; + } +} diff --git a/app/GraphQL/Mutations/CreateBrief.php b/app/GraphQL/Mutations/CreateBrief.php new file mode 100644 index 0000000..8d2181a --- /dev/null +++ b/app/GraphQL/Mutations/CreateBrief.php @@ -0,0 +1,58 @@ +} $args + */ + public function __invoke(mixed $root, array $args): ContentBrief + { + $user = Auth::user(); + $input = $args['input']; + $this->authz->authorize($user, 'brief.create', $input['space_id']); + + $brief = ContentBrief::create([ + 'space_id' => $input['space_id'], + 'title' => $input['title'], + 'description' => $input['description'] ?? null, + 'content_type_slug' => $input['content_type_slug'], + 'target_locale' => $input['target_locale'] ?? 'en', + 'target_keywords' => $input['target_keywords'] ?? null, + 'priority' => $input['priority'] ?? 'normal', + 'persona_id' => $input['persona_id'] ?? null, + 'pipeline_id' => $input['pipeline_id'] ?? null, + 'source' => 'api', + 'status' => 'pending', + ]); + + // If a pipeline_id was provided, use it; otherwise look for active pipeline in the space + $pipelineId = $input['pipeline_id'] ?? null; + + $pipeline = $pipelineId + ? ContentPipeline::findOrFail($pipelineId) + : ContentPipeline::where('space_id', $input['space_id']) + ->where('is_active', true) + ->first(); + + if ($pipeline) { + $this->executor->start($brief, $pipeline); + } + + $this->authz->log($user, 'brief.create', $brief); + + return $brief->load(['space']); + } +} diff --git a/app/GraphQL/Mutations/CreateContent.php b/app/GraphQL/Mutations/CreateContent.php new file mode 100644 index 0000000..6e1c0cf --- /dev/null +++ b/app/GraphQL/Mutations/CreateContent.php @@ -0,0 +1,58 @@ +} $args + */ + public function __invoke(mixed $root, array $args): Content + { + $user = Auth::user(); + $this->authz->authorize($user, 'content.create', $args['input']['space_id'] ?? null); + + $input = $args['input']; + $termIds = $input['taxonomy_term_ids'] ?? []; + unset($input['taxonomy_term_ids']); + + $content = Content::create([ + 'space_id' => $input['space_id'], + 'content_type_id' => $input['content_type_id'], + 'slug' => $input['slug'], + 'locale' => $input['locale'] ?? 'en', + 'status' => $input['status'] ?? 'draft', + 'hero_image_id' => $input['hero_image_id'] ?? null, + ]); + + // Create initial version with title/body + $version = ContentVersion::create([ + 'content_id' => $content->id, + 'version_number' => 1, + 'title' => $input['title'], + 'body' => $input['body'] ?? null, + 'body_format' => 'html', + 'status' => 'draft', + 'author_type' => 'user', + 'author_id' => (string) $user->id, + ]); + + $content->update(['current_version_id' => $version->id]); + + // Attach taxonomy terms if provided + if (! empty($termIds)) { + $content->taxonomyTerms()->sync($termIds); + } + + $this->authz->log($user, 'content.create', $content); + + return $content->load(['currentVersion', 'contentType', 'space']); + } +} diff --git a/app/GraphQL/Mutations/DeleteContent.php b/app/GraphQL/Mutations/DeleteContent.php new file mode 100644 index 0000000..94a976d --- /dev/null +++ b/app/GraphQL/Mutations/DeleteContent.php @@ -0,0 +1,32 @@ +authz->authorize($user, 'content.delete', $content->space_id); + + // Load relations before soft-delete for return value + $content->load(['currentVersion', 'contentType', 'space']); + + $this->authz->log($user, 'content.delete', $content); + + // Soft delete (Content uses SoftDeletes trait) + $content->delete(); + + return $content; + } +} diff --git a/app/GraphQL/Mutations/DeleteMediaAsset.php b/app/GraphQL/Mutations/DeleteMediaAsset.php new file mode 100644 index 0000000..a5e6e70 --- /dev/null +++ b/app/GraphQL/Mutations/DeleteMediaAsset.php @@ -0,0 +1,28 @@ +authz->authorize($user, 'media.delete', $asset->space_id); + + $this->authz->log($user, 'media.delete', $asset); + + $asset->delete(); + + return $asset; + } +} diff --git a/app/GraphQL/Mutations/PublishContent.php b/app/GraphQL/Mutations/PublishContent.php new file mode 100644 index 0000000..d3a4851 --- /dev/null +++ b/app/GraphQL/Mutations/PublishContent.php @@ -0,0 +1,35 @@ +authz->authorize($user, 'content.publish', $content->space_id); + + // Use the model's publish() method to ensure consistent status transitions + $content->publish(); + + $this->authz->log($user, 'content.publish', $content); + + $fresh = $content->fresh(['currentVersion', 'contentType', 'space']); + + // Fire subscription broadcast + ContentPublishedEvent::dispatch($fresh); + + return $fresh; + } +} diff --git a/app/GraphQL/Mutations/RejectPipelineRun.php b/app/GraphQL/Mutations/RejectPipelineRun.php new file mode 100644 index 0000000..29b3d13 --- /dev/null +++ b/app/GraphQL/Mutations/RejectPipelineRun.php @@ -0,0 +1,44 @@ +findOrFail($args['id']); + $this->authz->authorize($user, 'pipeline.approve', $run->pipeline->space_id); + + if ($run->status !== 'paused_for_review') { + throw ValidationException::withMessages([ + 'id' => ['This pipeline run is not awaiting review (current status: '.$run->status.').'], + ]); + } + + $run->update([ + 'status' => 'rejected', + 'stage_results' => array_merge($run->stage_results ?? [], [ + '_rejection' => [ + 'reason' => $args['reason'] ?? null, + 'rejected_by' => $user->id, + 'rejected_at' => now()->toIso8601String(), + ], + ]), + ]); + + $this->authz->log($user, 'pipeline.reject', $run, ['reason' => $args['reason'] ?? null]); + + return $run->fresh(['pipeline', 'content']); + } +} diff --git a/app/GraphQL/Mutations/TriggerPipeline.php b/app/GraphQL/Mutations/TriggerPipeline.php new file mode 100644 index 0000000..181eb22 --- /dev/null +++ b/app/GraphQL/Mutations/TriggerPipeline.php @@ -0,0 +1,51 @@ +authz->authorize($user, 'pipeline.trigger', $pipeline->space_id); + + $existingContent = isset($args['contentId']) + ? Content::findOrFail($args['contentId']) + : null; + + // Create a minimal brief to drive the pipeline run + $brief = ContentBrief::create([ + 'space_id' => $pipeline->space_id, + 'title' => 'Manual trigger via API', + 'content_type_slug' => 'general', + 'target_locale' => 'en', + 'source' => 'api', + 'priority' => 'normal', + 'status' => 'pending', + 'pipeline_id' => $pipeline->id, + ]); + + $run = $this->executor->start($brief, $pipeline, $existingContent); + + $this->authz->log($user, 'pipeline.trigger', $run); + + return $run->load(['pipeline', 'content']); + } +} diff --git a/app/GraphQL/Mutations/UnpublishContent.php b/app/GraphQL/Mutations/UnpublishContent.php new file mode 100644 index 0000000..c155724 --- /dev/null +++ b/app/GraphQL/Mutations/UnpublishContent.php @@ -0,0 +1,28 @@ +authz->authorize($user, 'content.publish', $content->space_id); + + $content->update(['status' => 'draft']); + + $this->authz->log($user, 'content.unpublish', $content); + + return $content->fresh(['currentVersion', 'contentType', 'space']); + } +} diff --git a/app/GraphQL/Mutations/UpdateBrief.php b/app/GraphQL/Mutations/UpdateBrief.php new file mode 100644 index 0000000..ee60d6e --- /dev/null +++ b/app/GraphQL/Mutations/UpdateBrief.php @@ -0,0 +1,29 @@ +} $args + */ + public function __invoke(mixed $root, array $args): ContentBrief + { + $user = Auth::user(); + $brief = ContentBrief::findOrFail($args['id']); + $this->authz->authorize($user, 'brief.update', $brief->space_id); + + $input = array_filter($args['input'], fn ($v) => $v !== null); + $brief->update($input); + + $this->authz->log($user, 'brief.update', $brief); + + return $brief->fresh(['space']); + } +} diff --git a/app/GraphQL/Mutations/UpdateContent.php b/app/GraphQL/Mutations/UpdateContent.php new file mode 100644 index 0000000..cb1b5a3 --- /dev/null +++ b/app/GraphQL/Mutations/UpdateContent.php @@ -0,0 +1,81 @@ +} $args + */ + public function __invoke(mixed $root, array $args): Content + { + $user = Auth::user(); + $content = Content::findOrFail($args['id']); + $this->authz->authorize($user, 'content.update', $content->space_id); + + $input = $args['input']; + $termIds = $input['taxonomy_term_ids'] ?? null; + unset($input['taxonomy_term_ids']); + + // Update top-level content fields (slug, status, hero_image_id) + $contentFields = []; + if (isset($input['slug'])) { + $contentFields['slug'] = $input['slug']; + } + if (isset($input['status'])) { + $contentFields['status'] = $input['status']; + } + if (array_key_exists('hero_image_id', $input)) { + $contentFields['hero_image_id'] = $input['hero_image_id']; + } + + if (! empty($contentFields)) { + $content->update($contentFields); + } + + // Create a new version when title or body changes + if (isset($input['title']) || isset($input['body'])) { + $prev = $content->currentVersion; + $prevNumber = $prev !== null ? $prev->version_number : 0; + $prevTitle = $prev !== null ? $prev->title : ''; + $prevBody = $prev !== null ? $prev->body : null; + $prevFormat = $prev !== null ? $prev->body_format : 'html'; + $prevExcerpt = $prev !== null ? $prev->excerpt : null; + $prevSeo = $prev !== null ? $prev->seo_data : null; + $prevFields = $prev !== null ? $prev->structured_fields : null; + + $version = ContentVersion::create([ + 'content_id' => $content->id, + 'version_number' => $prevNumber + 1, + 'title' => $input['title'] ?? $prevTitle, + 'body' => $input['body'] ?? $prevBody, + 'body_format' => $prevFormat, + 'status' => 'draft', + 'author_type' => 'user', + 'author_id' => (string) $user->id, + 'parent_version_id' => $prev?->id, + 'excerpt' => $prevExcerpt, + 'seo_data' => $prevSeo, + 'structured_fields' => $prevFields, + ]); + + $content->update(['current_version_id' => $version->id]); + } + + // Sync taxonomy terms if provided + if ($termIds !== null) { + $content->taxonomyTerms()->sync($termIds); + } + + $this->authz->log($user, 'content.update', $content); + + return $content->fresh(['currentVersion', 'contentType', 'space']); + } +} diff --git a/app/GraphQL/Mutations/UpdateMediaAsset.php b/app/GraphQL/Mutations/UpdateMediaAsset.php new file mode 100644 index 0000000..0a8d3e3 --- /dev/null +++ b/app/GraphQL/Mutations/UpdateMediaAsset.php @@ -0,0 +1,29 @@ +} $args + */ + public function __invoke(mixed $root, array $args): MediaAsset + { + $user = Auth::user(); + $asset = MediaAsset::findOrFail($args['id']); + $this->authz->authorize($user, 'media.update', $asset->space_id); + + $input = array_filter($args['input'], fn ($v) => $v !== null); + $asset->update($input); + + $this->authz->log($user, 'media.update', $asset); + + return $asset->fresh(); + } +} diff --git a/app/GraphQL/Queries/BriefsQuery.php b/app/GraphQL/Queries/BriefsQuery.php new file mode 100644 index 0000000..e4a4e05 --- /dev/null +++ b/app/GraphQL/Queries/BriefsQuery.php @@ -0,0 +1,55 @@ +, pageInfo: array, totalCount: int} + */ + public function __invoke(mixed $root, array $args): array + { + $first = $args['first'] ?? 20; + $after = $args['after'] ?? null; + + $query = ContentBrief::query() + ->where('space_id', $args['spaceId']) + ->orderByDesc('created_at'); + + if (! empty($args['status'])) { + $query->where('status', strtolower((string) $args['status'])); + } + + $totalCount = (clone $query)->count(); + + if ($after !== null) { + $afterId = base64_decode($after); + $query->where('id', '<', $afterId); + } + + $items = $query->limit($first + 1)->get(); + $hasNextPage = $items->count() > $first; + $items = $items->take($first); + + $edges = $items->map(fn (ContentBrief $brief) => [ + 'node' => $brief, + 'cursor' => base64_encode((string) $brief->id), + ])->values()->all(); + + return [ + 'edges' => $edges, + 'pageInfo' => [ + 'hasNextPage' => $hasNextPage, + 'hasPreviousPage' => $after !== null, + 'startCursor' => isset($edges[0]) ? $edges[0]['cursor'] : null, + 'endCursor' => isset($edges[count($edges) - 1]) ? $edges[count($edges) - 1]['cursor'] : null, + ], + 'totalCount' => $totalCount, + ]; + } +} diff --git a/app/GraphQL/Queries/ContentBySlugQuery.php b/app/GraphQL/Queries/ContentBySlugQuery.php new file mode 100644 index 0000000..51b0655 --- /dev/null +++ b/app/GraphQL/Queries/ContentBySlugQuery.php @@ -0,0 +1,30 @@ +first(); + + if (! $space) { + return null; + } + + /** @var Content|null */ + return Content::query() + ->where('space_id', $space->id) + ->where('slug', $args['slug']) + ->where('locale', $args['locale']) + ->where('status', 'published') + ->whereNotNull('published_at') + ->first(); + } +} diff --git a/app/GraphQL/Queries/ContentTypesQuery.php b/app/GraphQL/Queries/ContentTypesQuery.php new file mode 100644 index 0000000..1ef2576 --- /dev/null +++ b/app/GraphQL/Queries/ContentTypesQuery.php @@ -0,0 +1,18 @@ + + */ + public function __invoke(mixed $root, array $args): Collection + { + return ContentType::where('space_id', $args['spaceId'])->get(); + } +} diff --git a/app/GraphQL/Queries/ContentsQuery.php b/app/GraphQL/Queries/ContentsQuery.php new file mode 100644 index 0000000..b208334 --- /dev/null +++ b/app/GraphQL/Queries/ContentsQuery.php @@ -0,0 +1,75 @@ +, paginatorInfo: array} + */ + public function __invoke(mixed $root, array $args): array + { + $space = Space::where('slug', $args['spaceSlug'])->first(); + + if (! $space) { + return [ + 'data' => collect(), + 'paginatorInfo' => $this->emptyPaginatorInfo($args['first'] ?? 20), + ]; + } + + $query = Content::query() + ->where('space_id', $space->id) + ->where('status', $args['status'] ?? 'published'); + + if (! empty($args['locale'])) { + $query->where('locale', $args['locale']); + } + + if (! empty($args['contentType'])) { + $query->whereHas('contentType', fn ($q) => $q->where('slug', $args['contentType'])); + } + + $perPage = $args['first'] ?? 20; + $page = $args['page'] ?? 1; + + /** @var LengthAwarePaginator $paginator */ + $paginator = $query->paginate($perPage, ['*'], 'page', $page); + + return [ + 'data' => $paginator->getCollection(), + 'paginatorInfo' => [ + 'count' => $paginator->count(), + 'currentPage' => $paginator->currentPage(), + 'firstItem' => $paginator->firstItem(), + 'hasMorePages' => $paginator->hasMorePages(), + 'lastItem' => $paginator->lastItem(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]; + } + + /** + * @return array + */ + private function emptyPaginatorInfo(int $perPage): array + { + return [ + 'count' => 0, + 'currentPage' => 1, + 'firstItem' => null, + 'hasMorePages' => false, + 'lastItem' => null, + 'lastPage' => 1, + 'perPage' => $perPage, + 'total' => 0, + ]; + } +} diff --git a/app/GraphQL/Queries/MediaAssetsQuery.php b/app/GraphQL/Queries/MediaAssetsQuery.php new file mode 100644 index 0000000..e976b1f --- /dev/null +++ b/app/GraphQL/Queries/MediaAssetsQuery.php @@ -0,0 +1,51 @@ +, pageInfo: array, totalCount: int} + */ + public function __invoke(mixed $root, array $args): array + { + $first = $args['first'] ?? 20; + $after = $args['after'] ?? null; + + $query = MediaAsset::query() + ->where('space_id', $args['spaceId']) + ->orderByDesc('created_at'); + + $totalCount = (clone $query)->count(); + + if ($after !== null) { + $afterId = base64_decode($after); + $query->where('id', '<', $afterId); + } + + $items = $query->limit($first + 1)->get(); + $hasNextPage = $items->count() > $first; + $items = $items->take($first); + + $edges = $items->map(fn (MediaAsset $asset) => [ + 'node' => $asset, + 'cursor' => base64_encode((string) $asset->id), + ])->values()->all(); + + return [ + 'edges' => $edges, + 'pageInfo' => [ + 'hasNextPage' => $hasNextPage, + 'hasPreviousPage' => $after !== null, + 'startCursor' => isset($edges[0]) ? $edges[0]['cursor'] : null, + 'endCursor' => isset($edges[count($edges) - 1]) ? $edges[count($edges) - 1]['cursor'] : null, + ], + 'totalCount' => $totalCount, + ]; + } +} diff --git a/app/GraphQL/Queries/PagesQuery.php b/app/GraphQL/Queries/PagesQuery.php new file mode 100644 index 0000000..4b97635 --- /dev/null +++ b/app/GraphQL/Queries/PagesQuery.php @@ -0,0 +1,52 @@ +, pageInfo: array, totalCount: int} + */ + public function __invoke(mixed $root, array $args): array + { + $first = $args['first'] ?? 20; + $after = $args['after'] ?? null; + + $query = Page::query() + ->where('space_id', $args['spaceId']) + ->where('status', 'published') + ->orderByDesc('created_at'); + + $totalCount = (clone $query)->count(); + + if ($after !== null) { + $afterId = base64_decode($after); + $query->where('id', '<', $afterId); + } + + $items = $query->limit($first + 1)->get(); + $hasNextPage = $items->count() > $first; + $items = $items->take($first); + + $edges = $items->map(fn (Page $page) => [ + 'node' => $page, + 'cursor' => base64_encode((string) $page->id), + ])->values()->all(); + + return [ + 'edges' => $edges, + 'pageInfo' => [ + 'hasNextPage' => $hasNextPage, + 'hasPreviousPage' => $after !== null, + 'startCursor' => isset($edges[0]) ? $edges[0]['cursor'] : null, + 'endCursor' => isset($edges[count($edges) - 1]) ? $edges[count($edges) - 1]['cursor'] : null, + ], + 'totalCount' => $totalCount, + ]; + } +} diff --git a/app/GraphQL/Queries/PersonasQuery.php b/app/GraphQL/Queries/PersonasQuery.php new file mode 100644 index 0000000..d3bc042 --- /dev/null +++ b/app/GraphQL/Queries/PersonasQuery.php @@ -0,0 +1,20 @@ + + */ + public function __invoke(mixed $root, array $args): \Illuminate\Database\Eloquent\Collection + { + return Persona::query() + ->where('space_id', $args['spaceId']) + ->orderBy('name') + ->get(); + } +} diff --git a/app/GraphQL/Queries/PipelineRunsQuery.php b/app/GraphQL/Queries/PipelineRunsQuery.php new file mode 100644 index 0000000..0ad44c2 --- /dev/null +++ b/app/GraphQL/Queries/PipelineRunsQuery.php @@ -0,0 +1,52 @@ +, pageInfo: array, totalCount: int} + */ + public function __invoke(ContentPipeline $root, array $args): array + { + $first = $args['first'] ?? 20; + $after = $args['after'] ?? null; + + $query = PipelineRun::query() + ->where('pipeline_id', $root->id) + ->orderByDesc('created_at'); + + $totalCount = (clone $query)->count(); + + if ($after !== null) { + $afterId = base64_decode($after); + $query->where('id', '<', $afterId); + } + + $items = $query->limit($first + 1)->get(); + $hasNextPage = $items->count() > $first; + $items = $items->take($first); + + $edges = $items->map(fn (PipelineRun $run) => [ + 'node' => $run, + 'cursor' => base64_encode((string) $run->id), + ])->values()->all(); + + return [ + 'edges' => $edges, + 'pageInfo' => [ + 'hasNextPage' => $hasNextPage, + 'hasPreviousPage' => $after !== null, + 'startCursor' => isset($edges[0]) ? $edges[0]['cursor'] : null, + 'endCursor' => isset($edges[count($edges) - 1]) ? $edges[count($edges) - 1]['cursor'] : null, + ], + 'totalCount' => $totalCount, + ]; + } +} diff --git a/app/GraphQL/Queries/PipelinesQuery.php b/app/GraphQL/Queries/PipelinesQuery.php new file mode 100644 index 0000000..e44cc83 --- /dev/null +++ b/app/GraphQL/Queries/PipelinesQuery.php @@ -0,0 +1,20 @@ + + */ + public function __invoke(mixed $root, array $args): \Illuminate\Database\Eloquent\Collection + { + return ContentPipeline::query() + ->where('space_id', $args['spaceId']) + ->orderBy('name') + ->get(); + } +} diff --git a/app/GraphQL/Queries/SpaceContentsQuery.php b/app/GraphQL/Queries/SpaceContentsQuery.php new file mode 100644 index 0000000..77d59c3 --- /dev/null +++ b/app/GraphQL/Queries/SpaceContentsQuery.php @@ -0,0 +1,47 @@ +, paginatorInfo: array} + */ + public function __invoke(Space $root, array $args): array + { + $query = Content::query()->where('space_id', $root->id); + + if (! empty($args['status'])) { + $query->where('status', $args['status']); + } + + if (! empty($args['locale'])) { + $query->where('locale', $args['locale']); + } + + $perPage = $args['first'] ?? 20; + $page = $args['page'] ?? 1; + + /** @var LengthAwarePaginator $paginator */ + $paginator = $query->paginate($perPage, ['*'], 'page', $page); + + return [ + 'data' => $paginator->getCollection(), + 'paginatorInfo' => [ + 'count' => $paginator->count(), + 'currentPage' => $paginator->currentPage(), + 'firstItem' => $paginator->firstItem(), + 'hasMorePages' => $paginator->hasMorePages(), + 'lastItem' => $paginator->lastItem(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]; + } +} diff --git a/app/GraphQL/Queries/VocabulariesQuery.php b/app/GraphQL/Queries/VocabulariesQuery.php new file mode 100644 index 0000000..d72718c --- /dev/null +++ b/app/GraphQL/Queries/VocabulariesQuery.php @@ -0,0 +1,20 @@ + + */ + public function __invoke(mixed $root, array $args): \Illuminate\Database\Eloquent\Collection + { + return Vocabulary::query() + ->where('space_id', $args['spaceId']) + ->ordered() + ->get(); + } +} diff --git a/app/GraphQL/Queries/WebhooksQuery.php b/app/GraphQL/Queries/WebhooksQuery.php new file mode 100644 index 0000000..1463fc8 --- /dev/null +++ b/app/GraphQL/Queries/WebhooksQuery.php @@ -0,0 +1,20 @@ + + */ + public function __invoke(mixed $root, array $args): \Illuminate\Database\Eloquent\Collection + { + return Webhook::query() + ->where('space_id', $args['spaceId']) + ->orderByDesc('created_at') + ->get(); + } +} diff --git a/app/GraphQL/Subscriptions/ContentPublished.php b/app/GraphQL/Subscriptions/ContentPublished.php new file mode 100644 index 0000000..a3a5dd0 --- /dev/null +++ b/app/GraphQL/Subscriptions/ContentPublished.php @@ -0,0 +1,46 @@ +user(); + if ($user === null) { + return false; + } + + $spaceId = $subscriber->args['spaceId']; + + try { + $this->authz->authorize($user, 'content.view', $spaceId); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * Filter subscribers who should receive the subscription. + */ + public function filter(Subscriber $subscriber, mixed $root): bool + { + if (! $root instanceof Content) { + return false; + } + + return $subscriber->args['spaceId'] === $root->space_id; + } +} diff --git a/app/GraphQL/Subscriptions/ContentUpdated.php b/app/GraphQL/Subscriptions/ContentUpdated.php new file mode 100644 index 0000000..a75f6fc --- /dev/null +++ b/app/GraphQL/Subscriptions/ContentUpdated.php @@ -0,0 +1,51 @@ +user(); + if ($user === null) { + return false; + } + + $contentId = $subscriber->args['contentId']; + $content = Content::find($contentId); + + if ($content === null) { + return false; + } + + try { + $this->authz->authorize($user, 'content.view', $content->space_id); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * Filter subscribers who should receive the subscription. + */ + public function filter(Subscriber $subscriber, mixed $root): bool + { + if (! $root instanceof Content) { + return false; + } + + return $subscriber->args['contentId'] === $root->id; + } +} diff --git a/app/GraphQL/Subscriptions/PipelineRunCompleted.php b/app/GraphQL/Subscriptions/PipelineRunCompleted.php new file mode 100644 index 0000000..3cd49e8 --- /dev/null +++ b/app/GraphQL/Subscriptions/PipelineRunCompleted.php @@ -0,0 +1,47 @@ +user(); + if ($user === null) { + return false; + } + + $spaceId = $subscriber->args['spaceId']; + + try { + $this->authz->authorize($user, 'pipeline.view', $spaceId); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * Filter subscribers who should receive the subscription. + */ + public function filter(Subscriber $subscriber, mixed $root): bool + { + if (! $root instanceof PipelineRun) { + return false; + } + + return $subscriber->args['spaceId'] === $root->pipeline->space_id + && $root->status === 'completed'; + } +} diff --git a/app/GraphQL/Subscriptions/PipelineRunUpdated.php b/app/GraphQL/Subscriptions/PipelineRunUpdated.php new file mode 100644 index 0000000..d4387c2 --- /dev/null +++ b/app/GraphQL/Subscriptions/PipelineRunUpdated.php @@ -0,0 +1,51 @@ +user(); + if ($user === null) { + return false; + } + + $runId = $subscriber->args['runId']; + $run = PipelineRun::with('pipeline')->find($runId); + + if ($run === null) { + return false; + } + + try { + $this->authz->authorize($user, 'pipeline.view', $run->pipeline->space_id); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * Filter subscribers who should receive the subscription. + */ + public function filter(Subscriber $subscriber, mixed $root): bool + { + if (! $root instanceof PipelineRun) { + return false; + } + + return $subscriber->args['runId'] === $root->id; + } +} diff --git a/app/Policies/ContentPipelinePolicy.php b/app/Policies/ContentPipelinePolicy.php new file mode 100644 index 0000000..99ef898 --- /dev/null +++ b/app/Policies/ContentPipelinePolicy.php @@ -0,0 +1,38 @@ +isAdmin()) { + return true; + } + + return null; + } + + public function trigger(User $user, ContentPipeline $pipeline): bool + { + return in_array($user->role, ['admin', 'editor']); + } + + public function approve(User $user, ContentPipeline $pipeline): bool + { + return in_array($user->role, ['admin', 'editor']); + } + + public function create(User $user): bool + { + return in_array($user->role, ['admin', 'editor']); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d5ee79d..7812729 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,7 +8,9 @@ use App\Listeners\IndexContentForSearch; use App\Listeners\RemoveFromSearchIndex; use App\Models\Content; +use App\Models\ContentPipeline; use App\Models\Setting; +use App\Policies\ContentPipelinePolicy; use App\Policies\ContentPolicy; use App\Services\AI\CostTracker; use App\Services\AI\ImageManager; @@ -107,7 +109,17 @@ public function register(): void public function boot(): void { // Register content access policies + // Global admin bypass — admins can do anything + Gate::before(function ($user, string $ability): ?bool { + if (method_exists($user, 'isAdmin') && $user->isAdmin()) { + return true; + } + + return null; + }); + Gate::policy(Content::class, ContentPolicy::class); + Gate::policy(ContentPipeline::class, ContentPipelinePolicy::class); // Load DB settings into config (overrides .env defaults) Setting::loadIntoConfig(); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 0000000..163ecfc --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,39 @@ +> + */ + protected $listen = []; + + public function boot(): void + { + parent::boot(); + + \Illuminate\Support\Facades\Event::listen( + ContentPublishedEvent::class, + function (ContentPublishedEvent $event): void { + Subscription::broadcast('contentPublished', $event->content); + Subscription::broadcast('contentUpdated', $event->content); + } + ); + + \Illuminate\Support\Facades\Event::listen( + PipelineRunUpdatedEvent::class, + function (PipelineRunUpdatedEvent $event): void { + Subscription::broadcast('pipelineRunUpdated', $event->pipelineRun); + Subscription::broadcast('pipelineRunCompleted', $event->pipelineRun); + } + ); + } +} diff --git a/bootstrap/cache/packages.php b/bootstrap/cache/packages.php index 1f4afa8..aa191da 100755 --- a/bootstrap/cache/packages.php +++ b/bootstrap/cache/packages.php @@ -34,6 +34,13 @@ 0 => 'Laravel\\Tinker\\TinkerServiceProvider', ), ), + 'mll-lab/laravel-graphiql' => + array ( + 'providers' => + array ( + 0 => 'MLL\\GraphiQL\\GraphiQLServiceProvider', + ), + ), 'nesbot/carbon' => array ( 'providers' => @@ -55,6 +62,23 @@ 0 => 'Termwind\\Laravel\\TermwindServiceProvider', ), ), + 'nuwave/lighthouse' => + array ( + 'providers' => + array ( + 0 => 'Nuwave\\Lighthouse\\LighthouseServiceProvider', + 1 => 'Nuwave\\Lighthouse\\Async\\AsyncServiceProvider', + 2 => 'Nuwave\\Lighthouse\\Auth\\AuthServiceProvider', + 3 => 'Nuwave\\Lighthouse\\Bind\\BindServiceProvider', + 4 => 'Nuwave\\Lighthouse\\Cache\\CacheServiceProvider', + 5 => 'Nuwave\\Lighthouse\\GlobalId\\GlobalIdServiceProvider', + 6 => 'Nuwave\\Lighthouse\\OrderBy\\OrderByServiceProvider', + 7 => 'Nuwave\\Lighthouse\\Pagination\\PaginationServiceProvider', + 8 => 'Nuwave\\Lighthouse\\SoftDeletes\\SoftDeletesServiceProvider', + 9 => 'Nuwave\\Lighthouse\\Testing\\TestingServiceProvider', + 10 => 'Nuwave\\Lighthouse\\Validation\\ValidationServiceProvider', + ), + ), 'spatie/laravel-ignition' => array ( 'aliases' => diff --git a/bootstrap/cache/services.php b/bootstrap/cache/services.php index 58cab2c..dfab768 100755 --- a/bootstrap/cache/services.php +++ b/bootstrap/cache/services.php @@ -29,13 +29,25 @@ 25 => 'Laravel\\Sanctum\\SanctumServiceProvider', 26 => 'Laravel\\Scout\\ScoutServiceProvider', 27 => 'Laravel\\Tinker\\TinkerServiceProvider', - 28 => 'Carbon\\Laravel\\ServiceProvider', - 29 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - 30 => 'Termwind\\Laravel\\TermwindServiceProvider', - 31 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - 32 => 'Tighten\\Ziggy\\ZiggyServiceProvider', - 33 => 'App\\Providers\\AppServiceProvider', - 34 => 'App\\Providers\\I18nServiceProvider', + 28 => 'MLL\\GraphiQL\\GraphiQLServiceProvider', + 29 => 'Carbon\\Laravel\\ServiceProvider', + 30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + 31 => 'Termwind\\Laravel\\TermwindServiceProvider', + 32 => 'Nuwave\\Lighthouse\\LighthouseServiceProvider', + 33 => 'Nuwave\\Lighthouse\\Async\\AsyncServiceProvider', + 34 => 'Nuwave\\Lighthouse\\Auth\\AuthServiceProvider', + 35 => 'Nuwave\\Lighthouse\\Bind\\BindServiceProvider', + 36 => 'Nuwave\\Lighthouse\\Cache\\CacheServiceProvider', + 37 => 'Nuwave\\Lighthouse\\GlobalId\\GlobalIdServiceProvider', + 38 => 'Nuwave\\Lighthouse\\OrderBy\\OrderByServiceProvider', + 39 => 'Nuwave\\Lighthouse\\Pagination\\PaginationServiceProvider', + 40 => 'Nuwave\\Lighthouse\\SoftDeletes\\SoftDeletesServiceProvider', + 41 => 'Nuwave\\Lighthouse\\Testing\\TestingServiceProvider', + 42 => 'Nuwave\\Lighthouse\\Validation\\ValidationServiceProvider', + 43 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', + 44 => 'Tighten\\Ziggy\\ZiggyServiceProvider', + 45 => 'App\\Providers\\AppServiceProvider', + 46 => 'App\\Providers\\I18nServiceProvider', ), 'eager' => array ( @@ -52,13 +64,25 @@ 10 => 'Inertia\\ServiceProvider', 11 => 'Laravel\\Sanctum\\SanctumServiceProvider', 12 => 'Laravel\\Scout\\ScoutServiceProvider', - 13 => 'Carbon\\Laravel\\ServiceProvider', - 14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - 15 => 'Termwind\\Laravel\\TermwindServiceProvider', - 16 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - 17 => 'Tighten\\Ziggy\\ZiggyServiceProvider', - 18 => 'App\\Providers\\AppServiceProvider', - 19 => 'App\\Providers\\I18nServiceProvider', + 13 => 'MLL\\GraphiQL\\GraphiQLServiceProvider', + 14 => 'Carbon\\Laravel\\ServiceProvider', + 15 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + 16 => 'Termwind\\Laravel\\TermwindServiceProvider', + 17 => 'Nuwave\\Lighthouse\\LighthouseServiceProvider', + 18 => 'Nuwave\\Lighthouse\\Async\\AsyncServiceProvider', + 19 => 'Nuwave\\Lighthouse\\Auth\\AuthServiceProvider', + 20 => 'Nuwave\\Lighthouse\\Bind\\BindServiceProvider', + 21 => 'Nuwave\\Lighthouse\\Cache\\CacheServiceProvider', + 22 => 'Nuwave\\Lighthouse\\GlobalId\\GlobalIdServiceProvider', + 23 => 'Nuwave\\Lighthouse\\OrderBy\\OrderByServiceProvider', + 24 => 'Nuwave\\Lighthouse\\Pagination\\PaginationServiceProvider', + 25 => 'Nuwave\\Lighthouse\\SoftDeletes\\SoftDeletesServiceProvider', + 26 => 'Nuwave\\Lighthouse\\Testing\\TestingServiceProvider', + 27 => 'Nuwave\\Lighthouse\\Validation\\ValidationServiceProvider', + 28 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', + 29 => 'Tighten\\Ziggy\\ZiggyServiceProvider', + 30 => 'App\\Providers\\AppServiceProvider', + 31 => 'App\\Providers\\I18nServiceProvider', ), 'deferred' => array ( diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 02ffd45..6ce89df 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -3,4 +3,6 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\I18nServiceProvider::class, + App\Providers\EventServiceProvider::class, + Nuwave\Lighthouse\Subscriptions\SubscriptionServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 935b2b4..b740845 100755 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "laravel/tinker": "^2.11", "league/flysystem-aws-s3-v3": "^3.32", "meilisearch/meilisearch-php": "^1.16", + "nuwave/lighthouse": "^6.65", "pgvector/pgvector": "^0.2.2", "tightenco/ziggy": "^2.0" }, @@ -21,6 +22,7 @@ "larastan/larastan": "^3.0", "laravel/pint": "^1.27", "laravel/sail": "^1.53", + "mll-lab/laravel-graphiql": "^4.0", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.9", "phpunit/phpunit": "^11.5", diff --git a/composer.lock b/composer.lock index c079458..3b511a6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1940ee9d60c49c3c95dcb88b09326048", + "content-hash": "6ed231c7fd31a64bb503f90145223068", "packages": [ { "name": "aws/aws-crt-php", @@ -1203,6 +1203,48 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "haydenpierce/class-finder", + "version": "0.5.3", + "source": { + "type": "git", + "url": "git@gitlab.com:hpierce1102/ClassFinder.git", + "reference": "40703445c18784edcc6411703e7c3869af11ec8c" + }, + "dist": { + "type": "zip", + "url": "https://gitlab.com/api/v4/projects/hpierce1102%2FClassFinder/repository/archive.zip?sha=40703445c18784edcc6411703e7c3869af11ec8c", + "reference": "40703445c18784edcc6411703e7c3869af11ec8c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3" + }, + "require-dev": { + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": "~9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "HaydenPierce\\ClassFinder\\": "src/", + "HaydenPierce\\ClassFinder\\UnitTest\\": "test/unit" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Hayden Pierce", + "email": "hayden@haydenpierce.com" + } + ], + "description": "A library that can provide of a list of classes in a given namespace", + "time": "2023-06-18T17:43:01+00:00" + }, { "name": "inertiajs/inertia-laravel", "version": "v2.0.21", @@ -1510,6 +1552,64 @@ ], "time": "2023-05-21T07:57:08+00:00" }, + { + "name": "laragraph/utils", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/laragraph/utils.git", + "reference": "0c32d90566658ab5a1979b13e8ab6c13b375c91f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laragraph/utils/zipball/0c32d90566658ab5a1979b13e8ab6c13b375c91f", + "reference": "0c32d90566658ab5a1979b13e8ab6c13b375c91f", + "shasum": "" + }, + "require": { + "illuminate/contracts": "~5.6.0 || ~5.7.0 || ~5.8.0 || ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || ^13", + "illuminate/http": "~5.6.0 || ~5.7.0 || ~5.8.0 || ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || ^13", + "php": "^7.2 || ^8", + "thecodingmachine/safe": "^1.1 || ^2 || ^3", + "webonyx/graphql-php": "^0.13.2 || ^14 || ^15" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.11", + "jangregor/phpstan-prophecy": "^1", + "mll-lab/php-cs-fixer-config": "^4", + "orchestra/testbench": "~3.6.0 || ~3.7.0 || ~3.8.0 || ~3.9.0 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 || ^10 || ^11", + "phpstan/extension-installer": "^1", + "phpstan/phpstan": "^1", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9 || ^10.5 || ^11 || ^12", + "thecodingmachine/phpstan-safe-rule": "^1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laragraph\\Utils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benedikt Franke", + "email": "benedikt@franke.tech" + } + ], + "description": "Utilities for using GraphQL with Laravel", + "homepage": "https://github.com/laragraph/utils", + "support": { + "issues": "https://github.com/laragraph/utils/issues", + "source": "https://github.com/laragraph/utils" + }, + "time": "2026-03-04T07:16:49+00:00" + }, { "name": "laravel/framework", "version": "v12.53.0", @@ -3332,6 +3432,136 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "nuwave/lighthouse", + "version": "v6.65.0", + "source": { + "type": "git", + "url": "https://github.com/nuwave/lighthouse.git", + "reference": "994038374ed1f94d33a0a4af272040fc07c6639b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nuwave/lighthouse/zipball/994038374ed1f94d33a0a4af272040fc07c6639b", + "reference": "994038374ed1f94d33a0a4af272040fc07c6639b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "haydenpierce/class-finder": "^0.4 || ^0.5", + "illuminate/auth": "^9 || ^10 || ^11 || ^12", + "illuminate/bus": "^9 || ^10 || ^11 || ^12", + "illuminate/contracts": "^9 || ^10 || ^11 || ^12", + "illuminate/http": "^9 || ^10 || ^11 || ^12", + "illuminate/pagination": "^9 || ^10 || ^11 || ^12", + "illuminate/queue": "^9 || ^10 || ^11 || ^12", + "illuminate/routing": "^9 || ^10 || ^11 || ^12", + "illuminate/support": "^9 || ^10 || ^11 || ^12", + "illuminate/validation": "^9 || ^10 || ^11 || ^12", + "laragraph/utils": "^1.5 || ^2", + "php": "^8", + "thecodingmachine/safe": "^1 || ^2 || ^3", + "webonyx/graphql-php": "^15" + }, + "require-dev": { + "algolia/algoliasearch-client-php": "^3", + "bensampo/laravel-enum": "^5 || ^6", + "ergebnis/composer-normalize": "^2.2.2", + "fakerphp/faker": "^1.21", + "google/protobuf": "^3.21", + "larastan/larastan": "^2.9.14 || ^3.0.4", + "laravel/framework": "^9 || ^10 || ^11 || ^12", + "laravel/legacy-factories": "^1.1.1", + "laravel/pennant": "^1", + "laravel/scout": "^8 || ^9 || ^10", + "mattiasgeniar/phpunit-query-count-assertions": "^1.1", + "mll-lab/graphql-php-scalars": "^6.4.1", + "mll-lab/php-cs-fixer-config": "^5", + "mockery/mockery": "^1.5", + "nesbot/carbon": "^2.62.1 || ^3.8.4", + "orchestra/testbench": "^7.50 || ^8.32 || ^9.10 || ^10.1", + "phpbench/phpbench": "^1.2.6", + "phpstan/extension-installer": "^1", + "phpstan/phpstan": "^1.12.18 || ^2", + "phpstan/phpstan-mockery": "^1.1.3 || ^2", + "phpstan/phpstan-phpunit": "^1.1.1 || ^2", + "phpunit/phpunit": "^9.6.4 || ^10 || ^11 || ^12", + "predis/predis": "^1.1 || ^2.1", + "pusher/pusher-php-server": "^5 || ^6 || ^7.0.2", + "rector/rector": "^1 || ^2", + "thecodingmachine/phpstan-safe-rule": "^1.2" + }, + "suggest": { + "ext-protobuf": "Improve protobuf serialization performance (used for tracing)", + "google/protobuf": "Required when using the tracing driver federated-tracing", + "laravel/pennant": "Required for the @feature directive", + "laravel/scout": "Required for the @search directive", + "mll-lab/graphql-php-scalars": "Useful scalar types, required for @whereConditions", + "mll-lab/laravel-graphiql": "A graphical interactive in-browser GraphQL IDE - integrated with Laravel", + "pusher/pusher-php-server": "Required when using the Pusher Subscriptions driver" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Nuwave\\Lighthouse\\LighthouseServiceProvider", + "Nuwave\\Lighthouse\\Async\\AsyncServiceProvider", + "Nuwave\\Lighthouse\\Auth\\AuthServiceProvider", + "Nuwave\\Lighthouse\\Bind\\BindServiceProvider", + "Nuwave\\Lighthouse\\Cache\\CacheServiceProvider", + "Nuwave\\Lighthouse\\GlobalId\\GlobalIdServiceProvider", + "Nuwave\\Lighthouse\\OrderBy\\OrderByServiceProvider", + "Nuwave\\Lighthouse\\Pagination\\PaginationServiceProvider", + "Nuwave\\Lighthouse\\SoftDeletes\\SoftDeletesServiceProvider", + "Nuwave\\Lighthouse\\Testing\\TestingServiceProvider", + "Nuwave\\Lighthouse\\Validation\\ValidationServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Nuwave\\Lighthouse\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Moore", + "email": "chris@nuwavecommerce.com", + "homepage": "https://www.nuwavecommerce.com" + }, + { + "name": "Benedikt Franke", + "email": "benedikt@franke.tech", + "homepage": "https://franke.tech" + } + ], + "description": "A framework for serving GraphQL from Laravel", + "homepage": "https://lighthouse-php.com", + "keywords": [ + "graphql", + "laravel", + "laravel-graphql" + ], + "support": { + "issues": "https://github.com/nuwave/lighthouse/issues", + "source": "https://github.com/nuwave/lighthouse" + }, + "funding": [ + { + "url": "https://github.com/spawnia", + "type": "github" + }, + { + "url": "https://www.patreon.com/lighthouse_php", + "type": "patreon" + } + ], + "time": "2026-02-14T15:42:43+00:00" + }, { "name": "pgvector/pgvector", "version": "v0.2.2", @@ -6874,6 +7104,149 @@ ], "time": "2026-02-15T10:53:20+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tightenco/ziggy", "version": "v2.6.1", @@ -7156,6 +7529,85 @@ } ], "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.31.0", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "ef79ce18c14dd0d88bfc3a6c02a00f2adcbdf1bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/ef79ce18c14dd0d88bfc3a6c02a00f2adcbdf1bd", + "reference": "ef79ce18c14dd0d88bfc3a6c02a00f2adcbdf1bd", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.94.2", + "mll-lab/php-cs-fixer-config": "5.13.0", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.40", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.10", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", + "thecodingmachine/safe": "^1.3 || ^2 || ^3", + "ticketswap/phpstan-error-formatter": "1.2.6" + }, + "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": [ + "api", + "graphql" + ], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.31.0" + }, + "funding": [ + { + "url": "https://github.com/spawnia", + "type": "github" + }, + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" + } + ], + "time": "2026-03-14T18:44:21+00:00" } ], "packages-dev": [ @@ -7605,6 +8057,72 @@ }, "time": "2026-02-06T12:16:02+00:00" }, + { + "name": "mll-lab/laravel-graphiql", + "version": "v4.0.2", + "source": { + "type": "git", + "url": "https://github.com/mll-lab/laravel-graphiql.git", + "reference": "50135ffb1874e41cd6bb59d6334e8639fe579c72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mll-lab/laravel-graphiql/zipball/50135ffb1874e41cd6bb59d6334e8639fe579c72", + "reference": "50135ffb1874e41cd6bb59d6334e8639fe579c72", + "shasum": "" + }, + "require": { + "illuminate/console": "^9 || ^10 || ^11 || ^12", + "illuminate/contracts": "^9 || ^10 || ^11 || ^12", + "illuminate/support": "^9 || ^10 || ^11 || ^12", + "php": "^8" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.45", + "larastan/larastan": "^2.9.14 || ^3.1", + "laravel/framework": "^9 || ^10 || ^11 || ^12", + "mll-lab/php-cs-fixer-config": "^5.10", + "orchestra/testbench": "^7.52 || ^8.33 || ^9.11 || ^10", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.20 || ^2.1.7", + "phpstan/phpstan-phpunit": "^1.4.2 || ^2.0.6", + "phpunit/phpunit": "^9.6.22 || ^10.5.45 || ^11.5.15 || ^12.0.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "MLL\\GraphiQL\\GraphiQLServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "MLL\\GraphiQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benedikt Franke", + "email": "benedikt@franke.tech" + } + ], + "description": "Easily integrate GraphiQL into your Laravel project", + "keywords": [ + "graphiql", + "graphql", + "laravel" + ], + "support": { + "issues": "https://github.com/mll-lab/laravel-graphiql/issues", + "source": "https://github.com/mll-lab/laravel-graphiql/tree/v4.0.2" + }, + "time": "2025-05-12T07:21:46+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", diff --git a/config/lighthouse.php b/config/lighthouse.php new file mode 100644 index 0000000..644429b --- /dev/null +++ b/config/lighthouse.php @@ -0,0 +1,562 @@ + false, to disable the default route + | registration and take full control. + | + */ + + 'route' => [ + /* + * The URI the endpoint responds to, e.g. mydomain.com/graphql. + */ + 'uri' => '/graphql', + + /* + * Lighthouse creates a named route for convenient URL generation and redirects. + */ + 'name' => 'graphql', + + /* + * Beware that middleware defined here runs before the GraphQL execution phase, + * make sure to return spec-compliant responses in case an error is thrown. + */ + 'middleware' => [ + // Authenticate via Sanctum when token is present + \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + // Ensures the request is not vulnerable to cross-site request forgery. + // Nuwave\Lighthouse\Http\Middleware\EnsureXHR::class, + + // Always set the `Accept: application/json` header. + Nuwave\Lighthouse\Http\Middleware\AcceptJson::class, + + // Logs in a user if they are authenticated. In contrast to Laravel's 'auth' + // middleware, this delegates auth and permission checks to the field level. + Nuwave\Lighthouse\Http\Middleware\AttemptAuthentication::class, + + // Logs every incoming GraphQL query. + // Nuwave\Lighthouse\Http\Middleware\LogGraphQLQueries::class, + ], + + /* + * The `prefix`, `domain` and `where` configuration options are optional. + */ + // 'prefix' => '', + // 'domain' => '', + // 'where' => [], + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | The guards to use for authenticating GraphQL requests, if needed. + | Used in directives such as `@guard` or the `AttemptAuthentication` middleware. + | Falls back to the Laravel default if `null`. + | + */ + + 'guards' => ['sanctum'], + + /* + |-------------------------------------------------------------------------- + | Schema Path + |-------------------------------------------------------------------------- + | + | Path to your .graphql schema file. + | Additional schema files may be imported from within that file. + | + */ + + 'schema_path' => base_path('graphql/schema.graphql'), + + /* + |-------------------------------------------------------------------------- + | Schema Cache + |-------------------------------------------------------------------------- + | + | A large part of schema generation consists of parsing and AST manipulation. + | This operation is very expensive, so it is highly recommended enabling + | caching of the final schema to optimize performance of large schemas. + | + */ + + 'schema_cache' => [ + /* + * Setting to true enables schema caching. + */ + 'enable' => env('LIGHTHOUSE_SCHEMA_CACHE_ENABLE', env('APP_ENV') !== 'local'), + + /* + * File path to store the lighthouse schema. + */ + 'path' => env('LIGHTHOUSE_SCHEMA_CACHE_PATH', base_path('bootstrap/cache/lighthouse-schema.php')), + ], + + /* + |-------------------------------------------------------------------------- + | Cache Directive Tags + |-------------------------------------------------------------------------- + | + | Should the `@cache` directive use a tagged cache? + | + */ + 'cache_directive_tags' => false, + + /* + |-------------------------------------------------------------------------- + | Query Cache + |-------------------------------------------------------------------------- + | + | Caches the result of parsing incoming query strings to boost performance on subsequent requests. + | + */ + + 'query_cache' => [ + /* + * Setting to true enables query caching. + */ + 'enable' => env('LIGHTHOUSE_QUERY_CACHE_ENABLE', true), + + /* + * Configures which mechanism to use for the query cache. + * - store: use an external shared cache through a Laravel cache store like Redis or Memcached + * - opcache: store parsed queries in PHP files on the local filesystem to leverage OPcache + * - hybrid: leverage OPcache, but use a shared cache store when local files are not found + */ + 'mode' => env('LIGHTHOUSE_QUERY_CACHE_MODE', 'store'), + + /* + * Specifies the path where the PHP files are stored when using opcache or hybrid mode. + * The given path must be a folder, as every query will produce its own file. + */ + 'opcache_path' => env('LIGHTHOUSE_QUERY_CACHE_OPCACHE_PATH', base_path('bootstrap/cache')), + + /* + * Allows using a specific cache store, uses the app's default if set to null. + * Not relevant when using opcache mode. + */ + 'store' => env('LIGHTHOUSE_QUERY_CACHE_STORE', env('APP_ENV') === 'production' ? 'redis' : 'file'), + + /* + * Duration in seconds the query should remain cached, null means forever. + * Not relevant when using opcache mode. + */ + 'ttl' => env('LIGHTHOUSE_QUERY_CACHE_TTL', 24 * 60 * 60), + ], + + /* + |-------------------------------------------------------------------------- + | Validation Cache + |-------------------------------------------------------------------------- + | + | Caches the result of validating queries to boost performance on subsequent requests. + | + */ + + 'validation_cache' => [ + /* + * Setting to true enables validation caching. + */ + 'enable' => env('LIGHTHOUSE_VALIDATION_CACHE_ENABLE', false), + + /* + * Allows using a specific cache store, uses the app's default if set to null. + */ + 'store' => env('LIGHTHOUSE_VALIDATION_CACHE_STORE', null), + + /* + * Duration in seconds the validation result should remain cached, null means forever. + */ + 'ttl' => env('LIGHTHOUSE_VALIDATION_CACHE_TTL', 24 * 60 * 60), + ], + + /* + |-------------------------------------------------------------------------- + | Parse source location + |-------------------------------------------------------------------------- + | + | Should the source location be included in the AST nodes resulting from query parsing? + | Setting this to `false` improves performance, but omits the key `locations` from errors, + | see https://spec.graphql.org/October2021/#sec-Errors.Error-result-format. + | + */ + + 'parse_source_location' => true, + + /* + |-------------------------------------------------------------------------- + | Namespaces + |-------------------------------------------------------------------------- + | + | These are the default namespaces where Lighthouse looks for classes to + | extend functionality of the schema. You may pass in either a string + | or an array, they are tried in order and the first match is used. + | + */ + + 'namespaces' => [ + 'models' => ['App', 'App\\Models'], + 'queries' => 'App\\GraphQL\\Queries', + 'mutations' => 'App\\GraphQL\\Mutations', + 'subscriptions' => 'App\\GraphQL\\Subscriptions', + 'types' => 'App\\GraphQL\\Types', + 'interfaces' => 'App\\GraphQL\\Interfaces', + 'unions' => 'App\\GraphQL\\Unions', + 'scalars' => 'App\\GraphQL\\Scalars', + 'directives' => ['App\\GraphQL\\Directives', 'App\\GraphQL\\Middleware'], + 'validators' => 'App\\GraphQL\\Validators', + ], + + /* + |-------------------------------------------------------------------------- + | Security + |-------------------------------------------------------------------------- + | + | Control how Lighthouse handles security related query validation. + | Read more at https://webonyx.github.io/graphql-php/security/ + | + */ + + 'security' => [ + 'max_query_complexity' => env('LIGHTHOUSE_MAX_COMPLEXITY', 500), + 'max_query_depth' => env('LIGHTHOUSE_MAX_DEPTH', 10), + 'disable_introspection' => (bool) env('LIGHTHOUSE_SECURITY_DISABLE_INTROSPECTION', false) + ? GraphQL\Validator\Rules\DisableIntrospection::ENABLED + : GraphQL\Validator\Rules\DisableIntrospection::DISABLED, + ], + + /* + |-------------------------------------------------------------------------- + | Pagination + |-------------------------------------------------------------------------- + | + | Set defaults for the pagination features within Lighthouse, such as + | the @paginate directive, or paginated relation directives. + | + */ + + 'pagination' => [ + /* + * Allow clients to query paginated lists without specifying the amount of items. + * Setting this to `null` means clients have to explicitly ask for the count. + */ + 'default_count' => 20, + + /* + * Limit the maximum amount of items that clients can request from paginated lists. + * Setting this to `null` means the count is unrestricted. + */ + 'max_count' => 100, + ], + + /* + |-------------------------------------------------------------------------- + | Debug + |-------------------------------------------------------------------------- + | + | Control the debug level as described in https://webonyx.github.io/graphql-php/error-handling/ + | Debugging is only applied if the global Laravel debug config is set to true. + | + | When you set this value through an environment variable, use the following reference table: + | 0 => INCLUDE_NONE + | 1 => INCLUDE_DEBUG_MESSAGE + | 2 => INCLUDE_TRACE + | 3 => INCLUDE_TRACE | INCLUDE_DEBUG_MESSAGE + | 4 => RETHROW_INTERNAL_EXCEPTIONS + | 5 => RETHROW_INTERNAL_EXCEPTIONS | INCLUDE_DEBUG_MESSAGE + | 6 => RETHROW_INTERNAL_EXCEPTIONS | INCLUDE_TRACE + | 7 => RETHROW_INTERNAL_EXCEPTIONS | INCLUDE_TRACE | INCLUDE_DEBUG_MESSAGE + | 8 => RETHROW_UNSAFE_EXCEPTIONS + | 9 => RETHROW_UNSAFE_EXCEPTIONS | INCLUDE_DEBUG_MESSAGE + | 10 => RETHROW_UNSAFE_EXCEPTIONS | INCLUDE_TRACE + | 11 => RETHROW_UNSAFE_EXCEPTIONS | INCLUDE_TRACE | INCLUDE_DEBUG_MESSAGE + | 12 => RETHROW_UNSAFE_EXCEPTIONS | RETHROW_INTERNAL_EXCEPTIONS + | 13 => RETHROW_UNSAFE_EXCEPTIONS | RETHROW_INTERNAL_EXCEPTIONS | INCLUDE_DEBUG_MESSAGE + | 14 => RETHROW_UNSAFE_EXCEPTIONS | RETHROW_INTERNAL_EXCEPTIONS | INCLUDE_TRACE + | 15 => RETHROW_UNSAFE_EXCEPTIONS | RETHROW_INTERNAL_EXCEPTIONS | INCLUDE_TRACE | INCLUDE_DEBUG_MESSAGE + | + */ + + 'debug' => env('LIGHTHOUSE_DEBUG', GraphQL\Error\DebugFlag::INCLUDE_DEBUG_MESSAGE | GraphQL\Error\DebugFlag::INCLUDE_TRACE), + + /* + |-------------------------------------------------------------------------- + | Error Handlers + |-------------------------------------------------------------------------- + | + | Register error handlers that receive the Errors that occur during execution + | and handle them. You may use this to log, filter or format the errors. + | The classes must implement \Nuwave\Lighthouse\Execution\ErrorHandler + | + */ + + 'error_handlers' => [ + Nuwave\Lighthouse\Execution\AuthenticationErrorHandler::class, + Nuwave\Lighthouse\Execution\AuthorizationErrorHandler::class, + Nuwave\Lighthouse\Execution\ValidationErrorHandler::class, + Nuwave\Lighthouse\Execution\ReportingErrorHandler::class, + ], + + /* + |-------------------------------------------------------------------------- + | Field Middleware + |-------------------------------------------------------------------------- + | + | Register global field middleware directives that wrap around every field. + | Execution happens in the defined order, before other field middleware. + | The classes must implement \Nuwave\Lighthouse\Support\Contracts\FieldMiddleware + | + */ + + 'field_middleware' => [ + Nuwave\Lighthouse\Schema\Directives\TrimDirective::class, + Nuwave\Lighthouse\Schema\Directives\ConvertEmptyStringsToNullDirective::class, + Nuwave\Lighthouse\Schema\Directives\SanitizeDirective::class, + Nuwave\Lighthouse\Validation\ValidateDirective::class, + Nuwave\Lighthouse\Schema\Directives\TransformArgsDirective::class, + Nuwave\Lighthouse\Schema\Directives\SpreadDirective::class, + Nuwave\Lighthouse\Schema\Directives\RenameArgsDirective::class, + Nuwave\Lighthouse\Schema\Directives\DropArgsDirective::class, + // Tracks query cost, execution time, and user for analytics + App\GraphQL\Middleware\CostTrackingMiddleware::class, + ], + + /* + |-------------------------------------------------------------------------- + | Global ID + |-------------------------------------------------------------------------- + | + | The name that is used for the global id field on the Node interface. + | When creating a Relay compliant server, this must be named "id". + | + */ + + 'global_id_field' => 'id', + + /* + |-------------------------------------------------------------------------- + | Persisted Queries + |-------------------------------------------------------------------------- + | + | Lighthouse supports Automatic Persisted Queries (APQ), compatible with the + | [Apollo implementation](https://www.apollographql.com/docs/apollo-server/performance/apq). + | You may set this flag to either process or deny these queries. + | + | APQ allows clients to send a query hash instead of the full query string + | on subsequent requests, saving bandwidth. The first request registers the + | query; thereafter only the hash is needed. + | + | Cache store selection: + | - local/dev: 'file' (default) — persisted to filesystem + | - production: 'redis' — shared across all servers + | + | Configure via LIGHTHOUSE_QUERY_CACHE_STORE env variable. + | + */ + + 'persisted_queries' => env('LIGHTHOUSE_PERSISTED_QUERIES', true), + + /* + |-------------------------------------------------------------------------- + | Transactional Mutations + |-------------------------------------------------------------------------- + | + | If set to true, the execution of built-in directives that mutate models + | will be wrapped in a transaction to ensure atomicity. + | The transaction is committed after the root field resolves, + | thus errors in nested fields do not cause a rollback. + | + */ + + 'transactional_mutations' => true, + + /* + |-------------------------------------------------------------------------- + | Mass Assignment Protection + |-------------------------------------------------------------------------- + | + | If set to true, mutations will use forceFill() over fill() when populating + | a model with arguments in mutation directives. Since GraphQL constrains + | allowed inputs by design, mass assignment protection is not needed. + | + */ + + 'force_fill' => true, + + /* + |-------------------------------------------------------------------------- + | Batchload Relations + |-------------------------------------------------------------------------- + | + | If set to true, relations marked with directives like @hasMany or @belongsTo + | will be optimized by combining the queries through the BatchLoader. + | + */ + + 'batchload_relations' => true, + + /* + |-------------------------------------------------------------------------- + | Shortcut Foreign Key Selection + |-------------------------------------------------------------------------- + | + | If set to true, Lighthouse will shortcut queries where the client selects only the + | foreign key pointing to a related model. Only works if the related model's primary + | key field is called exactly `id` for every type in your schema. + | + */ + + 'shortcut_foreign_key_selection' => false, + + /* + |-------------------------------------------------------------------------- + | GraphQL Subscriptions + |-------------------------------------------------------------------------- + | + | Here you can define GraphQL subscription broadcaster and storage drivers + | as well their required configuration options. + | + */ + + 'subscriptions' => [ + /* + * Determines if broadcasts should be queued by default. + */ + 'queue_broadcasts' => env('LIGHTHOUSE_QUEUE_BROADCASTS', true), + + /* + * Determines the queue to use for broadcasting queue jobs. + */ + 'broadcasts_queue_name' => env('LIGHTHOUSE_BROADCASTS_QUEUE_NAME', null), + + /* + * Default subscription storage. + * + * Any Laravel supported cache driver options are available here. + */ + 'storage' => env('LIGHTHOUSE_SUBSCRIPTION_STORAGE', 'array'), + + /* + * Default subscription storage time to live in seconds. + * + * Indicates how long a subscription can be active before it's automatically removed from storage. + * Setting this to `null` means the subscriptions are stored forever. This may cause + * stale subscriptions to linger indefinitely in case cleanup fails for any reason. + */ + 'storage_ttl' => env('LIGHTHOUSE_SUBSCRIPTION_STORAGE_TTL', null), + + /* + * Encrypt subscription channels by prefixing their names with "private-encrypted-"? + */ + 'encrypted_channels' => env('LIGHTHOUSE_SUBSCRIPTION_ENCRYPTED', false), + + /* + * Default subscription broadcaster. + */ + 'broadcaster' => env('LIGHTHOUSE_BROADCASTER', 'log'), + + /* + * Subscription broadcasting drivers with config options. + */ + 'broadcasters' => [ + 'log' => [ + 'driver' => 'log', + ], + 'echo' => [ + 'driver' => 'echo', + 'connection' => env('LIGHTHOUSE_SUBSCRIPTION_REDIS_CONNECTION', 'default'), + 'routes' => Nuwave\Lighthouse\Subscriptions\SubscriptionRouter::class.'@echoRoutes', + ], + 'pusher' => [ + 'driver' => 'pusher', + 'connection' => 'pusher', + 'routes' => Nuwave\Lighthouse\Subscriptions\SubscriptionRouter::class.'@pusher', + ], + 'reverb' => [ + 'driver' => 'pusher', + 'connection' => 'reverb', + 'routes' => Nuwave\Lighthouse\Subscriptions\SubscriptionRouter::class.'@reverb', + ], + ], + + /* + * Should the subscriptions extension be excluded when the response has no subscription channel? + * This optimizes performance by sending less data, but clients must anticipate this appropriately. + */ + 'exclude_empty' => env('LIGHTHOUSE_SUBSCRIPTION_EXCLUDE_EMPTY', true), + ], + + /* + |-------------------------------------------------------------------------- + | Defer + |-------------------------------------------------------------------------- + | + | Configuration for the experimental @defer directive support. + | + */ + + 'defer' => [ + /* + * Maximum number of nested fields that can be deferred in one query. + * Once reached, remaining fields will be resolved synchronously. + * 0 means unlimited. + */ + 'max_nested_fields' => 0, + + /* + * Maximum execution time for deferred queries in milliseconds. + * Once reached, remaining fields will be resolved synchronously. + * 0 means unlimited. + */ + 'max_execution_ms' => 0, + ], + + /* + |-------------------------------------------------------------------------- + | Apollo Federation + |-------------------------------------------------------------------------- + | + | Lighthouse can act as a federated service: https://www.apollographql.com/docs/federation/federation-spec. + | + */ + + 'federation' => [ + /* + * Location of resolver classes when resolving the `_entities` field. + */ + 'entities_resolver_namespace' => 'App\\GraphQL\\Entities', + ], + + /* + |-------------------------------------------------------------------------- + | Tracing + |-------------------------------------------------------------------------- + | + | Configuration for tracing support. + | + */ + + 'tracing' => [ + /* + * Driver used for tracing. + * + * Accepts the fully qualified class name of a class that implements Nuwave\Lighthouse\Tracing\Tracing. + * Lighthouse provides: + * - Nuwave\Lighthouse\Tracing\ApolloTracing\ApolloTracing::class + * - Nuwave\Lighthouse\Tracing\FederatedTracing\FederatedTracing::class + * + * In Lighthouse v7 the default will be changed to 'Nuwave\Lighthouse\Tracing\FederatedTracing\FederatedTracing::class'. + */ + 'driver' => Nuwave\Lighthouse\Tracing\ApolloTracing\ApolloTracing::class, + ], +]; diff --git a/docs/graphql-api.md b/docs/graphql-api.md new file mode 100644 index 0000000..403abe7 --- /dev/null +++ b/docs/graphql-api.md @@ -0,0 +1,343 @@ +# GraphQL API — Numen + +> **Endpoint:** `POST /graphql` +> **Explorer (dev):** `GET /graphiql` + +Numen ships a full GraphQL API powered by [Lighthouse PHP](https://lighthouse-php.com/). It covers all content, space, media, pipeline, and subscription operations. + +## Quick Start + +### 1. Get an API Token + +```bash +POST /v1/auth/login +{ "email": "...", "password": "..." } +# Response: { "token": "1|abc..." } +``` + +### 2. Set the Authorization Header + +``` +Authorization: Bearer +Content-Type: application/json +``` + +### 3. Send Your First Query + +```bash +curl -X POST https://your-numen.app/graphql \ + -H "Authorization: Bearer 1|abc..." \ + -H "Content-Type: application/json" \ + -d '{"query": "{ me { id name email } }"}' +``` + +## Authentication + +Numen uses **Laravel Sanctum** tokens. Pass your token in the `Authorization` header on every request: + +``` +Authorization: Bearer 1|your-token-here +``` + +Tokens created via the REST `/v1/auth/login` endpoint work here too. + +## Example Queries + +### Current User + +```graphql +query Me { + me { + id + name + email + roles { + name + spaceId + } + } +} +``` + +### Content by Slug + +```graphql +query ContentBySlug($slug: String!, $spaceId: ID!) { + contentBySlug(slug: $slug, spaceId: $spaceId) { + id + title + slug + body + status + seoTitle + seoDescription + publishedAt + space { + id + name + } + tags { + name + } + mediaAssets { + id + url + altText + } + } +} +``` + +### Content List with Cursor Pagination + +```graphql +query Contents($spaceId: ID!, $after: String, $first: Int) { + contents( + spaceId: $spaceId + first: $first + after: $after + orderBy: [{ column: PUBLISHED_AT, order: DESC }] + ) { + edges { + cursor + node { + id + title + slug + status + publishedAt + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +### Spaces + +```graphql +query Spaces { + spaces { + id + name + slug + description + contentsCount + } +} +``` + +## Example Mutations + +### Create a Brief (triggers pipeline) + +```graphql +mutation CreateBrief($input: CreateBriefInput!) { + createBrief(input: $input) { + id + topic + status + content { + id + title + status + } + } +} +``` + +Variables: +```json +{ + "input": { + "spaceId": "01HX...", + "topic": "How to use GraphQL in Laravel", + "targetAudience": "PHP developers", + "tone": "professional", + "wordCount": 1200, + "keywords": ["graphql", "laravel"], + "autoPublish": false + } +} +``` + +### Create Content Directly + +```graphql +mutation CreateContent($input: CreateContentInput!) { + createContent(input: $input) { + id + title + slug + status + createdAt + } +} +``` + +### Publish Content + +```graphql +mutation PublishContent($id: ID!) { + publishContent(id: $id) { + id + status + publishedAt + } +} +``` + +### Trigger Pipeline Run + +```graphql +mutation TriggerPipeline($contentId: ID!, $pipelineId: ID) { + triggerPipeline(contentId: $contentId, pipelineId: $pipelineId) { + id + status + startedAt + stages { + name + status + } + } +} +``` + +## Subscriptions + +Numen supports real-time updates via GraphQL subscriptions (WebSocket). + +### Content Updated + +```graphql +subscription OnContentUpdated($spaceId: ID!) { + contentUpdated(spaceId: $spaceId) { + id + title + status + updatedAt + } +} +``` + +### Pipeline Stage Completed + +```graphql +subscription OnPipelineProgress($contentId: ID!) { + pipelineStageCompleted(contentId: $contentId) { + pipelineRunId + stageName + status + output + completedAt + } +} +``` + +### JavaScript Setup + +```javascript +import { createClient } from 'graphql-ws'; + +const client = createClient({ + url: 'wss://your-numen.app/graphql', + connectionParams: { + Authorization: `Bearer ${token}`, + }, +}); + +client.subscribe( + { query: `subscription { contentPublished(spaceId: "01HX...") { id title slug publishedAt } }` }, + { next: (data) => console.log('Published:', data), error: console.error } +); +``` + +## Cursor Pagination + +All list fields use cursor-based pagination (Relay-spec): + +```graphql +query { + contents(spaceId: "01HX...", first: 20, after: "eyJpZCI6MTAwfQ") { + edges { + cursor + node { id title } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +Pass `endCursor` as `after` in the next request to get the next page. + +## Complexity and Depth Limits + +| Limit | Default | Env var | +|-------|---------|---------| +| Max complexity score | 500 | `GRAPHQL_MAX_COMPLEXITY` | +| Max query depth | 10 | `GRAPHQL_MAX_DEPTH` | + +Queries exceeding these limits receive a `422` response. + +## Caching + +Frequently-read queries (published content by slug) are cached via the `@cache` directive. Default TTL is 300 seconds. Cache is automatically invalidated on content update/publish. + +## Persisted Queries (APQ) + +Numen supports Automatic Persisted Queries to reduce bandwidth. Compatible with Apollo Client out of the box. + +## GraphiQL Explorer + +In local development, an interactive GraphQL IDE is available at: + +``` +http://localhost:8000/graphiql +``` + +Set your auth token via the **Headers** tab: +```json +{ "Authorization": "Bearer 1|your-token" } +``` + +## Error Handling + +```json +{ + "errors": [{ + "message": "Unauthenticated.", + "extensions": { "category": "authentication" } + }] +} +``` + +| Category | HTTP Status | Meaning | +|----------|-------------|---------| +| `authentication` | 401 | Missing or invalid token | +| `authorization` | 403 | Insufficient permissions | +| `validation` | 422 | Invalid input | +| `not_found` | 404 | Resource not found | + +## Apollo Client Integration + +```javascript +import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; +import { setContext } from '@apollo/client/link/context'; + +const authLink = setContext((_, { headers }) => ({ + headers: { ...headers, authorization: `Bearer ${localStorage.getItem('numen_token')}` }, +})); + +const client = new ApolloClient({ + link: authLink.concat(createHttpLink({ uri: 'https://your-numen.app/graphql' })), + cache: new InMemoryCache(), +}); +``` + +*Last updated: 2026-03-15 — GraphQL API Layer v0.9.0* diff --git a/graphql/schema.graphql b/graphql/schema.graphql new file mode 100644 index 0000000..c78e5b2 --- /dev/null +++ b/graphql/schema.graphql @@ -0,0 +1,474 @@ +"A datetime string with format `Y-m-d H:i:s`, e.g. `2018-05-23 13:43:32`." +scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime") + +"Arbitrary data encoded in JavaScript Object Notation. See https://www.json.org/." +scalar JSON @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\JSON") + +type Query { + contentBySlug(slug: String!, locale: String! = "en", spaceSlug: String!): Content + @field(resolver: "App\\GraphQL\\Queries\\ContentBySlugQuery") + @cache(maxAge: 30) + + contents( + spaceSlug: String! + locale: String + contentType: String + status: String + first: Int = 20 + page: Int + ): ContentPaginator + @field(resolver: "App\\GraphQL\\Queries\\ContentsQuery") + @complexity(resolver: "App\\GraphQL\\Complexity\\PaginatedComplexity") + @cache(maxAge: 60) + + space(id: ID!): Space @guard @find(model: "App\\Models\\Space") + spaces: [Space!]! @guard @all(model: "App\\Models\\Space") + content(id: ID!): Content @guard @find(model: "App\\Models\\Content") @cache(maxAge: 30) + contentTypes(spaceId: ID!): [ContentType!]! + @guard + @field(resolver: "App\\GraphQL\\Queries\\ContentTypesQuery") + @cache(maxAge: 300) + contentVersion(id: ID!): ContentVersion + @guard + @find(model: "App\\Models\\ContentVersion") +} + +type Space { + id: ID! + name: String! + slug: String! + settings: JSON + api_config: JSON + created_at: DateTime! + updated_at: DateTime! + contentTypes: [ContentType!]! @hasMany @with(relation: "contentTypes") @cache(maxAge: 300) + contents(status: String, locale: String, first: Int = 20, page: Int): ContentPaginator + @field(resolver: "App\\GraphQL\\Queries\\SpaceContentsQuery") + @complexity(resolver: "App\\GraphQL\\Complexity\\PaginatedComplexity") +} + +type ContentType { + id: ID! + space_id: ID! + name: String! + slug: String! + schema: JSON! + generation_config: JSON + seo_config: JSON + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") + contents: [Content!]! @hasMany +} + +type Content { + id: ID! + space_id: ID! + content_type_id: ID! + slug: String! + status: String! + locale: String! + canonical_id: ID + taxonomy: JSON + metadata: JSON + hero_image_id: ID + published_at: DateTime + expires_at: DateTime + refresh_at: DateTime + scheduled_publish_at: DateTime + created_at: DateTime! + updated_at: DateTime! + deleted_at: DateTime + space: Space! @belongsTo @with(relation: "space") + contentType: ContentType! @belongsTo(relation: "contentType") @with(relation: "contentType") + currentVersion: ContentVersion @belongsTo(relation: "currentVersion") @with(relation: "currentVersion") + draftVersion: ContentVersion @belongsTo(relation: "draftVersion") @with(relation: "draftVersion") + heroImage: MediaAsset @belongsTo(relation: "heroImage") @with(relation: "heroImage") + taxonomyTerms: [TaxonomyTerm!]! @hasMany @with(relation: "taxonomyTerms") + versions: [ContentVersion!]! @hasMany +} + +type ContentVersion { + id: ID! + content_id: ID! + version_number: Int! + label: String + status: String! + parent_version_id: ID + title: String! + excerpt: String + body: String + body_format: String! + structured_fields: JSON + seo_data: JSON + author_type: String! + author_id: String! + change_reason: String + ai_metadata: JSON + quality_score: String + seo_score: String + scheduled_at: DateTime + content_hash: String + locked_by: String + locked_at: DateTime + created_at: DateTime! + updated_at: DateTime! + content: Content! @belongsTo @with(relation: "content") + blocks: [ContentBlock!]! @hasMany @with(relation: "blocks") +} + +type ContentPaginator { + data: [Content!]! + paginatorInfo: PaginatorInfo! +} + +type PaginatorInfo { + count: Int! + currentPage: Int! + firstItem: Int + hasMorePages: Boolean! + lastItem: Int + lastPage: Int! + perPage: Int! + total: Int! +} + +enum ContentStatus { DRAFT PUBLISHED ARCHIVED SCHEDULED } +enum BriefStatus { PENDING IN_PROGRESS COMPLETED CANCELLED } +enum PipelineRunStatus { QUEUED RUNNING PAUSED_FOR_REVIEW COMPLETED FAILED CANCELLED } +enum MediaSource { UPLOAD AI_GENERATED EXTERNAL IMPORTED } + +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} + +type PipelineRunEdge { node: PipelineRun! cursor: String! } +type PipelineRunConnection { edges: [PipelineRunEdge!]! pageInfo: PageInfo! totalCount: Int! } +type ContentBriefEdge { node: ContentBrief! cursor: String! } +type ContentBriefConnection { edges: [ContentBriefEdge!]! pageInfo: PageInfo! totalCount: Int! } +type MediaAssetEdge { node: MediaAsset! cursor: String! } +type MediaAssetConnection { edges: [MediaAssetEdge!]! pageInfo: PageInfo! totalCount: Int! } +type PageEdge { node: Page! cursor: String! } +type PageConnection { edges: [PageEdge!]! pageInfo: PageInfo! totalCount: Int! } + +"A Persona defines an AI character with a role and capabilities. system_prompt is intentionally excluded." +type Persona { + id: ID! + space_id: ID! + name: String! + role: String! + capabilities: JSON + is_active: Boolean! + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") +} + +"A ContentPipeline defines an automated workflow for content creation." +type ContentPipeline { + id: ID! + space_id: ID! + name: String! + stages: JSON! + is_active: Boolean! + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") + runs(first: Int = 20, after: String): PipelineRunConnection! + @field(resolver: "App\\GraphQL\\Queries\\PipelineRunsQuery") + @complexity(resolver: "App\\GraphQL\\Complexity\\PaginatedComplexity") +} + +"A PipelineRun is a single execution of a ContentPipeline." +type PipelineRun { + id: ID! + pipeline_id: ID! + content_id: ID + status: PipelineRunStatus! + current_stage: String + stage_results: JSON + started_at: DateTime + completed_at: DateTime + created_at: DateTime! + updated_at: DateTime! + pipeline: ContentPipeline! @belongsTo(relation: "pipeline") @with(relation: "pipeline") + content: Content @belongsTo @with(relation: "content") +} + +"A ContentBrief is a specification for content to be created." +type ContentBrief { + id: ID! + space_id: ID! + title: String! + description: String + content_type_slug: String! + target_locale: String! + target_keywords: JSON + priority: String! + status: BriefStatus! + due_at: DateTime + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") +} + +"A MediaAsset represents an uploaded or AI-generated media file." +type MediaAsset { + id: ID! + space_id: ID! + filename: String! + mime_type: String! + size_bytes: Int! + source: MediaSource! + alt_text: String + caption: String + tags: JSON + width: Int + height: Int + url: String + is_public: Boolean! + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") +} + +"A Vocabulary is a named set of taxonomy terms." +type Vocabulary { + id: ID! + space_id: ID! + name: String! + slug: String! + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") + terms: [TaxonomyTerm!]! @hasMany @with(relation: "terms") @cache(maxAge: 300) +} + +"A TaxonomyTerm is a single entry within a Vocabulary." +type TaxonomyTerm { + id: ID! + vocabulary_id: ID! + parent_id: ID + name: String! + slug: String! + description: String + depth: Int! + created_at: DateTime! + updated_at: DateTime! + vocabulary: Vocabulary! @belongsTo @with(relation: "vocabulary") + children: [TaxonomyTerm!]! @hasMany +} + +"A Webhook delivers event notifications to an external URL." +type Webhook { + id: ID! + space_id: ID! + url: String! + events: JSON! + is_active: Boolean! + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") +} + +"A Page represents a structured content page built from components." +type Page { + id: ID! + space_id: ID! + slug: String! + title: String! + status: ContentStatus! + template: String + created_at: DateTime! + updated_at: DateTime! + space: Space! @belongsTo @with(relation: "space") + components: [PageComponent!]! @hasMany @with(relation: "components") +} + +"A PageComponent is a content block within a Page." +type PageComponent { + id: ID! + page_id: ID! + type: String! + sort_order: Int! + data: JSON + created_at: DateTime! + updated_at: DateTime! + page: Page! @belongsTo @with(relation: "page") +} + +"A ContentBlock is a typed block attached to a ContentVersion." +type ContentBlock { + id: ID! + version_id: ID! + type: String! + slot: String + sort_order: Int! + data: JSON + created_at: DateTime! + updated_at: DateTime! + version: ContentVersion! @belongsTo(relation: "version") @with(relation: "version") +} + +extend type Query { + personas(spaceId: ID!): [Persona!]! + @guard + @field(resolver: "App\\GraphQL\\Queries\\PersonasQuery") + + pipelines(spaceId: ID!): [ContentPipeline!]! + @guard + @field(resolver: "App\\GraphQL\\Queries\\PipelinesQuery") + + pipelineRun(id: ID!): PipelineRun + @guard + @find(model: "App\\Models\\PipelineRun") + + mediaAssets(spaceId: ID!, first: Int = 20, after: String): MediaAssetConnection! + @guard + @field(resolver: "App\\GraphQL\\Queries\\MediaAssetsQuery") + @complexity(resolver: "App\\GraphQL\\Complexity\\PaginatedComplexity") + + vocabularies(spaceId: ID!): [Vocabulary!]! + @guard + @field(resolver: "App\\GraphQL\\Queries\\VocabulariesQuery") + @cache(maxAge: 300) + + briefs(spaceId: ID!, status: BriefStatus, first: Int = 20, after: String): ContentBriefConnection! + @guard + @field(resolver: "App\\GraphQL\\Queries\\BriefsQuery") + @complexity(resolver: "App\\GraphQL\\Complexity\\PaginatedComplexity") + + webhooks(spaceId: ID!): [Webhook!]! + @guard + @field(resolver: "App\\GraphQL\\Queries\\WebhooksQuery") + + pages(spaceId: ID!, first: Int = 20, after: String): PageConnection! + @field(resolver: "App\\GraphQL\\Queries\\PagesQuery") + @complexity(resolver: "App\\GraphQL\\Complexity\\PaginatedComplexity") + @cache(maxAge: 60) +} + +input CreateContentInput { + space_id: ID! + content_type_id: ID! + title: String! + slug: String! + body: String + locale: String = "en" + status: String = "draft" + taxonomy_term_ids: [ID!] + hero_image_id: ID +} + +input UpdateContentInput { + title: String + slug: String + body: String + status: String + taxonomy_term_ids: [ID!] + hero_image_id: ID +} + +input CreateBriefInput { + space_id: ID! + title: String! + description: String + content_type_slug: String! + target_locale: String = "en" + target_keywords: [String!] + priority: String = "normal" + persona_id: ID + pipeline_id: ID +} + +input UpdateBriefInput { + title: String + description: String + priority: String + status: String +} + +input UpdateMediaAssetInput { + alt_text: String + caption: String + tags: [String!] +} + +type Mutation { + createContent(input: CreateContentInput!): Content + @guard + @can(ability: "create", model: "App\\Models\\Content") + @field(resolver: "App\\GraphQL\\Mutations\\CreateContent") + + updateContent(id: ID!, input: UpdateContentInput!): Content + @guard + @can(ability: "update", find: "id", model: "App\\Models\\Content") + @field(resolver: "App\\GraphQL\\Mutations\\UpdateContent") + + publishContent(id: ID!): Content + @guard + @can(ability: "publish", find: "id", model: "App\\Models\\Content") + @field(resolver: "App\\GraphQL\\Mutations\\PublishContent") + + unpublishContent(id: ID!): Content + @guard + @can(ability: "unpublish", find: "id", model: "App\\Models\\Content") + @field(resolver: "App\\GraphQL\\Mutations\\UnpublishContent") + + deleteContent(id: ID!): Content + @guard + @can(ability: "delete", find: "id", model: "App\\Models\\Content") + @field(resolver: "App\\GraphQL\\Mutations\\DeleteContent") + + createBrief(input: CreateBriefInput!): ContentBrief + @guard + @can(ability: "create", model: "App\\Models\\ContentBrief") + @field(resolver: "App\\GraphQL\\Mutations\\CreateBrief") + + updateBrief(id: ID!, input: UpdateBriefInput!): ContentBrief + @guard + @can(ability: "update", find: "id", model: "App\\Models\\ContentBrief") + @field(resolver: "App\\GraphQL\\Mutations\\UpdateBrief") + + triggerPipeline(pipelineId: ID!, contentId: ID): PipelineRun + @guard + @can(ability: "trigger", model: "App\\Models\\ContentPipeline") + @field(resolver: "App\\GraphQL\\Mutations\\TriggerPipeline") + + approvePipelineRun(id: ID!): PipelineRun + @guard + @can(ability: "approve", find: "id", model: "App\\Models\\PipelineRun") + @field(resolver: "App\\GraphQL\\Mutations\\ApprovePipelineRun") + + rejectPipelineRun(id: ID!, reason: String): PipelineRun + @guard + @can(ability: "reject", find: "id", model: "App\\Models\\PipelineRun") + @field(resolver: "App\\GraphQL\\Mutations\\RejectPipelineRun") + + updateMediaAsset(id: ID!, input: UpdateMediaAssetInput!): MediaAsset + @guard + @can(ability: "update", find: "id", model: "App\\Models\\MediaAsset") + @field(resolver: "App\\GraphQL\\Mutations\\UpdateMediaAsset") + + deleteMediaAsset(id: ID!): MediaAsset + @guard + @can(ability: "delete", find: "id", model: "App\\Models\\MediaAsset") + @field(resolver: "App\\GraphQL\\Mutations\\DeleteMediaAsset") +} + +type Subscription { + contentPublished(spaceId: ID!): Content + @subscription(class: "App\\GraphQL\\Subscriptions\\ContentPublished") + + contentUpdated(contentId: ID!): Content + @subscription(class: "App\\GraphQL\\Subscriptions\\ContentUpdated") + + pipelineRunUpdated(runId: ID!): PipelineRun + @subscription(class: "App\\GraphQL\\Subscriptions\\PipelineRunUpdated") + + pipelineRunCompleted(spaceId: ID!): PipelineRun + @subscription(class: "App\\GraphQL\\Subscriptions\\PipelineRunCompleted") +} diff --git a/package-lock.json b/package-lock.json index 1c4baab..883f66b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,10 @@ { - "name": "ai-cms", + "name": "numen", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "numen", "dependencies": { "marked": "^17.0.4" }, diff --git a/tests/Feature/GraphQLMutationTest.php b/tests/Feature/GraphQLMutationTest.php new file mode 100644 index 0000000..f6bf1c7 --- /dev/null +++ b/tests/Feature/GraphQLMutationTest.php @@ -0,0 +1,208 @@ +adminUser = User::factory()->admin()->create(); + $this->space = Space::factory()->create(); + $this->contentType = ContentType::factory()->create(['space_id' => $this->space->id]); + } + + public function test_can_create_content(): void + { + $this->actingAs($this->adminUser); + + $response = $this->graphQL(/** @lang GraphQL */ ' + mutation CreateContent($input: CreateContentInput!) { + createContent(input: $input) { + id + slug + status + } + } + ', [ + 'input' => [ + 'space_id' => $this->space->id, + 'content_type_id' => $this->contentType->id, + 'title' => 'My New Post', + 'slug' => 'my-new-post', + 'body' => '

Hello world

', + 'locale' => 'en', + 'status' => 'draft', + ], + ]); + + $response->assertJsonPath('data.createContent.slug', 'my-new-post'); + $response->assertJsonPath('data.createContent.status', 'draft'); + + $this->assertDatabaseHas('contents', [ + 'slug' => 'my-new-post', + 'status' => 'draft', + ]); + } + + public function test_can_publish_content(): void + { + $this->actingAs($this->adminUser); + + $content = Content::factory()->create([ + 'space_id' => $this->space->id, + 'content_type_id' => $this->contentType->id, + 'status' => 'draft', + ]); + + $response = $this->graphQL(/** @lang GraphQL */ ' + mutation PublishContent($id: ID!) { + publishContent(id: $id) { + id + status + } + } + ', [ + 'id' => $content->id, + ]); + + $response->assertJsonPath('data.publishContent.id', $content->id); + $response->assertJsonPath('data.publishContent.status', 'published'); + + $this->assertDatabaseHas('contents', [ + 'id' => $content->id, + 'status' => 'published', + ]); + } + + public function test_can_delete_content(): void + { + $this->actingAs($this->adminUser); + + $content = Content::factory()->create([ + 'space_id' => $this->space->id, + 'content_type_id' => $this->contentType->id, + ]); + + $response = $this->graphQL(/** @lang GraphQL */ ' + mutation DeleteContent($id: ID!) { + deleteContent(id: $id) { + id + } + } + ', [ + 'id' => $content->id, + ]); + + $response->assertJsonPath('data.deleteContent.id', $content->id); + + $this->assertSoftDeleted('contents', ['id' => $content->id]); + } + + public function test_can_trigger_pipeline(): void + { + // Use Queue::fake to prevent actual job dispatch (avoids needing real AI personas) + \Illuminate\Support\Facades\Queue::fake(); + + $this->actingAs($this->adminUser); + + // pipeline with only human_gate stages — pauses immediately without dispatching AI jobs + $pipeline = ContentPipeline::factory()->create([ + 'space_id' => $this->space->id, + 'stages' => [['name' => 'review', 'type' => 'human_gate']], + ]); + + $content = Content::factory()->create([ + 'space_id' => $this->space->id, + 'content_type_id' => $this->contentType->id, + ]); + + $response = $this->graphQL(/** @lang GraphQL */ ' + mutation TriggerPipeline($pipelineId: ID!, $contentId: ID) { + triggerPipeline(pipelineId: $pipelineId, contentId: $contentId) { + id + } + } + ', [ + 'pipelineId' => $pipeline->id, + 'contentId' => $content->id, + ]); + + $response->assertJsonStructure([ + 'data' => [ + 'triggerPipeline' => ['id'], + ], + ]); + + $this->assertDatabaseHas('pipeline_runs', [ + 'pipeline_id' => $pipeline->id, + 'content_id' => $content->id, + ]); + } + + public function test_unauthorized_user_cannot_mutate(): void + { + // No actingAs — unauthenticated request + $response = $this->graphQL(/** @lang GraphQL */ ' + mutation CreateContent($input: CreateContentInput!) { + createContent(input: $input) { + id + } + } + ', [ + 'input' => [ + 'space_id' => $this->space->id, + 'content_type_id' => $this->contentType->id, + 'title' => 'Hacked Post', + 'slug' => 'hacked-post', + ], + ]); + + $response->assertGraphQLErrorMessage('Unauthenticated.'); + $this->assertDatabaseMissing('contents', ['slug' => 'hacked-post']); + } + + public function test_create_content_validates_input(): void + { + $this->actingAs($this->adminUser); + + // Missing required fields: title and slug + $response = $this->graphQL(/** @lang GraphQL */ ' + mutation CreateContent($input: CreateContentInput!) { + createContent(input: $input) { + id + } + } + ', [ + 'input' => [ + 'space_id' => $this->space->id, + 'content_type_id' => $this->contentType->id, + // title and slug intentionally omitted → GraphQL schema validation + ], + ]); + + // GraphQL schema requires title/slug → should get a validation error + $this->assertNotNull($response->json('errors')); + } +} diff --git a/tests/Feature/GraphQLQueryTest.php b/tests/Feature/GraphQLQueryTest.php new file mode 100644 index 0000000..9e75b35 --- /dev/null +++ b/tests/Feature/GraphQLQueryTest.php @@ -0,0 +1,293 @@ +adminUser = User::factory()->admin()->create(); + $this->space = Space::factory()->create(); + } + + public function test_can_query_spaces(): void + { + Space::factory()->count(2)->create(); + + $this->actingAs($this->adminUser); + + $response = $this->graphQL(/** @lang GraphQL */ ' + { + spaces { + id + name + slug + } + } + '); + + $response->assertJsonStructure([ + 'data' => [ + 'spaces' => [ + '*' => ['id', 'name', 'slug'], + ], + ], + ]); + + $response->assertJsonPath('data.spaces.0.name', fn ($v) => is_string($v)); + } + + public function test_can_query_content_by_slug(): void + { + $contentType = ContentType::factory()->create(['space_id' => $this->space->id]); + $content = Content::factory()->published()->create([ + 'space_id' => $this->space->id, + 'content_type_id' => $contentType->id, + 'slug' => 'hello-world', + 'locale' => 'en', + ]); + + // Public query — no auth required + $response = $this->graphQL(/** @lang GraphQL */ ' + { + contentBySlug(slug: "hello-world", spaceSlug: "'.$this->space->slug.'") { + id + slug + status + } + } + '); + + $response->assertJsonPath('data.contentBySlug.slug', 'hello-world'); + $response->assertJsonPath('data.contentBySlug.status', 'published'); + } + + public function test_can_query_contents_with_pagination(): void + { + $contentType = ContentType::factory()->create(['space_id' => $this->space->id]); + Content::factory()->published()->count(5)->create([ + 'space_id' => $this->space->id, + 'content_type_id' => $contentType->id, + ]); + + // contents is a public paginated query + $response = $this->graphQL(/** @lang GraphQL */ ' + { + contents(spaceSlug: "'.$this->space->slug.'", first: 3) { + data { + id + slug + } + paginatorInfo { + total + hasMorePages + } + } + } + '); + + $response->assertJsonStructure([ + 'data' => [ + 'contents' => [ + 'data' => [['id', 'slug']], + 'paginatorInfo' => ['total', 'hasMorePages'], + ], + ], + ]); + + $this->assertCount(3, $response->json('data.contents.data')); + $this->assertEquals(5, $response->json('data.contents.paginatorInfo.total')); + $this->assertTrue($response->json('data.contents.paginatorInfo.hasMorePages')); + } + + public function test_guard_protects_admin_queries(): void + { + $response = $this->graphQL(/** @lang GraphQL */ ' + { + spaces { + id + name + } + } + '); + + // Unauthenticated request should receive an error (guard rejects) + $response->assertGraphQLErrorMessage('Unauthenticated.'); + } + + public function test_authenticated_user_can_query_admin_data(): void + { + ContentType::factory()->create(['space_id' => $this->space->id]); + + $this->actingAs($this->adminUser); + + $response = $this->graphQL(/** @lang GraphQL */ ' + { + spaces { + id + name + slug + } + } + '); + + $response->assertJsonMissingValidationErrors(); + $response->assertJsonStructure([ + 'data' => ['spaces'], + ]); + } + + public function test_content_includes_current_version(): void + { + $this->actingAs($this->adminUser); + + $contentType = ContentType::factory()->create(['space_id' => $this->space->id]); + $content = Content::factory()->published()->create([ + 'space_id' => $this->space->id, + 'content_type_id' => $contentType->id, + ]); + + $response = $this->graphQL(/** @lang GraphQL */ ' + { + content(id: "'.$content->id.'") { + id + slug + status + } + } + '); + + $response->assertJsonPath('data.content.id', $content->id); + $response->assertJsonPath('data.content.status', 'published'); + } + + public function test_can_query_personas_without_system_prompt(): void + { + $this->actingAs($this->adminUser); + + Persona::factory()->create([ + 'space_id' => $this->space->id, + 'system_prompt' => 'Super secret business logic — must never be exposed', + ]); + + $response = $this->graphQL(/** @lang GraphQL */ ' + { + personas(spaceId: "'.$this->space->id.'") { + id + name + role + is_active + } + } + '); + + $response->assertJsonStructure([ + 'data' => [ + 'personas' => [ + '*' => ['id', 'name', 'role'], + ], + ], + ]); + + // system_prompt must NOT appear anywhere in the response + $this->assertStringNotContainsString('system_prompt', json_encode($response->json())); + $this->assertStringNotContainsString('Super secret', json_encode($response->json())); + } + + public function test_can_query_media_assets(): void + { + $this->actingAs($this->adminUser); + + MediaAsset::factory()->count(3)->create([ + 'space_id' => $this->space->id, + ]); + + $response = $this->graphQL(/** @lang GraphQL */ ' + { + mediaAssets(spaceId: "'.$this->space->id.'") { + edges { + node { + id + filename + mime_type + } + } + totalCount + } + } + '); + + $response->assertJsonStructure([ + 'data' => [ + 'mediaAssets' => [ + 'edges' => [['node' => ['id', 'filename', 'mime_type']]], + 'totalCount', + ], + ], + ]); + + $this->assertEquals(3, $response->json('data.mediaAssets.totalCount')); + } + + public function test_can_query_taxonomies(): void + { + $this->actingAs($this->adminUser); + + $vocab = Vocabulary::factory()->create([ + 'space_id' => $this->space->id, + ]); + TaxonomyTerm::factory()->count(2)->create([ + 'vocabulary_id' => $vocab->id, + ]); + + $response = $this->graphQL(/** @lang GraphQL */ ' + { + vocabularies(spaceId: "'.$this->space->id.'") { + id + name + slug + terms { + id + name + } + } + } + '); + + $response->assertJsonStructure([ + 'data' => [ + 'vocabularies' => [ + '*' => [ + 'id', 'name', 'slug', + 'terms' => [['id', 'name']], + ], + ], + ], + ]); + + $this->assertCount(2, $response->json('data.vocabularies.0.terms')); + } +} diff --git a/tests/Feature/GraphQLSubscriptionTest.php b/tests/Feature/GraphQLSubscriptionTest.php new file mode 100644 index 0000000..0439695 --- /dev/null +++ b/tests/Feature/GraphQLSubscriptionTest.php @@ -0,0 +1,75 @@ +graphQL(/** @lang GraphQL */ ' + { + __schema { + subscriptionType { + name + fields { + name + } + } + } + } + '); + + $response->assertJsonPath('data.__schema.subscriptionType.name', 'Subscription'); + + $fields = collect($response->json('data.__schema.subscriptionType.fields')) + ->pluck('name'); + + $this->assertTrue($fields->contains('contentPublished'), 'contentPublished subscription is missing'); + $this->assertTrue($fields->contains('contentUpdated'), 'contentUpdated subscription is missing'); + $this->assertTrue($fields->contains('pipelineRunUpdated'), 'pipelineRunUpdated subscription is missing'); + $this->assertTrue($fields->contains('pipelineRunCompleted'), 'pipelineRunCompleted subscription is missing'); + } + + public function test_content_published_subscription_exists(): void + { + $response = $this->graphQL(/** @lang GraphQL */ ' + { + __type(name: "Subscription") { + fields { + name + args { + name + type { + name + kind + } + } + } + } + } + '); + + $fields = collect($response->json('data.__type.fields')); + $contentPublished = $fields->firstWhere('name', 'contentPublished'); + + $this->assertNotNull($contentPublished, 'contentPublished field not found in Subscription type'); + + $argNames = collect($contentPublished['args'])->pluck('name'); + $this->assertTrue($argNames->contains('spaceId'), 'contentPublished should accept spaceId argument'); + } +} diff --git a/tests/Unit/PaginatedComplexityTest.php b/tests/Unit/PaginatedComplexityTest.php new file mode 100644 index 0000000..0874ab7 --- /dev/null +++ b/tests/Unit/PaginatedComplexityTest.php @@ -0,0 +1,53 @@ +resolver = new PaginatedComplexity; + } + + public function test_calculates_complexity_correctly(): void + { + // childComplexity=5, first=10 → 5 * 10 = 50 + $result = ($this->resolver)(5, ['first' => 10]); + $this->assertEquals(50, $result); + } + + public function test_default_complexity_for_no_args(): void + { + // No 'first' arg → defaults to 20 + $result = ($this->resolver)(3, []); + $this->assertEquals(60, $result); // 3 * 20 = 60 + } + + public function test_clamps_first_to_maximum_of_1000(): void + { + // first=5000 should be clamped to 1000 + $result = ($this->resolver)(2, ['first' => 5000]); + $this->assertEquals(2000, $result); // 2 * 1000 = 2000 + } + + public function test_handles_zero_child_complexity(): void + { + $result = ($this->resolver)(0, ['first' => 100]); + $this->assertEquals(0, $result); + } + + public function test_handles_first_arg_as_string(): void + { + // Args from GraphQL may arrive as strings — cast should handle it + $result = ($this->resolver)(4, ['first' => '5']); + $this->assertEquals(20, $result); // 4 * 5 = 20 + } +}