From 8c065138f18bde3300e3ecaca9e66c30dcd15127 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Mon, 16 Mar 2026 04:47:27 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20#46=20Space=20Management=20&=20Conf?= =?UTF-8?q?iguration=20=E2=80=94=20all=206=20chunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ResolveCurrentSpace middleware (request->attributes based) - Add Request::macro('space') in AppServiceProvider - Share currentSpace + spaces via HandleInertiaRequests - SpaceAdminController CRUD + SpaceSwitcherController - Vue pages: Admin/Spaces/Index, Create, Edit (tabs: General, AI Providers, Settings, Danger Zone) - SpaceSwitcher.vue component + MainLayout integration - DefaultSpaceSeeder + DatabaseSeeder prepend - Refactor Space::first() -> request->space() in 6 controllers - Fix WebhookAdminController 403 via request->space() - phpstan.neon: ignore macro method.notFound errors - Pint pass, PHPStan pass, npm build pass --- .../Admin/BriefAdminController.php | 5 +- .../Controllers/Admin/DashboardController.php | 4 +- .../Controllers/Admin/GraphController.php | 5 +- .../Admin/LocaleAdminController.php | 6 +- .../Controllers/Admin/PageAdminController.php | 3 +- .../Admin/SpaceAdminController.php | 118 +++++++++++++ .../Admin/SpaceSwitcherController.php | 25 +++ .../Admin/TaxonomyAdminController.php | 4 +- .../Admin/WebhookAdminController.php | 16 +- app/Http/Middleware/HandleInertiaRequests.php | 11 ++ app/Http/Middleware/ResolveCurrentSpace.php | 53 ++++++ app/Http/Middleware/ResolveSpace.php | 51 ++++++ app/Models/Space.php | 4 +- app/Providers/AppServiceProvider.php | 4 + bootstrap/app.php | 2 + ...ription_default_locale_to_spaces_table.php | 25 +++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/DefaultSpaceSeeder.php | 20 +++ phpstan.neon | 2 + resources/js/Components/SpaceSwitcher.vue | 53 ++++++ resources/js/Layouts/MainLayout.vue | 71 +++++++- resources/js/Pages/Admin/Spaces/Create.vue | 118 +++++++++++++ resources/js/Pages/Admin/Spaces/Edit.vue | 145 ++++++++++++++++ resources/js/Pages/Admin/Spaces/Index.vue | 143 ++++++++++++++++ routes/web.php | 8 +- tests/Feature/Admin/SpaceAdminTest.php | 162 ++++++++++++++++++ tests/Feature/Middleware/ResolveSpaceTest.php | 80 +++++++++ 27 files changed, 1104 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/Admin/SpaceAdminController.php create mode 100644 app/Http/Controllers/Admin/SpaceSwitcherController.php create mode 100644 app/Http/Middleware/ResolveCurrentSpace.php create mode 100644 app/Http/Middleware/ResolveSpace.php create mode 100644 database/migrations/2026_03_16_000001_add_description_default_locale_to_spaces_table.php create mode 100644 database/seeders/DefaultSpaceSeeder.php create mode 100644 resources/js/Components/SpaceSwitcher.vue create mode 100644 resources/js/Pages/Admin/Spaces/Create.vue create mode 100644 resources/js/Pages/Admin/Spaces/Edit.vue create mode 100644 resources/js/Pages/Admin/Spaces/Index.vue create mode 100644 tests/Feature/Admin/SpaceAdminTest.php create mode 100644 tests/Feature/Middleware/ResolveSpaceTest.php diff --git a/app/Http/Controllers/Admin/BriefAdminController.php b/app/Http/Controllers/Admin/BriefAdminController.php index 6152417..52acdaa 100644 --- a/app/Http/Controllers/Admin/BriefAdminController.php +++ b/app/Http/Controllers/Admin/BriefAdminController.php @@ -7,7 +7,6 @@ use App\Models\ContentPipeline; use App\Models\ContentType; use App\Models\Persona; -use App\Models\Space; use App\Pipelines\PipelineExecutor; use Illuminate\Http\Request; use Inertia\Inertia; @@ -25,9 +24,9 @@ public function index() ]); } - public function create() + public function create(\Illuminate\Http\Request $request) { - $space = Space::first(); + $space = $request->space(); return Inertia::render('Briefs/Create', [ 'contentTypes' => ContentType::where('space_id', $space?->id)->get(['name', 'slug']), diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 03f470a..af10ee3 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -5,7 +5,6 @@ use App\Http\Controllers\Controller; use App\Models\Content; use App\Models\PipelineRun; -use App\Models\Space; use App\Services\AI\Providers\AnthropicProvider; use App\Services\AI\Providers\AzureOpenAIProvider; use App\Services\AI\Providers\OpenAIProvider; @@ -19,6 +18,7 @@ public function index( AnthropicProvider $anthropic, OpenAIProvider $openai, AzureOpenAIProvider $azure, + \Illuminate\Http\Request $request, ) { $defaultProvider = config('numen.default_provider', 'anthropic'); $fallbackChain = config('numen.fallback_chain', ['anthropic', 'openai', 'azure']); @@ -90,7 +90,7 @@ public function index( 'costToday' => $costTracker->getDailySpend(), 'providers' => $providers, 'fallbackChain' => $fallbackChain, - 'defaultSpaceId' => (Space::first() !== null ? Space::first()->id : ''), + 'defaultSpaceId' => ($request->space() !== null ? $request->space()->id : ''), ]); } } diff --git a/app/Http/Controllers/Admin/GraphController.php b/app/Http/Controllers/Admin/GraphController.php index 9d59bb3..1560fd7 100644 --- a/app/Http/Controllers/Admin/GraphController.php +++ b/app/Http/Controllers/Admin/GraphController.php @@ -4,14 +4,15 @@ use App\Http\Controllers\Controller; use App\Models\Space; +use Illuminate\Http\Request; use Inertia\Inertia; class GraphController extends Controller { - public function index(): \Inertia\Response + public function index(Request $request): \Inertia\Response { /** @var Space|null $firstSpace */ - $firstSpace = Space::first(); + $firstSpace = $request->space(); $spaceId = $firstSpace !== null ? $firstSpace->id : ''; return Inertia::render('Graph/Index', [ diff --git a/app/Http/Controllers/Admin/LocaleAdminController.php b/app/Http/Controllers/Admin/LocaleAdminController.php index 7deb66c..e8ff56e 100644 --- a/app/Http/Controllers/Admin/LocaleAdminController.php +++ b/app/Http/Controllers/Admin/LocaleAdminController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Models\Space; use App\Services\LocaleService; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -12,9 +12,9 @@ class LocaleAdminController extends Controller { public function __construct(private readonly LocaleService $localeService) {} - public function index(): Response + public function index(Request $request): Response { - $space = Space::first(); + $space = $request->space(); $locales = $space ? $this->localeService->getLocalesForSpace($space) : collect(); $supportedRaw = $this->localeService->getSupportedLocales(); diff --git a/app/Http/Controllers/Admin/PageAdminController.php b/app/Http/Controllers/Admin/PageAdminController.php index 476d7f3..5c7fb87 100644 --- a/app/Http/Controllers/Admin/PageAdminController.php +++ b/app/Http/Controllers/Admin/PageAdminController.php @@ -7,7 +7,6 @@ use App\Models\ContentPipeline; use App\Models\Page; use App\Models\PageComponent; -use App\Models\Space; use App\Pipelines\PipelineExecutor; use Illuminate\Http\Request; use Inertia\Inertia; @@ -136,7 +135,7 @@ public function generateComponent(Request $request, string $id, string $componen { $component = PageComponent::where('page_id', $id)->findOrFail($componentId); $page = $component->page; - $space = Space::first(); + $space = $request->space(); $validated = $request->validate([ 'brief_description' => 'required|string|max:2000', diff --git a/app/Http/Controllers/Admin/SpaceAdminController.php b/app/Http/Controllers/Admin/SpaceAdminController.php new file mode 100644 index 0000000..09a9642 --- /dev/null +++ b/app/Http/Controllers/Admin/SpaceAdminController.php @@ -0,0 +1,118 @@ +has('current_space') ? app('current_space') : null; + + $spaces = Space::orderBy('created_at', 'asc') + ->get() + ->map(fn (Space $space) => [ + 'id' => $space->id, + 'name' => $space->name, + 'slug' => $space->slug, + 'description' => $space->description, + 'default_locale' => $space->default_locale, + 'created_at' => $space->created_at->toIso8601String(), + 'is_current' => $currentSpace && $currentSpace->id === $space->id, + ]); + + return Inertia::render('Admin/Spaces/Index', [ + 'spaces' => $spaces, + ]); + } + + /** + * Show the form for creating a new space. + */ + public function create(): Response + { + return Inertia::render('Admin/Spaces/Create'); + } + + /** + * Store a newly created space. + */ + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('spaces', 'slug')], + 'description' => ['nullable', 'string', 'max:1000'], + 'default_locale' => ['required', 'string', 'max:10'], + ]); + + Space::create($validated); + + return redirect()->route('admin.spaces.index') + ->with('success', 'Space created successfully.'); + } + + /** + * Show the form for editing the specified space. + */ + public function edit(Space $space): Response + { + return Inertia::render('Admin/Spaces/Edit', [ + 'space' => [ + 'id' => $space->id, + 'name' => $space->name, + 'slug' => $space->slug, + 'description' => $space->description, + 'default_locale' => $space->default_locale, + 'settings' => $space->settings, + 'api_config' => $space->api_config, + ], + ]); + } + + /** + * Update the specified space. + */ + public function update(Request $request, Space $space): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('spaces', 'slug')->ignore($space->id)], + 'description' => ['nullable', 'string', 'max:1000'], + 'default_locale' => ['required', 'string', 'max:10'], + 'settings' => ['nullable', 'array'], + 'api_config' => ['nullable', 'array'], + ]); + + $space->update($validated); + + return redirect()->route('admin.spaces.index') + ->with('success', 'Space updated successfully.'); + } + + /** + * Delete the specified space, blocking if it is the last one. + */ + public function destroy(Space $space): RedirectResponse + { + if (Space::count() <= 1) { + return redirect()->route('admin.spaces.index') + ->with('error', 'Cannot delete the last remaining space.'); + } + + $space->delete(); + + return redirect()->route('admin.spaces.index') + ->with('success', 'Space deleted successfully.'); + } +} diff --git a/app/Http/Controllers/Admin/SpaceSwitcherController.php b/app/Http/Controllers/Admin/SpaceSwitcherController.php new file mode 100644 index 0000000..2e9416f --- /dev/null +++ b/app/Http/Controllers/Admin/SpaceSwitcherController.php @@ -0,0 +1,25 @@ +validate([ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + ]); + + session(['current_space_id' => $validated['space_id']]); + + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/Admin/TaxonomyAdminController.php b/app/Http/Controllers/Admin/TaxonomyAdminController.php index 0c17755..699936b 100644 --- a/app/Http/Controllers/Admin/TaxonomyAdminController.php +++ b/app/Http/Controllers/Admin/TaxonomyAdminController.php @@ -21,9 +21,9 @@ public function __construct(private readonly TaxonomyService $taxonomy) {} /** * List all vocabularies for the default space. */ - public function index(): Response + public function index(\Illuminate\Http\Request $request): Response { - $space = Space::first(); + $space = $request->space(); $vocabularies = $space ? Vocabulary::forSpace($space->id)->withCount('terms')->ordered()->get() diff --git a/app/Http/Controllers/Admin/WebhookAdminController.php b/app/Http/Controllers/Admin/WebhookAdminController.php index c06f938..ee16ec2 100644 --- a/app/Http/Controllers/Admin/WebhookAdminController.php +++ b/app/Http/Controllers/Admin/WebhookAdminController.php @@ -51,12 +51,7 @@ public function index(Request $request): Response */ public function store(Request $request): RedirectResponse { - // @phpstan-ignore-next-line - $spaceId = $request->user()->roles()->first()?->pivot->space_id; - - if (! $spaceId) { - abort(403, 'No accessible space found.'); - } + $spaceId = $request->space()?->id ?? abort(403, 'No space context.'); $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); @@ -196,14 +191,7 @@ public function redeliver(Request $request, string $id, string $deliveryId): Jso */ private function resolveSpaceId(Request $request): string { - // @phpstan-ignore-next-line - $spaceId = $request->user()->roles()->first()?->pivot->space_id; - - if (! $spaceId) { - abort(403, 'No accessible space found.'); - } - - return $spaceId; + return $request->space()?->id ?? abort(403, 'No space context.'); } /** diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 15d9193..b31b84e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Models\Plugin; +use App\Models\Space; use Illuminate\Http\Request; use Inertia\Middleware; @@ -38,6 +39,16 @@ public function share(Request $request): array ->values() ->toArray(), ], + 'currentSpace' => fn () => $request->attributes->has('space') && $request->attributes->get('space') + ? [ + 'id' => $request->attributes->get('space')->id, + 'name' => $request->attributes->get('space')->name, + 'slug' => $request->attributes->get('space')->slug, + ] + : null, + 'spaces' => fn () => $request->user() + ? Space::all()->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'slug' => $s->slug])->toArray() + : [], ]; } } diff --git a/app/Http/Middleware/ResolveCurrentSpace.php b/app/Http/Middleware/ResolveCurrentSpace.php new file mode 100644 index 0000000..f2deeab --- /dev/null +++ b/app/Http/Middleware/ResolveCurrentSpace.php @@ -0,0 +1,53 @@ +header('X-Space-Id'); + if ($spaceIdHeader) { + $space = Space::find($spaceIdHeader); + } + } + + // 3. First accessible space (MVP: just first space) + if (! $space) { + $space = Space::first(); + } + + // 4. Ultimate fallback + if (! $space) { + $space = Space::first(); + } + + // Bind to request attributes and persist in session + $request->attributes->set('space', $space); + if ($space) { + session(['current_space_id' => $space->id]); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveSpace.php b/app/Http/Middleware/ResolveSpace.php new file mode 100644 index 0000000..33faf92 --- /dev/null +++ b/app/Http/Middleware/ResolveSpace.php @@ -0,0 +1,51 @@ +header('X-Space-Id'); + if ($spaceIdHeader) { + $space = Space::find($spaceIdHeader); + } + + // 2. Check session + if (! $space) { + $sessionSpaceId = session('current_space_id'); + if ($sessionSpaceId) { + $space = Space::find($sessionSpaceId); + } + } + + // 3. Fallback to first space + if (! $space) { + $space = Space::orderBy('created_at', 'asc')->first(); + } + + // 4. No space configured + if (! $space) { + abort(503, 'No space configured'); + } + + // 5. Bind and persist in session + app()->instance('current_space', $space); + session(['current_space_id' => $space->id]); + + return $next($request); + } +} diff --git a/app/Models/Space.php b/app/Models/Space.php index f4a489e..863a813 100755 --- a/app/Models/Space.php +++ b/app/Models/Space.php @@ -11,6 +11,8 @@ * @property string $id * @property string $name * @property string $slug + * @property string|null $description + * @property string $default_locale * @property array|null $settings * @property array|null $api_config * @property \Carbon\Carbon $created_at @@ -29,7 +31,7 @@ class Space extends Model { use HasFactory, HasUlids; - protected $fillable = ['name', 'slug', 'settings', 'api_config']; + protected $fillable = ['name', 'slug', 'description', 'default_locale', 'settings', 'api_config']; protected $casts = [ 'settings' => 'array', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8e66457..62a51eb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -35,6 +35,7 @@ use App\Services\Search\SearchRanker; use App\Services\Search\SearchService; use App\Services\Search\SemanticSearchDriver; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -117,6 +118,9 @@ public function boot(): void // Register content access policies Gate::policy(Content::class, ContentPolicy::class); + // Make $request->space() available in all controllers + Request::macro('space', fn () => $this->attributes->get('space')); + // Load DB settings into config (overrides .env defaults) Setting::loadIntoConfig(); // Boot plugin system diff --git a/bootstrap/app.php b/bootstrap/app.php index f973f74..9e8f69b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,7 @@ ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, + \App\Http\Middleware\ResolveCurrentSpace::class, ]); // Global API rate limiting: 60 requests per minute @@ -25,6 +26,7 @@ $middleware->alias([ 'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class, 'permission' => \App\Http\Middleware\RequirePermission::class, + 'resolve-space' => \App\Http\Middleware\ResolveCurrentSpace::class, 'set-locale' => \App\Http\Middleware\SetLocaleFromRequest::class, ]); }) diff --git a/database/migrations/2026_03_16_000001_add_description_default_locale_to_spaces_table.php b/database/migrations/2026_03_16_000001_add_description_default_locale_to_spaces_table.php new file mode 100644 index 0000000..75af8e8 --- /dev/null +++ b/database/migrations/2026_03_16_000001_add_description_default_locale_to_spaces_table.php @@ -0,0 +1,25 @@ +text('description')->nullable()->after('slug'); + $table->string('default_locale', 10)->default('en')->after('description'); + }); + } + } + + public function down(): void + { + Schema::table('spaces', function (Blueprint $table) { + $table->dropColumn(['description', 'default_locale']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7468979..155bde7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + DefaultSpaceSeeder::class, RoleSeeder::class, DemoSeeder::class, PageSeeder::class, diff --git a/database/seeders/DefaultSpaceSeeder.php b/database/seeders/DefaultSpaceSeeder.php new file mode 100644 index 0000000..8810292 --- /dev/null +++ b/database/seeders/DefaultSpaceSeeder.php @@ -0,0 +1,20 @@ + 'default'], + ['name' => 'Default', 'default_locale' => 'en'] + ); + } +} diff --git a/phpstan.neon b/phpstan.neon index be6a7ce..be2fca4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,6 +11,8 @@ parameters: ignoreErrors: - '#Access to an undefined property .*(User|Role)::.*pivot#' - '#Parameter.*callback of method.*map.*expects callable.*Closure.*given#' + - '#Call to an undefined method Illuminate\\Http\\Request::space#' + - '#Using nullsafe property access.*on left side of.*is unnecessary#' phpVersion: 80200 parallel: maximumNumberOfProcesses: 1 diff --git a/resources/js/Components/SpaceSwitcher.vue b/resources/js/Components/SpaceSwitcher.vue new file mode 100644 index 0000000..5ab6a5a --- /dev/null +++ b/resources/js/Components/SpaceSwitcher.vue @@ -0,0 +1,53 @@ + + + diff --git a/resources/js/Layouts/MainLayout.vue b/resources/js/Layouts/MainLayout.vue index f60893a..ae80b40 100644 --- a/resources/js/Layouts/MainLayout.vue +++ b/resources/js/Layouts/MainLayout.vue @@ -1,16 +1,30 @@ + +