diff --git a/app/Boot.php b/app/Boot.php index bd6dc06..1ef2c2f 100644 --- a/app/Boot.php +++ b/app/Boot.php @@ -6,20 +6,15 @@ namespace App; use Core\Boot as CoreBoot; -/** - * Lethean.io — TLD website and blockchain services. - * - * Website modules handle domain resolution and web routes. - * Mod modules provide business logic and API endpoints. - */ class Boot extends CoreBoot { public static array $providers = [ \Core\Storage\CacheResilienceProvider::class, \Core\LifecycleEventProvider::class, - \Core\Front\FrontServiceProvider::class, - - // Websites — domain-scoped route registration + \Core\Front\Web\Boot::class, + \Core\Front\Client\Boot::class, + \Core\Front\Cli\Boot::class, + \Core\Front\Components\Boot::class, \Website\Lethean\Boot::class, \Website\Explorer\Boot::class, \Website\Names\Boot::class, @@ -27,9 +22,6 @@ class Boot extends CoreBoot \Website\Pool\Boot::class, ]; - /** - * Mod boot classes — loaded via lifecycle events. - */ public static array $modules = [ \Mod\Chain\Boot::class, \Mod\Explorer\Boot::class, @@ -37,4 +29,9 @@ class Boot extends CoreBoot \Mod\Pool\Boot::class, \Mod\Names\Boot::class, ]; + + protected static function basePath(): string + { + return dirname(__DIR__); + } } diff --git a/app/Core/Actions/Action.php b/app/Core/Actions/Action.php new file mode 100644 index 0000000..b8ccbec --- /dev/null +++ b/app/Core/Actions/Action.php @@ -0,0 +1,52 @@ +createPage->handle($user, $data); + * + * // Via static helper + * $page = CreatePage::run($user, $data); + * + * // Via app container + * $page = app(CreatePage::class)->handle($user, $data); + * + * Directory structure: + * app/Mod/{Module}/Actions/ + * ├── CreateThing.php + * ├── UpdateThing.php + * ├── DeleteThing.php + * └── Thing/ + * ├── PublishThing.php + * └── ArchiveThing.php + */ +trait Action +{ + /** + * Run the action via the container. + * + * Resolves the action from the container (with dependencies) + * and calls handle() with the provided arguments. + */ + public static function run(mixed ...$args): mixed + { + return app(static::class)->handle(...$args); + } +} diff --git a/app/Core/Actions/Actionable.php b/app/Core/Actions/Actionable.php new file mode 100644 index 0000000..cf09e72 --- /dev/null +++ b/app/Core/Actions/Actionable.php @@ -0,0 +1,19 @@ +app->runningInConsole()) { + return; + } + + // Guard against table not existing (pre-migration) + try { + if (! Schema::hasTable('scheduled_actions')) { + return; + } + } catch (\Throwable) { + // DB unreachable — skip gracefully so scheduler doesn't crash + return; + } + + $this->app->booted(function () { + $this->registerScheduledActions(); + }); + } + + private function registerScheduledActions(): void + { + $schedule = $this->app->make(Schedule::class); + $actions = ScheduledAction::enabled()->get(); + + foreach ($actions as $action) { + try { + $class = $action->action_class; + + // Validate namespace prefix against allowlist + $hasAllowedNamespace = false; + + foreach (self::ALLOWED_NAMESPACES as $prefix) { + if (str_starts_with($class, $prefix)) { + $hasAllowedNamespace = true; + + break; + } + } + + if (! $hasAllowedNamespace) { + logger()->warning("Scheduled action {$class} has disallowed namespace — skipping"); + + continue; + } + + if (! class_exists($class)) { + continue; + } + + // Verify the class uses the Action trait + if (! in_array(Action::class, class_uses_recursive($class), true)) { + logger()->warning("Scheduled action {$class} does not use the Action trait — skipping"); + + continue; + } + + // Validate frequency method against allowlist + $method = $action->frequencyMethod(); + + if (! in_array($method, self::ALLOWED_FREQUENCIES, true)) { + logger()->warning("Scheduled action {$class} has invalid frequency method: {$method}"); + + continue; + } + + $event = $schedule->call(function () use ($class, $action) { + $class::run(); + $action->markRun(); + })->name($class); + + // Apply frequency + $args = $action->frequencyArgs(); + $event->{$method}(...$args); + + // Apply options + if ($action->without_overlapping) { + $event->withoutOverlapping(); + } + + if ($action->run_in_background) { + $event->runInBackground(); + } + + if ($action->timezone) { + $event->timezone($action->timezone); + } + } catch (\Throwable $e) { + logger()->warning("Failed to register scheduled action: {$action->action_class} — {$e->getMessage()}"); + } + } + } +} diff --git a/app/Core/Actions/Scheduled.php b/app/Core/Actions/Scheduled.php new file mode 100644 index 0000000..1aeaecd --- /dev/null +++ b/app/Core/Actions/Scheduled.php @@ -0,0 +1,46 @@ +everyMinute() + * - 'dailyAt:09:00' → ->dailyAt('09:00') + * - 'weeklyOn:1,09:00' → ->weeklyOn(1, '09:00') + * - 'hourly' → ->hourly() + * - 'monthlyOn:1,00:00' → ->monthlyOn(1, '00:00') + * + * Usage: + * #[Scheduled(frequency: 'dailyAt:09:00', timezone: 'Europe/London')] + * class PublishDigest + * { + * use Action; + * public function handle(): void { ... } + * } + * + * Discovered by ScheduledActionScanner, persisted to scheduled_actions table + * via `php artisan schedule:sync`, and executed by ScheduleServiceProvider. + */ +#[Attribute(Attribute::TARGET_CLASS)] +class Scheduled +{ + public function __construct( + public string $frequency, + public ?string $timezone = null, + public bool $withoutOverlapping = true, + public bool $runInBackground = true, + ) {} +} diff --git a/app/Core/Actions/ScheduledAction.php b/app/Core/Actions/ScheduledAction.php new file mode 100644 index 0000000..1d489bd --- /dev/null +++ b/app/Core/Actions/ScheduledAction.php @@ -0,0 +1,107 @@ + 'boolean', + 'run_in_background' => 'boolean', + 'is_enabled' => 'boolean', + 'last_run_at' => 'datetime', + 'next_run_at' => 'datetime', + ]; + } + + /** + * Scope to only enabled actions. + */ + public function scopeEnabled(Builder $query): Builder + { + return $query->where('is_enabled', true); + } + + /** + * Parse the frequency string and return the method name. + * + * 'dailyAt:09:00' → 'dailyAt' + * 'everyMinute' → 'everyMinute' + */ + public function frequencyMethod(): string + { + return explode(':', $this->frequency, 2)[0]; + } + + /** + * Parse the frequency string and return the arguments. + * + * 'dailyAt:09:00' → ['09:00'] + * 'weeklyOn:1,09:00' → [1, '09:00'] + * 'everyMinute' → [] + * + * Numeric strings are cast to integers so methods like weeklyOn() + * receive the correct types. + */ + public function frequencyArgs(): array + { + $parts = explode(':', $this->frequency, 2); + + if (! isset($parts[1])) { + return []; + } + + return array_map( + static fn (string $arg) => ctype_digit($arg) ? (int) $arg : $arg, + explode(',', $parts[1]) + ); + } + + /** + * Record that this action has just run. + */ + public function markRun(): void + { + $this->update(['last_run_at' => now()]); + } +} diff --git a/app/Core/Actions/ScheduledActionScanner.php b/app/Core/Actions/ScheduledActionScanner.php new file mode 100644 index 0000000..4d4f501 --- /dev/null +++ b/app/Core/Actions/ScheduledActionScanner.php @@ -0,0 +1,181 @@ + $paths Directories to scan recursively + * @return array Map of class name to attribute instance + */ + public function scan(array $paths): array + { + $results = []; + + foreach ($paths as $path) { + if (! is_dir($path)) { + continue; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + // Skip test directories — test files extend base classes + // that aren't available without dev dependencies. + // Convention: module test dirs use capital "Tests/" (e.g. app/Mod/Lem/Tests/). + if (preg_match('#[/\\\\]Tests[/\\\\]#', $file->getPathname()) + || str_ends_with($file->getBasename(), 'Test.php')) { + continue; + } + + $contents = file_get_contents($file->getPathname()); + + if ($contents === false || ! str_contains($contents, '#[Scheduled')) { + continue; + } + + $class = $this->classFromFile($file->getPathname(), $contents); + + if ($class === null) { + continue; + } + + try { + if (! class_exists($class)) { + continue; + } + } catch (\Throwable) { + // Class may reference unavailable dependencies (e.g. dev-only) + continue; + } + + $attribute = $this->extractScheduled($class); + + if ($attribute !== null) { + $results[$class] = $attribute; + } + } + } + + return $results; + } + + /** + * Extract the #[Scheduled] attribute from a class. + */ + private function extractScheduled(string $class): ?Scheduled + { + try { + $ref = new ReflectionClass($class); + $attrs = $ref->getAttributes(Scheduled::class); + + if (empty($attrs)) { + return null; + } + + return $attrs[0]->newInstance(); + } catch (\ReflectionException) { + return null; + } + } + + /** + * Derive fully qualified class name from a PHP file. + * + * Reads the file's namespace declaration and class name. + */ + private function classFromFile(string $file, ?string $contents = null): ?string + { + $contents ??= file_get_contents($file); + + if ($contents === false) { + return null; + } + + $tokens = token_get_all($contents); + $namespace = null; + $class = null; + + $count = count($tokens); + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + if (! is_array($token)) { + continue; + } + + if ($token[0] === T_NAMESPACE) { + $namespaceParts = []; + + for ($j = $i + 1; $j < $count; $j++) { + $t = $tokens[$j]; + + if (is_array($t) && in_array($t[0], [T_NAME_QUALIFIED, T_STRING, T_NS_SEPARATOR], true)) { + $namespaceParts[] = $t[1]; + } elseif ($t === ';' || $t === '{') { + break; + } + } + + $namespace = implode('', $namespaceParts) ?: null; + } + + if ($token[0] === T_CLASS) { + for ($j = $i + 1; $j < $count; $j++) { + $t = $tokens[$j]; + + if (is_array($t) && $t[0] === T_WHITESPACE) { + continue; + } + if (is_array($t) && $t[0] === T_STRING) { + $class = $t[1]; + } + + break; + } + + break; + } + } + + if ($class === null) { + return null; + } + + return $namespace !== null ? "{$namespace}\\{$class}" : $class; + } +} diff --git a/app/Core/Activity/Boot.php b/app/Core/Activity/Boot.php new file mode 100644 index 0000000..31074ea --- /dev/null +++ b/app/Core/Activity/Boot.php @@ -0,0 +1,79 @@ + 'onConsole', + AdminPanelBooting::class => 'onAdmin', + ]; + + /** + * Register console commands. + */ + public function onConsole(ConsoleBooting $event): void + { + if (! $this->isEnabled()) { + return; + } + + $event->command(ActivityPruneCommand::class); + } + + /** + * Register admin panel components and routes. + */ + public function onAdmin(AdminPanelBooting $event): void + { + if (! $this->isEnabled()) { + return; + } + + // Register view namespace + $event->views('core.activity', __DIR__.'/View/Blade'); + + // Register Livewire component (only if Livewire is available) + if (app()->bound('livewire')) { + Livewire::component('core.activity-feed', ActivityFeed::class); + } + + // Bind service as singleton + app()->singleton(ActivityLogService::class); + } + + /** + * Check if activity logging is enabled. + */ + protected function isEnabled(): bool + { + return config('core.activity.enabled', true); + } +} diff --git a/app/Core/Activity/CLAUDE.md b/app/Core/Activity/CLAUDE.md new file mode 100644 index 0000000..49da5fe --- /dev/null +++ b/app/Core/Activity/CLAUDE.md @@ -0,0 +1,48 @@ +# Activity + +Workspace-aware activity logging built on `spatie/laravel-activitylog`. + +## What It Does + +Wraps Spatie's activity log with automatic `workspace_id` tagging, a fluent query service, a Livewire feed component for the admin panel, and a prune command for retention management. + +## Key Classes + +| Class | Purpose | +|-------|---------| +| `Boot` | Registers console commands, Livewire component, and service binding via lifecycle events | +| `Activity` (model) | Extends Spatie's model with `ActivityScopes` trait. Adds `workspace_id`, `old_values`, `new_values`, `changes`, `causer_name`, `subject_name` accessors | +| `ActivityLogService` | Fluent query builder: `logFor($model)`, `logBy($user)`, `forWorkspace($ws)`, `ofType('updated')`, `search('term')`, `paginate()`, `statistics()`, `timeline()`, `prune()` | +| `LogsActivity` (trait) | Drop-in trait for models. Auto-logs dirty attributes, auto-tags `workspace_id` from model or request context, generates human descriptions | +| `ActivityScopes` (trait) | 20+ Eloquent scopes: `forWorkspace`, `forSubject`, `byCauser`, `ofType`, `betweenDates`, `today`, `lastDays`, `search`, `withChanges`, `withExistingSubject` | +| `ActivityPruneCommand` | `php artisan activity:prune [--days=N] [--dry-run]` | +| `ActivityFeed` (Livewire) | `` with filters, search, pagination, detail modal | + +## Public API + +```php +// Make a model log activity +class Post extends Model { + use LogsActivity; + protected array $activityLogAttributes = ['title', 'status']; +} + +// Query activities +$service = app(ActivityLogService::class); +$service->logFor($post)->lastDays(7)->paginate(); +$service->forWorkspace($workspace)->ofType('deleted')->recent(10); +$service->statistics($workspace); // => [total, by_event, by_subject, by_user] +``` + +## Integration + +- Listens to `ConsoleBooting` and `AdminPanelBooting` lifecycle events +- `LogsActivity` trait auto-detects workspace from model's `workspace_id` attribute, request `workspace_model` attribute, or authenticated user's `defaultHostWorkspace()` +- Config: `core.activity.enabled`, `core.activity.retention_days` (default 90), `core.activity.log_name` +- Override activity model in `config/activitylog.php`: `'activity_model' => Activity::class` + +## Conventions + +- `LogsActivity::withoutActivityLogging(fn() => ...)` to suppress logging during bulk operations +- Models can implement `customizeActivity($activity, $event)` for custom property injection +- Config properties on model: `$activityLogAttributes`, `$activityLogName`, `$activityLogEvents`, `$activityLogWorkspace`, `$activityLogOnlyDirty` diff --git a/app/Core/Activity/Concerns/CLAUDE.md b/app/Core/Activity/Concerns/CLAUDE.md new file mode 100644 index 0000000..7ec5ea7 --- /dev/null +++ b/app/Core/Activity/Concerns/CLAUDE.md @@ -0,0 +1,17 @@ +# Activity/Concerns/ — Activity Logging Trait + +## Traits + +| Trait | Purpose | +|-------|---------| +| `LogsActivity` | Drop-in trait for models that should log changes. Wraps `spatie/laravel-activitylog` with sensible defaults: auto workspace_id tagging, dirty-only logging, empty log suppression. | + +## Configuration via Model Properties + +- `$activityLogAttributes` — array of attributes to log (default: all dirty) +- `$activityLogName` — custom log name +- `$activityLogEvents` — events to log (default: created, updated, deleted) +- `$activityLogWorkspace` — include workspace_id (default: true) +- `$activityLogOnlyDirty` — only log changed attributes (default: true) + +Static helpers: `activityLoggingEnabled()`, `withoutActivityLogging(callable)`. diff --git a/app/Core/Activity/Concerns/LogsActivity.php b/app/Core/Activity/Concerns/LogsActivity.php new file mode 100644 index 0000000..72f0e07 --- /dev/null +++ b/app/Core/Activity/Concerns/LogsActivity.php @@ -0,0 +1,229 @@ +shouldLogOnlyDirty()) { + $options->logOnlyDirty(); + } + + // Only log if there are actual changes + $options->dontSubmitEmptyLogs(); + + // Set log name from model property or config + $options->useLogName($this->getActivityLogName()); + + // Configure which attributes to log + $attributes = $this->getActivityLogAttributes(); + if ($attributes !== null) { + $options->logOnly($attributes); + } else { + $options->logAll(); + } + + // Configure which events to log + $events = $this->getActivityLogEvents(); + $options->logOnlyDirty(); + + // Set custom description generator + $options->setDescriptionForEvent(fn (string $eventName) => $this->getActivityDescription($eventName)); + + return $options; + } + + /** + * Tap into the activity before it's saved to add workspace_id. + */ + public function tapActivity(Activity $activity, string $eventName): void + { + if ($this->shouldIncludeWorkspace()) { + $workspaceId = $this->getActivityWorkspaceId(); + if ($workspaceId !== null) { + $activity->properties = $activity->properties->merge([ + 'workspace_id' => $workspaceId, + ]); + } + } + + // Allow further customisation in using models + if (method_exists($this, 'customizeActivity')) { + $this->customizeActivity($activity, $eventName); + } + } + + /** + * Get the workspace ID for this activity. + */ + protected function getActivityWorkspaceId(): ?int + { + // If model has workspace_id attribute, use it + if (isset($this->workspace_id)) { + return $this->workspace_id; + } + + // Try to get from current workspace context + return $this->getCurrentWorkspaceId(); + } + + /** + * Get the current workspace ID from context. + */ + protected function getCurrentWorkspaceId(): ?int + { + // First try to get from request attributes (set by middleware) + if (request()->attributes->has('workspace_model')) { + $workspace = request()->attributes->get('workspace_model'); + + return $workspace?->id; + } + + // Then try to get from authenticated user + $user = auth()->user(); + if ($user && method_exists($user, 'defaultHostWorkspace')) { + $workspace = $user->defaultHostWorkspace(); + + return $workspace?->id; + } + + return null; + } + + /** + * Generate a description for the activity event. + */ + protected function getActivityDescription(string $eventName): string + { + $modelName = class_basename(static::class); + + return match ($eventName) { + 'created' => "Created {$modelName}", + 'updated' => "Updated {$modelName}", + 'deleted' => "Deleted {$modelName}", + default => ucfirst($eventName)." {$modelName}", + }; + } + + /** + * Get the log name for this model. + */ + protected function getActivityLogName(): string + { + if (property_exists($this, 'activityLogName') && $this->activityLogName) { + return $this->activityLogName; + } + + return config('core.activity.log_name', 'default'); + } + + /** + * Get the attributes to log. + * + * @return array|null Null means log all attributes + */ + protected function getActivityLogAttributes(): ?array + { + if (property_exists($this, 'activityLogAttributes') && is_array($this->activityLogAttributes)) { + return $this->activityLogAttributes; + } + + return null; + } + + /** + * Get the events to log. + * + * @return array + */ + protected function getActivityLogEvents(): array + { + if (property_exists($this, 'activityLogEvents') && is_array($this->activityLogEvents)) { + return $this->activityLogEvents; + } + + return config('core.activity.default_events', ['created', 'updated', 'deleted']); + } + + /** + * Whether to include workspace_id in activity properties. + */ + protected function shouldIncludeWorkspace(): bool + { + if (property_exists($this, 'activityLogWorkspace')) { + return (bool) $this->activityLogWorkspace; + } + + return config('core.activity.include_workspace', true); + } + + /** + * Whether to only log dirty (changed) attributes. + */ + protected function shouldLogOnlyDirty(): bool + { + if (property_exists($this, 'activityLogOnlyDirty')) { + return (bool) $this->activityLogOnlyDirty; + } + + return true; + } + + /** + * Check if activity logging is enabled. + */ + public static function activityLoggingEnabled(): bool + { + return config('core.activity.enabled', true); + } + + /** + * Temporarily disable activity logging for a callback. + */ + public static function withoutActivityLogging(callable $callback): mixed + { + return activity()->withoutLogs($callback); + } +} diff --git a/app/Core/Activity/Console/ActivityPruneCommand.php b/app/Core/Activity/Console/ActivityPruneCommand.php new file mode 100644 index 0000000..72971f9 --- /dev/null +++ b/app/Core/Activity/Console/ActivityPruneCommand.php @@ -0,0 +1,66 @@ +option('days') + ? (int) $this->option('days') + : config('core.activity.retention_days', 90); + + if ($days <= 0) { + $this->warn('Activity pruning is disabled (retention_days = 0).'); + + return self::SUCCESS; + } + + $cutoffDate = now()->subDays($days); + + $this->info("Pruning activities older than {$days} days (before {$cutoffDate->toDateString()})..."); + + if ($this->option('dry-run')) { + // Count without deleting + $activityModel = config('core.activity.activity_model', Activity::class); + $count = $activityModel::where('created_at', '<', $cutoffDate)->count(); + + $this->info("Would delete {$count} activity records."); + + return self::SUCCESS; + } + + $deleted = $activityService->prune($days); + + $this->info("Deleted {$deleted} old activity records."); + + return self::SUCCESS; + } +} diff --git a/app/Core/Activity/Console/CLAUDE.md b/app/Core/Activity/Console/CLAUDE.md new file mode 100644 index 0000000..353595b --- /dev/null +++ b/app/Core/Activity/Console/CLAUDE.md @@ -0,0 +1,9 @@ +# Activity/Console/ — Activity Log Commands + +## Commands + +| Command | Signature | Purpose | +|---------|-----------|---------| +| `ActivityPruneCommand` | `activity:prune` | Prunes old activity logs. Options: `--days=N` (retention period), `--dry-run` (show count without deleting). Uses retention from config when days not specified. | + +Part of the Activity subsystem's maintenance tooling. Should be scheduled in the application's console kernel for regular cleanup. diff --git a/app/Core/Activity/Models/Activity.php b/app/Core/Activity/Models/Activity.php new file mode 100644 index 0000000..9f420d6 --- /dev/null +++ b/app/Core/Activity/Models/Activity.php @@ -0,0 +1,204 @@ + \Core\Activity\Models\Activity::class, + * + * @method static \Illuminate\Database\Eloquent\Builder forWorkspace(\Illuminate\Database\Eloquent\Model|int $workspace) + * @method static \Illuminate\Database\Eloquent\Builder forSubject(\Illuminate\Database\Eloquent\Model $subject) + * @method static \Illuminate\Database\Eloquent\Builder forSubjectType(string $subjectType) + * @method static \Illuminate\Database\Eloquent\Builder byCauser(\Illuminate\Contracts\Auth\Authenticatable|\Illuminate\Database\Eloquent\Model $user) + * @method static \Illuminate\Database\Eloquent\Builder byCauserId(int $causerId, string|null $causerType = null) + * @method static \Illuminate\Database\Eloquent\Builder ofType(string|array $event) + * @method static \Illuminate\Database\Eloquent\Builder createdEvents() + * @method static \Illuminate\Database\Eloquent\Builder updatedEvents() + * @method static \Illuminate\Database\Eloquent\Builder deletedEvents() + * @method static \Illuminate\Database\Eloquent\Builder betweenDates(\DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null) + * @method static \Illuminate\Database\Eloquent\Builder today() + * @method static \Illuminate\Database\Eloquent\Builder lastDays(int $days) + * @method static \Illuminate\Database\Eloquent\Builder lastHours(int $hours) + * @method static \Illuminate\Database\Eloquent\Builder search(string $search) + * @method static \Illuminate\Database\Eloquent\Builder inLog(string $logName) + * @method static \Illuminate\Database\Eloquent\Builder withChanges() + * @method static \Illuminate\Database\Eloquent\Builder withExistingSubject() + * @method static \Illuminate\Database\Eloquent\Builder withDeletedSubject() + * @method static \Illuminate\Database\Eloquent\Builder newest() + * @method static \Illuminate\Database\Eloquent\Builder oldest() + */ +class Activity extends SpatieActivity +{ + use ActivityScopes; + + /** + * Get the workspace ID from properties. + */ + public function getWorkspaceIdAttribute(): ?int + { + return $this->properties->get('workspace_id'); + } + + /** + * Get the old values from properties. + * + * @return array + */ + public function getOldValuesAttribute(): array + { + return $this->properties->get('old', []); + } + + /** + * Get the new values from properties. + * + * @return array + */ + public function getNewValuesAttribute(): array + { + return $this->properties->get('attributes', []); + } + + /** + * Get the changed attributes. + * + * @return Collection + */ + public function getChangesAttribute(): Collection + { + $old = $this->old_values; + $new = $this->new_values; + $changes = []; + + foreach ($new as $key => $newValue) { + $oldValue = $old[$key] ?? null; + if ($oldValue !== $newValue) { + $changes[$key] = [ + 'old' => $oldValue, + 'new' => $newValue, + ]; + } + } + + return collect($changes); + } + + /** + * Check if this activity has any changes. + */ + public function hasChanges(): bool + { + return ! empty($this->new_values) || ! empty($this->old_values); + } + + /** + * Get a human-readable summary of changes. + */ + public function getChangesSummary(): string + { + $changes = $this->changes; + + if ($changes->isEmpty()) { + return 'No changes recorded'; + } + + $parts = []; + foreach ($changes as $field => $values) { + $parts[] = sprintf( + '%s: %s -> %s', + $field, + $this->formatValue($values['old']), + $this->formatValue($values['new']) + ); + } + + return implode(', ', $parts); + } + + /** + * Format a value for display. + */ + protected function formatValue(mixed $value): string + { + if ($value === null) { + return 'null'; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_array($value)) { + return json_encode($value); + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + return (string) $value; + } + + /** + * Get the display name for the causer. + */ + public function getCauserNameAttribute(): string + { + $causer = $this->causer; + + if (! $causer) { + return 'System'; + } + + return $causer->name ?? $causer->email ?? 'User #'.$causer->getKey(); + } + + /** + * Get the display name for the subject. + */ + public function getSubjectNameAttribute(): ?string + { + $subject = $this->subject; + + if (! $subject) { + return null; + } + + // Try common name attributes + foreach (['name', 'title', 'label', 'email', 'slug'] as $attribute) { + if (isset($subject->{$attribute})) { + return (string) $subject->{$attribute}; + } + } + + return class_basename($subject).' #'.$subject->getKey(); + } + + /** + * Get the subject type as a readable name. + */ + public function getSubjectTypeNameAttribute(): ?string + { + return $this->subject_type ? class_basename($this->subject_type) : null; + } +} diff --git a/app/Core/Activity/Models/CLAUDE.md b/app/Core/Activity/Models/CLAUDE.md new file mode 100644 index 0000000..772b6fb --- /dev/null +++ b/app/Core/Activity/Models/CLAUDE.md @@ -0,0 +1,14 @@ +# Activity/Models/ — Activity Log Model + +## Models + +| Model | Extends | Purpose | +|-------|---------|---------| +| `Activity` | `Spatie\Activitylog\Models\Activity` | Extended activity model with workspace-aware scopes via the `ActivityScopes` trait. Adds query scopes for filtering by workspace, subject, causer, event type, date range, and search. | + +Configure as the activity model in `config/activitylog.php`: +```php +'activity_model' => \Core\Activity\Models\Activity::class, +``` + +Requires `spatie/laravel-activitylog`. diff --git a/app/Core/Activity/Scopes/ActivityScopes.php b/app/Core/Activity/Scopes/ActivityScopes.php new file mode 100644 index 0000000..64ca4f9 --- /dev/null +++ b/app/Core/Activity/Scopes/ActivityScopes.php @@ -0,0 +1,262 @@ +get(); + * Activity::forSubject($post)->ofType('updated')->get(); + * + * @requires spatie/laravel-activitylog + */ +trait ActivityScopes +{ + /** + * Scope activities to a specific workspace. + * + * Filters activities where either: + * - The workspace_id is stored in properties + * - The subject model has the given workspace_id + * + * @param Model|int $workspace Workspace model or ID + */ + public function scopeForWorkspace(Builder $query, Model|int $workspace): Builder + { + $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace; + + return $query->where(function (Builder $q) use ($workspaceId) { + // Check properties->workspace_id + $q->whereJsonContains('properties->workspace_id', $workspaceId); + + // Or check if subject has workspace_id + $q->orWhereHasMorph( + 'subject', + '*', + fn (Builder $subjectQuery) => $subjectQuery->where('workspace_id', $workspaceId) + ); + }); + } + + /** + * Scope activities to a specific subject model. + * + * @param Model $subject The subject model instance + */ + public function scopeForSubject(Builder $query, Model $subject): Builder + { + return $query + ->where('subject_type', get_class($subject)) + ->where('subject_id', $subject->getKey()); + } + + /** + * Scope activities to a specific subject type. + * + * @param string $subjectType Fully qualified class name + */ + public function scopeForSubjectType(Builder $query, string $subjectType): Builder + { + return $query->where('subject_type', $subjectType); + } + + /** + * Scope activities by the causer (user who performed the action). + * + * @param Authenticatable|Model $user The causer model + */ + public function scopeByCauser(Builder $query, Authenticatable|Model $user): Builder + { + return $query + ->where('causer_type', get_class($user)) + ->where('causer_id', $user->getKey()); + } + + /** + * Scope activities by causer ID (when you don't have the model). + * + * @param int $causerId The causer's primary key + * @param string|null $causerType Optional causer type (defaults to User model) + */ + public function scopeByCauserId(Builder $query, int $causerId, ?string $causerType = null): Builder + { + $query->where('causer_id', $causerId); + + if ($causerType !== null) { + $query->where('causer_type', $causerType); + } + + return $query; + } + + /** + * Scope activities by event type. + * + * @param string|array $event Event type(s): 'created', 'updated', 'deleted' + */ + public function scopeOfType(Builder $query, string|array $event): Builder + { + $events = is_array($event) ? $event : [$event]; + + return $query->whereIn('event', $events); + } + + /** + * Scope to only created events. + */ + public function scopeCreatedEvents(Builder $query): Builder + { + return $query->where('event', 'created'); + } + + /** + * Scope to only updated events. + */ + public function scopeUpdatedEvents(Builder $query): Builder + { + return $query->where('event', 'updated'); + } + + /** + * Scope to only deleted events. + */ + public function scopeDeletedEvents(Builder $query): Builder + { + return $query->where('event', 'deleted'); + } + + /** + * Scope activities within a date range. + * + * @param \DateTimeInterface|string $from Start date + * @param \DateTimeInterface|string|null $to End date (optional) + */ + public function scopeBetweenDates(Builder $query, \DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null): Builder + { + $query->where('created_at', '>=', $from); + + if ($to !== null) { + $query->where('created_at', '<=', $to); + } + + return $query; + } + + /** + * Scope activities from today. + */ + public function scopeToday(Builder $query): Builder + { + return $query->whereDate('created_at', now()->toDateString()); + } + + /** + * Scope activities from the last N days. + * + * @param int $days Number of days + */ + public function scopeLastDays(Builder $query, int $days): Builder + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + /** + * Scope activities from the last N hours. + * + * @param int $hours Number of hours + */ + public function scopeLastHours(Builder $query, int $hours): Builder + { + return $query->where('created_at', '>=', now()->subHours($hours)); + } + + /** + * Search activities by description. + * + * @param string $search Search term + */ + public function scopeSearch(Builder $query, string $search): Builder + { + $term = '%'.addcslashes($search, '%_').'%'; + + return $query->where(function (Builder $q) use ($term) { + $q->where('description', 'LIKE', $term) + ->orWhere('properties', 'LIKE', $term); + }); + } + + /** + * Scope to activities in a specific log. + * + * @param string $logName The log name + */ + public function scopeInLog(Builder $query, string $logName): Builder + { + return $query->where('log_name', $logName); + } + + /** + * Scope to activities with changes (non-empty properties). + */ + public function scopeWithChanges(Builder $query): Builder + { + return $query->where(function (Builder $q) { + $q->whereJsonLength('properties->attributes', '>', 0) + ->orWhereJsonLength('properties->old', '>', 0); + }); + } + + /** + * Scope to activities for models that still exist. + */ + public function scopeWithExistingSubject(Builder $query): Builder + { + return $query->whereHas('subject'); + } + + /** + * Scope to activities for models that have been deleted. + */ + public function scopeWithDeletedSubject(Builder $query): Builder + { + return $query->whereDoesntHave('subject'); + } + + /** + * Order by newest first. + */ + public function scopeNewest(Builder $query): Builder + { + return $query->latest('created_at'); + } + + /** + * Order by oldest first. + */ + public function scopeOldest(Builder $query): Builder + { + return $query->oldest('created_at'); + } +} diff --git a/app/Core/Activity/Scopes/CLAUDE.md b/app/Core/Activity/Scopes/CLAUDE.md new file mode 100644 index 0000000..624d5d2 --- /dev/null +++ b/app/Core/Activity/Scopes/CLAUDE.md @@ -0,0 +1,9 @@ +# Activity/Scopes/ — Activity Query Scopes + +## Traits + +| Trait | Purpose | +|-------|---------| +| `ActivityScopes` | Comprehensive query scopes for activity log filtering. Includes: `forWorkspace`, `forSubject`, `forSubjectType`, `byCauser`, `byCauserId`, `ofType`, `createdEvents`, `updatedEvents`, `deletedEvents`, `betweenDates`, `today`, `lastDays`, `lastHours`, `search`, `inLog`, `withChanges`, `withExistingSubject`, `withDeletedSubject`, `newest`, `oldest`. | + +Used by `Core\Activity\Models\Activity`. Workspace scoping checks both `properties->workspace_id` and subject model's `workspace_id`. Requires `spatie/laravel-activitylog`. diff --git a/app/Core/Activity/Services/ActivityLogService.php b/app/Core/Activity/Services/ActivityLogService.php new file mode 100644 index 0000000..a39a297 --- /dev/null +++ b/app/Core/Activity/Services/ActivityLogService.php @@ -0,0 +1,448 @@ +logFor($post); + * + * // Get activities by a user within a workspace + * $activities = $service->logBy($user)->forWorkspace($workspace)->recent(); + * + * // Search activities + * $results = $service->search('updated post'); + * + * @requires spatie/laravel-activitylog + */ +class ActivityLogService +{ + protected ?Builder $query = null; + + protected ?int $workspaceId = null; + + /** + * Get the base activity query. + */ + protected function newQuery(): Builder + { + return Activity::query()->latest(); + } + + /** + * Get or create the current query builder. + */ + protected function query(): Builder + { + if ($this->query === null) { + $this->query = $this->newQuery(); + } + + return $this->query; + } + + /** + * Reset the query builder for a new chain. + */ + public function fresh(): self + { + $this->query = null; + $this->workspaceId = null; + + return $this; + } + + /** + * Get activities for a specific model (subject). + * + * @param Model $subject The model to get activities for + */ + public function logFor(Model $subject): self + { + $this->query() + ->where('subject_type', get_class($subject)) + ->where('subject_id', $subject->getKey()); + + return $this; + } + + /** + * Get activities performed by a specific user. + * + * @param Authenticatable|Model $causer The user who caused the activities + */ + public function logBy(Authenticatable|Model $causer): self + { + $this->query() + ->where('causer_type', get_class($causer)) + ->where('causer_id', $causer->getKey()); + + return $this; + } + + /** + * Scope activities to a specific workspace. + * + * @param Model|int $workspace The workspace or workspace ID + */ + public function forWorkspace(Model|int $workspace): self + { + $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace; + $this->workspaceId = $workspaceId; + + $this->query()->where(function (Builder $q) use ($workspaceId) { + $q->whereJsonContains('properties->workspace_id', $workspaceId) + ->orWhere(function (Builder $subQ) use ($workspaceId) { + // Also check if subject has workspace_id + $subQ->whereHas('subject', function (Builder $subjectQuery) use ($workspaceId) { + $subjectQuery->where('workspace_id', $workspaceId); + }); + }); + }); + + return $this; + } + + /** + * Filter activities by subject type. + * + * @param string $subjectType Fully qualified class name + */ + public function forSubjectType(string $subjectType): self + { + $this->query()->where('subject_type', $subjectType); + + return $this; + } + + /** + * Filter activities by event type. + * + * @param string|array $event Event type(s): 'created', 'updated', 'deleted', etc. + */ + public function ofType(string|array $event): self + { + $events = is_array($event) ? $event : [$event]; + + $this->query()->whereIn('event', $events); + + return $this; + } + + /** + * Filter activities by log name. + * + * @param string $logName The log name to filter by + */ + public function inLog(string $logName): self + { + $this->query()->where('log_name', $logName); + + return $this; + } + + /** + * Filter activities within a date range. + */ + public function between(\DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null): self + { + $this->query()->where('created_at', '>=', $from); + + if ($to !== null) { + $this->query()->where('created_at', '<=', $to); + } + + return $this; + } + + /** + * Filter activities from the last N days. + * + * @param int $days Number of days + */ + public function lastDays(int $days): self + { + $this->query()->where('created_at', '>=', now()->subDays($days)); + + return $this; + } + + /** + * Search activity descriptions. + * + * @param string $query Search query + */ + public function search(string $query): self + { + $searchTerm = '%'.addcslashes($query, '%_').'%'; + + $this->query()->where(function (Builder $q) use ($searchTerm) { + $q->where('description', 'LIKE', $searchTerm) + ->orWhere('properties', 'LIKE', $searchTerm); + }); + + return $this; + } + + /** + * Get recent activities with optional limit. + * + * @param int $limit Maximum number of activities to return + */ + public function recent(int $limit = 50): Collection + { + return $this->query() + ->with(['causer', 'subject']) + ->limit($limit) + ->get(); + } + + /** + * Get paginated activities. + * + * @param int $perPage Number of activities per page + */ + public function paginate(int $perPage = 15): LengthAwarePaginator + { + return $this->query() + ->with(['causer', 'subject']) + ->paginate($perPage); + } + + /** + * Get all filtered activities. + */ + public function get(): Collection + { + return $this->query() + ->with(['causer', 'subject']) + ->get(); + } + + /** + * Get the first activity. + */ + public function first(): ?Activity + { + return $this->query() + ->with(['causer', 'subject']) + ->first(); + } + + /** + * Count the activities. + */ + public function count(): int + { + return $this->query()->count(); + } + + /** + * Get activity statistics for a workspace. + * + * @return array{total: int, by_event: array, by_subject: array, by_user: array} + */ + public function statistics(Model|int|null $workspace = null): array + { + $query = $this->newQuery(); + + if ($workspace !== null) { + $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace; + $query->whereJsonContains('properties->workspace_id', $workspaceId); + } + + // Get totals by event type + $byEvent = (clone $query) + ->selectRaw('event, COUNT(*) as count') + ->groupBy('event') + ->pluck('count', 'event') + ->toArray(); + + // Get totals by subject type + $bySubject = (clone $query) + ->selectRaw('subject_type, COUNT(*) as count') + ->whereNotNull('subject_type') + ->groupBy('subject_type') + ->pluck('count', 'subject_type') + ->mapWithKeys(fn ($count, $type) => [class_basename($type) => $count]) + ->toArray(); + + // Get top users + $byUser = (clone $query) + ->selectRaw('causer_id, causer_type, COUNT(*) as count') + ->whereNotNull('causer_id') + ->groupBy('causer_id', 'causer_type') + ->orderByDesc('count') + ->limit(10) + ->get() + ->mapWithKeys(function ($row) { + $causer = $row->causer; + $name = $causer?->name ?? $causer?->email ?? "User #{$row->causer_id}"; + + return [$name => $row->count]; + }) + ->toArray(); + + return [ + 'total' => $query->count(), + 'by_event' => $byEvent, + 'by_subject' => $bySubject, + 'by_user' => $byUser, + ]; + } + + /** + * Get timeline of activities grouped by date. + * + * @param int $days Number of days to include + */ + public function timeline(int $days = 30): \Illuminate\Support\Collection + { + return $this->lastDays($days) + ->query() + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date'); + } + + /** + * Format an activity for display. + * + * @return array{ + * id: int, + * event: string, + * description: string, + * timestamp: string, + * relative_time: string, + * actor: array|null, + * subject: array|null, + * changes: array|null, + * workspace_id: int|null + * } + */ + public function format(Activity $activity): array + { + $causer = $activity->causer; + $subject = $activity->subject; + $properties = $activity->properties; + + // Extract changes if available + $changes = null; + if ($properties->has('attributes') || $properties->has('old')) { + $changes = [ + 'old' => $properties->get('old', []), + 'new' => $properties->get('attributes', []), + ]; + } + + return [ + 'id' => $activity->id, + 'event' => $activity->event ?? 'activity', + 'description' => $activity->description, + 'timestamp' => $activity->created_at->toIso8601String(), + 'relative_time' => $activity->created_at->diffForHumans(), + 'actor' => $causer ? [ + 'id' => $causer->getKey(), + 'name' => $causer->name ?? $causer->email ?? 'Unknown', + 'avatar' => method_exists($causer, 'avatarUrl') ? $causer->avatarUrl() : null, + 'initials' => $this->getInitials($causer->name ?? $causer->email ?? 'U'), + ] : null, + 'subject' => $subject ? [ + 'id' => $subject->getKey(), + 'type' => class_basename($subject), + 'name' => $this->getSubjectName($subject), + 'url' => $this->getSubjectUrl($subject), + ] : null, + 'changes' => $changes, + 'workspace_id' => $properties->get('workspace_id'), + ]; + } + + /** + * Get initials from a name. + */ + protected function getInitials(string $name): string + { + $words = explode(' ', trim($name)); + + if (count($words) >= 2) { + return strtoupper(substr($words[0], 0, 1).substr(end($words), 0, 1)); + } + + return strtoupper(substr($name, 0, 2)); + } + + /** + * Get the display name for a subject. + */ + protected function getSubjectName(Model $subject): string + { + // Try common name attributes + foreach (['name', 'title', 'label', 'email', 'slug'] as $attribute) { + if (isset($subject->{$attribute})) { + return (string) $subject->{$attribute}; + } + } + + return class_basename($subject).' #'.$subject->getKey(); + } + + /** + * Get the URL for a subject if available. + */ + protected function getSubjectUrl(Model $subject): ?string + { + // If model has a getUrl method, use it + if (method_exists($subject, 'getUrl')) { + return $subject->getUrl(); + } + + // If model has a url attribute + if (isset($subject->url)) { + return $subject->url; + } + + return null; + } + + /** + * Delete activities older than the retention period. + * + * @param int|null $days Days to retain (null = use config) + * @return int Number of deleted activities + */ + public function prune(?int $days = null): int + { + $retentionDays = $days ?? config('core.activity.retention_days', 90); + + if ($retentionDays <= 0) { + return 0; + } + + $cutoffDate = now()->subDays($retentionDays); + + return Activity::where('created_at', '<', $cutoffDate)->delete(); + } +} diff --git a/app/Core/Activity/Services/CLAUDE.md b/app/Core/Activity/Services/CLAUDE.md new file mode 100644 index 0000000..3296275 --- /dev/null +++ b/app/Core/Activity/Services/CLAUDE.md @@ -0,0 +1,11 @@ +# Activity/Services/ — Activity Log Service + +## Services + +| Service | Purpose | +|---------|---------| +| `ActivityLogService` | Fluent interface for querying and managing activity logs. Methods: `logFor($model)`, `logBy($user)`, `forWorkspace($workspace)`, `recent()`, `search($term)`. Chainable query builder with workspace awareness. | + +Provides the business logic layer over Spatie's activity log. Used by the `ActivityFeed` Livewire component and available for injection throughout the application. + +Requires `spatie/laravel-activitylog`. diff --git a/app/Core/Activity/View/Blade/admin/CLAUDE.md b/app/Core/Activity/View/Blade/admin/CLAUDE.md new file mode 100644 index 0000000..2f5148c --- /dev/null +++ b/app/Core/Activity/View/Blade/admin/CLAUDE.md @@ -0,0 +1,9 @@ +# Activity/View/Blade/admin/ — Activity Feed Blade Template + +## Templates + +| File | Purpose | +|------|---------| +| `activity-feed.blade.php` | Admin panel activity log display — paginated list with filters (user, model type, event type, date range), activity detail modal with full diff view, optional polling for real-time updates. | + +Rendered by the `ActivityFeed` Livewire component via the `core.activity::admin.*` view namespace. diff --git a/app/Core/Activity/View/Blade/admin/activity-feed.blade.php b/app/Core/Activity/View/Blade/admin/activity-feed.blade.php new file mode 100644 index 0000000..d393a06 --- /dev/null +++ b/app/Core/Activity/View/Blade/admin/activity-feed.blade.php @@ -0,0 +1,322 @@ +
0) wire:poll.{{ $pollInterval }}s @endif> + Activity Log + + {{-- Statistics Cards --}} +
+ +
Total Activities
+
{{ number_format($this->statistics['total']) }}
+
+ + +
Created
+
{{ number_format($this->statistics['by_event']['created'] ?? 0) }}
+
+ + +
Updated
+
{{ number_format($this->statistics['by_event']['updated'] ?? 0) }}
+
+ + +
Deleted
+
{{ number_format($this->statistics['by_event']['deleted'] ?? 0) }}
+
+
+ + {{-- Filters --}} + +
+ + @foreach ($this->causers as $id => $name) + {{ $name }} + @endforeach + + + + @foreach ($this->subjectTypes as $type => $label) + {{ $label }} + @endforeach + + + + @foreach ($this->eventTypes as $type => $label) + {{ $label }} + @endforeach + + + + @foreach ($this->dateRanges as $days => $label) + {{ $label }} + @endforeach + + + + + @if ($causerId || $subjectType || $eventType || $daysBack !== 30 || $search) + + Clear Filters + + @endif +
+
+ + {{-- Activity List --}} + + @if ($this->activities->isEmpty()) +
+ + No Activities Found + + @if ($causerId || $subjectType || $eventType || $search) + Try adjusting your filters to see more results. + @else + Activity logging is enabled but no activities have been recorded yet. + @endif + +
+ @else +
+ @foreach ($this->activities as $activity) + @php + $formatted = $this->formatActivity($activity); + @endphp +
+ {{-- Avatar --}} +
+ @if ($formatted['actor']) + @if ($formatted['actor']['avatar']) + {{ $formatted['actor']['name'] }} + @else +
+ {{ $formatted['actor']['initials'] }} +
+ @endif + @else +
+ +
+ @endif +
+ + {{-- Details --}} +
+
+ + {{ $formatted['actor']['name'] ?? 'System' }} + + + {{ $formatted['description'] }} + +
+ + @if ($formatted['subject']) +
+ {{ $formatted['subject']['type'] }}: + @if ($formatted['subject']['url']) + + {{ $formatted['subject']['name'] }} + + @else + {{ $formatted['subject']['name'] }} + @endif +
+ @endif + + @if ($formatted['changes']) +
+
+ @php $changeCount = 0; @endphp + @foreach ($formatted['changes']['new'] as $key => $newValue) + @if (($formatted['changes']['old'][$key] ?? null) !== $newValue && $changeCount < 3) + @if ($changeCount > 0) + | + @endif + {{ $key }}: + {{ is_array($formatted['changes']['old'][$key] ?? null) ? json_encode($formatted['changes']['old'][$key]) : ($formatted['changes']['old'][$key] ?? 'null') }} + + {{ is_array($newValue) ? json_encode($newValue) : $newValue }} + @php $changeCount++; @endphp + @endif + @endforeach + @if (count(array_filter($formatted['changes']['new'], fn($v, $k) => ($formatted['changes']['old'][$k] ?? null) !== $v, ARRAY_FILTER_USE_BOTH)) > 3) + +{{ count($formatted['changes']['new']) - 3 }} more + @endif +
+
+ @endif + +
+ {{ $formatted['relative_time'] }} +
+
+ + {{-- Event Badge --}} +
+ + + {{ ucfirst($formatted['event']) }} + +
+
+ @endforeach +
+ + {{-- Pagination --}} + @if ($this->activities->hasPages()) +
+ {{ $this->activities->links() }} +
+ @endif + @endif +
+ + {{-- Detail Modal --}} + + @if ($this->selectedActivity) + @php + $selected = $this->formatActivity($this->selectedActivity); + @endphp +
+ Activity Details + + {{-- Activity Header --}} +
+ @if ($selected['actor']) + @if ($selected['actor']['avatar']) + {{ $selected['actor']['name'] }} + @else +
+ {{ $selected['actor']['initials'] }} +
+ @endif + @else +
+ +
+ @endif + +
+
+ + {{ $selected['actor']['name'] ?? 'System' }} + + + {{ ucfirst($selected['event']) }} + +
+
+ {{ $selected['description'] }} +
+
+ {{ $selected['relative_time'] }} · {{ \Carbon\Carbon::parse($selected['timestamp'])->format('M j, Y \a\t g:i A') }} +
+
+
+ + {{-- Subject Info --}} + @if ($selected['subject']) + +
Subject
+
+ {{ $selected['subject']['type'] }} + @if ($selected['subject']['url']) + + {{ $selected['subject']['name'] }} + + @else + {{ $selected['subject']['name'] }} + @endif +
+
+ @endif + + {{-- Changes Diff --}} + @if ($selected['changes'] && (count($selected['changes']['old']) > 0 || count($selected['changes']['new']) > 0)) +
+
Changes
+
+ + + + + + + + + + @foreach ($selected['changes']['new'] as $key => $newValue) + @php + $oldValue = $selected['changes']['old'][$key] ?? null; + @endphp + @if ($oldValue !== $newValue) + + + + + + @endif + @endforeach + +
FieldOld ValueNew Value
{{ $key }} + @if (is_array($oldValue)) +
{{ json_encode($oldValue, JSON_PRETTY_PRINT) }}
+ @elseif ($oldValue === null) + null + @elseif (is_bool($oldValue)) + {{ $oldValue ? 'true' : 'false' }} + @else + {{ $oldValue }} + @endif +
+ @if (is_array($newValue)) +
{{ json_encode($newValue, JSON_PRETTY_PRINT) }}
+ @elseif ($newValue === null) + null + @elseif (is_bool($newValue)) + {{ $newValue ? 'true' : 'false' }} + @else + {{ $newValue }} + @endif +
+
+
+ @endif + + {{-- Raw Properties --}} + + + + Raw Properties + + +
{{ json_encode($this->selectedActivity->properties, JSON_PRETTY_PRINT) }}
+
+
+
+ + {{-- Actions --}} +
+ Close +
+
+ @endif +
+
diff --git a/app/Core/Activity/View/Modal/Admin/ActivityFeed.php b/app/Core/Activity/View/Modal/Admin/ActivityFeed.php new file mode 100644 index 0000000..2ee47ac --- /dev/null +++ b/app/Core/Activity/View/Modal/Admin/ActivityFeed.php @@ -0,0 +1,370 @@ + + * + * + */ +class ActivityFeed extends Component +{ + use WithPagination; + + /** + * Filter by workspace ID. + */ + public ?int $workspaceId = null; + + /** + * Filter by causer (user) ID. + */ + #[Url] + public ?int $causerId = null; + + /** + * Filter by subject type (model class basename). + */ + #[Url] + public string $subjectType = ''; + + /** + * Filter by event type. + */ + #[Url] + public string $eventType = ''; + + /** + * Filter by date range (days back). + */ + #[Url] + public int $daysBack = 30; + + /** + * Search query. + */ + #[Url] + public string $search = ''; + + /** + * Currently selected activity for detail view. + */ + public ?int $selectedActivityId = null; + + /** + * Whether to show the detail modal. + */ + public bool $showDetailModal = false; + + /** + * Polling interval in seconds (0 = disabled). + */ + public int $pollInterval = 0; + + /** + * Number of items per page. + */ + public int $perPage = 15; + + protected ActivityLogService $activityService; + + public function boot(ActivityLogService $activityService): void + { + $this->activityService = $activityService; + } + + public function mount(?int $workspaceId = null, int $pollInterval = 0, int $perPage = 15): void + { + $this->workspaceId = $workspaceId; + $this->pollInterval = $pollInterval; + $this->perPage = $perPage; + } + + /** + * Get available subject types for filtering. + * + * @return array + */ + #[Computed] + public function subjectTypes(): array + { + $types = Activity::query() + ->whereNotNull('subject_type') + ->distinct() + ->pluck('subject_type') + ->mapWithKeys(fn ($type) => [class_basename($type) => class_basename($type)]) + ->toArray(); + + return ['' => 'All Types'] + $types; + } + + /** + * Get available event types for filtering. + * + * @return array + */ + #[Computed] + public function eventTypes(): array + { + return [ + '' => 'All Events', + 'created' => 'Created', + 'updated' => 'Updated', + 'deleted' => 'Deleted', + ]; + } + + /** + * Get available users (causers) for filtering. + * + * @return array + */ + #[Computed] + public function causers(): array + { + $causers = Activity::query() + ->whereNotNull('causer_id') + ->with('causer') + ->distinct() + ->get() + ->mapWithKeys(function ($activity) { + $causer = $activity->causer; + if (! $causer) { + return []; + } + $name = $causer->name ?? $causer->email ?? "User #{$causer->getKey()}"; + + return [$causer->getKey() => $name]; + }) + ->filter() + ->toArray(); + + return ['' => 'All Users'] + $causers; + } + + /** + * Get date range options. + * + * @return array + */ + #[Computed] + public function dateRanges(): array + { + return [ + 1 => 'Last 24 hours', + 7 => 'Last 7 days', + 30 => 'Last 30 days', + 90 => 'Last 90 days', + 365 => 'Last year', + ]; + } + + /** + * Get paginated activities. + */ + #[Computed] + public function activities(): LengthAwarePaginator + { + $service = $this->activityService->fresh(); + + // Apply workspace filter + if ($this->workspaceId) { + $service->forWorkspace($this->workspaceId); + } + + // Apply causer filter + if ($this->causerId) { + // We need to work around the service's user expectation + $service->query()->where('causer_id', $this->causerId); + } + + // Apply subject type filter + if ($this->subjectType) { + // Find the full class name that matches the basename + $fullType = Activity::query() + ->where('subject_type', 'LIKE', '%\\'.$this->subjectType) + ->orWhere('subject_type', $this->subjectType) + ->value('subject_type'); + + if ($fullType) { + $service->forSubjectType($fullType); + } + } + + // Apply event type filter + if ($this->eventType) { + $service->ofType($this->eventType); + } + + // Apply date range + $service->lastDays($this->daysBack); + + // Apply search + if ($this->search) { + $service->search($this->search); + } + + return $service->paginate($this->perPage); + } + + /** + * Get the selected activity for the detail modal. + */ + #[Computed] + public function selectedActivity(): ?Activity + { + if (! $this->selectedActivityId) { + return null; + } + + return Activity::with(['causer', 'subject'])->find($this->selectedActivityId); + } + + /** + * Get activity statistics. + * + * @return array{total: int, by_event: array, by_subject: array} + */ + #[Computed] + public function statistics(): array + { + return $this->activityService->statistics($this->workspaceId); + } + + /** + * Show the detail modal for an activity. + */ + public function showDetail(int $activityId): void + { + $this->selectedActivityId = $activityId; + $this->showDetailModal = true; + } + + /** + * Close the detail modal. + */ + public function closeDetail(): void + { + $this->showDetailModal = false; + $this->selectedActivityId = null; + } + + /** + * Reset all filters. + */ + public function resetFilters(): void + { + $this->causerId = null; + $this->subjectType = ''; + $this->eventType = ''; + $this->daysBack = 30; + $this->search = ''; + $this->resetPage(); + } + + /** + * Handle filter changes by resetting pagination. + */ + public function updatedCauserId(): void + { + $this->resetPage(); + } + + public function updatedSubjectType(): void + { + $this->resetPage(); + } + + public function updatedEventType(): void + { + $this->resetPage(); + } + + public function updatedDaysBack(): void + { + $this->resetPage(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + /** + * Format an activity for display. + * + * @return array{ + * id: int, + * event: string, + * description: string, + * timestamp: string, + * relative_time: string, + * actor: array|null, + * subject: array|null, + * changes: array|null, + * workspace_id: int|null + * } + */ + public function formatActivity(Activity $activity): array + { + return $this->activityService->format($activity); + } + + /** + * Get the event color class. + */ + public function eventColor(string $event): string + { + return match ($event) { + 'created' => 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + 'updated' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + 'deleted' => 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + }; + } + + /** + * Get the event icon. + */ + public function eventIcon(string $event): string + { + return match ($event) { + 'created' => 'plus-circle', + 'updated' => 'pencil', + 'deleted' => 'trash', + default => 'clock', + }; + } + + public function render(): View + { + return view('core.activity::admin.activity-feed'); + } +} diff --git a/app/Core/Activity/View/Modal/Admin/CLAUDE.md b/app/Core/Activity/View/Modal/Admin/CLAUDE.md new file mode 100644 index 0000000..8e305a7 --- /dev/null +++ b/app/Core/Activity/View/Modal/Admin/CLAUDE.md @@ -0,0 +1,11 @@ +# Activity/View/Modal/Admin/ — Activity Feed Livewire Component + +## Components + +| Component | Purpose | +|-----------|---------| +| `ActivityFeed` | Livewire component for displaying activity logs in the admin panel. Paginated list with URL-bound filters (causer, subject type, event type, date range, search). Supports workspace scoping and optional polling for real-time updates. | + +Usage: `` or `` + +Requires `spatie/laravel-activitylog`. diff --git a/app/Core/Boot.php b/app/Core/Boot.php new file mode 100644 index 0000000..0c3ea0e --- /dev/null +++ b/app/Core/Boot.php @@ -0,0 +1,94 @@ +withProviders(static::$providers) + ->withMiddleware(function (Middleware $middleware): void { + // Session middleware priority + $middleware->priority([ + StartSession::class, + ]); + + $middleware->redirectGuestsTo('/login'); + $middleware->redirectUsersTo('/hub'); + + // Front module configures middleware groups (web, admin, api, mcp) + Front\Boot::middleware($middleware); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // Clean exception handling for open-source + // Apps can add Sentry, custom error pages, etc. + })->create(); + } + + /** + * Get the application base path. + * + * Works whether Core is in vendor/ or packages/ (monorepo). + */ + protected static function basePath(): string + { + // Check for monorepo structure (packages/core-php/src/Core/Boot.php) + // The monorepo root has app/ directory while the package root doesn't + $monorepoBase = dirname(__DIR__, 4); + if (file_exists($monorepoBase.'/composer.json') && is_dir($monorepoBase.'/app')) { + return $monorepoBase; + } + + // Standard vendor structure (vendor/*/core/src/Core/Boot.php) + return dirname(__DIR__, 5); + } +} diff --git a/app/Core/Bouncer/BlocklistService.php b/app/Core/Bouncer/BlocklistService.php new file mode 100644 index 0000000..38c6801 --- /dev/null +++ b/app/Core/Bouncer/BlocklistService.php @@ -0,0 +1,347 @@ +call(function () { + * app(BlocklistService::class)->syncFromHoneypot(); + * })->hourly(); + * ``` + * + * ### Reviewing Pending Blocks + * + * ```php + * $blocklist = app(BlocklistService::class); + * + * // Get all pending entries (paginated for large blocklists) + * $pending = $blocklist->getPending(perPage: 50); + * + * // Approve a block + * $blocklist->approve('192.168.1.100'); + * + * // Reject a block (IP will not be blocked) + * $blocklist->reject('192.168.1.100'); + * ``` + * + * ## Cache Behaviour + * + * - Blocklist is cached for 5 minutes (CACHE_TTL constant) + * - Only 'approved' entries with valid expiry are included in cache + * - Cache is automatically cleared on block/unblock/approve operations + * - Use `clearCache()` to force cache refresh + * + * ## Manual Blocking + * + * ```php + * $blocklist = app(BlocklistService::class); + * + * // Block an IP immediately (approved status) + * $blocklist->block('192.168.1.100', 'spam', BlocklistService::STATUS_APPROVED); + * + * // Unblock an IP + * $blocklist->unblock('192.168.1.100'); + * + * // Check if IP is blocked + * if ($blocklist->isBlocked('192.168.1.100')) { + * // IP is actively blocked + * } + * ``` + * + * @see Boot For honeypot configuration options + * @see BouncerMiddleware For the blocking middleware + */ +class BlocklistService +{ + protected const CACHE_KEY = 'bouncer:blocklist'; + + protected const CACHE_TTL = 300; // 5 minutes + + protected const DEFAULT_PER_PAGE = 50; + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_REJECTED = 'rejected'; + + /** + * Check if IP is blocked. + */ + public function isBlocked(string $ip): bool + { + $blocklist = $this->getBlocklist(); + + return isset($blocklist[$ip]); + } + + /** + * Add IP to blocklist (immediately approved for manual blocks). + */ + public function block(string $ip, string $reason = 'manual', string $status = self::STATUS_APPROVED): void + { + DB::table('blocked_ips')->updateOrInsert( + ['ip_address' => $ip], + [ + 'reason' => $reason, + 'status' => $status, + 'blocked_at' => now(), + 'expires_at' => now()->addDays(30), + ] + ); + + $this->clearCache(); + } + + /** + * Remove IP from blocklist. + */ + public function unblock(string $ip): void + { + DB::table('blocked_ips')->where('ip_address', $ip)->delete(); + $this->clearCache(); + } + + /** + * Get full blocklist (cached). Only returns approved entries. + * + * Used for O(1) IP lookup checks. For admin UIs with large blocklists, + * use getBlocklistPaginated() instead. + */ + public function getBlocklist(): array + { + return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function (): array { + if (! $this->tableExists()) { + return []; + } + + return DB::table('blocked_ips') + ->where('status', self::STATUS_APPROVED) + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->pluck('reason', 'ip_address') + ->toArray(); + }); + } + + /** + * Get paginated blocklist for admin UI. + * + * Returns all entries (approved, pending, rejected) with pagination. + * Use this for admin interfaces displaying large blocklists. + * + * @param int|null $perPage Number of entries per page (default: 50) + * @param string|null $status Filter by status (null for all statuses) + */ + public function getBlocklistPaginated(?int $perPage = null, ?string $status = null): LengthAwarePaginator + { + if (! $this->tableExists()) { + return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage ?? self::DEFAULT_PER_PAGE); + } + + $query = DB::table('blocked_ips') + ->orderBy('blocked_at', 'desc'); + + if ($status !== null) { + $query->where('status', $status); + } + + return $query->paginate($perPage ?? self::DEFAULT_PER_PAGE); + } + + /** + * Check if the blocked_ips table exists. + */ + protected function tableExists(): bool + { + return Cache::remember('bouncer:blocked_ips_table_exists', 3600, function (): bool { + return DB::getSchemaBuilder()->hasTable('blocked_ips'); + }); + } + + /** + * Sync blocklist from honeypot critical hits. + * + * Creates entries in 'pending' status for human review. + * Call this from a scheduled job or after honeypot hits. + */ + public function syncFromHoneypot(): int + { + if (! DB::getSchemaBuilder()->hasTable('honeypot_hits')) { + return 0; + } + + $criticalIps = DB::table('honeypot_hits') + ->where('severity', 'critical') + ->where('created_at', '>=', now()->subDay()) + ->distinct() + ->pluck('ip_address'); + + $count = 0; + foreach ($criticalIps as $ip) { + $exists = DB::table('blocked_ips') + ->where('ip_address', $ip) + ->exists(); + + if (! $exists) { + DB::table('blocked_ips')->insert([ + 'ip_address' => $ip, + 'reason' => 'honeypot_critical', + 'status' => self::STATUS_PENDING, + 'blocked_at' => now(), + 'expires_at' => now()->addDays(7), + ]); + $count++; + } + } + + return $count; + } + + /** + * Get pending entries awaiting human review. + * + * @param int|null $perPage Number of entries per page. Pass null for all entries (legacy behavior). + * @return array|LengthAwarePaginator Array if $perPage is null, paginator otherwise. + */ + public function getPending(?int $perPage = null): array|LengthAwarePaginator + { + if (! $this->tableExists()) { + return $perPage === null + ? [] + : new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage); + } + + $query = DB::table('blocked_ips') + ->where('status', self::STATUS_PENDING) + ->orderBy('blocked_at', 'desc'); + + if ($perPage === null) { + return $query->get()->toArray(); + } + + return $query->paginate($perPage); + } + + /** + * Approve a pending block entry. + */ + public function approve(string $ip): bool + { + $updated = DB::table('blocked_ips') + ->where('ip_address', $ip) + ->where('status', self::STATUS_PENDING) + ->update(['status' => self::STATUS_APPROVED]); + + if ($updated > 0) { + $this->clearCache(); + } + + return $updated > 0; + } + + /** + * Reject a pending block entry. + */ + public function reject(string $ip): bool + { + $updated = DB::table('blocked_ips') + ->where('ip_address', $ip) + ->where('status', self::STATUS_PENDING) + ->update(['status' => self::STATUS_REJECTED]); + + return $updated > 0; + } + + /** + * Clear the cache. + */ + public function clearCache(): void + { + Cache::forget(self::CACHE_KEY); + } + + /** + * Get stats for dashboard. + */ + public function getStats(): array + { + if (! $this->tableExists()) { + return [ + 'total_blocked' => 0, + 'active_blocked' => 0, + 'pending_review' => 0, + 'by_reason' => [], + 'by_status' => [], + ]; + } + + return [ + 'total_blocked' => DB::table('blocked_ips')->count(), + 'active_blocked' => DB::table('blocked_ips') + ->where('status', self::STATUS_APPROVED) + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->count(), + 'pending_review' => DB::table('blocked_ips') + ->where('status', self::STATUS_PENDING) + ->count(), + 'by_reason' => DB::table('blocked_ips') + ->selectRaw('reason, COUNT(*) as count') + ->groupBy('reason') + ->pluck('count', 'reason') + ->toArray(), + 'by_status' => DB::table('blocked_ips') + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(), + ]; + } +} diff --git a/app/Core/Bouncer/Boot.php b/app/Core/Bouncer/Boot.php new file mode 100644 index 0000000..1c90ea6 --- /dev/null +++ b/app/Core/Bouncer/Boot.php @@ -0,0 +1,101 @@ + [ + * 'honeypot' => [ + * 'critical_paths' => [ + * 'admin', + * 'wp-admin', + * '.env', + * '.git', + * 'backup', // Add custom paths + * 'config.php', + * ], + * ], + * ], + * ``` + * + * ### Blocking Workflow + * + * 1. Bot hits a honeypot path (e.g., /admin) + * 2. Path is checked against `critical_paths` (prefix matching) + * 3. If critical and `auto_block_critical` is true, IP is blocked immediately + * 4. Otherwise, entry is added to `honeypot_hits` with 'pending' status + * 5. Admin reviews pending entries via `BlocklistService::getPending()` + * 6. Admin approves or rejects via `approve($ip)` or `reject($ip)` + * + * ### Rate Limiting + * + * To prevent DoS via log flooding, honeypot logging is rate-limited: + * - Default: 10 entries per IP per minute + * - Exceeded entries are silently dropped + * - Rate limit uses Laravel's RateLimiter facade + * + * @see BlocklistService For IP blocking functionality + * @see BouncerMiddleware For the early-exit middleware + */ +class Boot extends ServiceProvider +{ + public function register(): void + { + $this->app->singleton(BlocklistService::class); + $this->app->singleton(RedirectService::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + } +} diff --git a/app/Core/Bouncer/BouncerMiddleware.php b/app/Core/Bouncer/BouncerMiddleware.php new file mode 100644 index 0000000..0968820 --- /dev/null +++ b/app/Core/Bouncer/BouncerMiddleware.php @@ -0,0 +1,91 @@ +setTrustedProxies($request); + + $ip = $request->ip(); + $path = $request->path(); + + // Check blocklist - fastest rejection + if ($this->blocklist->isBlocked($ip)) { + return $this->blockedResponse($ip); + } + + // Check SEO redirects + if ($redirect = $this->redirects->match($path)) { + return redirect($redirect['to'], $redirect['status']); + } + + return $next($request); + } + + /** + * Configure trusted proxies for correct client IP detection. + * + * TRUSTED_PROXIES env var: comma-separated IPs or '*' for all. + * Production: set to load balancer IPs (e.g., hermes.lb.host.uk.com) + * Development: defaults to '*' (trust all) + */ + protected function setTrustedProxies(Request $request): void + { + $trustedProxies = env('TRUSTED_PROXIES', '*'); + + $proxies = $trustedProxies === '*' + ? $request->server->get('REMOTE_ADDR') // Trust the immediate proxy + : explode(',', $trustedProxies); + + $request->setTrustedProxies( + is_array($proxies) ? $proxies : [$proxies], + Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO + ); + } + + /** + * Response for blocked IPs - minimal processing. + */ + protected function blockedResponse(string $ip): Response + { + return response('🫖', 418, [ + 'Content-Type' => 'text/plain', + 'X-Blocked' => 'true', + 'X-Powered-By' => 'Earl Grey', + ]); + } +} diff --git a/app/Core/Bouncer/CLAUDE.md b/app/Core/Bouncer/CLAUDE.md new file mode 100644 index 0000000..ac0d6ff --- /dev/null +++ b/app/Core/Bouncer/CLAUDE.md @@ -0,0 +1,61 @@ +# Bouncer + +Early-exit security middleware + whitelist-based action authorisation gate. + +## What It Does + +Two subsystems in one: + +1. **Bouncer** (top-level): IP blocklist + SEO redirects, runs before all other middleware +2. **Gate** (subdirectory): Whitelist-based controller action authorisation with training mode + +## Bouncer (IP Blocking + Redirects) + +### Key Classes + +| Class | Purpose | +|-------|---------| +| `Boot` | ServiceProvider registering `BlocklistService`, `RedirectService`, and migrations | +| `BouncerMiddleware` | Early-exit middleware: sets trusted proxies, checks blocklist (O(1) via cached set), handles SEO redirects, then passes through | +| `BlocklistService` | IP blocking with Redis-cached lookup. Statuses: `pending` (honeypot, needs review), `approved` (active block), `rejected` (reviewed, not blocked). Methods: `isBlocked()`, `block()`, `unblock()`, `syncFromHoneypot()`, `approve()`, `reject()`, `getPending()`, `getStats()` | +| `RedirectService` | Cached SEO redirects from `seo_redirects` table. Supports exact match and wildcard (`path/*`). Methods: `match()`, `add()`, `remove()` | + +### Hidden Ideas + +- Blocked IPs get `418 I'm a teapot` with `X-Powered-By: Earl Grey` +- Honeypot monitors paths from `robots.txt` disallow list; critical paths (`/admin`, `/.env`, `/wp-admin`) trigger auto-block +- Rate-limited honeypot logging prevents DoS via log flooding +- `TRUSTED_PROXIES` env var: comma-separated IPs or `*` (trust all) + +## Gate (Action Whitelist) + +Philosophy: **"If it wasn't trained, it doesn't exist."** + +### Key Classes + +| Class | Purpose | +|-------|---------| +| `Gate\Boot` | ServiceProvider registering middleware, migrations, route macros, and training routes | +| `ActionGateService` | Resolves action name from route (3-level priority), checks against `ActionPermission` table, logs to `ActionRequest`. Methods: `check()`, `allow()`, `deny()`, `resolveAction()` | +| `ActionGateMiddleware` | Enforces gate: allowed = pass, denied = 403, training = approval prompt (JSON for API, redirect for web) | +| `Action` (attribute) | `#[Action('product.create', scope: 'product')]` on controller methods | +| `ActionPermission` (model) | Whitelist record: action + guard + role + scope. Methods: `isAllowed()`, `train()`, `revoke()`, `allowedFor()` | +| `ActionRequest` (model) | Audit log of all permission checks. Methods: `log()`, `pending()`, `deniedActionsSummary()`, `prune()` | +| `RouteActionMacro` | Adds `->action('name')`, `->bypassGate()`, `->requiresTraining()` to Route | + +### Action Resolution Priority + +1. Route action: `Route::post(...)->action('product.create')` +2. Controller attribute: `#[Action('product.create')]` +3. Auto-resolved: `ProductController@store` becomes `product.store` + +### Training Mode + +When `core.bouncer.training_mode = true`, unknown actions prompt for approval instead of blocking. Training routes at `/_bouncer/approve` and `/_bouncer/pending`. + +## Integration + +- BouncerMiddleware runs FIRST in the stack (replaces Laravel TrustProxies) +- ActionGateMiddleware appends to `web`, `admin`, `api`, `client` groups +- Config: `core.bouncer.enabled`, `core.bouncer.training_mode`, `core.bouncer.guarded_middleware` +- DB tables: `blocked_ips`, `seo_redirects`, `honeypot_hits`, `core_action_permissions`, `core_action_requests` diff --git a/app/Core/Bouncer/Database/Seeders/CLAUDE.md b/app/Core/Bouncer/Database/Seeders/CLAUDE.md new file mode 100644 index 0000000..ec7e928 --- /dev/null +++ b/app/Core/Bouncer/Database/Seeders/CLAUDE.md @@ -0,0 +1,7 @@ +# Bouncer/Database/Seeders/ — Bouncer Seeders + +## Seeders + +| File | Purpose | +|------|---------| +| `WebsiteRedirectSeeder.php` | Seeds 301 redirects for renamed website URLs. Uses the `RedirectService` to register old-to-new path mappings (e.g., `/services/biohost` -> `/services/bio`). Added during URL simplification (2026-01-16). | diff --git a/app/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php b/app/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php new file mode 100644 index 0000000..457415b --- /dev/null +++ b/app/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php @@ -0,0 +1,54 @@ + new path. + */ + protected array $redirects = [ + // Service pages + '/services/biohost' => '/services/bio', + '/services/socialhost' => '/services/social', + '/services/trusthost' => '/services/trust', + '/services/mailhost' => '/services/mail', + '/services/analyticshost' => '/services/analytics', + '/services/notifyhost' => '/services/notify', + + // MCP documentation + '/developers/mcp/biohost' => '/developers/mcp/bio', + '/developers/mcp/socialhost' => '/developers/mcp/social', + ]; + + public function __construct( + protected RedirectService $service, + ) {} + + public function run(): void + { + foreach ($this->redirects as $from => $to) { + $this->service->add($from, $to, 301); + } + + $this->command?->info('Seeded '.count($this->redirects).' website redirects.'); + } +} diff --git a/app/Core/Bouncer/Gate/ActionGateMiddleware.php b/app/Core/Bouncer/Gate/ActionGateMiddleware.php new file mode 100644 index 0000000..580be0b --- /dev/null +++ b/app/Core/Bouncer/Gate/ActionGateMiddleware.php @@ -0,0 +1,155 @@ + BouncerGate (action whitelisting) -> Laravel Gate/Policy -> Controller + * ``` + * + * ## Behavior by Mode + * + * **Production (training_mode = false):** + * - Allowed actions proceed normally + * - Unknown/denied actions return 403 Forbidden + * + * **Training Mode (training_mode = true):** + * - Allowed actions proceed normally + * - Unknown actions return a training response: + * - API requests: JSON with action details and approval prompt + * - Web requests: Redirect back with flash message + */ +class ActionGateMiddleware +{ + public function __construct( + protected ActionGateService $gateService, + ) {} + + public function handle(Request $request, Closure $next): Response + { + // Skip for routes that explicitly bypass the gate + if ($request->route()?->getAction('bypass_gate')) { + return $next($request); + } + + $result = $this->gateService->check($request); + + return match ($result['result']) { + ActionGateService::RESULT_ALLOWED => $next($request), + ActionGateService::RESULT_TRAINING => $this->trainingResponse($request, $result), + default => $this->deniedResponse($request, $result), + }; + } + + /** + * Generate response for training mode. + */ + protected function trainingResponse(Request $request, array $result): Response + { + $action = $result['action']; + $scope = $result['scope']; + + if ($this->wantsJson($request)) { + return $this->trainingJsonResponse($request, $action, $scope); + } + + return $this->trainingWebResponse($request, $action, $scope); + } + + /** + * JSON response for training mode (API requests). + */ + protected function trainingJsonResponse(Request $request, string $action, ?string $scope): JsonResponse + { + return response()->json([ + 'error' => 'action_not_trained', + 'message' => "Action '{$action}' is not trained. Approve this action to continue.", + 'action' => $action, + 'scope' => $scope, + 'route' => $request->path(), + 'method' => $request->method(), + 'training_mode' => true, + 'approval_url' => $this->approvalUrl($action, $scope, $request), + ], 403); + } + + /** + * Web response for training mode (browser requests). + */ + protected function trainingWebResponse(Request $request, string $action, ?string $scope): RedirectResponse + { + $message = "Action '{$action}' requires training approval."; + + return redirect() + ->back() + ->with('bouncer_training', [ + 'action' => $action, + 'scope' => $scope, + 'route' => $request->path(), + 'method' => $request->method(), + 'message' => $message, + ]) + ->withInput(); + } + + /** + * Generate response for denied action. + */ + protected function deniedResponse(Request $request, array $result): Response + { + $action = $result['action']; + + if ($this->wantsJson($request)) { + return response()->json([ + 'error' => 'action_denied', + 'message' => "Action '{$action}' is not permitted.", + 'action' => $action, + ], 403); + } + + abort(403, "Action '{$action}' is not permitted."); + } + + /** + * Check if request expects JSON response. + */ + protected function wantsJson(Request $request): bool + { + return $request->expectsJson() + || $request->is('api/*') + || $request->header('Accept') === 'application/json'; + } + + /** + * Generate URL for approving an action. + */ + protected function approvalUrl(string $action, ?string $scope, Request $request): string + { + return route('bouncer.gate.approve', [ + 'action' => $action, + 'scope' => $scope, + 'redirect' => $request->fullUrl(), + ]); + } +} diff --git a/app/Core/Bouncer/Gate/ActionGateService.php b/app/Core/Bouncer/Gate/ActionGateService.php new file mode 100644 index 0000000..975164a --- /dev/null +++ b/app/Core/Bouncer/Gate/ActionGateService.php @@ -0,0 +1,370 @@ + ActionGateMiddleware -> ActionGateService::check() -> Controller + * | + * v + * ActionPermission + * (allowed/denied) + * ``` + * + * ## Action Resolution Priority + * + * 1. Route action (via `->action('name')` macro) + * 2. Controller method attribute (`#[Action('name')]`) + * 3. Auto-resolved from controller@method + */ +class ActionGateService +{ + /** + * Result of permission check. + */ + public const RESULT_ALLOWED = 'allowed'; + + public const RESULT_DENIED = 'denied'; + + public const RESULT_TRAINING = 'training'; + + /** + * Cache of resolved action names. + * + * @var array + */ + protected array $actionCache = []; + + /** + * Check if an action is permitted. + * + * @return array{result: string, action: string, scope: string|null} + */ + public function check(Request $request): array + { + $route = $request->route(); + + if (! $route instanceof Route) { + return $this->denied('unknown', null); + } + + // Resolve action name and scope + $resolved = $this->resolveAction($route); + $action = $resolved['action']; + $scope = $resolved['scope']; + + // Determine guard and role + $guard = $this->resolveGuard($route); + $role = $this->resolveRole($request); + + // Check permission + $allowed = ActionPermission::isAllowed($action, $guard, $role, $scope); + + // Log the request + $status = $allowed + ? ActionRequest::STATUS_ALLOWED + : ($this->isTrainingMode() ? ActionRequest::STATUS_PENDING : ActionRequest::STATUS_DENIED); + + ActionRequest::log( + method: $request->method(), + route: $request->path(), + action: $action, + guard: $guard, + status: $status, + scope: $scope, + role: $role, + userId: $request->user()?->id, + ipAddress: $request->ip(), + ); + + if ($allowed) { + return $this->allowed($action, $scope); + } + + if ($this->isTrainingMode()) { + return $this->training($action, $scope); + } + + return $this->denied($action, $scope); + } + + /** + * Allow an action (create permission). + */ + public function allow( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null, + ?string $route = null, + ?int $trainedBy = null + ): ActionPermission { + return ActionPermission::train($action, $guard, $role, $scope, $route, $trainedBy); + } + + /** + * Deny an action (revoke permission). + */ + public function deny( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): bool { + return ActionPermission::revoke($action, $guard, $role, $scope); + } + + /** + * Check if training mode is enabled. + */ + public function isTrainingMode(): bool + { + return (bool) config('core.bouncer.training_mode', false); + } + + /** + * Resolve the action name for a route. + * + * @return array{action: string, scope: string|null} + */ + public function resolveAction(Route $route): array + { + $cacheKey = $route->getName() ?? $route->uri(); + + if (isset($this->actionCache[$cacheKey])) { + return $this->actionCache[$cacheKey]; + } + + // 1. Check for explicit route action + $routeAction = $route->getAction('bouncer_action'); + if ($routeAction) { + $result = [ + 'action' => $routeAction, + 'scope' => $route->getAction('bouncer_scope'), + ]; + $this->actionCache[$cacheKey] = $result; + + return $result; + } + + // 2. Check controller method attribute (requires container) + try { + $controller = $route->getController(); + $method = $route->getActionMethod(); + + if ($controller !== null && $method !== 'Closure') { + $attributeResult = $this->resolveFromAttribute($controller, $method); + if ($attributeResult !== null) { + $this->actionCache[$cacheKey] = $attributeResult; + + return $attributeResult; + } + } + } catch (\Throwable) { + // Container not available or controller doesn't exist + // Fall through to auto-resolution + } + + // 3. Auto-resolve from controller@method + $result = [ + 'action' => $this->autoResolveAction($route), + 'scope' => null, + ]; + $this->actionCache[$cacheKey] = $result; + + return $result; + } + + /** + * Resolve action from controller/method attribute. + * + * @return array{action: string, scope: string|null}|null + */ + protected function resolveFromAttribute(object $controller, string $method): ?array + { + try { + $reflection = new ReflectionMethod($controller, $method); + $attributes = $reflection->getAttributes(Action::class); + + if (empty($attributes)) { + // Check class-level attribute as fallback + $classReflection = new ReflectionClass($controller); + $attributes = $classReflection->getAttributes(Action::class); + } + + if (! empty($attributes)) { + /** @var Action $action */ + $action = $attributes[0]->newInstance(); + + return [ + 'action' => $action->name, + 'scope' => $action->scope, + ]; + } + } catch (\ReflectionException) { + // Fall through to auto-resolution + } + + return null; + } + + /** + * Auto-resolve action name from controller and method. + * + * Examples: + * - ProductController@store -> product.store + * - Admin\UserController@index -> admin.user.index + * - Api\V1\OrderController@show -> api.v1.order.show + */ + protected function autoResolveAction(Route $route): string + { + $uses = $route->getAction('uses'); + + if (is_string($uses) && str_contains($uses, '@')) { + [$controllerClass, $method] = explode('@', $uses); + + // Remove 'Controller' suffix and convert to dot notation + $parts = explode('\\', $controllerClass); + $parts = array_map(function ($part) { + // Remove 'Controller' suffix + if (str_ends_with($part, 'Controller')) { + $part = substr($part, 0, -10); + } + + // Convert PascalCase to snake_case, then to kebab-case dots + return strtolower(preg_replace('/(? ! in_array($p, ['app', 'http', 'controllers'])); + + $parts[] = strtolower($method); + + return implode('.', array_values($parts)); + } + + // Fallback for closures or invokable controllers + return 'route.'.($route->getName() ?? $route->uri()); + } + + /** + * Resolve the guard from route middleware. + */ + protected function resolveGuard(Route $route): string + { + $middleware = $route->gatherMiddleware(); + + foreach (['admin', 'api', 'client', 'web'] as $guard) { + if (in_array($guard, $middleware)) { + return $guard; + } + } + + return 'web'; + } + + /** + * Resolve the user's role. + */ + protected function resolveRole(Request $request): ?string + { + $user = $request->user(); + + if (! $user) { + return null; + } + + // Common role resolution strategies + if (method_exists($user, 'getRole')) { + return $user->getRole(); + } + + if (method_exists($user, 'role') && is_callable([$user, 'role'])) { + $role = $user->role(); + + return is_object($role) ? ($role->name ?? null) : $role; + } + + if (property_exists($user, 'role')) { + return $user->role; + } + + return null; + } + + /** + * Build an allowed result. + * + * @return array{result: string, action: string, scope: string|null} + */ + protected function allowed(string $action, ?string $scope): array + { + return [ + 'result' => self::RESULT_ALLOWED, + 'action' => $action, + 'scope' => $scope, + ]; + } + + /** + * Build a denied result. + * + * @return array{result: string, action: string, scope: string|null} + */ + protected function denied(string $action, ?string $scope): array + { + return [ + 'result' => self::RESULT_DENIED, + 'action' => $action, + 'scope' => $scope, + ]; + } + + /** + * Build a training mode result. + * + * @return array{result: string, action: string, scope: string|null} + */ + protected function training(string $action, ?string $scope): array + { + return [ + 'result' => self::RESULT_TRAINING, + 'action' => $action, + 'scope' => $scope, + ]; + } + + /** + * Clear the action resolution cache. + */ + public function clearCache(): void + { + $this->actionCache = []; + } +} diff --git a/app/Core/Bouncer/Gate/Attributes/Action.php b/app/Core/Bouncer/Gate/Attributes/Action.php new file mode 100644 index 0000000..2744afd --- /dev/null +++ b/app/Core/Bouncer/Gate/Attributes/Action.php @@ -0,0 +1,63 @@ + `product.store` + * - `Admin\UserController@index` -> `admin.user.index` + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +class Action +{ + /** + * Create a new Action attribute. + * + * @param string $name The action identifier (e.g., 'product.create') + * @param string|null $scope Optional scope for resource-specific permissions + */ + public function __construct( + public readonly string $name, + public readonly ?string $scope = null, + ) {} +} diff --git a/app/Core/Bouncer/Gate/Attributes/CLAUDE.md b/app/Core/Bouncer/Gate/Attributes/CLAUDE.md new file mode 100644 index 0000000..18e637c --- /dev/null +++ b/app/Core/Bouncer/Gate/Attributes/CLAUDE.md @@ -0,0 +1,14 @@ +# Bouncer/Gate/Attributes/ — Action Gate PHP Attributes + +## Attributes + +| Attribute | Target | Purpose | +|-----------|--------|---------| +| `#[Action(name, scope?)]` | Method, Class | Declares an explicit action name for permission checking, overriding auto-resolution from controller/method names. Optional `scope` for resource-specific permissions. | + +Without this attribute, action names are auto-resolved: `ProductController@store` becomes `product.store`. + +```php +#[Action('product.create')] +public function store(Request $request) { ... } +``` diff --git a/app/Core/Bouncer/Gate/Boot.php b/app/Core/Bouncer/Gate/Boot.php new file mode 100644 index 0000000..361c75c --- /dev/null +++ b/app/Core/Bouncer/Gate/Boot.php @@ -0,0 +1,150 @@ + ActionGateMiddleware -> Laravel Gate/Policy -> Controller + * ``` + * + * ## Configuration + * + * See `config/core.php` under the 'bouncer' key for all options. + */ +class Boot extends ServiceProvider +{ + /** + * Configure action gate middleware. + * + * Call this from your application's bootstrap to add the gate to middleware groups. + * + * ```php + * // bootstrap/app.php + * ->withMiddleware(function (Middleware $middleware) { + * \Core\Bouncer\Gate\Boot::middleware($middleware); + * }) + * ``` + */ + public static function middleware(Middleware $middleware): void + { + // Add to specific middleware groups that should be gated + $guardedGroups = config('core.bouncer.guarded_middleware', ['web', 'admin', 'api', 'client']); + + foreach ($guardedGroups as $group) { + $middleware->appendToGroup($group, ActionGateMiddleware::class); + } + + // Register middleware alias for manual use + $middleware->alias([ + 'action.gate' => ActionGateMiddleware::class, + ]); + } + + public function register(): void + { + // Register as singleton for caching benefits + $this->app->singleton(ActionGateService::class); + + // Merge config defaults + $this->mergeConfigFrom( + dirname(__DIR__, 2).'/config.php', + 'core' + ); + } + + public function boot(): void + { + // Skip if disabled + if (! config('core.bouncer.enabled', true)) { + return; + } + + // Load migrations + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + + // Register route macros + RouteActionMacro::register(); + + // Register training/approval routes if in training mode + if (config('core.bouncer.training_mode', false)) { + $this->registerTrainingRoutes(); + } + } + + /** + * Register routes for training mode approval workflow. + */ + protected function registerTrainingRoutes(): void + { + Route::middleware(['web', 'auth']) + ->prefix('_bouncer') + ->name('bouncer.gate.') + ->group(function () { + // Approve an action + Route::post('/approve', function () { + $action = request('action'); + $scope = request('scope'); + $redirect = request('redirect', '/'); + + if (! $action) { + return back()->with('error', 'No action specified'); + } + + $guard = request('guard', 'web'); + $role = request('role'); + + app(ActionGateService::class)->allow( + action: $action, + guard: $guard, + role: $role, + scope: $scope, + route: request('route'), + trainedBy: auth()->id(), + ); + + return redirect($redirect)->with('success', "Action '{$action}' has been approved."); + })->name('approve'); + + // List pending actions + Route::get('/pending', function () { + $pending = Models\ActionRequest::pending() + ->groupBy('action') + ->map(fn ($requests) => [ + 'action' => $requests->first()->action, + 'count' => $requests->count(), + 'routes' => $requests->pluck('route')->unique()->values(), + 'last_at' => $requests->max('created_at'), + ]) + ->values(); + + if (request()->wantsJson()) { + return response()->json(['pending' => $pending]); + } + + return view('bouncer::pending', ['pending' => $pending]); + })->name('pending'); + }); + } +} diff --git a/app/Core/Bouncer/Gate/CLAUDE.md b/app/Core/Bouncer/Gate/CLAUDE.md new file mode 100644 index 0000000..e2f4323 --- /dev/null +++ b/app/Core/Bouncer/Gate/CLAUDE.md @@ -0,0 +1,18 @@ +# Bouncer/Gate/ — Action Gate Authorisation + +Whitelist-based request authorisation system. Philosophy: "If it wasn't trained, it doesn't exist." + +## Files + +| File | Purpose | +|------|---------| +| `Boot.php` | ServiceProvider — registers middleware, configures action gate. | +| `ActionGateMiddleware.php` | Intercepts requests, checks if the target action is permitted. Production mode blocks unknown actions (403). Training mode prompts for approval. | +| `ActionGateService.php` | Core service — resolves action names from routes/controllers, checks `ActionPermission` records. Supports `#[Action]` attribute, auto-resolution from controller names, and training mode. | +| `RouteActionMacro.php` | Adds `->action('name')` and `->bypassGate()` macros to Laravel routes for fluent action naming. | + +## Integration Flow + +``` +Request -> ActionGateMiddleware -> ActionGateService::check() -> ActionPermission (allowed/denied) -> Controller +``` diff --git a/app/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php b/app/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php new file mode 100644 index 0000000..33fe98b --- /dev/null +++ b/app/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php @@ -0,0 +1,77 @@ +id(); + $table->string('action'); // product.create, order.refund + $table->string('scope')->nullable(); // Resource type or specific ID + $table->string('guard')->default('web'); // web, api, admin + $table->string('role')->nullable(); // admin, editor, or null for any auth + $table->boolean('allowed')->default(false); + $table->string('source'); // 'trained', 'seeded', 'manual' + $table->string('trained_route')->nullable(); + $table->foreignId('trained_by')->nullable(); + $table->timestamp('trained_at')->nullable(); + $table->timestamps(); + + $table->unique(['action', 'scope', 'guard', 'role'], 'action_permission_unique'); + $table->index('action'); + $table->index(['guard', 'allowed']); + }); + + // 2. Action Requests (audit log) + Schema::create('core_action_requests', function (Blueprint $table) { + $table->id(); + $table->string('method', 10); // GET, POST, etc. + $table->string('route'); // /admin/products + $table->string('action'); // product.create + $table->string('scope')->nullable(); + $table->string('guard'); // web, api, admin + $table->string('role')->nullable(); + $table->foreignId('user_id')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('status', 20); // allowed, denied, pending + $table->boolean('was_trained')->default(false); + $table->timestamps(); + + $table->index(['action', 'status']); + $table->index(['user_id', 'created_at']); + $table->index('status'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('core_action_requests'); + Schema::dropIfExists('core_action_permissions'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/app/Core/Bouncer/Gate/Migrations/CLAUDE.md b/app/Core/Bouncer/Gate/Migrations/CLAUDE.md new file mode 100644 index 0000000..70d06c1 --- /dev/null +++ b/app/Core/Bouncer/Gate/Migrations/CLAUDE.md @@ -0,0 +1,7 @@ +# Bouncer/Gate/Migrations/ — Action Gate Schema + +## Migrations + +| File | Purpose | +|------|---------| +| `0001_01_01_000002_create_action_permission_tables.php` | Creates `core_action_permissions` (whitelisted actions) and `core_action_requests` (audit log) tables. | diff --git a/app/Core/Bouncer/Gate/Models/ActionPermission.php b/app/Core/Bouncer/Gate/Models/ActionPermission.php new file mode 100644 index 0000000..df34dbb --- /dev/null +++ b/app/Core/Bouncer/Gate/Models/ActionPermission.php @@ -0,0 +1,207 @@ + 'boolean', + 'trained_at' => 'datetime', + ]; + + /** + * Source constants. + */ + public const SOURCE_TRAINED = 'trained'; + + public const SOURCE_SEEDED = 'seeded'; + + public const SOURCE_MANUAL = 'manual'; + + /** + * User who trained this permission. + */ + public function trainer(): BelongsTo + { + return $this->belongsTo(config('auth.providers.users.model'), 'trained_by'); + } + + /** + * Check if action is allowed for the given context. + */ + public static function isAllowed( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): bool { + $query = static::query() + ->where('action', $action) + ->where('guard', $guard) + ->where('allowed', true); + + // Check scope match (null matches any, or exact match) + if ($scope !== null) { + $query->where(function ($q) use ($scope) { + $q->whereNull('scope') + ->orWhere('scope', $scope); + }); + } + + // Check role match (null role in permission = any authenticated) + if ($role !== null) { + $query->where(function ($q) use ($role) { + $q->whereNull('role') + ->orWhere('role', $role); + }); + } else { + // No role provided, only match null role permissions + $query->whereNull('role'); + } + + return $query->exists(); + } + + /** + * Find or create a permission for the given action context. + */ + public static function findOrCreateFor( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): self { + return static::firstOrCreate( + [ + 'action' => $action, + 'guard' => $guard, + 'role' => $role, + 'scope' => $scope, + ], + [ + 'allowed' => false, + 'source' => self::SOURCE_MANUAL, + ] + ); + } + + /** + * Train (allow) an action. + */ + public static function train( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null, + ?string $route = null, + ?int $trainedBy = null + ): self { + $permission = static::findOrCreateFor($action, $guard, $role, $scope); + + $permission->update([ + 'allowed' => true, + 'source' => self::SOURCE_TRAINED, + 'trained_route' => $route, + 'trained_by' => $trainedBy, + 'trained_at' => now(), + ]); + + return $permission; + } + + /** + * Revoke an action permission. + */ + public static function revoke( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): bool { + return static::query() + ->where('action', $action) + ->where('guard', $guard) + ->where('role', $role) + ->where('scope', $scope) + ->update(['allowed' => false]) > 0; + } + + /** + * Get all actions for a guard. + * + * @return Collection + */ + public static function forGuard(string $guard): Collection + { + return static::where('guard', $guard)->get(); + } + + /** + * Get all allowed actions for a guard/role combination. + * + * @return Collection + */ + public static function allowedFor(string $guard, ?string $role = null): Collection + { + $query = static::where('guard', $guard) + ->where('allowed', true); + + if ($role !== null) { + $query->where(function ($q) use ($role) { + $q->whereNull('role') + ->orWhere('role', $role); + }); + } else { + $query->whereNull('role'); + } + + return $query->get(); + } +} diff --git a/app/Core/Bouncer/Gate/Models/ActionRequest.php b/app/Core/Bouncer/Gate/Models/ActionRequest.php new file mode 100644 index 0000000..8eb31b6 --- /dev/null +++ b/app/Core/Bouncer/Gate/Models/ActionRequest.php @@ -0,0 +1,181 @@ + 'boolean', + ]; + + /** + * Status constants. + */ + public const STATUS_ALLOWED = 'allowed'; + + public const STATUS_DENIED = 'denied'; + + public const STATUS_PENDING = 'pending'; + + /** + * User who made the request. + */ + public function user(): BelongsTo + { + return $this->belongsTo(config('auth.providers.users.model'), 'user_id'); + } + + /** + * Log an action request. + */ + public static function log( + string $method, + string $route, + string $action, + string $guard, + string $status, + ?string $scope = null, + ?string $role = null, + ?int $userId = null, + ?string $ipAddress = null, + bool $wasTrained = false + ): self { + return static::create([ + 'method' => $method, + 'route' => $route, + 'action' => $action, + 'scope' => $scope, + 'guard' => $guard, + 'role' => $role, + 'user_id' => $userId, + 'ip_address' => $ipAddress, + 'status' => $status, + 'was_trained' => $wasTrained, + ]); + } + + /** + * Get pending requests (for training review). + * + * @return Collection + */ + public static function pending(): Collection + { + return static::where('status', self::STATUS_PENDING) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get denied requests for an action. + * + * @return Collection + */ + public static function deniedFor(string $action): Collection + { + return static::where('action', $action) + ->where('status', self::STATUS_DENIED) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get requests by user. + * + * @return Collection + */ + public static function forUser(int $userId): Collection + { + return static::where('user_id', $userId) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get unique actions that were denied (candidates for training). + * + * @return array + */ + public static function deniedActionsSummary(): array + { + return static::where('status', self::STATUS_DENIED) + ->selectRaw('action, COUNT(*) as count, MAX(created_at) as last_at') + ->groupBy('action') + ->orderByDesc('count') + ->get() + ->keyBy('action') + ->map(fn ($row) => [ + 'action' => $row->action, + 'count' => (int) $row->count, + 'last_at' => $row->last_at, + ]) + ->toArray(); + } + + /** + * Prune old request logs. + */ + public static function prune(int $days = 30): int + { + return static::where('created_at', '<', now()->subDays($days)) + ->delete(); + } + + /** + * Mark this request as having triggered training. + */ + public function markTrained(): self + { + $this->update(['was_trained' => true]); + + return $this; + } +} diff --git a/app/Core/Bouncer/Gate/Models/CLAUDE.md b/app/Core/Bouncer/Gate/Models/CLAUDE.md new file mode 100644 index 0000000..bb4db3e --- /dev/null +++ b/app/Core/Bouncer/Gate/Models/CLAUDE.md @@ -0,0 +1,8 @@ +# Bouncer/Gate/Models/ — Action Gate Models + +## Models + +| Model | Table | Purpose | +|-------|-------|---------| +| `ActionPermission` | `core_action_permissions` | Whitelisted action record. Stores action identifier, scope, guard, role, allowed flag, and training metadata (who trained it, when, from which route). Source: `trained`, `seeded`, or `manual`. | +| `ActionRequest` | `core_action_requests` | Audit log entry for all action permission checks. Records HTTP method, route, action, guard, user, IP, status (allowed/denied/pending), and whether training was triggered. | diff --git a/app/Core/Bouncer/Gate/RouteActionMacro.php b/app/Core/Bouncer/Gate/RouteActionMacro.php new file mode 100644 index 0000000..16ff3b2 --- /dev/null +++ b/app/Core/Bouncer/Gate/RouteActionMacro.php @@ -0,0 +1,86 @@ +action('product.create'); + * + * Route::delete('/products/{product}', [ProductController::class, 'destroy']) + * ->action('product.delete', scope: 'product'); + * + * Route::get('/public-page', PageController::class) + * ->bypassGate(); // Skip action gate entirely + * ``` + */ +class RouteActionMacro +{ + /** + * Register route macros for action gate. + */ + public static function register(): void + { + /** + * Set the action name for bouncer gate checking. + * + * @param string $action The action identifier (e.g., 'product.create') + * @param string|null $scope Optional resource scope + * @return Route + */ + Route::macro('action', function (string $action, ?string $scope = null): Route { + /** @var Route $this */ + $this->setAction(array_merge($this->getAction(), [ + 'bouncer_action' => $action, + 'bouncer_scope' => $scope, + ])); + + return $this; + }); + + /** + * Bypass the action gate for this route. + * + * Use sparingly for routes that should never be gated (e.g., login page). + * + * @return Route + */ + Route::macro('bypassGate', function (): Route { + /** @var Route $this */ + $this->setAction(array_merge($this->getAction(), [ + 'bypass_gate' => true, + ])); + + return $this; + }); + + /** + * Mark this route as requiring training (explicit pending state). + * + * @return Route + */ + Route::macro('requiresTraining', function (): Route { + /** @var Route $this */ + $this->setAction(array_merge($this->getAction(), [ + 'requires_training' => true, + ])); + + return $this; + }); + } +} diff --git a/app/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php b/app/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php new file mode 100644 index 0000000..e2d9dcc --- /dev/null +++ b/app/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php @@ -0,0 +1,389 @@ +loadMigrationsFrom(__DIR__.'/../../Migrations'); + } + + protected function getPackageProviders($app): array + { + return [ + Boot::class, + ]; + } + + protected function defineEnvironment($app): void + { + $app['config']->set('core.bouncer.enabled', true); + $app['config']->set('core.bouncer.training_mode', false); + } + + // ========================================================================= + // ActionPermission Model Tests + // ========================================================================= + + public function test_action_permission_can_be_created(): void + { + $permission = ActionPermission::create([ + 'action' => 'product.create', + 'guard' => 'web', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_MANUAL, + ]); + + $this->assertDatabaseHas('core_action_permissions', [ + 'action' => 'product.create', + 'guard' => 'web', + 'allowed' => true, + ]); + } + + public function test_is_allowed_returns_true_for_permitted_action(): void + { + ActionPermission::create([ + 'action' => 'product.view', + 'guard' => 'web', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web')); + } + + public function test_is_allowed_returns_false_for_non_existent_action(): void + { + $this->assertFalse(ActionPermission::isAllowed('unknown.action', 'web')); + } + + public function test_is_allowed_returns_false_for_denied_action(): void + { + ActionPermission::create([ + 'action' => 'product.delete', + 'guard' => 'web', + 'allowed' => false, + 'source' => ActionPermission::SOURCE_MANUAL, + ]); + + $this->assertFalse(ActionPermission::isAllowed('product.delete', 'web')); + } + + public function test_is_allowed_respects_guard(): void + { + ActionPermission::create([ + 'action' => 'product.create', + 'guard' => 'admin', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.create', 'admin')); + $this->assertFalse(ActionPermission::isAllowed('product.create', 'web')); + } + + public function test_is_allowed_respects_role(): void + { + ActionPermission::create([ + 'action' => 'product.create', + 'guard' => 'web', + 'role' => 'editor', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.create', 'web', 'editor')); + $this->assertFalse(ActionPermission::isAllowed('product.create', 'web', 'viewer')); + } + + public function test_null_role_permission_allows_any_role(): void + { + ActionPermission::create([ + 'action' => 'product.view', + 'guard' => 'web', + 'role' => null, + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', 'admin')); + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', 'editor')); + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', null)); + } + + public function test_train_creates_and_allows_action(): void + { + $permission = ActionPermission::train( + action: 'order.refund', + guard: 'admin', + role: 'manager', + route: '/admin/orders/1/refund', + trainedBy: 1 + ); + + $this->assertTrue($permission->allowed); + $this->assertEquals(ActionPermission::SOURCE_TRAINED, $permission->source); + $this->assertEquals('/admin/orders/1/refund', $permission->trained_route); + $this->assertEquals(1, $permission->trained_by); + $this->assertNotNull($permission->trained_at); + } + + public function test_revoke_denies_action(): void + { + ActionPermission::train('product.delete', 'web'); + + $result = ActionPermission::revoke('product.delete', 'web'); + + $this->assertTrue($result); + $this->assertFalse(ActionPermission::isAllowed('product.delete', 'web')); + } + + // ========================================================================= + // ActionRequest Model Tests + // ========================================================================= + + public function test_action_request_can_be_logged(): void + { + $request = ActionRequest::log( + method: 'POST', + route: '/products', + action: 'product.create', + guard: 'web', + status: ActionRequest::STATUS_ALLOWED, + userId: 1, + ipAddress: '127.0.0.1' + ); + + $this->assertDatabaseHas('core_action_requests', [ + 'method' => 'POST', + 'action' => 'product.create', + 'status' => 'allowed', + ]); + } + + public function test_pending_returns_pending_requests(): void + { + ActionRequest::log('GET', '/test', 'test.action', 'web', ActionRequest::STATUS_PENDING); + ActionRequest::log('POST', '/test', 'test.create', 'web', ActionRequest::STATUS_ALLOWED); + + $pending = ActionRequest::pending(); + + $this->assertCount(1, $pending); + $this->assertEquals('test.action', $pending->first()->action); + } + + public function test_denied_actions_summary_groups_by_action(): void + { + ActionRequest::log('GET', '/a', 'product.view', 'web', ActionRequest::STATUS_DENIED); + ActionRequest::log('GET', '/b', 'product.view', 'web', ActionRequest::STATUS_DENIED); + ActionRequest::log('POST', '/c', 'product.create', 'web', ActionRequest::STATUS_DENIED); + + $summary = ActionRequest::deniedActionsSummary(); + + $this->assertArrayHasKey('product.view', $summary); + $this->assertEquals(2, $summary['product.view']['count']); + $this->assertArrayHasKey('product.create', $summary); + $this->assertEquals(1, $summary['product.create']['count']); + } + + // ========================================================================= + // ActionGateService Tests + // ========================================================================= + + public function test_service_allows_permitted_action(): void + { + ActionPermission::train('product.index', 'web'); + + $service = new ActionGateService; + $route = $this->createMockRoute('ProductController@index', 'web'); + $request = $this->createMockRequest($route); + + $result = $service->check($request); + + $this->assertEquals(ActionGateService::RESULT_ALLOWED, $result['result']); + } + + public function test_service_denies_unknown_action_in_production(): void + { + config(['core.bouncer.training_mode' => false]); + + $service = new ActionGateService; + $route = $this->createMockRoute('ProductController@store', 'web'); + $request = $this->createMockRequest($route); + + $result = $service->check($request); + + $this->assertEquals(ActionGateService::RESULT_DENIED, $result['result']); + } + + public function test_service_returns_training_in_training_mode(): void + { + config(['core.bouncer.training_mode' => true]); + + $service = new ActionGateService; + $route = $this->createMockRoute('OrderController@refund', 'web'); + $request = $this->createMockRequest($route); + + $result = $service->check($request); + + $this->assertEquals(ActionGateService::RESULT_TRAINING, $result['result']); + } + + public function test_service_logs_request(): void + { + ActionPermission::train('product.show', 'web'); + + $service = new ActionGateService; + $route = $this->createMockRoute('ProductController@show', 'web'); + $request = $this->createMockRequest($route); + + $service->check($request); + + $this->assertDatabaseHas('core_action_requests', [ + 'action' => 'product.show', + 'status' => 'allowed', + ]); + } + + // ========================================================================= + // Action Resolution Tests + // ========================================================================= + + public function test_resolves_action_from_route_action(): void + { + $service = new ActionGateService; + + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->setAction(array_merge($route->getAction(), [ + 'bouncer_action' => 'products.list', + 'bouncer_scope' => 'catalog', + ])); + + $result = $service->resolveAction($route); + + $this->assertEquals('products.list', $result['action']); + $this->assertEquals('catalog', $result['scope']); + } + + public function test_auto_resolves_action_from_controller_method(): void + { + $service = new ActionGateService; + + $route = new Route(['POST'], '/products', ['uses' => 'ProductController@store']); + + $result = $service->resolveAction($route); + + $this->assertEquals('product.store', $result['action']); + } + + public function test_auto_resolves_namespaced_controller(): void + { + $service = new ActionGateService; + + $route = new Route(['GET'], '/admin/users', ['uses' => 'Admin\\UserController@index']); + + $result = $service->resolveAction($route); + + $this->assertEquals('admin.user.index', $result['action']); + } + + // ========================================================================= + // Route Macro Tests + // ========================================================================= + + public function test_route_action_macro_sets_action(): void + { + $route = RouteFacade::get('/test', fn () => 'test') + ->action('custom.action'); + + $this->assertEquals('custom.action', $route->getAction('bouncer_action')); + } + + public function test_route_action_macro_sets_scope(): void + { + $route = RouteFacade::get('/test/{id}', fn () => 'test') + ->action('resource.view', 'resource'); + + $this->assertEquals('resource.view', $route->getAction('bouncer_action')); + $this->assertEquals('resource', $route->getAction('bouncer_scope')); + } + + public function test_route_bypass_gate_macro(): void + { + $route = RouteFacade::get('/login', fn () => 'login') + ->bypassGate(); + + $this->assertTrue($route->getAction('bypass_gate')); + } + + // ========================================================================= + // Action Attribute Tests + // ========================================================================= + + public function test_action_attribute_stores_name(): void + { + $attribute = new Action('product.create'); + + $this->assertEquals('product.create', $attribute->name); + $this->assertNull($attribute->scope); + } + + public function test_action_attribute_stores_scope(): void + { + $attribute = new Action('product.delete', scope: 'product'); + + $this->assertEquals('product.delete', $attribute->name); + $this->assertEquals('product', $attribute->scope); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + protected function createMockRoute(string $uses, string $middlewareGroup = 'web'): Route + { + $route = new Route(['GET'], '/test', ['uses' => $uses]); + $route->middleware($middlewareGroup); + + return $route; + } + + protected function createMockRequest(Route $route): Request + { + $request = Request::create('/test', 'GET'); + $request->setRouteResolver(fn () => $route); + + return $request; + } +} diff --git a/app/Core/Bouncer/Gate/Tests/Feature/CLAUDE.md b/app/Core/Bouncer/Gate/Tests/Feature/CLAUDE.md new file mode 100644 index 0000000..3531fbb --- /dev/null +++ b/app/Core/Bouncer/Gate/Tests/Feature/CLAUDE.md @@ -0,0 +1,7 @@ +# Bouncer/Gate/Tests/Feature/ — Action Gate Feature Tests + +## Test Files + +| File | Purpose | +|------|---------| +| `ActionGateTest.php` | Integration tests for the full action gate flow — middleware interception, permission enforcement, training mode responses, route macro behaviour. | diff --git a/app/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php b/app/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php new file mode 100644 index 0000000..e2892e4 --- /dev/null +++ b/app/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php @@ -0,0 +1,235 @@ +service = new ActionGateService; + } + + // ========================================================================= + // Auto-Resolution Tests (via uses action string) + // ========================================================================= + + public function test_auto_resolves_simple_controller(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('product.index', $result['action']); + } + + public function test_auto_resolves_nested_namespace(): void + { + $route = new Route(['POST'], '/admin/users', ['uses' => 'Admin\\UserController@store']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('admin.user.store', $result['action']); + } + + public function test_auto_resolves_deeply_nested_namespace(): void + { + $route = new Route(['GET'], '/api/v1/orders', ['uses' => 'Api\\V1\\OrderController@show']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('api.v1.order.show', $result['action']); + } + + public function test_auto_resolves_pascal_case_controller(): void + { + $route = new Route(['GET'], '/user-profiles', ['uses' => 'UserProfileController@index']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('user_profile.index', $result['action']); + } + + public function test_filters_common_namespace_prefixes(): void + { + $route = new Route(['GET'], '/test', ['uses' => 'App\\Http\\Controllers\\TestController@index']); + + $result = $this->service->resolveAction($route); + + // Should not include 'app', 'http', 'controllers' + $this->assertEquals('test.index', $result['action']); + } + + // ========================================================================= + // Route Action Override Tests + // ========================================================================= + + public function test_route_action_takes_precedence(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->setAction(array_merge($route->getAction(), [ + 'bouncer_action' => 'catalog.list', + ])); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('catalog.list', $result['action']); + } + + public function test_route_scope_is_preserved(): void + { + $route = new Route(['DELETE'], '/products/1', ['uses' => 'ProductController@destroy']); + $route->setAction(array_merge($route->getAction(), [ + 'bouncer_action' => 'product.delete', + 'bouncer_scope' => 'product', + ])); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('product.delete', $result['action']); + $this->assertEquals('product', $result['scope']); + } + + // ========================================================================= + // Closure/Named Route Tests + // ========================================================================= + + public function test_closure_routes_use_uri_fallback(): void + { + $route = new Route(['GET'], '/hello', fn () => 'hello'); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('route.hello', $result['action']); + } + + public function test_named_closure_routes_use_name(): void + { + $route = new Route(['GET'], '/hello', fn () => 'hello'); + $route->name('greeting.hello'); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('route.greeting.hello', $result['action']); + } + + // ========================================================================= + // Caching Tests + // ========================================================================= + + public function test_caches_resolved_actions(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->name('products.index'); + + // First call + $result1 = $this->service->resolveAction($route); + + // Second call should use cache + $result2 = $this->service->resolveAction($route); + + $this->assertEquals($result1, $result2); + } + + public function test_clear_cache_works(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->name('products.index'); + + $this->service->resolveAction($route); + $this->service->clearCache(); + + // Should not throw - just verify it works + $result = $this->service->resolveAction($route); + $this->assertNotEmpty($result['action']); + } + + // ========================================================================= + // Guard Resolution Tests + // ========================================================================= + + public function test_resolves_admin_guard(): void + { + $route = new Route(['GET'], '/admin/dashboard', ['uses' => 'DashboardController@index']); + $route->middleware('admin'); + + $method = new \ReflectionMethod($this->service, 'resolveGuard'); + $method->setAccessible(true); + + $guard = $method->invoke($this->service, $route); + + $this->assertEquals('admin', $guard); + } + + public function test_resolves_api_guard(): void + { + $route = new Route(['GET'], '/api/users', ['uses' => 'UserController@index']); + $route->middleware('api'); + + $method = new \ReflectionMethod($this->service, 'resolveGuard'); + $method->setAccessible(true); + + $guard = $method->invoke($this->service, $route); + + $this->assertEquals('api', $guard); + } + + public function test_defaults_to_web_guard(): void + { + $route = new Route(['GET'], '/home', ['uses' => 'HomeController@index']); + + $method = new \ReflectionMethod($this->service, 'resolveGuard'); + $method->setAccessible(true); + + $guard = $method->invoke($this->service, $route); + + $this->assertEquals('web', $guard); + } + + // ========================================================================= + // Action Attribute Tests + // ========================================================================= + + public function test_action_attribute_stores_name(): void + { + $attribute = new Action('product.create'); + + $this->assertEquals('product.create', $attribute->name); + $this->assertNull($attribute->scope); + } + + public function test_action_attribute_stores_scope(): void + { + $attribute = new Action('product.delete', scope: 'product'); + + $this->assertEquals('product.delete', $attribute->name); + $this->assertEquals('product', $attribute->scope); + } + + // ========================================================================= + // Result Builder Tests + // ========================================================================= + + public function test_result_constants_are_defined(): void + { + $this->assertEquals('allowed', ActionGateService::RESULT_ALLOWED); + $this->assertEquals('denied', ActionGateService::RESULT_DENIED); + $this->assertEquals('training', ActionGateService::RESULT_TRAINING); + } +} diff --git a/app/Core/Bouncer/Gate/Tests/Unit/CLAUDE.md b/app/Core/Bouncer/Gate/Tests/Unit/CLAUDE.md new file mode 100644 index 0000000..469c9e8 --- /dev/null +++ b/app/Core/Bouncer/Gate/Tests/Unit/CLAUDE.md @@ -0,0 +1,7 @@ +# Bouncer/Gate/Tests/Unit/ — Action Gate Unit Tests + +## Test Files + +| File | Purpose | +|------|---------| +| `ActionGateServiceTest.php` | Unit tests for the `ActionGateService`. Tests action name resolution from routes and controllers, permission checking, training mode behaviour, and `#[Action]` attribute support. | diff --git a/app/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php b/app/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php new file mode 100644 index 0000000..1afcbd9 --- /dev/null +++ b/app/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php @@ -0,0 +1,68 @@ +id(); + $table->string('ip_address', 45); + $table->string('ip_range', 18)->nullable(); + $table->string('reason')->nullable(); + $table->string('source', 32)->default('manual'); + $table->string('status', 32)->default('active'); + $table->unsignedInteger('hit_count')->default(0); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_hit_at')->nullable(); + $table->timestamps(); + + $table->unique(['ip_address', 'ip_range']); + $table->index(['status', 'expires_at']); + $table->index('ip_address'); + }); + + // 2. Rate Limit Buckets + Schema::create('rate_limit_buckets', function (Blueprint $table) { + $table->id(); + $table->string('key'); + $table->string('bucket_type', 32); + $table->unsignedInteger('tokens')->default(0); + $table->unsignedInteger('max_tokens'); + $table->timestamp('last_refill_at'); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->unique(['key', 'bucket_type']); + $table->index('expires_at'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('rate_limit_buckets'); + Schema::dropIfExists('blocked_ips'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/app/Core/Bouncer/Migrations/CLAUDE.md b/app/Core/Bouncer/Migrations/CLAUDE.md new file mode 100644 index 0000000..f1639ab --- /dev/null +++ b/app/Core/Bouncer/Migrations/CLAUDE.md @@ -0,0 +1,9 @@ +# Bouncer/Migrations/ — Bouncer Schema Migrations + +## Migrations + +| File | Purpose | +|------|---------| +| `0001_01_01_000001_create_bouncer_tables.php` | Creates core bouncer tables for IP/domain blocklisting, redirect rules, and rate limiting configuration. | + +Uses early timestamps to run before application migrations. diff --git a/app/Core/Bouncer/RedirectService.php b/app/Core/Bouncer/RedirectService.php new file mode 100644 index 0000000..a2365f9 --- /dev/null +++ b/app/Core/Bouncer/RedirectService.php @@ -0,0 +1,132 @@ +getRedirects(); + $path = '/'.ltrim($path, '/'); + + // Exact match first + if (isset($redirects[$path])) { + return $redirects[$path]; + } + + // Wildcard matches (path/*) + foreach ($redirects as $from => $redirect) { + if (str_ends_with($from, '*')) { + $prefix = rtrim($from, '*'); + if (str_starts_with($path, $prefix)) { + // Replace the matched portion + $suffix = substr($path, strlen($prefix)); + $to = str_ends_with($redirect['to'], '*') + ? rtrim($redirect['to'], '*').$suffix + : $redirect['to']; + + return ['to' => $to, 'status' => $redirect['status']]; + } + } + } + + return null; + } + + /** + * Get all redirects (cached). + * + * @return array + */ + public function getRedirects(): array + { + return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () { + if (! $this->tableExists()) { + return []; + } + + return DB::table('seo_redirects') + ->where('active', true) + ->get() + ->keyBy('from_path') + ->map(fn ($row) => [ + 'to' => $row->to_path, + 'status' => $row->status_code, + ]) + ->toArray(); + }); + } + + /** + * Add a redirect. + */ + public function add(string $from, string $to, int $status = 301): void + { + DB::table('seo_redirects')->updateOrInsert( + ['from_path' => $from], + [ + 'to_path' => $to, + 'status_code' => $status, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + + $this->clearCache(); + } + + /** + * Remove a redirect. + */ + public function remove(string $from): void + { + DB::table('seo_redirects')->where('from_path', $from)->delete(); + $this->clearCache(); + } + + /** + * Clear the cache. + */ + public function clearCache(): void + { + Cache::forget(self::CACHE_KEY); + } + + /** + * Check if redirects table exists. + */ + protected function tableExists(): bool + { + return Cache::remember('bouncer:redirects_table_exists', 3600, function () { + return DB::getSchemaBuilder()->hasTable('seo_redirects'); + }); + } +} diff --git a/app/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php b/app/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php new file mode 100644 index 0000000..f241089 --- /dev/null +++ b/app/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php @@ -0,0 +1,594 @@ +service = new BlocklistService; + } + + protected function defineDatabaseMigrations(): void + { + // Create blocked_ips table for testing + Schema::create('blocked_ips', function ($table) { + $table->id(); + $table->string('ip_address', 45); + $table->string('ip_range', 18)->nullable(); + $table->string('reason')->nullable(); + $table->string('source', 32)->default('manual'); + $table->string('status', 32)->default('active'); + $table->unsignedInteger('hit_count')->default(0); + $table->timestamp('blocked_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_hit_at')->nullable(); + $table->timestamps(); + + $table->unique(['ip_address', 'ip_range']); + $table->index(['status', 'expires_at']); + $table->index('ip_address'); + }); + + // Create honeypot_hits table for testing syncFromHoneypot + Schema::create('honeypot_hits', function ($table) { + $table->id(); + $table->string('ip_address', 45); + $table->string('path'); + $table->string('severity', 32)->default('low'); + $table->timestamps(); + }); + } + + // ========================================================================= + // Blocking Tests + // ========================================================================= + + public function test_block_adds_ip_to_blocklist(): void + { + $this->service->block('192.168.1.100', 'test_reason'); + + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'reason' => 'test_reason', + 'status' => BlocklistService::STATUS_APPROVED, + ]); + } + + public function test_block_with_custom_status(): void + { + $this->service->block('192.168.1.100', 'honeypot', BlocklistService::STATUS_PENDING); + + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'reason' => 'honeypot', + 'status' => BlocklistService::STATUS_PENDING, + ]); + } + + public function test_block_updates_existing_entry(): void + { + // First block + $this->service->block('192.168.1.100', 'first_reason'); + + // Second block should update + $this->service->block('192.168.1.100', 'updated_reason'); + + $this->assertDatabaseCount('blocked_ips', 1); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'reason' => 'updated_reason', + ]); + } + + public function test_block_clears_cache(): void + { + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + Cache::shouldReceive('remember') + ->andReturn([]); + + $this->service->block('192.168.1.100', 'test'); + } + + // ========================================================================= + // Unblocking Tests + // ========================================================================= + + public function test_unblock_removes_ip_from_blocklist(): void + { + $this->service->block('192.168.1.100', 'test'); + $this->service->unblock('192.168.1.100'); + + $this->assertDatabaseMissing('blocked_ips', [ + 'ip_address' => '192.168.1.100', + ]); + } + + public function test_unblock_clears_cache(): void + { + // First add the IP + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + ]); + + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + $this->service->unblock('192.168.1.100'); + } + + public function test_unblock_does_not_fail_on_non_existent_ip(): void + { + // This should not throw an exception + $this->service->unblock('192.168.1.200'); + + $this->assertTrue(true); + } + + // ========================================================================= + // IP Blocked Check Tests + // ========================================================================= + + public function test_is_blocked_returns_true_for_blocked_ip(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + 'expires_at' => now()->addDay(), + ]); + + // Clear any existing cache + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertTrue($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_returns_false_for_non_blocked_ip(): void + { + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.200')); + } + + public function test_is_blocked_returns_false_for_expired_block(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now()->subDays(2), + 'expires_at' => now()->subDay(), // Expired yesterday + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_returns_false_for_pending_status(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + 'expires_at' => now()->addDay(), + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_returns_false_for_rejected_status(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_REJECTED, + 'blocked_at' => now(), + 'expires_at' => now()->addDay(), + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_works_with_null_expiry(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'permanent', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + 'expires_at' => null, // Permanent block + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertTrue($this->service->isBlocked('192.168.1.100')); + } + + // ========================================================================= + // Sync From Honeypot Tests + // ========================================================================= + + public function test_sync_from_honeypot_adds_critical_hits(): void + { + // Insert honeypot critical hits + DB::table('honeypot_hits')->insert([ + ['ip_address' => '10.0.0.1', 'path' => '/admin', 'severity' => 'critical', 'created_at' => now()], + ['ip_address' => '10.0.0.2', 'path' => '/wp-admin', 'severity' => 'critical', 'created_at' => now()], + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(2, $count); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '10.0.0.1', + 'reason' => 'honeypot_critical', + 'status' => BlocklistService::STATUS_PENDING, + ]); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '10.0.0.2', + 'reason' => 'honeypot_critical', + 'status' => BlocklistService::STATUS_PENDING, + ]); + } + + public function test_sync_from_honeypot_ignores_non_critical_hits(): void + { + DB::table('honeypot_hits')->insert([ + ['ip_address' => '10.0.0.1', 'path' => '/robots.txt', 'severity' => 'low', 'created_at' => now()], + ['ip_address' => '10.0.0.2', 'path' => '/favicon.ico', 'severity' => 'medium', 'created_at' => now()], + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(0, $count); + $this->assertDatabaseCount('blocked_ips', 0); + } + + public function test_sync_from_honeypot_ignores_old_hits(): void + { + DB::table('honeypot_hits')->insert([ + 'ip_address' => '10.0.0.1', + 'path' => '/admin', + 'severity' => 'critical', + 'created_at' => now()->subDays(2), // Older than 24 hours + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(0, $count); + $this->assertDatabaseCount('blocked_ips', 0); + } + + public function test_sync_from_honeypot_skips_already_blocked_ips(): void + { + // Already blocked IP + DB::table('blocked_ips')->insert([ + 'ip_address' => '10.0.0.1', + 'reason' => 'manual', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + ]); + + // Critical hit from same IP + DB::table('honeypot_hits')->insert([ + 'ip_address' => '10.0.0.1', + 'path' => '/admin', + 'severity' => 'critical', + 'created_at' => now(), + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(0, $count); + $this->assertDatabaseCount('blocked_ips', 1); + } + + public function test_sync_from_honeypot_deduplicates_ips(): void + { + // Multiple hits from same IP + DB::table('honeypot_hits')->insert([ + ['ip_address' => '10.0.0.1', 'path' => '/admin', 'severity' => 'critical', 'created_at' => now()], + ['ip_address' => '10.0.0.1', 'path' => '/wp-admin', 'severity' => 'critical', 'created_at' => now()], + ['ip_address' => '10.0.0.1', 'path' => '/phpmyadmin', 'severity' => 'critical', 'created_at' => now()], + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(1, $count); + $this->assertDatabaseCount('blocked_ips', 1); + } + + // ========================================================================= + // Pagination Tests + // ========================================================================= + + public function test_get_blocklist_paginated_returns_paginator(): void + { + // Insert multiple blocked IPs + for ($i = 1; $i <= 10; $i++) { + DB::table('blocked_ips')->insert([ + 'ip_address' => "192.168.1.{$i}", + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + ]); + } + + $result = $this->service->getBlocklistPaginated(5); + + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + $this->assertEquals(10, $result->total()); + $this->assertEquals(5, $result->perPage()); + $this->assertCount(5, $result->items()); + } + + public function test_get_blocklist_paginated_filters_by_status(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ['ip_address' => '192.168.1.3', 'reason' => 'test', 'status' => BlocklistService::STATUS_REJECTED, 'blocked_at' => now()], + ]); + + $approved = $this->service->getBlocklistPaginated(10, BlocklistService::STATUS_APPROVED); + $pending = $this->service->getBlocklistPaginated(10, BlocklistService::STATUS_PENDING); + + $this->assertEquals(1, $approved->total()); + $this->assertEquals(1, $pending->total()); + } + + public function test_get_blocklist_paginated_orders_by_blocked_at_desc(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()->subHours(2)], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()], + ['ip_address' => '192.168.1.3', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()->subHour()], + ]); + + $result = $this->service->getBlocklistPaginated(10); + $items = collect($result->items()); + + // Should be ordered most recent first + $this->assertEquals('192.168.1.2', $items->first()->ip_address); + $this->assertEquals('192.168.1.1', $items->last()->ip_address); + } + + public function test_get_pending_returns_array_when_per_page_is_null(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ]); + + $result = $this->service->getPending(null); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + } + + public function test_get_pending_returns_paginator_when_per_page_provided(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ]); + + $result = $this->service->getPending(1); + + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + $this->assertEquals(2, $result->total()); + $this->assertEquals(1, $result->perPage()); + } + + // ========================================================================= + // Approval/Rejection Tests + // ========================================================================= + + public function test_approve_changes_pending_to_approved(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + ]); + + $result = $this->service->approve('192.168.1.100'); + + $this->assertTrue($result); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'status' => BlocklistService::STATUS_APPROVED, + ]); + } + + public function test_approve_returns_false_for_non_pending_entry(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, // Already approved + 'blocked_at' => now(), + ]); + + $result = $this->service->approve('192.168.1.100'); + + $this->assertFalse($result); + } + + public function test_approve_returns_false_for_non_existent_entry(): void + { + $result = $this->service->approve('192.168.1.200'); + + $this->assertFalse($result); + } + + public function test_approve_clears_cache(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + ]); + + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + $this->service->approve('192.168.1.100'); + } + + public function test_reject_changes_pending_to_rejected(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + ]); + + $result = $this->service->reject('192.168.1.100'); + + $this->assertTrue($result); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'status' => BlocklistService::STATUS_REJECTED, + ]); + } + + public function test_reject_returns_false_for_non_pending_entry(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, // Not pending + 'blocked_at' => now(), + ]); + + $result = $this->service->reject('192.168.1.100'); + + $this->assertFalse($result); + } + + // ========================================================================= + // Stats Tests + // ========================================================================= + + public function test_get_stats_returns_complete_statistics(): void + { + // Insert test data - each row must have same columns + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'manual', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now(), 'expires_at' => now()->addDay()], + ['ip_address' => '192.168.1.2', 'reason' => 'manual', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now(), 'expires_at' => now()->subDay()], // Expired + ['ip_address' => '192.168.1.3', 'reason' => 'honeypot', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now(), 'expires_at' => null], + ['ip_address' => '192.168.1.4', 'reason' => 'honeypot', 'status' => BlocklistService::STATUS_REJECTED, 'blocked_at' => now(), 'expires_at' => null], + ]); + + Cache::forget('bouncer:blocked_ips_table_exists'); + $stats = $this->service->getStats(); + + $this->assertEquals(4, $stats['total_blocked']); + $this->assertEquals(1, $stats['active_blocked']); // Only 1 approved and not expired + $this->assertEquals(1, $stats['pending_review']); + $this->assertEquals(['manual' => 2, 'honeypot' => 2], $stats['by_reason']); + $this->assertEquals([ + BlocklistService::STATUS_APPROVED => 2, + BlocklistService::STATUS_PENDING => 1, + BlocklistService::STATUS_REJECTED => 1, + ], $stats['by_status']); + } + + public function test_get_stats_returns_zeros_when_table_is_empty(): void + { + Cache::forget('bouncer:blocked_ips_table_exists'); + $stats = $this->service->getStats(); + + $this->assertEquals(0, $stats['total_blocked']); + $this->assertEquals(0, $stats['active_blocked']); + $this->assertEquals(0, $stats['pending_review']); + $this->assertEmpty($stats['by_reason']); + $this->assertEmpty($stats['by_status']); + } + + // ========================================================================= + // Cache Tests + // ========================================================================= + + public function test_clear_cache_removes_cached_blocklist(): void + { + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + $this->service->clearCache(); + } + + public function test_get_blocklist_uses_cache(): void + { + $cachedData = ['192.168.1.1' => 'test_reason']; + + Cache::shouldReceive('remember') + ->once() + ->with('bouncer:blocklist', 300, \Mockery::type('Closure')) + ->andReturn($cachedData); + + $result = $this->service->getBlocklist(); + + $this->assertEquals($cachedData, $result); + } + + // ========================================================================= + // Status Constants Tests + // ========================================================================= + + public function test_status_constants_are_defined(): void + { + $this->assertEquals('pending', BlocklistService::STATUS_PENDING); + $this->assertEquals('approved', BlocklistService::STATUS_APPROVED); + $this->assertEquals('rejected', BlocklistService::STATUS_REJECTED); + } +} diff --git a/app/Core/Bouncer/Tests/Unit/CLAUDE.md b/app/Core/Bouncer/Tests/Unit/CLAUDE.md new file mode 100644 index 0000000..94b9de4 --- /dev/null +++ b/app/Core/Bouncer/Tests/Unit/CLAUDE.md @@ -0,0 +1,7 @@ +# Bouncer/Tests/Unit/ — Bouncer Unit Tests + +## Test Files + +| File | Purpose | +|------|---------| +| `BlocklistServiceTest.php` | Unit tests for the IP/domain blocklist service. Tests blocking, allowing, and checking IPs and domains against the blocklist. | diff --git a/app/Core/CLAUDE.md b/app/Core/CLAUDE.md new file mode 100644 index 0000000..940ff77 --- /dev/null +++ b/app/Core/CLAUDE.md @@ -0,0 +1,83 @@ +# Core Orchestration + +Root-level files in `src/Core/` that wire the entire framework together. These are the bootstrap, module discovery, lazy loading, and pro-feature detection systems. + +## Files + +| File | Purpose | +|------|---------| +| `Init.php` | True entry point. `Core\Init::handle()` replaces Laravel's `bootstrap/app.php`. Runs WAF input filtering via `Input::capture()`, then delegates to `Boot::app()`. Prefers `App\Boot` if it exists. | +| `Boot.php` | Configures Laravel `Application` with providers, middleware, and exceptions. Provider load order is critical: `LifecycleEventProvider` -> `Website\Boot` -> `Front\Boot` -> `Mod\Boot`. | +| `LifecycleEventProvider.php` | The orchestrator. Registers `ModuleScanner` and `ModuleRegistry` as singletons, scans configured paths, wires lazy listeners. Static `fire*()` methods are called by frontage modules to dispatch lifecycle events and process collected requests (views, livewire, routes, middleware). | +| `ModuleScanner.php` | Discovers `Boot.php` files in subdirectories of given paths. Reads static `$listens` arrays via reflection without instantiating modules. Maps paths to namespaces (`/Core` -> `Core\`, `/Mod` -> `Mod\`, `/Website` -> `Website\`, `/Plug` -> `Plug\`). | +| `ModuleRegistry.php` | Coordinates scanner output into Laravel's event system. Sorts listeners by priority (highest first), creates `LazyModuleListener` instances, supports late-registration via `addPaths()`. | +| `LazyModuleListener.php` | The lazy-loading wrapper. Instantiates module on first event fire (cached thereafter). ServiceProviders use `resolveProvider()`, plain classes use `make()`. Records audit logs and profiling data. | +| `Pro.php` | Detects Flux Pro and FontAwesome Pro installations. Auto-enables pro features, falls back gracefully to free equivalents. Throws helpful dev-mode exceptions. | +| `config.php` | Framework configuration: branding, domains, CDN, organisation, social links, contact, FontAwesome, pro fallback behaviour, icon defaults, debug settings, seeder auto-discovery. | + +## Bootstrap Sequence + +``` +public/index.php + -> Core\Init::handle() + -> Input::capture() # WAF layer sanitises $_GET/$_POST + -> Boot::app() # Build Laravel Application + -> LifecycleEventProvider # register(): scan + wire lazy listeners + -> Website\Boot # register(): domain resolution + -> Front\Boot # boot(): fires lifecycle events + -> Mod\Boot # aggregates feature modules +``` + +## Module Declaration Pattern + +Modules declare interest in events via static `$listens`: + +```php +class Boot +{ + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + AdminPanelBooting::class => ['onAdmin', 10], // priority 10 + ]; +} +``` + +Modules are never instantiated until their event fires. + +## Lifecycle Events (fire* methods) + +| Method | Event | Middleware | Processes | +|--------|-------|-----------|-----------| +| `fireWebRoutes()` | `WebRoutesRegistering` | `web` | views, livewire, routes | +| `fireAdminBooting()` | `AdminPanelBooting` | `admin` | views, translations, livewire, routes | +| `fireClientRoutes()` | `ClientRoutesRegistering` | `client` | views, livewire, routes | +| `fireApiRoutes()` | `ApiRoutesRegistering` | `api` | routes | +| `fireMcpRoutes()` | `McpRoutesRegistering` | `mcp` | routes | +| `fireMcpTools()` | `McpToolsRegistering` | -- | returns handler class names | +| `fireConsoleBooting()` | `ConsoleBooting` | -- | artisan commands | +| `fireQueueWorkerBooting()` | `QueueWorkerBooting` | -- | queue-specific init | + +All route-registering fire methods call `refreshRoutes()` afterward to deduplicate names and refresh lookups. + +## Default Scan Paths + +- `app_path('Core')` -- application-level core modules +- `app_path('Mod')` -- feature modules +- `app_path('Website')` -- domain-scoped website modules +- `src/Core` -- framework's own modules +- `src/Mod` -- framework's own feature modules + +Configurable via `config('core.module_paths')`. + +## Priority System + +- Default: `0` +- Higher values run first: `['onAdmin', 100]` runs before `['onAdmin', 0]` +- Negative values run last: `['onCleanup', -10]` + +## Key Integration Points + +- `Init::boot()` returns `App\Boot` if it exists, allowing apps to customise providers +- `Boot::basePath()` auto-detects monorepo vs vendor structure +- `LifecycleEventProvider` processes middleware aliases, view namespaces, and Livewire components collected during event dispatch +- Route deduplication prevents `route:cache` failures when the same route file serves multiple domains diff --git a/app/Core/Cdn/Boot.php b/app/Core/Cdn/Boot.php new file mode 100644 index 0000000..b18ef3e --- /dev/null +++ b/app/Core/Cdn/Boot.php @@ -0,0 +1,153 @@ +mergeConfigFrom(__DIR__.'/config.php', 'cdn'); + $this->mergeConfigFrom(__DIR__.'/offload.php', 'offload'); + + // Register Plug managers as singletons (when available) + if (class_exists(CdnManager::class)) { + $this->app->singleton(CdnManager::class); + } + if (class_exists(StorageManager::class)) { + $this->app->singleton(StorageManager::class); + } + + // Register legacy services as singletons (for backward compatibility) + $this->app->singleton(BunnyCdnService::class); + $this->app->singleton(BunnyStorageService::class); + $this->app->singleton(StorageUrlResolver::class); + $this->app->singleton(FluxCdnService::class); + $this->app->singleton(AssetPipeline::class); + $this->app->singleton(StorageOffload::class); + + // Register backward compatibility aliases + $this->registerBackwardCompatAliases(); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // Register console commands + if ($this->app->runningInConsole()) { + $this->commands([ + CdnPurge::class, + PushAssetsToCdn::class, + PushFluxToCdn::class, + OffloadMigrateCommand::class, + ]); + } + } + + /** + * Register backward compatibility class aliases. + * + * These allow existing code using old namespaces to continue working + * while we migrate to the new Core structure. + */ + protected function registerBackwardCompatAliases(): void + { + // Services + if (! class_exists(\App\Services\BunnyCdnService::class)) { + class_alias(BunnyCdnService::class, \App\Services\BunnyCdnService::class); + } + + if (! class_exists(\App\Services\Storage\BunnyStorageService::class)) { + class_alias(BunnyStorageService::class, \App\Services\Storage\BunnyStorageService::class); + } + + if (! class_exists(\App\Services\Storage\StorageUrlResolver::class)) { + class_alias(StorageUrlResolver::class, \App\Services\Storage\StorageUrlResolver::class); + } + + if (! class_exists(\App\Services\Storage\AssetPipeline::class)) { + class_alias(AssetPipeline::class, \App\Services\Storage\AssetPipeline::class); + } + + if (! class_exists(\App\Services\Storage\StorageOffload::class)) { + class_alias(StorageOffload::class, \App\Services\Storage\StorageOffload::class); + } + + if (! class_exists(\App\Services\Cdn\FluxCdnService::class)) { + class_alias(FluxCdnService::class, \App\Services\Cdn\FluxCdnService::class); + } + + // Crypt + if (! class_exists(\App\Services\Crypt\LthnHash::class)) { + class_alias(LthnHash::class, \App\Services\Crypt\LthnHash::class); + } + + // Models + if (! class_exists(\App\Models\StorageOffload::class)) { + class_alias(Models\StorageOffload::class, \App\Models\StorageOffload::class); + } + + // Facades + if (! class_exists(Cdn::class)) { + class_alias(Facades\Cdn::class, Cdn::class); + } + + // Traits + if (! trait_exists(HasCdnUrls::class)) { + class_alias(Traits\HasCdnUrls::class, HasCdnUrls::class); + } + + // Middleware + if (! class_exists(RewriteOffloadedUrls::class)) { + class_alias(Middleware\RewriteOffloadedUrls::class, RewriteOffloadedUrls::class); + } + + // Jobs + if (! class_exists(PushAssetToCdn::class)) { + class_alias(Jobs\PushAssetToCdn::class, PushAssetToCdn::class); + } + } +} diff --git a/app/Core/Cdn/CLAUDE.md b/app/Core/Cdn/CLAUDE.md new file mode 100644 index 0000000..298cc4e --- /dev/null +++ b/app/Core/Cdn/CLAUDE.md @@ -0,0 +1,57 @@ +# Cdn + +BunnyCDN integration with vBucket workspace isolation and storage offloading. + +## What It Does + +Unified CDN and object storage layer providing: +- BunnyCDN pull zone operations (purge, stats) +- BunnyCDN storage zone operations (upload, download, list, delete) +- Context-aware URL building (CDN, origin, private, signed) +- vBucket-scoped paths using `LthnHash` for tenant isolation +- Asset pipeline for processing and offloading +- Flux Pro CDN delivery +- Storage offload migration from local to CDN + +## Key Classes + +| Class | Purpose | +|-------|---------| +| `Boot` | ServiceProvider registering all services as singletons + backward-compat aliases to `App\` namespaces | +| `BunnyCdnService` | Pull zone API: `purgeUrl()`, `purgeUrls()`, `purgeAll()`, `purgeByTag()`, `purgeWorkspace()`, `getStats()`, `getBandwidth()`, `listStorageFiles()`, `uploadFile()`, `deleteFile()`. Sanitises error messages to redact API keys | +| `BunnyStorageService` | Direct storage zone operations (separate from pull zone API) | +| `CdnUrlBuilder` | URL construction: `cdn()`, `origin()`, `private()`, `apex()`, `signed()`, `vBucket()`, `vBucketId()`, `vBucketPath()`, `asset()`, `withVersion()`, `urls()`, `allUrls()` | +| `StorageUrlResolver` | Context-aware URL resolution | +| `FluxCdnService` | Flux Pro component CDN delivery | +| `AssetPipeline` | Asset processing pipeline | +| `StorageOffload` (service) | Migrates files from local storage to CDN | +| `StorageOffload` (model) | Tracks offloaded files in DB | +| `Cdn` (facade) | `Cdn::purge(...)` etc. | +| `HasCdnUrls` (trait) | Adds CDN URL methods to Eloquent models | + +## Console Commands + +- `cdn:purge` -- Purge CDN cache +- `cdn:push-assets` -- Push assets to CDN storage +- `cdn:push-flux` -- Push Flux Pro assets to CDN +- `cdn:offload-migrate` -- Migrate local files to CDN storage + +## Middleware + +- `RewriteOffloadedUrls` -- Rewrites storage URLs in responses to CDN URLs +- `LocalCdnMiddleware` -- Serves CDN assets locally in development + +## vBucket Pattern + +Workspace-isolated CDN paths using `LthnHash::vBucketId()`: +``` +cdn.example.com/{vBucketId}/path/to/asset.js +``` +The vBucketId is a deterministic SHA-256 hash of the domain name, ensuring each workspace's assets are namespaced. + +## Integration + +- Reads credentials from `ConfigService` (DB-backed config), not just `.env` +- Signed URLs use HMAC-SHA256 with BunnyCDN token authentication +- Config files: `config.php` (CDN settings), `offload.php` (storage offload settings) +- Backward-compat aliases registered for all `App\Services\*` and `App\Models\*` namespaces diff --git a/app/Core/Cdn/Console/CLAUDE.md b/app/Core/Cdn/Console/CLAUDE.md new file mode 100644 index 0000000..b26cbf9 --- /dev/null +++ b/app/Core/Cdn/Console/CLAUDE.md @@ -0,0 +1,10 @@ +# Cdn/Console/ — CDN Artisan Commands + +## Commands + +| Command | Signature | Purpose | +|---------|-----------|---------| +| `CdnPurge` | `cdn:purge` | Purge CDN cache — by URL, tag, workspace, or global. | +| `OffloadMigrateCommand` | `cdn:offload-migrate` | Migrate local files to remote storage, creating offload records. | +| `PushAssetsToCdn` | `cdn:push` | Push local assets to CDN storage zone. | +| `PushFluxToCdn` | `cdn:push-flux` | Push Flux UI framework assets to CDN. | diff --git a/app/Core/Cdn/Console/CdnPurge.php b/app/Core/Cdn/Console/CdnPurge.php new file mode 100644 index 0000000..b2232d0 --- /dev/null +++ b/app/Core/Cdn/Console/CdnPurge.php @@ -0,0 +1,324 @@ +purger = new Purge; + } + } + + /** + * Execute the console command. + */ + public function handle(): int + { + if ($this->purger === null) { + $this->error('CDN Purge requires Core\Plug\Cdn\Bunny\Purge class. Plug module not installed.'); + + return self::FAILURE; + } + + $workspaceArg = $this->argument('workspace'); + $urls = $this->option('url'); + $tag = $this->option('tag'); + $everything = $this->option('everything'); + $dryRun = $this->option('dry-run'); + + if ($dryRun) { + $this->info('Dry run mode - no changes will be made'); + $this->newLine(); + } + + // Check for mutually exclusive options + $optionCount = ($everything ? 1 : 0) + (! empty($urls) ? 1 : 0) + (! empty($tag) ? 1 : 0) + (! empty($workspaceArg) ? 1 : 0); + if ($optionCount > 1 && $everything) { + $this->error('Cannot use --everything with other options'); + + return self::FAILURE; + } + + // Purge everything + if ($everything) { + return $this->purgeEverything($dryRun); + } + + // Purge specific URLs + if (! empty($urls)) { + return $this->purgeUrls($urls, $dryRun); + } + + // Purge by tag + if (! empty($tag)) { + return $this->purgeByTag($tag, $dryRun); + } + + // Purge by workspace + if (empty($workspaceArg)) { + $workspaceOptions = ['all', 'Select specific URLs']; + if (class_exists(Workspace::class)) { + $workspaceOptions = array_merge($workspaceOptions, Workspace::pluck('slug')->toArray()); + } + $workspaceArg = $this->choice( + 'What would you like to purge?', + $workspaceOptions, + 0 + ); + + if ($workspaceArg === 'Select specific URLs') { + $urlInput = $this->ask('Enter URL(s) to purge (comma-separated)'); + $urls = array_map('trim', explode(',', $urlInput)); + + return $this->purgeUrls($urls, $dryRun); + } + } + + if ($workspaceArg === 'all') { + return $this->purgeAllWorkspaces($dryRun); + } + + return $this->purgeWorkspace($workspaceArg, $dryRun); + } + + protected function purgeEverything(bool $dryRun): int + { + if (! $dryRun && ! $this->confirm('Are you sure you want to purge the ENTIRE CDN cache? This will affect all content.', false)) { + $this->info('Aborted'); + + return self::SUCCESS; + } + + $this->warn('Purging entire CDN cache...'); + + if ($dryRun) { + $this->info('Would purge: entire CDN cache'); + + return self::SUCCESS; + } + + try { + $result = $this->purger->all(); + + if ($result->isOk()) { + $this->info('CDN cache purged successfully'); + + return self::SUCCESS; + } + + $this->error('Failed to purge CDN cache: '.$result->message()); + + return self::FAILURE; + } catch (\Exception $e) { + $this->error("Purge failed: {$e->getMessage()}"); + + return self::FAILURE; + } + } + + protected function purgeUrls(array $urls, bool $dryRun): int + { + $this->info('Purging '.count($urls).' URL(s)...'); + + foreach ($urls as $url) { + $this->line(" - {$url}"); + } + + if ($dryRun) { + return self::SUCCESS; + } + + try { + $result = $this->purger->urls($urls); + + if ($result->isOk()) { + $this->newLine(); + $this->info('URLs purged successfully'); + + return self::SUCCESS; + } + + $this->error('Failed to purge URLs: '.$result->message()); + + return self::FAILURE; + } catch (\Exception $e) { + $this->error("Purge failed: {$e->getMessage()}"); + + return self::FAILURE; + } + } + + protected function purgeByTag(string $tag, bool $dryRun): int + { + $this->info("Purging cache tag: {$tag}"); + + if ($dryRun) { + $this->info("Would purge: all content with tag '{$tag}'"); + + return self::SUCCESS; + } + + try { + $result = $this->purger->tag($tag); + + if ($result->isOk()) { + $this->info('Cache tag purged successfully'); + + return self::SUCCESS; + } + + $this->error('Failed to purge cache tag: '.$result->message()); + + return self::FAILURE; + } catch (\Exception $e) { + $this->error("Purge failed: {$e->getMessage()}"); + + return self::FAILURE; + } + } + + protected function purgeAllWorkspaces(bool $dryRun): int + { + if (! class_exists(Workspace::class)) { + $this->error('Workspace purge requires Tenant module to be installed.'); + + return self::FAILURE; + } + + $workspaces = Workspace::all(); + + if ($workspaces->isEmpty()) { + $this->error('No workspaces found'); + + return self::FAILURE; + } + + $this->info("Purging {$workspaces->count()} workspaces..."); + $this->newLine(); + + $success = true; + + foreach ($workspaces as $workspace) { + $this->line("Workspace: {$workspace->slug}"); + + if ($dryRun) { + $this->line(" Would purge: workspace-{$workspace->uuid}"); + + continue; + } + + try { + $result = $this->purger->workspace($workspace->uuid); + + if ($result->isOk()) { + $this->line(' Purged'); + } else { + $this->line(' Failed: '.$result->message().''); + $success = false; + } + } catch (\Exception $e) { + $this->line(" Error: {$e->getMessage()}"); + $success = false; + } + } + + $this->newLine(); + + if ($success) { + $this->info('All workspaces purged successfully'); + + return self::SUCCESS; + } + + $this->warn('Some workspaces failed to purge'); + + return self::FAILURE; + } + + protected function purgeWorkspace(string $slug, bool $dryRun): int + { + if (! class_exists(Workspace::class)) { + $this->error('Workspace purge requires Tenant module to be installed.'); + + return self::FAILURE; + } + + $workspace = Workspace::where('slug', $slug)->first(); + + if (! $workspace) { + $this->error("Workspace not found: {$slug}"); + $this->newLine(); + $this->info('Available workspaces:'); + Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}")); + + return self::FAILURE; + } + + $this->info("Purging workspace: {$workspace->slug}"); + + if ($dryRun) { + $this->line("Would purge: workspace-{$workspace->uuid}"); + + return self::SUCCESS; + } + + try { + $result = $this->purger->workspace($workspace->uuid); + + if ($result->isOk()) { + $this->info('Workspace cache purged successfully'); + + return self::SUCCESS; + } + + $this->error('Failed to purge workspace cache: '.$result->message()); + + return self::FAILURE; + } catch (\Exception $e) { + $this->error("Purge failed: {$e->getMessage()}"); + + return self::FAILURE; + } + } +} diff --git a/app/Core/Cdn/Console/OffloadMigrateCommand.php b/app/Core/Cdn/Console/OffloadMigrateCommand.php new file mode 100644 index 0000000..bc4d102 --- /dev/null +++ b/app/Core/Cdn/Console/OffloadMigrateCommand.php @@ -0,0 +1,214 @@ +offloadService = $offloadService; + + if (! $this->offloadService->isEnabled()) { + $this->error('Storage offload is not enabled in configuration.'); + $this->info('Set STORAGE_OFFLOAD_ENABLED=true in your .env file.'); + + return self::FAILURE; + } + + // Determine path to scan + $path = $this->argument('path') ?? storage_path('app/public'); + $category = $this->option('category'); + $dryRun = $this->option('dry-run'); + $onlyMissing = $this->option('only-missing'); + + if (! is_dir($path)) { + $this->error("Directory not found: {$path}"); + + return self::FAILURE; + } + + $this->info("Scanning directory: {$path}"); + $this->info("Category: {$category}"); + $this->info("Disk: {$this->offloadService->getDiskName()}"); + + if ($dryRun) { + $this->warn('DRY RUN MODE - No files will be offloaded'); + } + + $this->line(''); + + // Scan for files + $files = $this->scanDirectory($path); + + if (empty($files)) { + $this->info('No eligible files found.'); + + return self::SUCCESS; + } + + $this->info('Found '.count($files).' file(s) to process.'); + + // Filter already offloaded files if requested + if ($onlyMissing) { + $files = array_filter($files, function ($file) { + return ! $this->offloadService->isOffloaded($file); + }); + + if (empty($files)) { + $this->info('All files are already offloaded.'); + + return self::SUCCESS; + } + + $this->info('Found '.count($files).' file(s) not yet offloaded.'); + } + + // Calculate total size + $totalSize = array_sum(array_map('filesize', $files)); + $this->info('Total size: '.$this->formatBytes($totalSize)); + $this->line(''); + + // Confirmation + if (! $dryRun && ! $this->option('force')) { + if (! $this->confirm('Proceed with offloading?')) { + $this->info('Cancelled.'); + + return self::SUCCESS; + } + } + + // Process files + $processed = 0; + $failed = 0; + $skipped = 0; + + $this->withProgressBar($files, function ($file) use ($category, $dryRun, &$processed, &$failed, &$skipped) { + // Check if already offloaded + if ($this->offloadService->isOffloaded($file)) { + $skipped++; + + return; + } + + if ($dryRun) { + $processed++; + + return; + } + + // Attempt to offload + $result = $this->offloadService->upload($file, null, $category); + + if ($result) { + $processed++; + } else { + $failed++; + } + }); + + $this->newLine(2); + + // Summary + $this->info('Migration complete!'); + $this->table( + ['Status', 'Count'], + [ + ['Processed', $processed], + ['Failed', $failed], + ['Skipped', $skipped], + ['Total', count($files)], + ] + ); + + if ($failed > 0) { + $this->warn('Some files failed to offload. Check logs for details.'); + + return self::FAILURE; + } + + return self::SUCCESS; + } + + /** + * Scan directory recursively for eligible files. + */ + protected function scanDirectory(string $path): array + { + $files = []; + $allowedExtensions = config('offload.allowed_extensions', []); + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $filePath = $file->getPathname(); + $extension = strtolower($file->getExtension()); + + // Skip if not in allowed extensions list (if configured) + if (! empty($allowedExtensions) && ! in_array($extension, $allowedExtensions)) { + continue; + } + + // Skip if exceeds max file size + $maxSize = config('offload.max_file_size', 100 * 1024 * 1024); + if ($file->getSize() > $maxSize) { + continue; + } + + $files[] = $filePath; + } + + return $files; + } + + /** + * Format bytes to human-readable format. + */ + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0; + $power = min($power, count($units) - 1); + + return round($bytes / (1024 ** $power), 2).' '.$units[$power]; + } +} diff --git a/app/Core/Cdn/Console/PushAssetsToCdn.php b/app/Core/Cdn/Console/PushAssetsToCdn.php new file mode 100644 index 0000000..c4d2d4c --- /dev/null +++ b/app/Core/Cdn/Console/PushAssetsToCdn.php @@ -0,0 +1,198 @@ +error('Push assets to CDN requires Core\Plug\Storage\Bunny\VBucket class. Plug module not installed.'); + + return self::FAILURE; + } + + $this->cdn = $cdn; + $this->dryRun = $this->option('dry-run'); + + // Create vBucket for workspace isolation + $domain = $this->option('domain'); + $this->vbucket = VBucket::public($domain); + + $pushFlux = $this->option('flux'); + $pushFontawesome = $this->option('fontawesome'); + $pushJs = $this->option('js'); + $pushAll = $this->option('all') || (! $pushFlux && ! $pushFontawesome && ! $pushJs); + + $this->info("Pushing assets to CDN storage zone for {$domain}..."); + $this->line("vBucket: {$this->vbucket->id()}"); + $this->newLine(); + + if ($pushAll || $pushFlux) { + $this->pushFluxAssets($flux); + } + + if ($pushAll || $pushFontawesome) { + $this->pushFontAwesomeAssets(); + } + + if ($pushAll || $pushJs) { + $this->pushJsAssets(); + } + + $this->newLine(); + + if ($this->dryRun) { + $this->info("Dry run complete. Would upload {$this->uploadCount} files."); + } else { + $this->info("Upload complete. {$this->uploadCount} files uploaded, {$this->failCount} failed."); + $this->line('CDN URL: '.config('cdn.urls.cdn')); + } + + return $this->failCount > 0 ? self::FAILURE : self::SUCCESS; + } + + protected function pushFluxAssets(FluxCdnService $flux): void + { + $this->components->info('Flux UI assets'); + + $assets = $flux->getCdnAssetPaths(); + + foreach ($assets as $sourcePath => $cdnPath) { + $this->uploadFile($sourcePath, $cdnPath); + } + } + + protected function pushFontAwesomeAssets(): void + { + $this->components->info('Font Awesome assets'); + + $basePath = public_path('vendor/fontawesome'); + + if (! File::isDirectory($basePath)) { + $this->warn(' Font Awesome directory not found at public/vendor/fontawesome'); + + return; + } + + // Push CSS files + $cssPath = "{$basePath}/css"; + if (File::isDirectory($cssPath)) { + foreach (File::files($cssPath) as $file) { + $cdnPath = 'vendor/fontawesome/css/'.$file->getFilename(); + $this->uploadFile($file->getPathname(), $cdnPath); + } + } + + // Push webfonts + $webfontsPath = "{$basePath}/webfonts"; + if (File::isDirectory($webfontsPath)) { + foreach (File::files($webfontsPath) as $file) { + $cdnPath = 'vendor/fontawesome/webfonts/'.$file->getFilename(); + $this->uploadFile($file->getPathname(), $cdnPath); + } + } + } + + protected function pushJsAssets(): void + { + $this->components->info('JavaScript assets'); + + $jsPath = public_path('js'); + + if (! File::isDirectory($jsPath)) { + $this->warn(' JavaScript directory not found at public/js'); + + return; + } + + foreach (File::files($jsPath) as $file) { + if ($file->getExtension() === 'js') { + $cdnPath = 'js/'.$file->getFilename(); + $this->uploadFile($file->getPathname(), $cdnPath); + } + } + } + + protected function uploadFile(string $sourcePath, string $cdnPath): void + { + if (! file_exists($sourcePath)) { + $this->warn(" ✗ Source not found: {$sourcePath}"); + $this->failCount++; + + return; + } + + $size = $this->formatBytes(filesize($sourcePath)); + + if ($this->dryRun) { + $this->line(" [DRY-RUN] {$cdnPath} ({$size})"); + $this->uploadCount++; + + return; + } + + // Push directly to CDN storage zone via vBucket (workspace-isolated) + $contents = file_get_contents($sourcePath); + $result = $this->vbucket->putContents($cdnPath, $contents); + + if ($result->isOk()) { + $this->line(" ✓ {$cdnPath} ({$size})"); + $this->uploadCount++; + } else { + $this->error(" ✗ Failed: {$cdnPath}"); + $this->failCount++; + } + } + + protected function formatBytes(int $bytes): string + { + if ($bytes >= 1048576) { + return round($bytes / 1048576, 2).' MB'; + } + + if ($bytes >= 1024) { + return round($bytes / 1024, 2).' KB'; + } + + return $bytes.' bytes'; + } +} diff --git a/app/Core/Cdn/Console/PushFluxToCdn.php b/app/Core/Cdn/Console/PushFluxToCdn.php new file mode 100644 index 0000000..51db58a --- /dev/null +++ b/app/Core/Cdn/Console/PushFluxToCdn.php @@ -0,0 +1,86 @@ +info('Pushing Flux assets to CDN...'); + + $assets = $flux->getCdnAssetPaths(); + + if (empty($assets)) { + $this->warn('No Flux assets found to push.'); + + return self::SUCCESS; + } + + $dryRun = $this->option('dry-run'); + + foreach ($assets as $sourcePath => $cdnPath) { + if (! file_exists($sourcePath)) { + $this->warn("Source file not found: {$sourcePath}"); + + continue; + } + + $size = $this->formatBytes(filesize($sourcePath)); + + if ($dryRun) { + $this->line(" [DRY-RUN] Would upload: {$cdnPath} ({$size})"); + + continue; + } + + $this->line(" Uploading: {$cdnPath} ({$size})"); + + $contents = file_get_contents($sourcePath); + $success = $cdn->storePublic($cdnPath, $contents, pushToCdn: true); + + if ($success) { + $this->info(' ✓ Uploaded to CDN'); + } else { + $this->error(' ✗ Failed to upload'); + } + } + + if (! $dryRun) { + $this->newLine(); + $this->info('Flux assets pushed to CDN successfully.'); + $this->line('CDN URL: '.config('cdn.urls.cdn').'/flux/'); + } + + return self::SUCCESS; + } + + protected function formatBytes(int $bytes): string + { + if ($bytes >= 1048576) { + return round($bytes / 1048576, 2).' MB'; + } + + if ($bytes >= 1024) { + return round($bytes / 1024, 2).' KB'; + } + + return $bytes.' bytes'; + } +} diff --git a/app/Core/Cdn/Facades/CLAUDE.md b/app/Core/Cdn/Facades/CLAUDE.md new file mode 100644 index 0000000..b6b4886 --- /dev/null +++ b/app/Core/Cdn/Facades/CLAUDE.md @@ -0,0 +1,9 @@ +# Cdn/Facades/ — CDN Facade + +## Facades + +| Facade | Resolves To | Purpose | +|--------|-------------|---------| +| `Cdn` | `StorageUrlResolver` | Static proxy for CDN operations — `cdn()`, `origin()`, `private()`, `signedUrl()`, `asset()`, `pushToCdn()`, `deleteFromCdn()`, `purge()`, `storePublic()`, `storePrivate()`, `vBucketCdn()`, and more. | + +Usage: `Cdn::cdn('images/logo.png')` returns the CDN URL for the asset. diff --git a/app/Core/Cdn/Facades/Cdn.php b/app/Core/Cdn/Facades/Cdn.php new file mode 100644 index 0000000..d4f3156 --- /dev/null +++ b/app/Core/Cdn/Facades/Cdn.php @@ -0,0 +1,53 @@ +onQueue(config('cdn.pipeline.queue', 'cdn')); + } + + /** + * Execute the job. + * + * @param object|null $storage StorageManager instance when Plug module available + */ + public function handle(?object $storage = null): void + { + if (! class_exists(StorageManager::class)) { + Log::warning('PushAssetToCdn: StorageManager not available, Plug module not installed'); + + return; + } + + // Resolve from container if not injected + if ($storage === null) { + $storage = app(StorageManager::class); + } + + if (! config('cdn.bunny.push_enabled', false)) { + Log::debug('PushAssetToCdn: Push disabled, skipping', [ + 'disk' => $this->disk, + 'path' => $this->path, + ]); + + return; + } + + $uploader = $storage->zone($this->zone)->upload(); + + if (! $uploader->isConfigured()) { + Log::warning('PushAssetToCdn: CDN storage not configured', [ + 'zone' => $this->zone, + ]); + + return; + } + + // Get contents from origin disk + $sourceDisk = Storage::disk($this->disk); + if (! $sourceDisk->exists($this->path)) { + Log::warning('PushAssetToCdn: Source file not found on disk', [ + 'disk' => $this->disk, + 'path' => $this->path, + ]); + + return; + } + + $contents = $sourceDisk->get($this->path); + $result = $uploader->contents($this->path, $contents); + + if ($result->hasError()) { + Log::error('PushAssetToCdn: Failed to push asset', [ + 'disk' => $this->disk, + 'path' => $this->path, + 'zone' => $this->zone, + 'error' => $result->message(), + ]); + + $this->fail(new \Exception("Failed to push {$this->path} to CDN zone {$this->zone}")); + } + + Log::info('PushAssetToCdn: Asset pushed successfully', [ + 'disk' => $this->disk, + 'path' => $this->path, + 'zone' => $this->zone, + ]); + } + + /** + * Get the tags that should be assigned to the job. + * + * @return array + */ + public function tags(): array + { + return [ + 'cdn', + 'push', + "zone:{$this->zone}", + "path:{$this->path}", + ]; + } + + /** + * Determine if the job should be unique. + */ + public function uniqueId(): string + { + return "{$this->zone}:{$this->path}"; + } + + /** + * The unique ID of the job. + */ + public function uniqueFor(): int + { + return 300; // 5 minutes + } +} diff --git a/app/Core/Cdn/Middleware/CLAUDE.md b/app/Core/Cdn/Middleware/CLAUDE.md new file mode 100644 index 0000000..54a9335 --- /dev/null +++ b/app/Core/Cdn/Middleware/CLAUDE.md @@ -0,0 +1,8 @@ +# Cdn/Middleware/ — CDN HTTP Middleware + +## Middleware + +| Class | Purpose | +|-------|---------| +| `LocalCdnMiddleware` | Adds aggressive caching headers and compression for requests on the `cdn.*` subdomain. Provides CDN-like behaviour without external services. | +| `RewriteOffloadedUrls` | Processes JSON responses and replaces local storage paths with remote equivalents when files have been offloaded to external storage. | diff --git a/app/Core/Cdn/Middleware/LocalCdnMiddleware.php b/app/Core/Cdn/Middleware/LocalCdnMiddleware.php new file mode 100644 index 0000000..e9d3ff8 --- /dev/null +++ b/app/Core/Cdn/Middleware/LocalCdnMiddleware.php @@ -0,0 +1,143 @@ + normal app + * cdn.core.test -> same app, but with CDN headers + */ +class LocalCdnMiddleware +{ + /** + * Handle an incoming request. + */ + public function handle(Request $request, Closure $next): Response + { + // Check if this is a CDN subdomain request + if (! $this->isCdnSubdomain($request)) { + return $next($request); + } + + // Process the request + $response = $next($request); + + // Add CDN headers to the response + $this->addCdnHeaders($response, $request); + + return $response; + } + + /** + * Check if request is to the CDN subdomain. + */ + protected function isCdnSubdomain(Request $request): bool + { + $host = $request->getHost(); + $cdnSubdomain = config('core.cdn.subdomain', 'cdn'); + $baseDomain = config('core.domain.base', 'core.test'); + + // Check for cdn.{domain} pattern + return str_starts_with($host, "{$cdnSubdomain}."); + } + + /** + * Add CDN-appropriate headers to the response. + */ + protected function addCdnHeaders(Response $response, Request $request): void + { + // Skip if response isn't successful + if (! $response->isSuccessful()) { + return; + } + + // Get cache settings from config + $maxAge = config('core.cdn.cache_max_age', 31536000); // 1 year + $immutable = config('core.cdn.cache_immutable', true); + + // Build Cache-Control header + $cacheControl = "public, max-age={$maxAge}"; + if ($immutable) { + $cacheControl .= ', immutable'; + } + + $response->headers->set('Cache-Control', $cacheControl); + + // Add ETag if possible + if ($response instanceof BinaryFileResponse) { + $file = $response->getFile(); + if ($file && $file->isFile()) { + $etag = md5($file->getMTime().$file->getSize()); + $response->headers->set('ETag', "\"{$etag}\""); + } + } + + // Vary on Accept-Encoding for compressed responses + $response->headers->set('Vary', 'Accept-Encoding'); + + // Add timing header for debugging + $response->headers->set('X-CDN-Cache', 'local'); + + // Set Content-Type headers for common static files + $this->setContentTypeHeaders($response, $request); + } + + /** + * Set appropriate Content-Type for static assets. + */ + protected function setContentTypeHeaders(Response $response, Request $request): void + { + $path = $request->getPathInfo(); + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + $mimeTypes = [ + 'js' => 'application/javascript; charset=utf-8', + 'mjs' => 'application/javascript; charset=utf-8', + 'css' => 'text/css; charset=utf-8', + 'json' => 'application/json; charset=utf-8', + 'svg' => 'image/svg+xml', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + 'ico' => 'image/x-icon', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'mp3' => 'audio/mpeg', + 'ogg' => 'audio/ogg', + 'xml' => 'application/xml; charset=utf-8', + 'txt' => 'text/plain; charset=utf-8', + 'map' => 'application/json; charset=utf-8', + ]; + + if (isset($mimeTypes[$extension])) { + $response->headers->set('Content-Type', $mimeTypes[$extension]); + } + } +} diff --git a/app/Core/Cdn/Middleware/RewriteOffloadedUrls.php b/app/Core/Cdn/Middleware/RewriteOffloadedUrls.php new file mode 100644 index 0000000..5110d2d --- /dev/null +++ b/app/Core/Cdn/Middleware/RewriteOffloadedUrls.php @@ -0,0 +1,168 @@ +offloadService = $offloadService; + } + + /** + * Handle an incoming request. + * + * Rewrites URLs in JSON responses to point to offloaded storage. + */ + public function handle(Request $request, Closure $next): Response + { + $response = $next($request); + + // Only process JSON responses + if (! $this->shouldProcess($response)) { + return $response; + } + + // Get response content + $content = $response->getContent(); + if (empty($content)) { + return $response; + } + + // Decode JSON + $data = json_decode($content, true); + if ($data === null) { + return $response; + } + + // Rewrite URLs in the data + $rewritten = $this->rewriteUrls($data); + + // Update response + $response->setContent(json_encode($rewritten)); + + return $response; + } + + /** + * Check if response should be processed. + */ + protected function shouldProcess(Response $response): bool + { + // Only process successful responses + if (! $response->isSuccessful()) { + return false; + } + + // Check content type + $contentType = $response->headers->get('Content-Type', ''); + + return str_contains($contentType, 'application/json'); + } + + /** + * Recursively rewrite URLs in data structure. + */ + protected function rewriteUrls(mixed $data): mixed + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = $this->rewriteUrls($value); + } + + return $data; + } + + if (is_string($data)) { + return $this->rewriteUrl($data); + } + + return $data; + } + + /** + * Rewrite a single URL if it matches a local storage path. + */ + protected function rewriteUrl(string $value): string + { + // Only process strings that look like URLs or paths + if (! $this->looksLikeStoragePath($value)) { + return $value; + } + + // Extract local path from URL + $localPath = $this->extractLocalPath($value); + if (! $localPath) { + return $value; + } + + // Check if this path has been offloaded + $offloadedUrl = $this->offloadService->url($localPath); + if ($offloadedUrl) { + return $offloadedUrl; + } + + return $value; + } + + /** + * Check if a string looks like a storage path. + */ + protected function looksLikeStoragePath(string $value): bool + { + // Check for /storage/ in the path + if (str_contains($value, '/storage/')) { + return true; + } + + // Check for storage_path pattern + if (preg_match('#/app/(public|private)/#', $value)) { + return true; + } + + return false; + } + + /** + * Extract local file path from URL. + */ + protected function extractLocalPath(string $url): ?string + { + // Handle /storage/ URLs (symlinked public storage) + if (str_contains($url, '/storage/')) { + $parts = explode('/storage/', $url, 2); + if (count($parts) === 2) { + return storage_path('app/public/'.$parts[1]); + } + } + + // Handle absolute paths + if (str_starts_with($url, storage_path())) { + return $url; + } + + return null; + } +} diff --git a/app/Core/Cdn/Models/CLAUDE.md b/app/Core/Cdn/Models/CLAUDE.md new file mode 100644 index 0000000..d7ed0ad --- /dev/null +++ b/app/Core/Cdn/Models/CLAUDE.md @@ -0,0 +1,7 @@ +# Cdn/Models/ — CDN Storage Models + +## Models + +| Model | Purpose | +|-------|---------| +| `StorageOffload` | Tracks files offloaded to remote storage. Records local path, remote path, disk, SHA-256 hash, file size, MIME type, category, metadata, and offload timestamp. Used by the URL rewriting middleware. | diff --git a/app/Core/Cdn/Models/StorageOffload.php b/app/Core/Cdn/Models/StorageOffload.php new file mode 100644 index 0000000..a0b39f3 --- /dev/null +++ b/app/Core/Cdn/Models/StorageOffload.php @@ -0,0 +1,162 @@ + 'array', + 'file_size' => 'integer', + 'offloaded_at' => 'datetime', + ]; + + /** + * Get the category. + */ + public function getCategory(): ?string + { + return $this->category; + } + + /** + * Get metadata value by key. + */ + public function getMetadata(?string $key = null): mixed + { + if ($key === null) { + return $this->metadata; + } + + return $this->metadata[$key] ?? null; + } + + /** + * Get the original filename from metadata. + */ + public function getOriginalName(): ?string + { + return $this->getMetadata('original_name'); + } + + /** + * Check if this offload is for a specific category. + */ + public function isCategory(string $category): bool + { + return $this->getCategory() === $category; + } + + /** + * Check if the file is an image. + */ + public function isImage(): bool + { + return $this->mime_type && str_starts_with($this->mime_type, 'image/'); + } + + /** + * Check if the file is a video. + */ + public function isVideo(): bool + { + return $this->mime_type && str_starts_with($this->mime_type, 'video/'); + } + + /** + * Check if the file is audio. + */ + public function isAudio(): bool + { + return $this->mime_type && str_starts_with($this->mime_type, 'audio/'); + } + + /** + * Scope to filter by category. + */ + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } + + /** + * Alias for scopeCategory - filter by category. + */ + public function scopeInCategory($query, string $category) + { + return $this->scopeCategory($query, $category); + } + + /** + * Scope to filter by disk. + */ + public function scopeDisk($query, string $disk) + { + return $query->where('disk', $disk); + } + + /** + * Alias for scopeDisk - filter by disk. + */ + public function scopeForDisk($query, string $disk) + { + return $this->scopeDisk($query, $disk); + } + + /** + * Get human-readable file size. + */ + public function getFileSizeHumanAttribute(): string + { + $bytes = $this->file_size ?? 0; + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0; + $power = min($power, count($units) - 1); + + return round($bytes / (1024 ** $power), 2).' '.$units[$power]; + } +} diff --git a/app/Core/Cdn/Services/AssetPipeline.php b/app/Core/Cdn/Services/AssetPipeline.php new file mode 100644 index 0000000..323d536 --- /dev/null +++ b/app/Core/Cdn/Services/AssetPipeline.php @@ -0,0 +1,396 @@ + private bucket (optional, for processing) + * 2. Process (resize, optimize, etc.) -> handled by caller + * 3. Store processed -> public bucket + * 4. Push to CDN storage zone + * + * Categories define path prefixes: + * - media: General media uploads + * - social: Social media assets + * - page: Page builder assets + * - avatar: User/workspace avatars + * - content: ContentMedia + * - static: Static assets + * - widget: Widget assets + * + * ## Methods + * + * | Method | Returns | Description | + * |--------|---------|-------------| + * | `store()` | `array` | Process and store an uploaded file to public bucket | + * | `storeContents()` | `array` | Store raw content (string/stream) to public bucket | + * | `storePrivate()` | `array` | Store to private bucket for DRM/gated content | + * | `copy()` | `array` | Copy file between buckets | + * | `delete()` | `bool` | Delete an asset from storage and CDN | + * | `deleteMany()` | `array` | Delete multiple assets | + * | `urls()` | `array` | Get CDN and origin URLs for a path | + * | `exists()` | `bool` | Check if a file exists in storage | + * | `size()` | `int\|null` | Get file size in bytes | + * | `mimeType()` | `string\|null` | Get file MIME type | + */ +class AssetPipeline +{ + protected StorageUrlResolver $urlResolver; + + /** + * Storage manager instance (Core\Plug\Storage\StorageManager when available). + */ + protected ?object $storage = null; + + public function __construct(StorageUrlResolver $urlResolver, ?object $storage = null) + { + $this->urlResolver = $urlResolver; + $this->storage = $storage; + } + + /** + * Process and store an uploaded file. + * + * @param UploadedFile $file The uploaded file + * @param string $category Category key (media, social, page, etc.) + * @param string|null $filename Custom filename (auto-generated if null) + * @param array $options Additional options (workspace_id, user_id, etc.) + * @return array{path: string, cdn_url: string, origin_url: string, size: int, mime: string} + */ + public function store(UploadedFile $file, string $category, ?string $filename = null, array $options = []): array + { + $filename = $filename ?? $this->generateFilename($file); + $path = $this->buildPath($category, $filename, $options); + + // Store to public bucket + $stored = $this->urlResolver->publicDisk()->putFileAs( + dirname($path), + $file, + basename($path) + ); + + if (! $stored) { + throw new \RuntimeException("Failed to store file at: {$path}"); + } + + // Queue CDN push if enabled + $this->queueCdnPush('hetzner-public', $path, 'public'); + + return [ + 'path' => $path, + 'cdn_url' => $this->urlResolver->cdn($path), + 'origin_url' => $this->urlResolver->origin($path), + 'size' => $file->getSize(), + 'mime' => $file->getMimeType(), + ]; + } + + /** + * Store raw content (string or stream). + * + * @param string|resource $contents File contents + * @param string $category Category key + * @param string $filename Filename with extension + * @param array $options Additional options + * @return array{path: string, cdn_url: string, origin_url: string} + */ + public function storeContents($contents, string $category, string $filename, array $options = []): array + { + $path = $this->buildPath($category, $filename, $options); + + $stored = $this->urlResolver->publicDisk()->put($path, $contents); + + if (! $stored) { + throw new \RuntimeException("Failed to store content at: {$path}"); + } + + $this->queueCdnPush('hetzner-public', $path, 'public'); + + return [ + 'path' => $path, + 'cdn_url' => $this->urlResolver->cdn($path), + 'origin_url' => $this->urlResolver->origin($path), + ]; + } + + /** + * Store to private bucket (for DRM/gated content). + * + * @param UploadedFile|string|resource $content File or contents + * @param string $category Category key + * @param string|null $filename Filename (required for non-UploadedFile) + * @param array $options Additional options + * @return array{path: string, private_url: string} + */ + public function storePrivate($content, string $category, ?string $filename = null, array $options = []): array + { + if ($content instanceof UploadedFile) { + $filename = $filename ?? $this->generateFilename($content); + $path = $this->buildPath($category, $filename, $options); + + $stored = $this->urlResolver->privateDisk()->putFileAs( + dirname($path), + $content, + basename($path) + ); + } else { + if (! $filename) { + throw new \InvalidArgumentException('Filename required for non-UploadedFile content'); + } + + $path = $this->buildPath($category, $filename, $options); + $stored = $this->urlResolver->privateDisk()->put($path, $content); + } + + if (! $stored) { + throw new \RuntimeException("Failed to store private content at: {$path}"); + } + + $this->queueCdnPush('hetzner-private', $path, 'private'); + + return [ + 'path' => $path, + 'private_url' => $this->urlResolver->private($path), + ]; + } + + /** + * Copy an existing file from one bucket to another. + * + * @param string $sourcePath Source path + * @param string $sourceBucket Source bucket ('public' or 'private') + * @param string $destBucket Destination bucket ('public' or 'private') + * @param string|null $destPath Destination path (same as source if null) + * @return array{path: string, bucket: string} + * + * @throws \RuntimeException If source file not found or copy fails + */ + public function copy(string $sourcePath, string $sourceBucket, string $destBucket, ?string $destPath = null): array + { + $sourceDisk = $sourceBucket === 'private' + ? $this->urlResolver->privateDisk() + : $this->urlResolver->publicDisk(); + + $destDisk = $destBucket === 'private' + ? $this->urlResolver->privateDisk() + : $this->urlResolver->publicDisk(); + + $destPath = $destPath ?? $sourcePath; + + $contents = $sourceDisk->get($sourcePath); + + if ($contents === null) { + throw new \RuntimeException("Source file not found: {$sourcePath}"); + } + + $stored = $destDisk->put($destPath, $contents); + + if (! $stored) { + throw new \RuntimeException("Failed to copy to: {$destPath}"); + } + + $hetznerDisk = $destBucket === 'private' ? 'hetzner-private' : 'hetzner-public'; + $this->queueCdnPush($hetznerDisk, $destPath, $destBucket); + + return [ + 'path' => $destPath, + 'bucket' => $destBucket, + ]; + } + + /** + * Delete an asset from storage and CDN. + * + * @param string $path File path + * @param string $bucket 'public' or 'private' + * @return bool True if deletion was successful + */ + public function delete(string $path, string $bucket = 'public'): bool + { + return $this->urlResolver->deleteAsset($path, $bucket); + } + + /** + * Delete multiple assets. + * + * @param array $paths File paths + * @param string $bucket 'public' or 'private' + * @return array Map of path to deletion success status + */ + public function deleteMany(array $paths, string $bucket = 'public'): array + { + $results = []; + $disk = $bucket === 'private' + ? $this->urlResolver->privateDisk() + : $this->urlResolver->publicDisk(); + + foreach ($paths as $path) { + $results[$path] = $disk->delete($path); + } + + // Bulk delete from CDN storage (requires StorageManager from Plug module) + if ($this->storage !== null) { + $this->storage->zone($bucket)->delete()->paths($paths); + } + + // Purge from CDN cache if enabled + if (config('cdn.pipeline.auto_purge', true)) { + foreach ($paths as $path) { + $this->urlResolver->purge($path); + } + } + + return $results; + } + + /** + * Get URLs for a path. + * + * @param string $path File path + * @return array{cdn: string, origin: string} + */ + public function urls(string $path): array + { + return $this->urlResolver->urls($path); + } + + /** + * Build storage path from category and filename. + * + * @param string $category Category key (media, social, etc.) + * @param string $filename Filename with extension + * @param array $options Options including workspace_id, user_id + * @return string Full storage path + */ + protected function buildPath(string $category, string $filename, array $options = []): string + { + $prefix = $this->urlResolver->pathPrefix($category); + $parts = [$prefix]; + + // Add workspace scope if provided + if (isset($options['workspace_id'])) { + $parts[] = 'ws_'.$options['workspace_id']; + } + + // Add user scope if provided + if (isset($options['user_id'])) { + $parts[] = 'u_'.$options['user_id']; + } + + // Add date partitioning for media files + if (in_array($category, ['media', 'social', 'content'])) { + $parts[] = date('Y/m'); + } + + $parts[] = $filename; + + return implode('/', $parts); + } + + /** + * Generate a unique filename. + * + * @param UploadedFile $file The uploaded file + * @return string Unique filename with original extension + */ + protected function generateFilename(UploadedFile $file): string + { + $extension = $file->getClientOriginalExtension(); + $hash = Str::random(16); + + return "{$hash}.{$extension}"; + } + + /** + * Queue a CDN push job if auto-push is enabled. + * + * @param string $disk Laravel disk name + * @param string $path Path within the disk + * @param string $zone Target CDN zone ('public' or 'private') + */ + protected function queueCdnPush(string $disk, string $path, string $zone): void + { + if (! config('cdn.pipeline.auto_push', true)) { + return; + } + + if (! config('cdn.bunny.push_enabled', false)) { + return; + } + + $queue = config('cdn.pipeline.queue'); + + if ($queue) { + PushAssetToCdn::dispatch($disk, $path, $zone); + } elseif ($this->storage !== null) { + // Synchronous push if no queue configured (requires StorageManager from Plug module) + $diskInstance = Storage::disk($disk); + if ($diskInstance->exists($path)) { + $contents = $diskInstance->get($path); + $this->storage->zone($zone)->upload()->contents($path, $contents); + } + } + } + + /** + * Check if a file exists in storage. + * + * @param string $path File path + * @param string $bucket 'public' or 'private' + * @return bool True if file exists + */ + public function exists(string $path, string $bucket = 'public'): bool + { + $disk = $bucket === 'private' + ? $this->urlResolver->privateDisk() + : $this->urlResolver->publicDisk(); + + return $disk->exists($path); + } + + /** + * Get file size in bytes. + * + * @param string $path File path + * @param string $bucket 'public' or 'private' + */ + public function size(string $path, string $bucket = 'public'): ?int + { + $disk = $bucket === 'private' + ? $this->urlResolver->privateDisk() + : $this->urlResolver->publicDisk(); + + return $disk->exists($path) ? $disk->size($path) : null; + } + + /** + * Get file MIME type. + * + * @param string $path File path + * @param string $bucket 'public' or 'private' + */ + public function mimeType(string $path, string $bucket = 'public'): ?string + { + $disk = $bucket === 'private' + ? $this->urlResolver->privateDisk() + : $this->urlResolver->publicDisk(); + + return $disk->exists($path) ? $disk->mimeType($path) : null; + } +} diff --git a/app/Core/Cdn/Services/BunnyCdnService.php b/app/Core/Cdn/Services/BunnyCdnService.php new file mode 100644 index 0000000..15066f4 --- /dev/null +++ b/app/Core/Cdn/Services/BunnyCdnService.php @@ -0,0 +1,386 @@ +apiKey = $this->config->get('cdn.bunny.api_key') ?? ''; + $this->pullZoneId = $this->config->get('cdn.bunny.pull_zone_id') ?? ''; + } + + /** + * Sanitize an error message to remove sensitive data like API keys. + * + * @param string $message The error message to sanitize + * @return string The sanitized message with API keys replaced by [REDACTED] + */ + protected function sanitizeErrorMessage(string $message): string + { + $sensitiveKeys = array_filter([ + $this->apiKey, + $this->config->get('cdn.bunny.storage.public.api_key'), + $this->config->get('cdn.bunny.storage.private.api_key'), + ]); + + foreach ($sensitiveKeys as $key) { + if ($key !== '' && str_contains($message, $key)) { + $message = str_replace($key, '[REDACTED]', $message); + } + } + + return $message; + } + + /** + * Check if the service is configured. + * + * @return bool True if BunnyCDN API key and pull zone ID are configured + */ + public function isConfigured(): bool + { + return $this->config->isConfigured('cdn.bunny'); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Cache Purging + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Purge a single URL from CDN cache. + * + * @param string $url The full URL to purge from cache + * @return bool True if purge was successful, false otherwise + */ + public function purgeUrl(string $url): bool + { + return $this->purgeUrls([$url]); + } + + /** + * Purge multiple URLs from CDN cache. + * + * @param array $urls Array of full URLs to purge from cache + * @return bool True if all purges were successful, false if any failed + */ + public function purgeUrls(array $urls): bool + { + if (! $this->isConfigured()) { + Log::warning('BunnyCDN: Cannot purge - not configured'); + + return false; + } + + try { + foreach ($urls as $url) { + $response = Http::withHeaders([ + 'AccessKey' => $this->apiKey, + ])->post("{$this->baseUrl}/purge", [ + 'url' => $url, + ]); + + if (! $response->successful()) { + Log::error('BunnyCDN: Purge failed', [ + 'url' => $url, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return false; + } + } + + return true; + } catch (\Exception $e) { + Log::error('BunnyCDN: Purge exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]); + + return false; + } + } + + /** + * Purge entire pull zone cache. + * + * @return bool True if purge was successful, false otherwise + */ + public function purgeAll(): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + $response = Http::withHeaders([ + 'AccessKey' => $this->apiKey, + ])->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache"); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('BunnyCDN: PurgeAll exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]); + + return false; + } + } + + /** + * Purge cache by tag. + * + * @param string $tag The cache tag to purge (e.g., 'workspace-uuid') + * @return bool True if purge was successful, false otherwise + */ + public function purgeByTag(string $tag): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + $response = Http::withHeaders([ + 'AccessKey' => $this->apiKey, + ])->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache", [ + 'CacheTag' => $tag, + ]); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('BunnyCDN: PurgeByTag exception', [ + 'tag' => $tag, + 'error' => $this->sanitizeErrorMessage($e->getMessage()), + ]); + + return false; + } + } + + /** + * Purge all cached content for a workspace. + * + * @param object $workspace Workspace model instance (requires uuid property) + * @return bool True if purge was successful, false otherwise + */ + public function purgeWorkspace(object $workspace): bool + { + return $this->purgeByTag("workspace-{$workspace->uuid}"); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Statistics + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Get CDN statistics for pull zone. + * + * @param string|null $dateFrom Start date in YYYY-MM-DD format + * @param string|null $dateTo End date in YYYY-MM-DD format + * @return array|null Statistics array or null on failure + */ + public function getStats(?string $dateFrom = null, ?string $dateTo = null): ?array + { + if (! $this->isConfigured()) { + return null; + } + + try { + $params = [ + 'pullZone' => $this->pullZoneId, + ]; + + if ($dateFrom) { + $params['dateFrom'] = $dateFrom; + } + if ($dateTo) { + $params['dateTo'] = $dateTo; + } + + $response = Http::withHeaders([ + 'AccessKey' => $this->apiKey, + ])->get("{$this->baseUrl}/statistics", $params); + + if ($response->successful()) { + return $response->json(); + } + + return null; + } catch (\Exception $e) { + Log::error('BunnyCDN: GetStats exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]); + + return null; + } + } + + /** + * Get bandwidth usage for pull zone. + * + * @param string|null $dateFrom Start date in YYYY-MM-DD format + * @param string|null $dateTo End date in YYYY-MM-DD format + * @return array{total_bandwidth: int, cached_bandwidth: int, origin_bandwidth: int}|null Bandwidth stats or null on failure + */ + public function getBandwidth(?string $dateFrom = null, ?string $dateTo = null): ?array + { + $stats = $this->getStats($dateFrom, $dateTo); + + if (! $stats) { + return null; + } + + return [ + 'total_bandwidth' => $stats['TotalBandwidthUsed'] ?? 0, + 'cached_bandwidth' => $stats['CacheHitRate'] ?? 0, + 'origin_bandwidth' => $stats['TotalOriginTraffic'] ?? 0, + ]; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Storage Zone Operations (via API) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * List files in a storage zone via API. + * + * Note: For direct storage operations, use BunnyStorageService instead. + * + * @param string $storageZoneName Name of the storage zone + * @param string $path Path within the storage zone (default: root) + * @return array>|null Array of file objects or null on failure + */ + public function listStorageFiles(string $storageZoneName, string $path = '/'): ?array + { + if (! $this->isConfigured()) { + return null; + } + + try { + $storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key'); + $region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com'); + + $url = "https://{$region}/{$storageZoneName}/{$path}"; + + $response = Http::withHeaders([ + 'AccessKey' => $storageApiKey, + ])->get($url); + + if ($response->successful()) { + return $response->json(); + } + + return null; + } catch (\Exception $e) { + Log::error('BunnyCDN: ListStorageFiles exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]); + + return null; + } + } + + /** + * Upload a file to storage zone via API. + * + * Note: For direct storage operations, use BunnyStorageService instead. + * + * @param string $storageZoneName Name of the storage zone + * @param string $path Target path within the storage zone + * @param string $contents File contents to upload + * @return bool True if upload was successful, false otherwise + */ + public function uploadFile(string $storageZoneName, string $path, string $contents): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + $storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key'); + $region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com'); + + $url = "https://{$region}/{$storageZoneName}/{$path}"; + + $response = Http::withHeaders([ + 'AccessKey' => $storageApiKey, + 'Content-Type' => 'application/octet-stream', + ])->withBody($contents, 'application/octet-stream')->put($url); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('BunnyCDN: UploadFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]); + + return false; + } + } + + /** + * Delete a file from storage zone via API. + * + * Note: For direct storage operations, use BunnyStorageService instead. + * + * @param string $storageZoneName Name of the storage zone + * @param string $path Path of the file to delete + * @return bool True if deletion was successful, false otherwise + */ + public function deleteFile(string $storageZoneName, string $path): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + $storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key'); + $region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com'); + + $url = "https://{$region}/{$storageZoneName}/{$path}"; + + $response = Http::withHeaders([ + 'AccessKey' => $storageApiKey, + ])->delete($url); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('BunnyCDN: DeleteFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]); + + return false; + } + } +} diff --git a/app/Core/Cdn/Services/BunnyStorageService.php b/app/Core/Cdn/Services/BunnyStorageService.php new file mode 100644 index 0000000..0d9c7f0 --- /dev/null +++ b/app/Core/Cdn/Services/BunnyStorageService.php @@ -0,0 +1,712 @@ + + */ + protected const MIME_TYPES = [ + // Images + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'avif' => 'image/avif', + 'heic' => 'image/heic', + 'heif' => 'image/heif', + + // Documents + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // Text/Code + 'txt' => 'text/plain', + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'mjs' => 'application/javascript', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'csv' => 'text/csv', + 'md' => 'text/markdown', + + // Audio + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'ogg' => 'audio/ogg', + 'flac' => 'audio/flac', + 'aac' => 'audio/aac', + 'm4a' => 'audio/mp4', + + // Video + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'mkv' => 'video/x-matroska', + 'avi' => 'video/x-msvideo', + 'mov' => 'video/quicktime', + 'm4v' => 'video/mp4', + + // Archives + 'zip' => 'application/zip', + 'tar' => 'application/x-tar', + 'gz' => 'application/gzip', + 'rar' => 'application/vnd.rar', + '7z' => 'application/x-7z-compressed', + + // Fonts + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'eot' => 'application/vnd.ms-fontobject', + + // Other + 'wasm' => 'application/wasm', + 'map' => 'application/json', + ]; + + public function __construct( + protected ConfigService $config, + ) {} + + /** + * Get the public storage zone client. + */ + public function publicClient(): ?Client + { + if ($this->publicClient === null && $this->isConfigured('public')) { + $this->publicClient = new Client( + $this->config->get('cdn.bunny.storage.public.api_key'), + $this->config->get('cdn.bunny.storage.public.name'), + $this->config->get('cdn.bunny.storage.public.region', Client::STORAGE_ZONE_FS_EU) + ); + } + + return $this->publicClient; + } + + /** + * Get the private storage zone client. + */ + public function privateClient(): ?Client + { + if ($this->privateClient === null && $this->isConfigured('private')) { + $this->privateClient = new Client( + $this->config->get('cdn.bunny.storage.private.api_key'), + $this->config->get('cdn.bunny.storage.private.name'), + $this->config->get('cdn.bunny.storage.private.region', Client::STORAGE_ZONE_FS_EU) + ); + } + + return $this->privateClient; + } + + /** + * Check if a storage zone is configured. + */ + public function isConfigured(string $zone = 'public'): bool + { + return $this->config->isConfigured("cdn.bunny.storage.{$zone}"); + } + + /** + * Check if CDN push is enabled. + */ + public function isPushEnabled(): bool + { + return (bool) $this->config->get('cdn.bunny.push_enabled', false); + } + + /** + * List files in a storage zone path. + */ + public function list(string $path, string $zone = 'public'): array + { + $client = $zone === 'private' ? $this->privateClient() : $this->publicClient(); + + if (! $client) { + return []; + } + + try { + return $client->listFiles($path); + } catch (\Exception $e) { + Log::error('BunnyStorage: Failed to list files', [ + 'path' => $path, + 'zone' => $zone, + 'error' => $e->getMessage(), + ]); + + return []; + } + } + + /** + * Upload a file to storage zone. + */ + public function upload(string $localPath, string $remotePath, string $zone = 'public'): bool + { + $client = $zone === 'private' ? $this->privateClient() : $this->publicClient(); + + if (! $client) { + Log::warning('BunnyStorage: Client not configured', ['zone' => $zone]); + + return false; + } + + if (! file_exists($localPath)) { + Log::error('BunnyStorage: Local file not found', ['local' => $localPath]); + + return false; + } + + $fileSize = filesize($localPath); + $maxSize = $this->getMaxFileSize(); + + if ($fileSize === false || $fileSize > $maxSize) { + Log::error('BunnyStorage: File size exceeds limit', [ + 'local' => $localPath, + 'size' => $fileSize, + 'max_size' => $maxSize, + ]); + + return false; + } + + $contentType = $this->detectContentType($localPath); + + return $this->executeWithRetry(function () use ($client, $localPath, $remotePath, $contentType) { + // The Bunny SDK upload method accepts optional headers parameter + // Pass content-type for proper CDN handling + $client->upload($localPath, $remotePath, ['Content-Type' => $contentType]); + + return true; + }, [ + 'local' => $localPath, + 'remote' => $remotePath, + 'zone' => $zone, + 'content_type' => $contentType, + ], 'Upload'); + } + + /** + * Get the maximum allowed file size in bytes. + */ + protected function getMaxFileSize(): int + { + return (int) $this->config->get('cdn.bunny.max_file_size', self::DEFAULT_MAX_FILE_SIZE); + } + + /** + * Detect the MIME content type for a file. + * + * First tries to detect from file contents using PHP's built-in function, + * then falls back to extension-based detection. + * + * @param string $path File path (local or remote) + * @param string|null $contents File contents for content-based detection + * @return string MIME type (defaults to application/octet-stream) + */ + public function detectContentType(string $path, ?string $contents = null): string + { + // Try content-based detection if contents provided and finfo available + if ($contents !== null && function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo !== false) { + $mimeType = finfo_buffer($finfo, $contents); + finfo_close($finfo); + if ($mimeType !== false && $mimeType !== 'application/octet-stream') { + return $mimeType; + } + } + } + + // Try mime_content_type for local files + if (file_exists($path) && function_exists('mime_content_type')) { + $mimeType = @mime_content_type($path); + if ($mimeType !== false && $mimeType !== 'application/octet-stream') { + return $mimeType; + } + } + + // Fall back to extension-based detection + return $this->getContentTypeFromExtension($path); + } + + /** + * Get content type based on file extension. + * + * @param string $path File path to extract extension from + * @return string MIME type (defaults to application/octet-stream) + */ + public function getContentTypeFromExtension(string $path): string + { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + return self::MIME_TYPES[$extension] ?? 'application/octet-stream'; + } + + /** + * Check if a MIME type is for a binary file. + */ + public function isBinaryContentType(string $mimeType): bool + { + // Text types are not binary + if (str_starts_with($mimeType, 'text/')) { + return false; + } + + // Some application types are text-based + $textApplicationTypes = [ + 'application/json', + 'application/xml', + 'application/javascript', + 'application/x-javascript', + ]; + + return ! in_array($mimeType, $textApplicationTypes, true); + } + + /** + * Execute an operation with exponential backoff retry. + */ + protected function executeWithRetry(callable $operation, array $context, string $operationName): bool + { + $lastException = null; + + for ($attempt = 1; $attempt <= self::MAX_RETRY_ATTEMPTS; $attempt++) { + try { + return $operation(); + } catch (\Exception $e) { + $lastException = $e; + + if ($attempt < self::MAX_RETRY_ATTEMPTS) { + $delayMs = self::RETRY_BASE_DELAY_MS * (2 ** ($attempt - 1)); + usleep($delayMs * 1000); + + Log::warning("BunnyStorage: {$operationName} attempt {$attempt} failed, retrying", array_merge($context, [ + 'attempt' => $attempt, + 'next_delay_ms' => $delayMs * 2, + ])); + } + } + } + + Log::error("BunnyStorage: {$operationName} failed after ".self::MAX_RETRY_ATTEMPTS.' attempts', array_merge($context, [ + 'error' => $lastException?->getMessage() ?? 'Unknown error', + ])); + + return false; + } + + /** + * Upload file contents directly. + */ + public function putContents(string $remotePath, string $contents, string $zone = 'public'): bool + { + $client = $zone === 'private' ? $this->privateClient() : $this->publicClient(); + + if (! $client) { + return false; + } + + $contentSize = strlen($contents); + $maxSize = $this->getMaxFileSize(); + + if ($contentSize > $maxSize) { + Log::error('BunnyStorage: Content size exceeds limit', [ + 'remote' => $remotePath, + 'size' => $contentSize, + 'max_size' => $maxSize, + ]); + + return false; + } + + $contentType = $this->detectContentType($remotePath, $contents); + + return $this->executeWithRetry(function () use ($client, $remotePath, $contents, $contentType) { + // The Bunny SDK putContents method accepts optional headers parameter + // Pass content-type for proper CDN handling + $client->putContents($remotePath, $contents, ['Content-Type' => $contentType]); + + return true; + }, [ + 'remote' => $remotePath, + 'zone' => $zone, + 'content_type' => $contentType, + ], 'putContents'); + } + + /** + * Download file contents. + */ + public function getContents(string $remotePath, string $zone = 'public'): ?string + { + $client = $zone === 'private' ? $this->privateClient() : $this->publicClient(); + + if (! $client) { + return null; + } + + try { + return $client->getContents($remotePath); + } catch (\Exception $e) { + Log::error('BunnyStorage: getContents failed', [ + 'remote' => $remotePath, + 'zone' => $zone, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Delete a file from storage zone. + */ + public function delete(string $remotePath, string $zone = 'public'): bool + { + $client = $zone === 'private' ? $this->privateClient() : $this->publicClient(); + + if (! $client) { + return false; + } + + try { + $client->delete($remotePath); + + return true; + } catch (\Exception $e) { + Log::error('BunnyStorage: Delete failed', [ + 'remote' => $remotePath, + 'zone' => $zone, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Delete multiple files. + */ + public function deleteMultiple(array $paths, string $zone = 'public'): array + { + $results = []; + + foreach ($paths as $path) { + $results[$path] = $this->delete($path, $zone); + } + + return $results; + } + + /** + * Copy a file from a Laravel disk to CDN storage zone. + */ + public function copyFromDisk(string $disk, string $path, string $zone = 'public'): bool + { + $diskInstance = Storage::disk($disk); + + if (! $diskInstance->exists($path)) { + Log::warning('BunnyStorage: Source file not found on disk', [ + 'disk' => $disk, + 'path' => $path, + ]); + + return false; + } + + $contents = $diskInstance->get($path); + + return $this->putContents($path, $contents, $zone); + } + + // ───────────────────────────────────────────────────────────────────────────── + // vBucket operations for workspace-isolated CDN paths + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Generate a vBucket ID for a domain/workspace. + */ + public function vBucketId(string $domain): string + { + return LthnHash::vBucketId($domain); + } + + /** + * Build a vBucket-scoped path. + */ + public function vBucketPath(string $domain, string $path): string + { + $vBucketId = $this->vBucketId($domain); + + return $vBucketId.'/'.ltrim($path, '/'); + } + + /** + * Upload content with vBucket scoping. + */ + public function vBucketPutContents(string $domain, string $path, string $contents, string $zone = 'public'): bool + { + $scopedPath = $this->vBucketPath($domain, $path); + + return $this->putContents($scopedPath, $contents, $zone); + } + + /** + * Upload file with vBucket scoping. + */ + public function vBucketUpload(string $domain, string $localPath, string $remotePath, string $zone = 'public'): bool + { + $scopedPath = $this->vBucketPath($domain, $remotePath); + + return $this->upload($localPath, $scopedPath, $zone); + } + + /** + * Get file contents with vBucket scoping. + */ + public function vBucketGetContents(string $domain, string $path, string $zone = 'public'): ?string + { + $scopedPath = $this->vBucketPath($domain, $path); + + return $this->getContents($scopedPath, $zone); + } + + /** + * Delete file with vBucket scoping. + */ + public function vBucketDelete(string $domain, string $path, string $zone = 'public'): bool + { + $scopedPath = $this->vBucketPath($domain, $path); + + return $this->delete($scopedPath, $zone); + } + + /** + * List files within a vBucket. + */ + public function vBucketList(string $domain, string $path = '', string $zone = 'public'): array + { + $scopedPath = $this->vBucketPath($domain, $path); + + return $this->list($scopedPath, $zone); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Health Check (implements HealthCheckable) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Perform a health check on the CDN storage zones. + * + * Tests connectivity by listing the root directory of configured storage zones. + * Returns a HealthCheckResult with status, latency, and zone information. + */ + public function healthCheck(): HealthCheckResult + { + $publicConfigured = $this->isConfigured('public'); + $privateConfigured = $this->isConfigured('private'); + + if (! $publicConfigured && ! $privateConfigured) { + return HealthCheckResult::unknown('No CDN storage zones configured'); + } + + $results = []; + $startTime = microtime(true); + $hasError = false; + $isDegraded = false; + + // Check public zone + if ($publicConfigured) { + $publicResult = $this->checkZoneHealth('public'); + $results['public'] = $publicResult; + if (! $publicResult['success']) { + $hasError = true; + } elseif ($publicResult['latency_ms'] > 1000) { + $isDegraded = true; + } + } + + // Check private zone + if ($privateConfigured) { + $privateResult = $this->checkZoneHealth('private'); + $results['private'] = $privateResult; + if (! $privateResult['success']) { + $hasError = true; + } elseif ($privateResult['latency_ms'] > 1000) { + $isDegraded = true; + } + } + + $totalLatency = (microtime(true) - $startTime) * 1000; + + if ($hasError) { + return HealthCheckResult::unhealthy( + 'One or more CDN storage zones are unreachable', + ['zones' => $results], + $totalLatency + ); + } + + if ($isDegraded) { + return HealthCheckResult::degraded( + 'CDN storage zones responding slowly', + ['zones' => $results], + $totalLatency + ); + } + + return HealthCheckResult::healthy( + 'All configured CDN storage zones operational', + ['zones' => $results], + $totalLatency + ); + } + + /** + * Check health of a specific storage zone. + * + * @param string $zone 'public' or 'private' + * @return array{success: bool, latency_ms: float, error?: string} + */ + protected function checkZoneHealth(string $zone): array + { + $startTime = microtime(true); + + try { + $client = $zone === 'private' ? $this->privateClient() : $this->publicClient(); + + if (! $client) { + return [ + 'success' => false, + 'latency_ms' => 0, + 'error' => 'Client not initialized', + ]; + } + + // List root directory as a simple connectivity check + // This is a read-only operation that should be fast + $client->listFiles('/'); + + $latencyMs = (microtime(true) - $startTime) * 1000; + + return [ + 'success' => true, + 'latency_ms' => round($latencyMs, 2), + ]; + } catch (\Exception $e) { + $latencyMs = (microtime(true) - $startTime) * 1000; + + Log::warning('BunnyStorage: Health check failed', [ + 'zone' => $zone, + 'error' => $e->getMessage(), + 'latency_ms' => $latencyMs, + ]); + + return [ + 'success' => false, + 'latency_ms' => round($latencyMs, 2), + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform a quick connectivity check. + * + * Simpler than healthCheck() - just returns true/false. + * + * @param string $zone 'public', 'private', or 'any' (default) + */ + public function isReachable(string $zone = 'any'): bool + { + if ($zone === 'any') { + // Check if any configured zone is reachable + if ($this->isConfigured('public')) { + $result = $this->checkZoneHealth('public'); + if ($result['success']) { + return true; + } + } + + if ($this->isConfigured('private')) { + $result = $this->checkZoneHealth('private'); + if ($result['success']) { + return true; + } + } + + return false; + } + + if (! $this->isConfigured($zone)) { + return false; + } + + $result = $this->checkZoneHealth($zone); + + return $result['success']; + } +} diff --git a/app/Core/Cdn/Services/CLAUDE.md b/app/Core/Cdn/Services/CLAUDE.md new file mode 100644 index 0000000..c0b54a1 --- /dev/null +++ b/app/Core/Cdn/Services/CLAUDE.md @@ -0,0 +1,13 @@ +# Cdn/Services/ — CDN Service Layer + +## Services + +| Service | Purpose | +|---------|---------| +| `BunnyCdnService` | BunnyCDN pull zone API — cache purging (URL, tag, workspace, global), statistics retrieval, pull zone management. Uses config from `ConfigService`. | +| `BunnyStorageService` | BunnyCDN storage zone API — file upload, download, delete, list. Supports public and private storage zones. | +| `StorageOffload` (service) | Manages file offloading to remote storage — upload, track, verify. Creates `StorageOffload` model records. | +| `StorageUrlResolver` | URL builder for all asset contexts — CDN, origin, private, signed, apex. Supports virtual buckets (vBucket) per domain. Backs the `Cdn` facade. | +| `CdnUrlBuilder` | Low-level URL construction for CDN paths with cache-busting and domain resolution. | +| `AssetPipeline` | Orchestrates asset processing — push to CDN, cache headers, versioning. | +| `FluxCdnService` | Pushes Flux UI assets to CDN for faster component loading. | diff --git a/app/Core/Cdn/Services/CdnUrlBuilder.php b/app/Core/Cdn/Services/CdnUrlBuilder.php new file mode 100644 index 0000000..3b6285e --- /dev/null +++ b/app/Core/Cdn/Services/CdnUrlBuilder.php @@ -0,0 +1,348 @@ +build($baseUrl, $path); + } + + /** + * Build an origin storage URL for a path. + * + * @param string $path Path relative to storage root + * @param string|null $baseUrl Optional base URL override (uses config if null) + * @return string Full origin URL + */ + public function origin(string $path, ?string $baseUrl = null): string + { + $baseUrl = $baseUrl ?? config('cdn.urls.public'); + + return $this->build($baseUrl, $path); + } + + /** + * Build a private storage URL for a path. + * + * @param string $path Path relative to storage root + * @param string|null $baseUrl Optional base URL override (uses config if null) + * @return string Full private URL + */ + public function private(string $path, ?string $baseUrl = null): string + { + $baseUrl = $baseUrl ?? config('cdn.urls.private'); + + return $this->build($baseUrl, $path); + } + + /** + * Build an apex domain URL for a path. + * + * @param string $path Path relative to web root + * @param string|null $baseUrl Optional base URL override (uses config if null) + * @return string Full apex URL + */ + public function apex(string $path, ?string $baseUrl = null): string + { + $baseUrl = $baseUrl ?? config('cdn.urls.apex'); + + return $this->build($baseUrl, $path); + } + + /** + * Build a signed URL for private CDN content with token authentication. + * + * @param string $path Path relative to storage root + * @param int|Carbon|null $expiry Expiry time in seconds, or Carbon instance. + * Defaults to config('cdn.signed_url_expiry', 3600) + * @param string|null $token Optional token override (uses config if null) + * @return string|null Signed URL or null if token not configured + */ + public function signed(string $path, int|Carbon|null $expiry = null, ?string $token = null): ?string + { + $token = $token ?? config('cdn.bunny.private.token'); + + if (empty($token)) { + return null; + } + + // Resolve expiry to Unix timestamp + $expires = $this->resolveExpiry($expiry); + $path = '/'.ltrim($path, '/'); + + // BunnyCDN token authentication format (using HMAC for security) + $hashableBase = $token.$path.$expires; + $hash = base64_encode(hash_hmac('sha256', $hashableBase, $token, true)); + + // URL-safe base64 + $hash = str_replace(['+', '/'], ['-', '_'], $hash); + $hash = rtrim($hash, '='); + + // Build base URL from config + $baseUrl = $this->buildSignedUrlBase(); + + return "{$baseUrl}{$path}?token={$hash}&expires={$expires}"; + } + + /** + * Build a vBucket-scoped CDN URL. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to vBucket root + * @param string|null $baseUrl Optional base URL override + * @return string Full vBucket-scoped CDN URL + */ + public function vBucket(string $domain, string $path, ?string $baseUrl = null): string + { + $vBucketId = $this->vBucketId($domain); + $scopedPath = $this->vBucketPath($domain, $path); + + return $this->cdn($scopedPath, $baseUrl); + } + + /** + * Generate a vBucket ID for a domain/workspace. + * + * Uses LTHN QuasiHash for deterministic, scoped identifiers. + * + * @param string $domain The domain name (e.g., "example.com") + * @return string 16-character vBucket identifier + */ + public function vBucketId(string $domain): string + { + return LthnHash::vBucketId($domain); + } + + /** + * Build a vBucket-scoped storage path. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to vBucket root + * @return string Full storage path with vBucket prefix + */ + public function vBucketPath(string $domain, string $path): string + { + $vBucketId = $this->vBucketId($domain); + + return "{$vBucketId}/".ltrim($path, '/'); + } + + /** + * Build a context-aware asset URL. + * + * @param string $path Path relative to storage root + * @param string $context Context ('admin', 'public') + * @return string URL appropriate for the context + */ + public function asset(string $path, string $context = 'public'): string + { + return $context === 'admin' ? $this->origin($path) : $this->cdn($path); + } + + /** + * Build a URL with version query parameter for cache busting. + * + * @param string $url The base URL + * @param string|null $version Version hash for cache busting + * @return string URL with version parameter + */ + public function withVersion(string $url, ?string $version): string + { + if (empty($version)) { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return "{$url}{$separator}id={$version}"; + } + + /** + * Build both CDN and origin URLs for API responses. + * + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string} + */ + public function urls(string $path): array + { + return [ + 'cdn' => $this->cdn($path), + 'origin' => $this->origin($path), + ]; + } + + /** + * Build all URL types for a path. + * + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string, private: string, apex: string} + */ + public function allUrls(string $path): array + { + return [ + 'cdn' => $this->cdn($path), + 'origin' => $this->origin($path), + 'private' => $this->private($path), + 'apex' => $this->apex($path), + ]; + } + + /** + * Build vBucket-scoped URLs for API responses. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string, vbucket: string} + */ + public function vBucketUrls(string $domain, string $path): array + { + $vBucketId = $this->vBucketId($domain); + $scopedPath = "{$vBucketId}/{$path}"; + + return [ + 'cdn' => $this->cdn($scopedPath), + 'origin' => $this->origin($scopedPath), + 'vbucket' => $vBucketId, + ]; + } + + /** + * Build a URL from base URL and path. + * + * @param string|null $baseUrl Base URL (falls back to apex if null) + * @param string $path Path to append + * @return string Full URL + */ + public function build(?string $baseUrl, string $path): string + { + if (empty($baseUrl)) { + // Fallback to apex domain if no base URL configured + $baseUrl = config('cdn.urls.apex', config('app.url')); + } + + $baseUrl = rtrim($baseUrl, '/'); + $path = ltrim($path, '/'); + + return "{$baseUrl}/{$path}"; + } + + /** + * Build the base URL for signed private URLs. + * + * @return string Base URL for signed URLs + */ + protected function buildSignedUrlBase(): string + { + $pullZone = config('cdn.bunny.private.pull_zone'); + + // Support both full URL and just hostname in config + if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) { + return rtrim($pullZone, '/'); + } + + return "https://{$pullZone}"; + } + + /** + * Resolve expiry parameter to a Unix timestamp. + * + * @param int|Carbon|null $expiry Expiry in seconds, Carbon instance, or null for config default + * @return int Unix timestamp when the URL expires + */ + protected function resolveExpiry(int|Carbon|null $expiry): int + { + if ($expiry instanceof Carbon) { + return $expiry->timestamp; + } + + $expirySeconds = $expiry ?? (int) config('cdn.signed_url_expiry', 3600); + + return time() + $expirySeconds; + } + + /** + * Get the path prefix for a content category. + * + * @param string $category Category key from config (media, social, page, etc.) + * @return string Path prefix + */ + public function pathPrefix(string $category): string + { + return config("cdn.paths.{$category}", $category); + } + + /** + * Build a full path with category prefix. + * + * @param string $category Category key + * @param string $path Relative path within category + * @return string Full path with category prefix + */ + public function categoryPath(string $category, string $path): string + { + $prefix = $this->pathPrefix($category); + + return "{$prefix}/{$path}"; + } +} diff --git a/app/Core/Cdn/Services/FluxCdnService.php b/app/Core/Cdn/Services/FluxCdnService.php new file mode 100644 index 0000000..2fd0ef5 --- /dev/null +++ b/app/Core/Cdn/Services/FluxCdnService.php @@ -0,0 +1,206 @@ +` | Get source-to-CDN path mapping | + * + * @see CdnUrlBuilder For the underlying URL building logic + */ +class FluxCdnService +{ + protected CdnUrlBuilder $urlBuilder; + + public function __construct(?CdnUrlBuilder $urlBuilder = null) + { + $this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder; + } + + /** + * Get the Flux scripts tag with CDN awareness. + * + * @param array $options Options like ['nonce' => 'abc123'] + * @return string HTML script tag + */ + public function scripts(array $options = []): string + { + $nonce = isset($options['nonce']) ? ' nonce="'.$options['nonce'].'"' : ''; + + // Use CDN when enabled (respects CDN_FORCE_LOCAL for testing) + if (! $this->shouldUseCdn()) { + return app('flux')->scripts($options); + } + + // In production, use CDN URL (no vBucket - shared platform asset) + $versionHash = $this->getVersionHash(); + $filename = config('app.debug') ? 'flux.js' : 'flux.min.js'; + $url = $this->cdnUrl("flux/{$filename}", $versionHash); + + return ''; + } + + /** + * Get the Flux editor scripts tag with CDN awareness. + * + * @return string HTML script tag for Flux editor + * + * @throws \Exception When Flux Pro is not available + */ + public function editorScripts(): string + { + if (! Flux::pro()) { + throw new \Exception('Flux Pro is required to use the Flux editor.'); + } + + // Use CDN when enabled (respects CDN_FORCE_LOCAL for testing) + if (! $this->shouldUseCdn()) { + return AssetManager::editorScripts(); + } + + // In production, use CDN URL (no vBucket - shared platform asset) + $versionHash = $this->getVersionHash('/editor.js'); + $filename = config('app.debug') ? 'editor.js' : 'editor.min.js'; + $url = $this->cdnUrl("flux/{$filename}", $versionHash); + + return ''; + } + + /** + * Get the Flux editor styles tag with CDN awareness. + * + * @return string HTML link tag for Flux editor styles + * + * @throws \Exception When Flux Pro is not available + */ + public function editorStyles(): string + { + if (! Flux::pro()) { + throw new \Exception('Flux Pro is required to use the Flux editor.'); + } + + // Use CDN when enabled (respects CDN_FORCE_LOCAL for testing) + if (! $this->shouldUseCdn()) { + return AssetManager::editorStyles(); + } + + // In production, use CDN URL (no vBucket - shared platform asset) + $versionHash = $this->getVersionHash('/editor.css'); + $url = $this->cdnUrl('flux/editor.css', $versionHash); + + return ''; + } + + /** + * Get version hash from Flux manifest. + * + * @param string $key Manifest key to look up + * @return string 8-character hash for cache busting + */ + protected function getVersionHash(string $key = '/flux.js'): string + { + $manifestPath = Flux::pro() + ? base_path('vendor/admin/flux-pro/dist/manifest.json') + : base_path('vendor/admin/flux/dist/manifest.json'); + + if (! file_exists($manifestPath)) { + return substr(md5(config('app.version', '1.0')), 0, 8); + } + + $manifest = json_decode(file_get_contents($manifestPath), true); + + return $manifest[$key] ?? substr(md5(config('app.version', '1.0')), 0, 8); + } + + /** + * Check if we should use CDN for Flux assets. + * + * Respects CDN_FORCE_LOCAL for testing. + * + * @return bool True if CDN should be used, false for local assets + */ + public function shouldUseCdn(): bool + { + return Cdn::isEnabled(); + } + + /** + * Build CDN URL for shared platform assets (no vBucket scoping). + * + * Flux assets are shared across all workspaces, so they don't use + * workspace-specific vBucket prefixes. + * + * @param string $path Asset path relative to CDN root + * @param string|null $version Optional version hash for cache busting + * @return string Full CDN URL with optional version query parameter + */ + protected function cdnUrl(string $path, ?string $version = null): string + { + $cdnUrl = config('cdn.urls.cdn'); + + if (empty($cdnUrl)) { + $baseUrl = asset($path); + + return $this->urlBuilder->withVersion($baseUrl, $version); + } + + $url = $this->urlBuilder->cdn($path); + + return $this->urlBuilder->withVersion($url, $version); + } + + /** + * Get the list of Flux files that should be uploaded to CDN. + * + * @return array Map of source path => CDN path + */ + public function getCdnAssetPaths(): array + { + $basePath = Flux::pro() + ? base_path('vendor/admin/flux-pro/dist') + : base_path('vendor/admin/flux/dist'); + + $files = [ + "{$basePath}/flux.js" => 'flux/flux.js', + "{$basePath}/flux.min.js" => 'flux/flux.min.js', + ]; + + // Add editor files for Pro + if (Flux::pro()) { + $files["{$basePath}/editor.js"] = 'flux/editor.js'; + $files["{$basePath}/editor.min.js"] = 'flux/editor.min.js'; + $files["{$basePath}/editor.css"] = 'flux/editor.css'; + } + + return $files; + } +} diff --git a/app/Core/Cdn/Services/StorageOffload.php b/app/Core/Cdn/Services/StorageOffload.php new file mode 100644 index 0000000..fa4ed06 --- /dev/null +++ b/app/Core/Cdn/Services/StorageOffload.php @@ -0,0 +1,410 @@ +disk = config('offload.disk') ?? 'hetzner-public'; + $this->enabled = config('offload.enabled') ?? false; + $this->keepLocal = config('offload.keep_local') ?? true; + $this->cdnUrl = config('offload.cdn_url'); + $this->maxFileSize = config('offload.max_file_size'); + $this->allowedExtensions = config('offload.allowed_extensions'); + $this->cacheEnabled = config('offload.cache.enabled') ?? false; + } + + /** + * Check if storage offload is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get the configured disk name. + */ + public function getDiskName(): string + { + return $this->disk; + } + + /** + * Get the disk instance. + */ + public function getDisk(): Filesystem + { + return Storage::disk($this->disk); + } + + /** + * Check if a local file has been offloaded. + */ + public function isOffloaded(string $localPath): bool + { + return OffloadModel::where('local_path', $localPath)->exists(); + } + + /** + * Get the offload record for a file. + */ + public function getRecord(string $localPath): ?OffloadModel + { + return OffloadModel::where('local_path', $localPath)->first(); + } + + /** + * Get the remote URL for an offloaded file. + */ + public function url(string $localPath): ?string + { + // Check cache first + if ($this->cacheEnabled) { + $cached = Cache::get("offload_url:{$localPath}"); + if ($cached !== null) { + return $cached ?: null; // Empty string means no record + } + } + + $record = OffloadModel::where('local_path', $localPath)->first(); + + if (! $record) { + if ($this->cacheEnabled) { + Cache::put("offload_url:{$localPath}", '', 3600); + } + + return null; + } + + // Use CDN URL if configured, otherwise fall back to disk URL + if ($this->cdnUrl) { + $url = rtrim($this->cdnUrl, '/').'/'.ltrim($record->remote_path, '/'); + } else { + $url = Storage::disk($this->disk)->url($record->remote_path); + } + + if ($this->cacheEnabled) { + Cache::put("offload_url:{$localPath}", $url, 3600); + } + + return $url; + } + + /** + * Upload a local file to remote storage. + * + * @param string $localPath Absolute path to local file + * @param string|null $remotePath Custom remote path (auto-generated if null) + * @param string $category Category for path prefixing + * @param array $metadata Additional metadata to store + * @return OffloadModel|null The offload record on success + */ + public function upload(string $localPath, ?string $remotePath = null, string $category = 'media', array $metadata = []): ?OffloadModel + { + if (! $this->enabled) { + Log::debug('StorageOffload: Offload disabled'); + + return null; + } + + if (! File::exists($localPath)) { + Log::warning('StorageOffload: Local file not found', ['path' => $localPath]); + + return null; + } + + $fileSize = File::size($localPath); + + // Check max file size + if ($this->maxFileSize !== null && $fileSize > $this->maxFileSize) { + Log::debug('StorageOffload: File exceeds max size', [ + 'path' => $localPath, + 'size' => $fileSize, + 'max' => $this->maxFileSize, + ]); + + return null; + } + + // Check allowed extensions + $extension = strtolower(pathinfo($localPath, PATHINFO_EXTENSION)); + if ($this->allowedExtensions !== null && ! in_array($extension, $this->allowedExtensions)) { + Log::debug('StorageOffload: Extension not allowed', [ + 'path' => $localPath, + 'extension' => $extension, + 'allowed' => $this->allowedExtensions, + ]); + + return null; + } + + // Check if already offloaded + if ($this->isOffloaded($localPath)) { + Log::debug('StorageOffload: File already offloaded', ['path' => $localPath]); + + return OffloadModel::where('local_path', $localPath)->first(); + } + + // Generate remote path if not provided + $remotePath = $remotePath ?? $this->generateRemotePath($localPath, $category); + + try { + // Read file contents + $contents = File::get($localPath); + $hash = hash('sha256', $contents); + $mimeType = File::mimeType($localPath); + + // Upload to remote storage + $disk = Storage::disk($this->disk); + $uploaded = $disk->put($remotePath, $contents); + + if (! $uploaded) { + Log::error('StorageOffload: Upload failed', [ + 'local' => $localPath, + 'remote' => $remotePath, + ]); + + return null; + } + + // Merge original_name into metadata only if not already set + if (! isset($metadata['original_name'])) { + $metadata['original_name'] = basename($localPath); + } + + // Create tracking record + $record = OffloadModel::create([ + 'local_path' => $localPath, + 'remote_path' => $remotePath, + 'disk' => $this->disk, + 'hash' => $hash, + 'file_size' => $fileSize, + 'mime_type' => $mimeType, + 'category' => $category, + 'metadata' => $metadata, + 'offloaded_at' => now(), + ]); + + // Delete local file if not keeping + if (! $this->keepLocal) { + File::delete($localPath); + } + + Log::info('StorageOffload: File offloaded successfully', [ + 'local' => $localPath, + 'remote' => $remotePath, + ]); + + return $record; + } catch (\Exception $e) { + Log::error('StorageOffload: Exception during upload', [ + 'path' => $localPath, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Batch upload multiple files. + * + * @param array $localPaths List of local paths + * @param string $category Category for path prefixing + * @return array{uploaded: int, failed: int, skipped: int} + */ + public function uploadBatch(array $localPaths, string $category = 'media'): array + { + $results = [ + 'uploaded' => 0, + 'failed' => 0, + 'skipped' => 0, + ]; + + foreach ($localPaths as $path) { + if ($this->isOffloaded($path)) { + $results['skipped']++; + + continue; + } + + $record = $this->upload($path, null, $category); + + if ($record) { + $results['uploaded']++; + } else { + $results['failed']++; + } + } + + return $results; + } + + /** + * Generate a remote path for a local file. + */ + protected function generateRemotePath(string $localPath, string $category): string + { + $extension = pathinfo($localPath, PATHINFO_EXTENSION); + $hash = Str::random(16); + + // Date-based partitioning + $datePath = date('Y/m'); + + // Add 's' suffix to category for plural paths + $categoryPath = $category; + if (! str_ends_with($categoryPath, 's')) { + $categoryPath .= 's'; + } + + return "{$categoryPath}/{$datePath}/{$hash}.{$extension}"; + } + + /** + * Get all offloaded files for a category. + * + * @return Collection + */ + public function getByCategory(string $category) + { + return OffloadModel::where('category', $category)->get(); + } + + /** + * Delete an offloaded file from remote storage. + */ + public function delete(string $localPath): bool + { + $record = OffloadModel::where('local_path', $localPath)->first(); + + if (! $record) { + return false; + } + + try { + // Delete from remote storage + Storage::disk($this->disk)->delete($record->remote_path); + + // Delete tracking record + $record->delete(); + + // Clear cache + if ($this->cacheEnabled) { + Cache::forget("offload_url:{$localPath}"); + } + + return true; + } catch (\Exception $e) { + Log::error('StorageOffload: Delete failed', [ + 'path' => $localPath, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Verify file integrity by comparing hash. + */ + public function verifyIntegrity(string $localPath): bool + { + $record = OffloadModel::where('local_path', $localPath)->first(); + + if (! $record) { + return false; + } + + try { + $remoteContents = Storage::disk($this->disk)->get($record->remote_path); + $remoteHash = hash('sha256', $remoteContents); + + return hash_equals($record->hash, $remoteHash); + } catch (\Exception $e) { + Log::error('StorageOffload: Integrity check failed', [ + 'path' => $localPath, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Get storage statistics. + */ + public function getStats(): array + { + $totalFiles = OffloadModel::count(); + $totalSize = OffloadModel::sum('file_size'); + + $byCategory = OffloadModel::selectRaw('category, COUNT(*) as count, SUM(file_size) as total_size') + ->groupBy('category') + ->get() + ->keyBy('category') + ->toArray(); + + return [ + 'total_files' => $totalFiles, + 'total_size' => $totalSize, + 'total_size_human' => $this->formatBytes($totalSize), + 'by_category' => $byCategory, + ]; + } + + /** + * Format bytes to human-readable string. + */ + protected function formatBytes(int|string|null $bytes): string + { + $bytes = (int) ($bytes ?? 0); + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0; + $power = min($power, count($units) - 1); + + return round($bytes / (1024 ** $power), 2).' '.$units[$power]; + } +} diff --git a/app/Core/Cdn/Services/StorageUrlResolver.php b/app/Core/Cdn/Services/StorageUrlResolver.php new file mode 100644 index 0000000..3ec6a84 --- /dev/null +++ b/app/Core/Cdn/Services/StorageUrlResolver.php @@ -0,0 +1,500 @@ + Origin URLs (Hetzner) + * - Public/embed requests -> CDN URLs (BunnyCDN) + * - API requests -> Both URLs returned + * + * Supports vBucket scoping for workspace-isolated CDN paths using LTHN QuasiHash. + * + * URL building is delegated to CdnUrlBuilder for consistency across services. + * + * ## Methods + * + * | Method | Returns | Description | + * |--------|---------|-------------| + * | `vBucketId()` | `string` | Generate vBucket ID for a domain | + * | `vBucketCdn()` | `string` | Get CDN URL with vBucket scoping | + * | `vBucketOrigin()` | `string` | Get origin URL with vBucket scoping | + * | `vBucketPath()` | `string` | Build vBucket-scoped storage path | + * | `vBucketUrls()` | `array` | Get both URLs with vBucket scoping | + * | `cdn()` | `string` | Get CDN delivery URL for a path | + * | `origin()` | `string` | Get origin URL (Hetzner) for a path | + * | `private()` | `string` | Get private storage URL for a path | + * | `signedUrl()` | `string\|null` | Get signed URL for private content | + * | `apex()` | `string` | Get apex domain URL for a path | + * | `asset()` | `string` | Get context-aware URL for a path | + * | `urls()` | `array` | Get both CDN and origin URLs | + * | `allUrls()` | `array` | Get all URLs (cdn, origin, private, apex) | + * | `detectContext()` | `string` | Detect current request context | + * | `isAdminContext()` | `bool` | Check if current context is admin | + * | `pushToCdn()` | `bool` | Push a file to CDN storage zone | + * | `deleteFromCdn()` | `bool` | Delete a file from CDN storage zone | + * | `purge()` | `bool` | Purge a path from CDN cache | + * | `cachedAsset()` | `string` | Get cached CDN URL with intelligent caching | + * | `publicDisk()` | `Filesystem` | Get the public storage disk | + * | `privateDisk()` | `Filesystem` | Get the private storage disk | + * | `storePublic()` | `bool` | Store file to public bucket | + * | `storePrivate()` | `bool` | Store file to private bucket | + * | `deleteAsset()` | `bool` | Delete file from storage and CDN | + * | `pathPrefix()` | `string` | Get path prefix for a category | + * | `categoryPath()` | `string` | Build full path with category prefix | + * + * @see CdnUrlBuilder For the underlying URL building logic + */ +class StorageUrlResolver +{ + protected BunnyStorageService $bunnyStorage; + + protected CdnUrlBuilder $urlBuilder; + + public function __construct(BunnyStorageService $bunnyStorage, ?CdnUrlBuilder $urlBuilder = null) + { + $this->bunnyStorage = $bunnyStorage; + $this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder; + } + + /** + * Get the URL builder instance. + */ + public function getUrlBuilder(): CdnUrlBuilder + { + return $this->urlBuilder; + } + + /** + * Generate a vBucket ID for a domain/workspace. + * + * Uses LTHN QuasiHash protocol for deterministic, scoped identifiers. + * Format: cdn.host.uk.com/{vBucketId}/path/to/asset.js + * + * @param string $domain The domain name (e.g., "host.uk.com") + * @return string 16-character vBucket identifier + */ + public function vBucketId(string $domain): string + { + return $this->urlBuilder->vBucketId($domain); + } + + /** + * Get CDN URL with vBucket scoping for workspace isolation. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to storage root + */ + public function vBucketCdn(string $domain, string $path): string + { + return $this->urlBuilder->vBucket($domain, $path); + } + + /** + * Get origin URL with vBucket scoping for workspace isolation. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to storage root + */ + public function vBucketOrigin(string $domain, string $path): string + { + $scopedPath = $this->urlBuilder->vBucketPath($domain, $path); + + return $this->urlBuilder->origin($scopedPath); + } + + /** + * Build a vBucket-scoped storage path. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to vBucket root + */ + public function vBucketPath(string $domain, string $path): string + { + return $this->urlBuilder->vBucketPath($domain, $path); + } + + /** + * Get both URLs with vBucket scoping for API responses. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string, vbucket: string} + */ + public function vBucketUrls(string $domain, string $path): array + { + return $this->urlBuilder->vBucketUrls($domain, $path); + } + + /** + * Get the CDN delivery URL for a path. + * Always returns the BunnyCDN pull zone URL. + * + * @param string $path Path relative to storage root + */ + public function cdn(string $path): string + { + return $this->urlBuilder->cdn($path); + } + + /** + * Get the origin URL for a path (Hetzner public bucket). + * Direct access to origin storage, bypassing CDN. + * + * @param string $path Path relative to storage root + */ + public function origin(string $path): string + { + return $this->urlBuilder->origin($path); + } + + /** + * Get the private storage URL for a path. + * For DRM/gated content - not publicly accessible. + * + * @param string $path Path relative to storage root + */ + public function private(string $path): string + { + return $this->urlBuilder->private($path); + } + + /** + * Get a signed URL for private CDN content with token authentication. + * Generates time-limited access URLs for gated/DRM content. + * + * @param string $path Path relative to storage root + * @param int|Carbon|null $expiry Expiry time in seconds, or a Carbon instance for absolute expiry. + * Defaults to config('cdn.signed_url_expiry', 3600) when null. + * @return string|null Signed URL or null if token not configured + */ + public function signedUrl(string $path, int|Carbon|null $expiry = null): ?string + { + return $this->urlBuilder->signed($path, $expiry); + } + + /** + * Build the base URL for signed private URLs. + * Uses config for the private pull zone URL. + * + * @deprecated Use CdnUrlBuilder::signed() instead + */ + protected function buildSignedUrlBase(): string + { + $pullZone = config('cdn.bunny.private.pull_zone'); + + // Support both full URL and just hostname in config + if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) { + return rtrim($pullZone, '/'); + } + + return "https://{$pullZone}"; + } + + /** + * Get the apex domain URL for a path. + * Fallback for assets served through main domain. + * + * @param string $path Path relative to web root + */ + public function apex(string $path): string + { + return $this->urlBuilder->apex($path); + } + + /** + * Get context-aware URL for a path. + * Automatically determines whether to return CDN or origin URL. + * + * @param string $path Path relative to storage root + * @param string|null $context Force context ('admin', 'public', or null for auto) + */ + public function asset(string $path, ?string $context = null): string + { + $context = $context ?? $this->detectContext(); + + return $this->urlBuilder->asset($path, $context); + } + + /** + * Get both CDN and origin URLs for API responses. + * + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string} + */ + public function urls(string $path): array + { + return $this->urlBuilder->urls($path); + } + + /** + * Get all URLs for a path (including private and apex). + * + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string, private: string, apex: string} + */ + public function allUrls(string $path): array + { + return $this->urlBuilder->allUrls($path); + } + + /** + * Detect the current request context based on headers and route. + * + * Checks for admin headers and route prefixes to determine context. + * + * @return string 'admin' or 'public' + */ + public function detectContext(): string + { + // Check for admin headers + foreach (config('cdn.context.admin_headers', []) as $header) { + if (Request::hasHeader($header)) { + return 'admin'; + } + } + + // Check for admin route prefixes + $path = Request::path(); + foreach (config('cdn.context.admin_prefixes', []) as $prefix) { + if (str_starts_with($path, $prefix)) { + return 'admin'; + } + } + + return config('cdn.context.default', 'public'); + } + + /** + * Check if the current context is admin/internal. + * + * @return bool True if in admin context + */ + public function isAdminContext(): bool + { + return $this->detectContext() === 'admin'; + } + + /** + * Push a file to the CDN storage zone. + * + * @param string $disk Laravel disk name ('hetzner-public' or 'hetzner-private') + * @param string $path Path within the disk + * @param string $zone Target zone ('public' or 'private') + */ + public function pushToCdn(string $disk, string $path, string $zone = 'public'): bool + { + if (! $this->bunnyStorage->isPushEnabled()) { + return false; + } + + return $this->bunnyStorage->copyFromDisk($disk, $path, $zone); + } + + /** + * Delete a file from the CDN storage zone. + * + * @param string $path Path within the storage zone + * @param string $zone Target zone ('public' or 'private') + */ + public function deleteFromCdn(string $path, string $zone = 'public'): bool + { + return $this->bunnyStorage->delete($path, $zone); + } + + /** + * Purge a path from the CDN cache. + * Uses the existing BunnyCdnService for pull zone API. + * + * @param string $path Path to purge + */ + public function purge(string $path): bool + { + // Use existing BunnyCdnService for pull zone operations + $bunnyCdnService = app(BunnyCdnService::class); + + if (! $bunnyCdnService->isConfigured()) { + return false; + } + + return $bunnyCdnService->purgeUrl($this->cdn($path)); + } + + /** + * Get cached CDN URL with intelligent caching. + * + * @param string $path Path relative to storage root + * @param string|null $context Force context ('admin', 'public', or null for auto) + */ + public function cachedAsset(string $path, ?string $context = null): string + { + if (! config('cdn.cache.enabled', true)) { + return $this->asset($path, $context); + } + + $context = $context ?? $this->detectContext(); + $cacheKey = config('cdn.cache.prefix', 'cdn_url').':'.$context.':'.md5($path); + $ttl = config('cdn.cache.ttl', 3600); + + return Cache::remember($cacheKey, $ttl, fn () => $this->asset($path, $context)); + } + + /** + * Build a URL from base URL and path. + * + * @param string|null $baseUrl Base URL (falls back to apex if null) + * @param string $path Path to append + * @return string Full URL + * + * @deprecated Use CdnUrlBuilder::build() instead + */ + protected function buildUrl(?string $baseUrl, string $path): string + { + return $this->urlBuilder->build($baseUrl, $path); + } + + /** + * Get the public storage disk. + * + * @return Filesystem + */ + public function publicDisk() + { + return Storage::disk(config('cdn.disks.public', 'hetzner-public')); + } + + /** + * Get the private storage disk. + * + * @return Filesystem + */ + public function privateDisk() + { + return Storage::disk(config('cdn.disks.private', 'hetzner-private')); + } + + /** + * Store file to public bucket and optionally push to CDN. + * + * @param string $path Target path + * @param string|resource $contents File contents or stream + * @param bool $pushToCdn Whether to also push to BunnyCDN storage zone + */ + public function storePublic(string $path, $contents, bool $pushToCdn = true): bool + { + $stored = $this->publicDisk()->put($path, $contents); + + if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) { + // Queue the push if configured, otherwise push synchronously + if ($queue = config('cdn.pipeline.queue')) { + dispatch(new PushAssetToCdn('hetzner-public', $path, 'public'))->onQueue($queue); + } else { + $this->pushToCdn('hetzner-public', $path, 'public'); + } + } + + return $stored; + } + + /** + * Store file to private bucket and optionally push to CDN. + * + * @param string $path Target path + * @param string|resource $contents File contents or stream + * @param bool $pushToCdn Whether to also push to BunnyCDN storage zone + */ + public function storePrivate(string $path, $contents, bool $pushToCdn = true): bool + { + $stored = $this->privateDisk()->put($path, $contents); + + if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) { + if ($queue = config('cdn.pipeline.queue')) { + dispatch(new PushAssetToCdn('hetzner-private', $path, 'private'))->onQueue($queue); + } else { + $this->pushToCdn('hetzner-private', $path, 'private'); + } + } + + return $stored; + } + + /** + * Delete file from storage and CDN. + * + * @param string $path File path + * @param string $bucket 'public' or 'private' + */ + public function deleteAsset(string $path, string $bucket = 'public'): bool + { + $disk = $bucket === 'private' ? $this->privateDisk() : $this->publicDisk(); + $deleted = $disk->delete($path); + + if ($deleted) { + $this->deleteFromCdn($path, $bucket); + + if (config('cdn.pipeline.auto_purge', true)) { + $this->purge($path); + } + } + + return $deleted; + } + + /** + * Get the path prefix for a content category. + * + * @param string $category Category key from config (media, social, page, etc.) + */ + public function pathPrefix(string $category): string + { + return $this->urlBuilder->pathPrefix($category); + } + + /** + * Build a full path with category prefix. + * + * @param string $category Category key + * @param string $path Relative path within category + */ + public function categoryPath(string $category, string $path): string + { + return $this->urlBuilder->categoryPath($category, $path); + } + + /** + * Resolve expiry parameter to a Unix timestamp. + * + * @param int|Carbon|null $expiry Expiry in seconds, Carbon instance, or null for config default + * @return int Unix timestamp when the URL expires + * + * @deprecated Use CdnUrlBuilder internally instead + */ + protected function resolveExpiry(int|Carbon|null $expiry): int + { + if ($expiry instanceof Carbon) { + return $expiry->timestamp; + } + + $expirySeconds = $expiry ?? (int) config('cdn.signed_url_expiry', 3600); + + return time() + $expirySeconds; + } +} diff --git a/app/Core/Cdn/Traits/CLAUDE.md b/app/Core/Cdn/Traits/CLAUDE.md new file mode 100644 index 0000000..063b873 --- /dev/null +++ b/app/Core/Cdn/Traits/CLAUDE.md @@ -0,0 +1,7 @@ +# Cdn/Traits/ — CDN Model Traits + +## Traits + +| Trait | Purpose | +|-------|---------| +| `HasCdnUrls` | For models with asset paths needing CDN URL resolution. Requires `$cdnPathAttribute` (attribute with storage path) and optional `$cdnBucket` (`public` or `private`). Provides `cdnUrl()` accessor. | diff --git a/app/Core/Cdn/Traits/HasCdnUrls.php b/app/Core/Cdn/Traits/HasCdnUrls.php new file mode 100644 index 0000000..35728cc --- /dev/null +++ b/app/Core/Cdn/Traits/HasCdnUrls.php @@ -0,0 +1,186 @@ +getCdnPath(); + + return $path ? Cdn::cdn($path) : null; + } + + /** + * Get the origin storage URL (Hetzner) for this model's asset. + */ + public function getOriginUrl(): ?string + { + $path = $this->getCdnPath(); + + return $path ? Cdn::origin($path) : null; + } + + /** + * Get context-aware URL for this model's asset. + * Returns CDN URL for public context, origin URL for admin context. + * + * @param string|null $context Force context ('admin', 'public', or null for auto) + */ + public function getAssetUrl(?string $context = null): ?string + { + $path = $this->getCdnPath(); + + return $path ? Cdn::asset($path, $context) : null; + } + + /** + * Get both CDN and origin URLs for API responses. + * + * @return array{cdn: string|null, origin: string|null} + */ + public function getAssetUrls(): array + { + $path = $this->getCdnPath(); + + if (! $path) { + return ['cdn' => null, 'origin' => null]; + } + + return Cdn::urls($path); + } + + /** + * Get all URLs for this model's asset (including private and apex). + * + * @return array{cdn: string|null, origin: string|null, private: string|null, apex: string|null} + */ + public function getAllAssetUrls(): array + { + $path = $this->getCdnPath(); + + if (! $path) { + return ['cdn' => null, 'origin' => null, 'private' => null, 'apex' => null]; + } + + return Cdn::allUrls($path); + } + + /** + * Get the storage path for this model's asset. + */ + public function getCdnPath(): ?string + { + $attribute = $this->getCdnPathAttribute(); + + return $this->{$attribute} ?? null; + } + + /** + * Get the attribute name containing the storage path. + * Override this method or set $cdnPathAttribute property. + */ + protected function getCdnPathAttribute(): string + { + return $this->cdnPathAttribute ?? 'path'; + } + + /** + * Get the bucket type for this model's assets. + * Override this method or set $cdnBucket property. + * + * @return string 'public' or 'private' + */ + public function getCdnBucket(): string + { + return $this->cdnBucket ?? 'public'; + } + + /** + * Push this model's asset to the CDN storage zone. + */ + public function pushToCdn(): bool + { + $path = $this->getCdnPath(); + + if (! $path) { + return false; + } + + $bucket = $this->getCdnBucket(); + $disk = $bucket === 'private' ? 'hetzner-private' : 'hetzner-public'; + + return Cdn::pushToCdn($disk, $path, $bucket); + } + + /** + * Delete this model's asset from the CDN storage zone. + */ + public function deleteFromCdn(): bool + { + $path = $this->getCdnPath(); + + if (! $path) { + return false; + } + + return Cdn::deleteFromCdn($path, $this->getCdnBucket()); + } + + /** + * Purge this model's asset from the CDN cache. + */ + public function purgeFromCdn(): bool + { + $path = $this->getCdnPath(); + + if (! $path) { + return false; + } + + return Cdn::purge($path); + } + + /** + * Scope to append CDN URLs to the model when converting to array/JSON. + * Add this to $appends property: 'cdn_url', 'origin_url', 'asset_urls' + */ + public function getCdnUrlAttribute(): ?string + { + return $this->getCdnUrl(); + } + + public function getOriginUrlAttribute(): ?string + { + return $this->getOriginUrl(); + } + + public function getAssetUrlsAttribute(): array + { + return $this->getAssetUrls(); + } +} diff --git a/app/Core/Cdn/config.php b/app/Core/Cdn/config.php new file mode 100644 index 0000000..6936cde --- /dev/null +++ b/app/Core/Cdn/config.php @@ -0,0 +1,182 @@ + env('CDN_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Signed URL Expiry + |-------------------------------------------------------------------------- + | + | Default expiry time (in seconds) for signed URLs when not specified + | per-request. Signed URLs provide time-limited access to private content. + | + */ + 'signed_url_expiry' => env('CDN_SIGNED_URL_EXPIRY', 3600), + + /* + |-------------------------------------------------------------------------- + | URL Configuration + |-------------------------------------------------------------------------- + | + | All URL building uses these config values for consistency. + | Never hardcode URLs in service methods. + | + */ + 'urls' => [ + // CDN delivery URL (when enabled) + 'cdn' => env('CDN_URL'), + + // Public origin URL (direct storage access, bypassing CDN) + 'public' => env('CDN_PUBLIC_URL'), + + // Private CDN URL (for signed/gated content) + 'private' => env('CDN_PRIVATE_URL'), + + // Apex domain fallback + 'apex' => env('APP_URL', 'https://core.test'), + ], + + /* + |-------------------------------------------------------------------------- + | Filesystem Disk Mapping + |-------------------------------------------------------------------------- + */ + 'disks' => [ + 'private' => env('CDN_PRIVATE_DISK', 'local'), + 'public' => env('CDN_PUBLIC_DISK', 'public'), + ], + + /* + |-------------------------------------------------------------------------- + | BunnyCDN Configuration (Optional) + |-------------------------------------------------------------------------- + | + | Only needed if using BunnyCDN as your CDN provider. + | + */ + 'bunny' => [ + // Public storage zone (compiled assets) + 'public' => [ + 'zone' => env('BUNNYCDN_PUBLIC_STORAGE_ZONE'), + 'region' => env('BUNNYCDN_PUBLIC_STORAGE_REGION', 'de'), + 'api_key' => env('BUNNYCDN_PUBLIC_STORAGE_API_KEY'), + 'read_only_key' => env('BUNNYCDN_PUBLIC_STORAGE_READ_KEY'), + 'pull_zone' => env('BUNNYCDN_PUBLIC_PULL_ZONE'), + ], + + // Private storage zone (DRM, gated content) + 'private' => [ + 'zone' => env('BUNNYCDN_PRIVATE_STORAGE_ZONE'), + 'region' => env('BUNNYCDN_PRIVATE_STORAGE_REGION', 'de'), + 'api_key' => env('BUNNYCDN_PRIVATE_STORAGE_API_KEY'), + 'read_only_key' => env('BUNNYCDN_PRIVATE_STORAGE_READ_KEY'), + 'pull_zone' => env('BUNNYCDN_PRIVATE_PULL_ZONE'), + 'token' => env('BUNNYCDN_PRIVATE_PULL_ZONE_TOKEN'), + ], + + // Account-level API (for cache purging) + 'pull_zone_id' => env('BUNNYCDN_PULL_ZONE_ID'), + 'api_key' => env('BUNNYCDN_API_KEY'), + + // Feature flags + 'push_enabled' => env('CDN_PUSH_ENABLED', false), + ], + + /* + |-------------------------------------------------------------------------- + | Context Detection + |-------------------------------------------------------------------------- + | + | Define which routes/contexts should use origin vs CDN URLs. + | + */ + 'context' => [ + // Route prefixes that should use origin URLs (admin/internal) + 'admin_prefixes' => ['admin', 'hub', 'api/v1/admin', 'dashboard'], + + // Headers that indicate internal/admin request + 'admin_headers' => ['X-Admin-Request', 'X-Internal-Request'], + + // Default context when not determinable + 'default' => 'public', + ], + + /* + |-------------------------------------------------------------------------- + | Asset Processing Pipeline + |-------------------------------------------------------------------------- + */ + 'pipeline' => [ + // Auto-push to CDN after processing (only when CDN enabled) + 'auto_push' => env('CDN_AUTO_PUSH', false), + + // Auto-purge CDN on asset update + 'auto_purge' => env('CDN_AUTO_PURGE', false), + + // Queue for async operations + 'queue' => env('CDN_QUEUE', 'default'), + ], + + /* + |-------------------------------------------------------------------------- + | Path Prefixes by Category + |-------------------------------------------------------------------------- + */ + 'paths' => [ + 'media' => 'media', + 'avatar' => 'avatars', + 'content' => 'content', + 'static' => 'static', + ], + + /* + |-------------------------------------------------------------------------- + | Local Cache Configuration + |-------------------------------------------------------------------------- + | + | When CDN is disabled, these settings control local asset caching. + | + */ + 'cache' => [ + 'enabled' => env('CDN_CACHE_ENABLED', true), + 'ttl' => env('CDN_CACHE_TTL', 3600), + 'prefix' => 'cdn_url', + + // Cache headers for static assets (when serving locally) + 'headers' => [ + 'max_age' => env('CDN_CACHE_MAX_AGE', 31536000), // 1 year + 'immutable' => env('CDN_CACHE_IMMUTABLE', true), + ], + ], +]; diff --git a/app/Core/Cdn/offload.php b/app/Core/Cdn/offload.php new file mode 100644 index 0000000..cf37019 --- /dev/null +++ b/app/Core/Cdn/offload.php @@ -0,0 +1,115 @@ + env('STORAGE_OFFLOAD_ENABLED', false), + + /** + * Default disk for offloading. + * Must be defined in config/filesystems.php + * + * Options: 'hetzner', 's3', or any custom S3-compatible disk + */ + 'disk' => env('STORAGE_OFFLOAD_DISK', 'hetzner'), + + /** + * Base URL for serving offloaded assets. + * Can be a CDN URL (e.g., BunnyCDN pull zone). + * + * If null, uses the disk's configured URL. + */ + 'cdn_url' => env('STORAGE_OFFLOAD_CDN_URL'), + + /** + * Hetzner Object Storage Configuration. + */ + 'hetzner' => [ + 'endpoint' => env('HETZNER_ENDPOINT', 'https://fsn1.your-objectstorage.com'), + 'region' => env('HETZNER_REGION', 'fsn1'), + 'bucket' => env('HETZNER_BUCKET'), + 'access_key' => env('HETZNER_ACCESS_KEY'), + 'secret_key' => env('HETZNER_SECRET_KEY'), + 'visibility' => 'public', + ], + + /** + * File path organisation within bucket. + */ + 'paths' => [ + 'page' => 'pages', + 'avatar' => 'avatars', + 'media' => 'media', + 'static' => 'static', + ], + + /** + * File types eligible for offloading. + */ + 'allowed_extensions' => [ + // Images + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', + // Documents + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + // Media + 'mp4', 'webm', 'mp3', 'wav', 'ogg', + // Archives + 'zip', 'tar', 'gz', + ], + + /** + * Maximum file size for offload (bytes). + * Files larger than this will remain local. + */ + 'max_file_size' => env('STORAGE_OFFLOAD_MAX_SIZE', 100 * 1024 * 1024), // 100MB + + /** + * Automatically offload on upload. + * If false, manual migration via artisan command required. + */ + 'auto_offload' => env('STORAGE_OFFLOAD_AUTO', true), + + /** + * Keep local copy after offloading. + * Useful for gradual migration or backup purposes. + */ + 'keep_local' => env('STORAGE_OFFLOAD_KEEP_LOCAL', false), + + /** + * Queue configuration for async offloading. + */ + 'queue' => [ + 'enabled' => env('STORAGE_OFFLOAD_QUEUE', true), + 'connection' => env('STORAGE_OFFLOAD_QUEUE_CONNECTION', 'redis'), + 'name' => env('STORAGE_OFFLOAD_QUEUE_NAME', 'storage-offload'), + ], + + /** + * Cache configuration for URL lookups. + */ + 'cache' => [ + 'enabled' => true, + 'ttl' => 3600, // 1 hour + 'prefix' => 'storage_offload', + ], + +]; diff --git a/app/Core/Config/Boot.php b/app/Core/Config/Boot.php new file mode 100644 index 0000000..7b884ba --- /dev/null +++ b/app/Core/Config/Boot.php @@ -0,0 +1,136 @@ +get('cdn.bunny.api_key', $workspace); + * if ($config->isConfigured('cdn.bunny', $workspace)) { ... } + * + * ## Import/Export + * + * Export config to JSON or YAML for backup, migration, or sharing: + * + * ```php + * $exporter = app(ConfigExporter::class); + * $json = $exporter->exportJson($workspace); + * $result = $exporter->importJson($json, $workspace); + * ``` + * + * CLI commands: + * - `config:export config.json` - Export to file + * - `config:import config.json` - Import from file + * + * ## Versioning & Rollback + * + * Create snapshots and rollback to previous states: + * + * ```php + * $versioning = app(ConfigVersioning::class); + * $version = $versioning->createVersion($workspace, 'Before migration'); + * $versioning->rollback($version->id, $workspace); + * ``` + * + * CLI commands: + * - `config:version list` - List all versions + * - `config:version create "Label"` - Create snapshot + * - `config:version rollback 123` - Rollback to version + * - `config:version compare 122 123` - Compare versions + * + * ## Configuration + * + * | Key | Type | Default | Description | + * |-----|------|---------|-------------| + * | `core.config.max_versions` | int | 50 | Max versions per scope | + */ +class Boot extends ServiceProvider +{ + /** + * Register services. + */ + public function register(): void + { + $this->app->singleton(ConfigResolver::class); + + $this->app->singleton(ConfigService::class, function ($app) { + return new ConfigService($app->make(ConfigResolver::class)); + }); + + // Alias for convenience + $this->app->alias(ConfigService::class, 'config.service'); + + // Register exporter service + $this->app->singleton(ConfigExporter::class, function ($app) { + return new ConfigExporter($app->make(ConfigService::class)); + }); + + // Register versioning service + $this->app->singleton(ConfigVersioning::class, function ($app) { + return new ConfigVersioning( + $app->make(ConfigService::class), + $app->make(ConfigExporter::class) + ); + }); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // Load migrations + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + + // Load views + $this->loadViewsFrom(__DIR__.'/View/Blade', 'core.config'); + + // Load routes + $this->loadRoutesFrom(__DIR__.'/Routes/admin.php'); + + // Register Livewire components + Livewire::component('app.core.config.view.modal.admin.workspace-config', View\Modal\Admin\WorkspaceConfig::class); + Livewire::component('app.core.config.view.modal.admin.config-panel', View\Modal\Admin\ConfigPanel::class); + + // Register console commands + if ($this->app->runningInConsole()) { + $this->commands([ + Console\ConfigPrimeCommand::class, + Console\ConfigListCommand::class, + Console\ConfigExportCommand::class, + Console\ConfigImportCommand::class, + Console\ConfigVersionCommand::class, + ]); + } + + // Boot key registry after app is ready (deferred to avoid DB during boot) + // Config resolver now uses lazy loading - no boot-time initialization needed + } + + /** + * Check if database is unavailable (migration context). + */ + protected function isDbUnavailable(): bool + { + // Check if we're running migrate or db commands + $command = $_SERVER['argv'][1] ?? ''; + + return in_array($command, ['migrate', 'migrate:fresh', 'migrate:reset', 'db:seed', 'db:wipe']); + } +} diff --git a/app/Core/Config/CLAUDE.md b/app/Core/Config/CLAUDE.md new file mode 100644 index 0000000..60075a6 --- /dev/null +++ b/app/Core/Config/CLAUDE.md @@ -0,0 +1,70 @@ +# Config + +Database-backed configuration with scoping, versioning, profiles, and admin UI. + +## What It Does + +Replaces/supplements Laravel's file-based config with a DB-backed system supporting: +- Hierarchical scope resolution (global -> workspace -> user) +- Configuration profiles (sets of values that can be switched) +- Version history with diffs +- Sensitive value encryption +- Import/export (JSON/YAML) +- Livewire admin panels +- Event-driven invalidation + +## Key Classes + +| Class | Purpose | +|-------|---------| +| `Boot` | Listens to `AdminPanelBooting` and `ConsoleBooting` for registration | +| `ConfigService` | Primary API: `get()`, `set()`, `isConfigured()`, plus scope-aware resolution | +| `ConfigResolver` | Resolves values through scope hierarchy: user -> workspace -> global -> default | +| `ConfigResult` | DTO wrapping resolved value with metadata (source scope, profile, etc.) | +| `ConfigVersioning` | Tracks changes with diffs between versions | +| `VersionDiff` | Computes and formats diffs between config versions | +| `ConfigExporter` | Export/import config as JSON/YAML | +| `ImportResult` | DTO for import operation results | + +## Models + +| Model | Table | Purpose | +|-------|-------|---------| +| `ConfigKey` | `config_keys` | Key definitions with type, default, validation rules, `is_sensitive` flag | +| `ConfigValue` | `config_values` | Actual values scoped by type (global/workspace/user) | +| `ConfigProfile` | `config_profiles` | Named sets of config values (soft-deletable) | +| `ConfigVersion` | `config_versions` | Version history snapshots | +| `ConfigResolved` | -- | Value object for resolved config | +| `Channel` | -- | Notification channel config | + +## Enums + +- `ConfigType` -- Value types (string, int, bool, json, etc.) +- `ScopeType` -- Resolution scopes (global, workspace, user) + +## Events + +- `ConfigChanged` -- Fired when any config value changes +- `ConfigInvalidated` -- Fired when cache needs clearing +- `ConfigLocked` -- Fired when a config key is locked + +## Console Commands + +- `config:prime` -- Pre-populate config cache +- `config:list` -- List all config keys and values +- `config:version` -- Show version history +- `config:import` -- Import config from file +- `config:export` -- Export config to file + +## Admin UI + +- `ConfigPanel` (Livewire) -- General config editing panel +- `WorkspaceConfig` (Livewire) -- Workspace-specific config panel +- Routes registered under admin prefix + +## Integration + +- `ConfigService` is used by other subsystems (e.g., `BunnyCdnService` reads CDN credentials via `$this->config->get('cdn.bunny.api_key')`) +- Sensitive keys (`is_sensitive = true`) are encrypted at rest +- Seeder: `ConfigKeySeeder` populates default keys +- 4 migrations covering base tables, soft deletes, versions, and sensitive flag diff --git a/app/Core/Config/Config.php b/app/Core/Config/Config.php new file mode 100644 index 0000000..d06d85e --- /dev/null +++ b/app/Core/Config/Config.php @@ -0,0 +1,171 @@ + $data Override data (optional) + */ + public function save(array $data = []): void + { + foreach ($this->form() as $name => $_) { + $payload = Arr::get($data, $name, $this->request?->input($name)); + + $this->persistData($name, $payload); + } + } + + /** + * Persist data to database and cache. + * + * @param string $name Configuration field name + * @param mixed $payload Value to store + */ + public function persistData(string $name, mixed $payload): void + { + $this->insert($name, $payload); + $this->putCache($name, $payload); + } + + /** + * Insert or update configuration in database. + * + * Requires Core\Mod\Social module to be installed. + * + * @param string $name Configuration field name + * @param mixed $payload Value to store + */ + public function insert(string $name, mixed $payload): void + { + if (! class_exists(\Core\Mod\Social\Models\Config::class)) { + return; + } + + \Core\Mod\Social\Models\Config::updateOrCreate( + ['name' => $name, 'group' => $this->group()], + ['payload' => $payload] + ); + } + + /** + * Get a configuration value. + * + * Checks cache first, then database, finally falls back to default from form(). + * Requires Core\Mod\Social module for database lookup. + * + * @param string $name Configuration field name + */ + public function get(string $name): mixed + { + return $this->getCache($name, function () use ($name) { + $default = Arr::get($this->form(), $name); + + if (! class_exists(\Core\Mod\Social\Models\Config::class)) { + return $default; + } + + $payload = \Core\Mod\Social\Models\Config::get( + property: "{$this->group()}.{$name}", + default: $default + ); + + $this->putCache($name, $payload); + + return $payload; + }); + } + + /** + * Get all configuration values for this group. + * + * @return array + */ + public function all(): array + { + return Arr::map($this->form(), function ($_, $name) { + return $this->get($name); + }); + } + + /** + * Store a value in cache. + * + * @param string $name Configuration field name + * @param mixed $default Value to cache + */ + public function putCache(string $name, mixed $default = null): void + { + Cache::put($this->resolveCacheKey($name), $default); + } + + /** + * Retrieve a value from cache. + * + * @param string $name Configuration field name + * @param mixed $default Default value or closure to execute if not cached + */ + public function getCache(string $name, mixed $default = null): mixed + { + return Cache::get($this->resolveCacheKey($name), $default); + } + + /** + * Remove cache entries for this configuration group. + * + * @param string|null $name Specific field name, or null to clear all + */ + public function forgetCache(?string $name = null): void + { + if (! $name) { + foreach (array_keys($this->form()) as $fieldName) { + $this->forgetCache($fieldName); + } + + return; + } + + Cache::forget($this->resolveCacheKey($name)); + } + + /** + * Build cache key for a configuration field. + * + * @param string $key Configuration field name + */ + private function resolveCacheKey(string $key): string + { + $prefix = config('social.cache_prefix', 'social'); + + return "{$prefix}.configs.{$this->group()}.{$key}"; + } +} diff --git a/app/Core/Config/ConfigExporter.php b/app/Core/Config/ConfigExporter.php new file mode 100644 index 0000000..f80eaf4 --- /dev/null +++ b/app/Core/Config/ConfigExporter.php @@ -0,0 +1,536 @@ +exportJson($workspace); + * file_put_contents('config.json', $json); + * + * // Export to YAML + * $yaml = $exporter->exportYaml($workspace); + * file_put_contents('config.yaml', $yaml); + * + * // Import from JSON + * $result = $exporter->importJson(file_get_contents('config.json'), $workspace); + * + * // Import from YAML + * $result = $exporter->importYaml(file_get_contents('config.yaml'), $workspace); + * ``` + * + * @see ConfigService For runtime config access + * @see ConfigVersioning For config versioning and rollback + */ +class ConfigExporter +{ + /** + * Current export format version. + */ + protected const FORMAT_VERSION = '1.0'; + + /** + * Placeholder for sensitive values in exports. + */ + protected const SENSITIVE_PLACEHOLDER = '***SENSITIVE***'; + + public function __construct( + protected ConfigService $config, + ) {} + + /** + * Export config to JSON format. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $includeSensitive Include sensitive values (default: false) + * @param bool $includeKeys Include key definitions (default: true) + * @param string|null $category Filter by category (optional) + * @return string JSON string + */ + public function exportJson( + ?object $workspace = null, + bool $includeSensitive = false, + bool $includeKeys = true, + ?string $category = null, + ): string { + $data = $this->buildExportData($workspace, $includeSensitive, $includeKeys, $category); + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * Export config to YAML format. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $includeSensitive Include sensitive values (default: false) + * @param bool $includeKeys Include key definitions (default: true) + * @param string|null $category Filter by category (optional) + * @return string YAML string + */ + public function exportYaml( + ?object $workspace = null, + bool $includeSensitive = false, + bool $includeKeys = true, + ?string $category = null, + ): string { + $data = $this->buildExportData($workspace, $includeSensitive, $includeKeys, $category); + + return Yaml::dump($data, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + } + + /** + * Build export data structure. + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + protected function buildExportData( + ?object $workspace, + bool $includeSensitive, + bool $includeKeys, + ?string $category, + ): array { + $data = [ + 'version' => self::FORMAT_VERSION, + 'exported_at' => now()->toIso8601String(), + 'scope' => [ + 'type' => $workspace ? 'workspace' : 'system', + 'id' => $workspace?->id, + ], + ]; + + // Get profile for this scope + $profile = $this->getProfile($workspace); + + if ($includeKeys) { + $data['keys'] = $this->exportKeys($category); + } + + $data['values'] = $this->exportValues($profile, $includeSensitive, $category); + + return $data; + } + + /** + * Export key definitions. + * + * @return array> + */ + protected function exportKeys(?string $category = null): array + { + $query = ConfigKey::query()->orderBy('category')->orderBy('code'); + + if ($category !== null) { + $escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category); + $query->where('code', 'LIKE', "{$escapedCategory}.%") + ->orWhere('category', $category); + } + + return $query->get()->map(function (ConfigKey $key) { + return [ + 'code' => $key->code, + 'type' => $key->type->value, + 'category' => $key->category, + 'description' => $key->description, + 'default_value' => $key->default_value, + 'is_sensitive' => $key->is_sensitive ?? false, + ]; + })->toArray(); + } + + /** + * Export config values. + * + * @return array> + */ + protected function exportValues(?ConfigProfile $profile, bool $includeSensitive, ?string $category): array + { + if ($profile === null) { + return []; + } + + $query = ConfigValue::query() + ->with('key') + ->where('profile_id', $profile->id); + + $values = $query->get(); + + return $values + ->filter(function (ConfigValue $value) use ($category) { + if ($category === null) { + return true; + } + $key = $value->key; + if ($key === null) { + return false; + } + + return str_starts_with($key->code, "{$category}.") || $key->category === $category; + }) + ->map(function (ConfigValue $value) use ($includeSensitive) { + $key = $value->key; + + // Mask sensitive values unless explicitly included + $displayValue = $value->value; + if ($key?->isSensitive() && ! $includeSensitive) { + $displayValue = self::SENSITIVE_PLACEHOLDER; + } + + return [ + 'key' => $key?->code ?? 'unknown', + 'value' => $displayValue, + 'locked' => $value->locked, + 'channel_id' => $value->channel_id, + ]; + }) + ->values() + ->toArray(); + } + + /** + * Import config from JSON format. + * + * @param string $json JSON string + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + * @return ImportResult Import result with stats + * + * @throws \InvalidArgumentException If JSON is invalid + */ + public function importJson(string $json, ?object $workspace = null, bool $dryRun = false): ImportResult + { + $data = json_decode($json, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException('Invalid JSON: '.json_last_error_msg()); + } + + return $this->importData($data, $workspace, $dryRun); + } + + /** + * Import config from YAML format. + * + * @param string $yaml YAML string + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + * @return ImportResult Import result with stats + * + * @throws \InvalidArgumentException If YAML is invalid + */ + public function importYaml(string $yaml, ?object $workspace = null, bool $dryRun = false): ImportResult + { + try { + $data = Yaml::parse($yaml); + } catch (\Exception $e) { + throw new \InvalidArgumentException('Invalid YAML: '.$e->getMessage()); + } + + return $this->importData($data, $workspace, $dryRun); + } + + /** + * Import config from parsed data. + * + * @param array $data Parsed import data + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + */ + protected function importData(array $data, ?object $workspace, bool $dryRun): ImportResult + { + $result = new ImportResult; + + // Validate version + $version = $data['version'] ?? '1.0'; + if (! $this->isVersionCompatible($version)) { + $result->addError("Incompatible export version: {$version} (expected {FORMAT_VERSION})"); + + return $result; + } + + // Get or create profile for this scope + $profile = $this->getOrCreateProfile($workspace); + + // Import keys if present + if (isset($data['keys']) && is_array($data['keys'])) { + $this->importKeys($data['keys'], $result, $dryRun); + } + + // Import values if present + if (isset($data['values']) && is_array($data['values'])) { + $this->importValues($data['values'], $profile, $result, $dryRun); + } + + // Re-prime config if changes were made + if (! $dryRun && $result->hasChanges()) { + $this->config->prime($workspace); + } + + return $result; + } + + /** + * Import key definitions. + * + * @param array> $keys + */ + protected function importKeys(array $keys, ImportResult $result, bool $dryRun): void + { + foreach ($keys as $keyData) { + $code = $keyData['code'] ?? null; + if ($code === null) { + $result->addSkipped('Key with no code'); + + continue; + } + + try { + $type = ConfigType::tryFrom($keyData['type'] ?? 'string') ?? ConfigType::STRING; + + $existing = ConfigKey::byCode($code); + + if ($existing !== null) { + // Update existing key + if (! $dryRun) { + $existing->update([ + 'type' => $type, + 'category' => $keyData['category'] ?? $existing->category, + 'description' => $keyData['description'] ?? $existing->description, + 'default_value' => $keyData['default_value'] ?? $existing->default_value, + 'is_sensitive' => $keyData['is_sensitive'] ?? $existing->is_sensitive, + ]); + } + $result->addUpdated($code, 'key'); + } else { + // Create new key + if (! $dryRun) { + ConfigKey::create([ + 'code' => $code, + 'type' => $type, + 'category' => $keyData['category'] ?? 'imported', + 'description' => $keyData['description'] ?? null, + 'default_value' => $keyData['default_value'] ?? null, + 'is_sensitive' => $keyData['is_sensitive'] ?? false, + ]); + } + $result->addCreated($code, 'key'); + } + } catch (\Exception $e) { + $result->addError("Failed to import key '{$code}': ".$e->getMessage()); + } + } + } + + /** + * Import config values. + * + * @param array> $values + */ + protected function importValues(array $values, ConfigProfile $profile, ImportResult $result, bool $dryRun): void + { + foreach ($values as $valueData) { + $keyCode = $valueData['key'] ?? null; + if ($keyCode === null) { + $result->addSkipped('Value with no key'); + + continue; + } + + // Skip sensitive placeholders + if ($valueData['value'] === self::SENSITIVE_PLACEHOLDER) { + $result->addSkipped("{$keyCode} (sensitive placeholder)"); + + continue; + } + + try { + $key = ConfigKey::byCode($keyCode); + if ($key === null) { + $result->addSkipped("{$keyCode} (key not found)"); + + continue; + } + + $channelId = $valueData['channel_id'] ?? null; + $existing = ConfigValue::findValue($profile->id, $key->id, $channelId); + + if ($existing !== null) { + // Update existing value + if (! $dryRun) { + $existing->value = $valueData['value']; + $existing->locked = $valueData['locked'] ?? false; + $existing->save(); + } + $result->addUpdated($keyCode, 'value'); + } else { + // Create new value + if (! $dryRun) { + $value = new ConfigValue; + $value->profile_id = $profile->id; + $value->key_id = $key->id; + $value->channel_id = $channelId; + $value->value = $valueData['value']; + $value->locked = $valueData['locked'] ?? false; + $value->save(); + } + $result->addCreated($keyCode, 'value'); + } + } catch (\Exception $e) { + $result->addError("Failed to import value '{$keyCode}': ".$e->getMessage()); + } + } + } + + /** + * Check if export version is compatible. + */ + protected function isVersionCompatible(string $version): bool + { + // For now, only support exact version match + // Can be extended to support backward compatibility + $supported = ['1.0']; + + return in_array($version, $supported, true); + } + + /** + * Get profile for a workspace (or system). + */ + protected function getProfile(?object $workspace): ?ConfigProfile + { + if ($workspace !== null) { + return ConfigProfile::forWorkspace($workspace->id); + } + + return ConfigProfile::system(); + } + + /** + * Get or create profile for a workspace (or system). + */ + protected function getOrCreateProfile(?object $workspace): ConfigProfile + { + if ($workspace !== null) { + return ConfigProfile::ensureWorkspace($workspace->id); + } + + return ConfigProfile::ensureSystem(); + } + + /** + * Export config to a file. + * + * @param string $path File path (extension determines format) + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $includeSensitive Include sensitive values + * + * @throws \RuntimeException If file cannot be written + */ + public function exportToFile( + string $path, + ?object $workspace = null, + bool $includeSensitive = false, + ): void { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + $content = match ($extension) { + 'yaml', 'yml' => $this->exportYaml($workspace, $includeSensitive), + default => $this->exportJson($workspace, $includeSensitive), + }; + + $result = file_put_contents($path, $content); + + if ($result === false) { + throw new \RuntimeException("Failed to write config export to: {$path}"); + } + } + + /** + * Import config from a file. + * + * @param string $path File path (extension determines format) + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + * @return ImportResult Import result with stats + * + * @throws \RuntimeException If file cannot be read + */ + public function importFromFile( + string $path, + ?object $workspace = null, + bool $dryRun = false, + ): ImportResult { + if (! file_exists($path)) { + throw new \RuntimeException("Config file not found: {$path}"); + } + + $content = file_get_contents($path); + if ($content === false) { + throw new \RuntimeException("Failed to read config file: {$path}"); + } + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + return match ($extension) { + 'yaml', 'yml' => $this->importYaml($content, $workspace, $dryRun), + default => $this->importJson($content, $workspace, $dryRun), + }; + } +} diff --git a/app/Core/Config/ConfigResolver.php b/app/Core/Config/ConfigResolver.php new file mode 100644 index 0000000..2c5e364 --- /dev/null +++ b/app/Core/Config/ConfigResolver.php @@ -0,0 +1,639 @@ + + */ + protected static array $values = []; + + /** + * Whether the hash has been loaded. + */ + protected static bool $loaded = false; + + /** + * Registered virtual providers. + * + * Supports both ConfigProvider instances and callable functions. + * + * @var array + */ + protected array $providers = []; + + // ========================================================================= + // THE HASH + // ========================================================================= + + /** + * Get a value from the hash. + */ + public static function get(string $key): mixed + { + return static::$values[$key] ?? null; + } + + /** + * Set a value in the hash. + */ + public static function set(string $key, mixed $value): void + { + static::$values[$key] = $value; + } + + /** + * Check if a value exists in the hash. + */ + public static function has(string $key): bool + { + return array_key_exists($key, static::$values); + } + + /** + * Clear keys matching a pattern (bi-directional). + */ + public static function clear(string $pattern): void + { + static::$values = array_filter( + static::$values, + fn ($k) => ! str_contains($k, $pattern), + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Clear entire hash. + */ + public static function clearAll(): void + { + static::$values = []; + static::$loaded = false; + } + + /** + * Get all values (for debugging). + * + * @return array + */ + public static function all(): array + { + return static::$values; + } + + /** + * Check if hash has been loaded. + */ + public static function isLoaded(): bool + { + return static::$loaded; + } + + /** + * Mark hash as loaded. + */ + public static function markLoaded(): void + { + static::$loaded = true; + } + + // ========================================================================= + // RESOLUTION ENGINE (only runs during lazy prime, not normal reads) + // ========================================================================= + + /** + * Resolve a single key for a workspace and optional channel. + * + * NOTE: This is the expensive path - only called when lazy-priming. + * Normal reads hit the hash directly via ConfigService. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param string|Channel|null $channel Channel code or object + */ + public function resolve( + string $keyCode, + ?object $workspace = null, + string|Channel|null $channel = null, + ): ConfigResult { + // Get key definition (DB query - only during resolve, not normal reads) + $key = ConfigKey::byCode($keyCode); + + if ($key === null) { + // Try JSON sub-key extraction + return $this->resolveJsonSubKey($keyCode, $workspace, $channel); + } + + // Build chains + $profileChain = $this->buildProfileChain($workspace); + $channelChain = $this->buildChannelChain($channel, $workspace); + + // Batch load all values for this key + $values = $this->batchLoadValues( + $key->id, + $profileChain->pluck('id')->all(), + $channelChain->pluck('id')->all() + ); + + // Build resolution matrix (profile × channel combinations) + $matrix = $this->buildResolutionMatrix($profileChain, $channelChain); + + // First pass: check for FINAL locks (from least specific scope) + $lockedResult = $this->findFinalLock($matrix, $values, $keyCode, $key); + if ($lockedResult !== null) { + return $lockedResult; + } + + // Second pass: find most specific value + foreach ($matrix as $combo) { + $value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']); + + if ($value !== null) { + return ConfigResult::found( + key: $keyCode, + value: $value->value, + type: $key->type, + locked: false, + resolvedFrom: $combo['scope_type'], + profileId: $combo['profile_id'], + channelId: $combo['channel_id'], + ); + } + } + + // Check virtual providers + $virtualValue = $this->resolveFromProviders($keyCode, $workspace, $channel); + if ($virtualValue !== null) { + return ConfigResult::virtual( + key: $keyCode, + value: $virtualValue, + type: $key->type, + ); + } + + // No value found - return default + return ConfigResult::notFound($keyCode, $key->getTypedDefault(), $key->type); + } + + /** + * Maximum recursion depth for JSON sub-key resolution. + */ + protected const MAX_SUBKEY_DEPTH = 10; + + /** + * Current recursion depth for sub-key resolution. + */ + protected int $subKeyDepth = 0; + + /** + * Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON). + */ + /** + * @param object|null $workspace Workspace model instance or null for system scope + */ + protected function resolveJsonSubKey( + string $keyCode, + ?object $workspace, + string|Channel|null $channel, + ): ConfigResult { + // Guard against stack overflow from deep nesting + if ($this->subKeyDepth >= self::MAX_SUBKEY_DEPTH) { + return ConfigResult::unconfigured($keyCode); + } + + $this->subKeyDepth++; + + try { + $parts = explode('.', $keyCode); + + // Try progressively shorter parent keys + for ($i = count($parts) - 1; $i > 0; $i--) { + $parentKey = implode('.', array_slice($parts, 0, $i)); + $subPath = implode('.', array_slice($parts, $i)); + + $parentResult = $this->resolve($parentKey, $workspace, $channel); + + if ($parentResult->found && is_array($parentResult->value)) { + $subValue = data_get($parentResult->value, $subPath); + + if ($subValue !== null) { + return ConfigResult::found( + key: $keyCode, + value: $subValue, + type: $parentResult->type, // Inherit parent type + locked: $parentResult->locked, + resolvedFrom: $parentResult->resolvedFrom, + profileId: $parentResult->profileId, + channelId: $parentResult->channelId, + ); + } + } + } + + return ConfigResult::unconfigured($keyCode); + } finally { + $this->subKeyDepth--; + } + } + + /** + * Build the channel inheritance chain. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @return Collection + */ + public function buildChannelChain( + string|Channel|null $channel, + ?object $workspace = null, + ): Collection { + $chain = new Collection; + + if ($channel === null) { + // No channel specified - just null (applies to all) + $chain->push(null); + + return $chain; + } + + // Resolve channel code to model + if (is_string($channel)) { + $channel = Channel::byCode($channel, $workspace?->id); + } + + if ($channel !== null) { + // Add channel inheritance chain + $chain = $chain->merge($channel->inheritanceChain()); + } + + // Always include null (all-channels fallback) + $chain->push(null); + + return $chain; + } + + /** + * Batch load all values for a key across profiles and channels. + * + * @param array $profileIds + * @param array $channelIds + * @return Collection + */ + protected function batchLoadValues(int $keyId, array $profileIds, array $channelIds): Collection + { + // Separate null from actual channel IDs for query + $actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null); + + return ConfigValue::where('key_id', $keyId) + ->whereIn('profile_id', $profileIds) + ->where(function ($query) use ($actualChannelIds) { + $query->whereNull('channel_id'); + if (! empty($actualChannelIds)) { + $query->orWhereIn('channel_id', $actualChannelIds); + } + }) + ->get(); + } + + /** + * Build resolution matrix (profile × channel combinations). + * + * Order: most specific first (workspace + specific channel) + * to least specific (system + null channel). + * + * @return array + */ + protected function buildResolutionMatrix(Collection $profileChain, Collection $channelChain): array + { + $matrix = []; + + foreach ($profileChain as $profile) { + foreach ($channelChain as $channel) { + $matrix[] = [ + 'profile_id' => $profile->id, + 'channel_id' => $channel?->id, + 'scope_type' => $profile->scope_type, + ]; + } + } + + return $matrix; + } + + /** + * Find a FINAL lock in the resolution matrix. + * + * Checks from least specific (system) to find any lock that + * would prevent more specific values from being used. + */ + protected function findFinalLock( + array $matrix, + Collection $values, + string $keyCode, + ConfigKey $key, + ): ?ConfigResult { + // Reverse to check from least specific (system) + $reversed = array_reverse($matrix); + + foreach ($reversed as $combo) { + $value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']); + + if ($value !== null && $value->isLocked()) { + return ConfigResult::found( + key: $keyCode, + value: $value->value, + type: $key->type, + locked: true, + resolvedFrom: $combo['scope_type'], + profileId: $combo['profile_id'], + channelId: $combo['channel_id'], + ); + } + } + + return null; + } + + /** + * Find a value in the batch-loaded collection. + */ + protected function findValueInBatch(Collection $values, int $profileId, ?int $channelId): ?ConfigValue + { + return $values->first(function (ConfigValue $value) use ($profileId, $channelId) { + return $value->profile_id === $profileId + && $value->channel_id === $channelId; + }); + } + + /** + * Register a virtual provider for a key pattern. + * + * Providers supply values from module data without database storage. + * Accepts either a ConfigProvider instance or a callable. + * + * @param string|ConfigProvider $patternOrProvider Key pattern (supports * wildcard) or ConfigProvider instance + * @param ConfigProvider|callable|null $provider ConfigProvider instance or fn(string $key, ?object $workspace, ?Channel $channel): mixed + */ + public function registerProvider(string|ConfigProvider $patternOrProvider, ConfigProvider|callable|null $provider = null): void + { + // Support both new interface-based and legacy callable patterns + if ($patternOrProvider instanceof ConfigProvider) { + $this->providers[$patternOrProvider->pattern()] = $patternOrProvider; + } elseif ($provider !== null) { + $this->providers[$patternOrProvider] = $provider; + } + } + + /** + * Resolve value from virtual providers. + * + * Supports both ConfigProvider instances and legacy callables. + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + public function resolveFromProviders( + string $keyCode, + ?object $workspace, + string|Channel|null $channel, + ): mixed { + foreach ($this->providers as $pattern => $provider) { + if ($this->matchesPattern($keyCode, $pattern)) { + // Support both ConfigProvider interface and legacy callable + $value = $provider instanceof ConfigProvider + ? $provider->resolve($keyCode, $workspace, $channel) + : $provider($keyCode, $workspace, $channel); + + if ($value !== null) { + return $value; + } + } + } + + return null; + } + + /** + * Check if a key matches a provider pattern. + */ + protected function matchesPattern(string $key, string $pattern): bool + { + if ($pattern === $key) { + return true; + } + + // Convert pattern to regex (e.g., "bio.*" → "^bio\..*$") + $regex = '/^'.str_replace(['.', '*'], ['\.', '.*'], $pattern).'$/'; + + return (bool) preg_match($regex, $key); + } + + /** + * Resolve all keys for a workspace. + * + * NOTE: Only called during prime, not normal reads. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @return array + */ + public function resolveAll(?object $workspace = null, string|Channel|null $channel = null): array + { + $results = []; + + // Query all keys from DB (only during prime) + foreach (ConfigKey::all() as $key) { + $results[$key->code] = $this->resolve($key->code, $workspace, $channel); + } + + return $results; + } + + /** + * Resolve all keys in a category. + * + * NOTE: Only called during prime, not normal reads. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @return array + */ + public function resolveCategory( + string $category, + ?object $workspace = null, + string|Channel|null $channel = null, + ): array { + $results = []; + + // Query keys by category from DB (only during prime) + foreach (ConfigKey::where('category', $category)->get() as $key) { + $results[$key->code] = $this->resolve($key->code, $workspace, $channel); + } + + return $results; + } + + /** + * Build the profile chain for resolution. + * + * Returns profiles ordered from most specific (workspace) to least (system). + * Chain: workspace → org → system + * + * @param object|null $workspace Workspace model instance or null for system scope + * @return Collection + */ + public function buildProfileChain(?object $workspace = null): Collection + { + $chain = new Collection; + + // Workspace profiles (most specific) + if ($workspace !== null) { + $workspaceProfiles = ConfigProfile::forScope(ScopeType::WORKSPACE, $workspace->id); + $chain = $chain->merge($workspaceProfiles); + + // Org layer - workspace belongs to organisation + $orgId = $this->resolveOrgId($workspace); + if ($orgId !== null) { + $orgProfiles = ConfigProfile::forScope(ScopeType::ORG, $orgId); + $chain = $chain->merge($orgProfiles); + } + } + + // System profiles (least specific) + $systemProfiles = ConfigProfile::forScope(ScopeType::SYSTEM, null); + $chain = $chain->merge($systemProfiles); + + // Add parent profile inheritance + $chain = $this->expandParentProfiles($chain); + + return $chain; + } + + /** + * Resolve organisation ID from workspace. + * + * Stub for now - will connect to Tenant module when org model exists. + * Organisation = multi-workspace grouping (agency accounts, teams). + * + * @param object|null $workspace Workspace model instance or null + */ + protected function resolveOrgId(?object $workspace): ?int + { + if ($workspace === null) { + return null; + } + + // Workspace::organisation_id when model has org support + // For now, return null (no org layer) + return $workspace->organisation_id ?? null; + } + + /** + * Expand chain to include parent profiles. + * + * @param Collection $chain + * @return Collection + */ + protected function expandParentProfiles(Collection $chain): Collection + { + $expanded = new Collection; + $seen = []; + + foreach ($chain as $profile) { + $this->addProfileWithParents($profile, $expanded, $seen); + } + + return $expanded; + } + + /** + * Add a profile and its parents to the chain. + * + * @param Collection $chain + * @param array $seen + */ + protected function addProfileWithParents(ConfigProfile $profile, Collection $chain, array &$seen): void + { + if (isset($seen[$profile->id])) { + return; + } + + $seen[$profile->id] = true; + $chain->push($profile); + + // Follow parent chain + if ($profile->parent_profile_id !== null) { + $parent = $profile->parent; + + if ($parent !== null) { + $this->addProfileWithParents($parent, $chain, $seen); + } + } + } + + /** + * Check if a key prefix is configured. + * + * Optimised to use EXISTS query instead of resolving each key. + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + public function isPrefixConfigured( + string $prefix, + ?object $workspace = null, + string|Channel|null $channel = null, + ): bool { + // Get profile IDs for this workspace + $profileChain = $this->buildProfileChain($workspace); + $profileIds = $profileChain->pluck('id')->all(); + + // Get channel IDs + $channelChain = $this->buildChannelChain($channel, $workspace); + $channelIds = $channelChain->map(fn ($c) => $c?->id)->all(); + $actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null); + + // Single EXISTS query + return ConfigValue::whereIn('profile_id', $profileIds) + ->where(function ($query) use ($actualChannelIds) { + $query->whereNull('channel_id'); + if (! empty($actualChannelIds)) { + $query->orWhereIn('channel_id', $actualChannelIds); + } + }) + ->whereHas('key', function ($query) use ($prefix) { + $query->where('code', 'LIKE', "{$prefix}.%"); + }) + ->exists(); + } +} diff --git a/app/Core/Config/ConfigResult.php b/app/Core/Config/ConfigResult.php new file mode 100644 index 0000000..b92f5ab --- /dev/null +++ b/app/Core/Config/ConfigResult.php @@ -0,0 +1,211 @@ +cast($value), + type: $type, + found: true, + locked: $locked, + virtual: false, + resolvedFrom: $resolvedFrom, + profileId: $profileId, + channelId: $channelId, + ); + } + + /** + * Create a result from a virtual provider. + */ + public static function virtual( + string $key, + mixed $value, + ConfigType $type, + ): self { + return new self( + key: $key, + value: $type->cast($value), + type: $type, + found: true, + locked: false, + virtual: true, + ); + } + + /** + * Create a not-found result with default value. + */ + public static function notFound(string $key, mixed $defaultValue, ConfigType $type): self + { + return new self( + key: $key, + value: $type->cast($defaultValue), + type: $type, + found: false, + locked: false, + ); + } + + /** + * Create a result for unconfigured key. + */ + public static function unconfigured(string $key): self + { + return new self( + key: $key, + value: null, + type: ConfigType::STRING, + found: false, + locked: false, + ); + } + + /** + * Check if the key was found (has a value). + */ + public function isConfigured(): bool + { + return $this->found && $this->value !== null; + } + + /** + * Check if the value is locked (FINAL). + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * Check if the value came from a virtual provider. + */ + public function isVirtual(): bool + { + return $this->virtual; + } + + /** + * Get the value, with optional fallback. + */ + public function get(mixed $default = null): mixed + { + return $this->value ?? $default; + } + + /** + * Get value as string. + */ + public function string(string $default = ''): string + { + return (string) ($this->value ?? $default); + } + + /** + * Get value as integer. + */ + public function int(int $default = 0): int + { + return (int) ($this->value ?? $default); + } + + /** + * Get value as boolean. + */ + public function bool(bool $default = false): bool + { + return (bool) ($this->value ?? $default); + } + + /** + * Get value as array. + */ + public function array(array $default = []): array + { + if ($this->value === null) { + return $default; + } + + return is_array($this->value) ? $this->value : $default; + } + + /** + * Convert to array for caching. + */ + public function toArray(): array + { + return [ + 'key' => $this->key, + 'value' => $this->value, + 'type' => $this->type->value, + 'found' => $this->found, + 'locked' => $this->locked, + 'virtual' => $this->virtual, + 'resolved_from' => $this->resolvedFrom?->value, + 'profile_id' => $this->profileId, + 'channel_id' => $this->channelId, + ]; + } + + /** + * Reconstruct from cached array. + */ + public static function fromArray(array $data): self + { + return new self( + key: $data['key'], + value: $data['value'], + type: ConfigType::from($data['type']), + found: $data['found'], + locked: $data['locked'], + virtual: $data['virtual'] ?? false, + resolvedFrom: ($data['resolved_from'] ?? null) ? ScopeType::from($data['resolved_from']) : null, + profileId: $data['profile_id'] ?? null, + channelId: $data['channel_id'] ?? null, + ); + } +} diff --git a/app/Core/Config/ConfigService.php b/app/Core/Config/ConfigService.php new file mode 100644 index 0000000..e5d50f0 --- /dev/null +++ b/app/Core/Config/ConfigService.php @@ -0,0 +1,822 @@ +get('cdn.bunny.api_key', $workspace); + * $config->set('cdn.bunny.api_key', 'new-value', $profile); + * + * // Module Boot.php - provide runtime value (no DB) + * $config->provide('mymodule.api_key', env('MYMODULE_API_KEY')); + * ``` + * + * ## Cache Invalidation Strategy + * + * The Config module uses a two-tier caching system: + * + * ### Tier 1: In-Memory Hash (Process-Scoped) + * - `ConfigResolver::$values` - Static array holding all config values + * - Cleared on process termination (dies with the request) + * - Cleared explicitly via `ConfigResolver::clearAll()` or `ConfigResolver::clear($key)` + * + * ### Tier 2: Database Resolved Table (Persistent) + * - `config_resolved` table - Materialised config resolution + * - Survives across requests, shared between all processes + * - Cleared via `ConfigResolved::clearScope()`, `clearWorkspace()`, or `clearKey()` + * + * ### Invalidation Triggers + * + * 1. **On Config Change (`set()`):** + * - Clears the specific key from both hash and database + * - Re-primes the key for the affected scope + * - Dispatches `ConfigChanged` event for module hooks + * + * 2. **On Lock/Unlock:** + * - Re-primes the key (lock affects all child scopes) + * - Dispatches `ConfigLocked` event + * + * 3. **Manual Invalidation:** + * - `invalidateWorkspace($workspace)` - Clears all config for a workspace + * - `invalidateKey($key)` - Clears a key across all scopes + * - Both dispatch `ConfigInvalidated` event + * + * 4. **Full Re-prime:** + * - `prime($workspace)` - Clears and recomputes all config for a scope + * - `primeAll()` - Primes system config + all workspaces (scheduled job) + * + * ### Lazy Loading + * + * When a key is not found in the hash: + * 1. If scope not loaded, `loadScope()` loads all resolved values for the scope + * 2. If still not found, `resolve()` computes and stores the value + * 3. Result is stored in both hash (for current request) and database (persistent) + * + * ### Events for Module Integration + * + * Modules can listen to cache events to refresh their own caches: + * - `ConfigChanged` - Fired when a config value is set/updated + * - `ConfigLocked` - Fired when a config value is locked + * - `ConfigInvalidated` - Fired when cache is manually invalidated + * + * ```php + * // In your module's Boot.php + * public static array $listens = [ + * ConfigChanged::class => 'onConfigChanged', + * ]; + * + * public function onConfigChanged(ConfigChanged $event): void + * { + * if ($event->keyCode === 'mymodule.api_key') { + * $this->refreshApiClient(); + * } + * } + * ``` + * + * @see ConfigResolver For the caching hash implementation + * @see ConfigResolved For the database cache model + * @see ConfigChanged Event fired on config changes + * @see ConfigInvalidated Event fired on cache invalidation + */ +class ConfigService +{ + /** + * Current workspace context (Workspace model instance or null for system scope). + */ + protected ?object $workspace = null; + + protected ?Channel $channel = null; + + public function __construct( + protected ConfigResolver $resolver, + ) {} + + /** + * Set the current context (called by middleware). + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + public function setContext(?object $workspace, ?Channel $channel = null): void + { + $this->workspace = $workspace; + $this->channel = $channel; + } + + /** + * Get current workspace context. + * + * @return object|null Workspace model instance or null + */ + public function getWorkspace(): ?object + { + return $this->workspace; + } + + /** + * Get a config value. + * + * Context (workspace/channel) is set by middleware via setContext(). + * This is just key/value - simple. + */ + public function get(string $key, mixed $default = null): mixed + { + $result = $this->resolve($key, $this->workspace, $this->channel); + + return $result->get($default); + } + + /** + * Get config for a specific workspace (admin use only). + * + * Use this when you need another workspace's settings - requires explicit intent. + * + * @param object $workspace Workspace model instance + */ + public function getForWorkspace(string $key, object $workspace, mixed $default = null): mixed + { + $result = $this->resolve($key, $workspace, null); + + return $result->get($default); + } + + /** + * Get a resolved ConfigResult. + * + * Read path: + * 1. Hash lookup (O(1)) + * 2. Lazy load scope if not loaded (1 query) + * 3. Hash lookup again + * 4. Compute via resolver if still not found (lazy prime) + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param string|Channel|null $channel Channel code or object + */ + public function resolve( + string $key, + ?object $workspace = null, + string|Channel|null $channel = null, + ): ConfigResult { + $workspaceId = $workspace?->id; + $channelId = $this->resolveChannelId($channel, $workspace); + + // 1. Check hash (O(1)) + if (ConfigResolver::has($key)) { + // Get full result from ConfigResolved (indexed lookup with metadata) + $resolved = ConfigResolved::lookup($key, $workspaceId, $channelId); + + if ($resolved !== null) { + return $resolved->toResult(); + } + + // Fallback: value in hash but not in DB (runtime provided) + return ConfigResult::found( + key: $key, + value: ConfigResolver::get($key), + type: ConfigType::STRING, + locked: false, + ); + } + + // 2. Scope not loaded - lazy load entire scope + if (! ConfigResolver::isLoaded()) { + $this->loadScope($workspaceId, $channelId); + + // Check hash again + if (ConfigResolver::has($key)) { + $resolved = ConfigResolved::lookup($key, $workspaceId, $channelId); + + if ($resolved !== null) { + return $resolved->toResult(); + } + + return ConfigResult::found( + key: $key, + value: ConfigResolver::get($key), + type: ConfigType::STRING, + locked: false, + ); + } + } + + // 3. Try JSON sub-key extraction + $subKeyResult = $this->resolveJsonSubKey($key, $workspace, $channel); + if ($subKeyResult->found) { + return $subKeyResult; + } + + // 4. Check virtual providers + $virtualValue = $this->resolver->resolveFromProviders($key, $workspace, $channel); + if ($virtualValue !== null) { + $keyModel = ConfigKey::byCode($key); + $type = $keyModel?->type ?? ConfigType::STRING; + + // Store in hash for next read + ConfigResolver::set($key, $virtualValue); + + return ConfigResult::virtual( + key: $key, + value: $virtualValue, + type: $type, + ); + } + + // 5. Lazy prime: compute via resolver + $result = $this->resolver->resolve($key, $workspace, $channel); + + // Store in hash + ConfigResolver::set($key, $result->value); + + // Store in DB for future requests + if ($result->isConfigured()) { + ConfigResolved::store( + keyCode: $key, + value: $result->value, + type: $result->type, + workspaceId: $workspaceId, + channelId: $channelId, + locked: $result->locked, + sourceProfileId: $result->profileId, + sourceChannelId: $result->channelId, + virtual: $result->virtual, + ); + } + + return $result; + } + + /** + * Load a scope into the hash from database. + */ + protected function loadScope(?int $workspaceId, ?int $channelId): void + { + $resolved = ConfigResolved::forScope($workspaceId, $channelId); + + foreach ($resolved as $row) { + ConfigResolver::set($row->key_code, $row->getTypedValue()); + } + + ConfigResolver::markLoaded(); + } + + /** + * Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON). + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + protected function resolveJsonSubKey( + string $keyCode, + ?object $workspace, + string|Channel|null $channel, + ): ConfigResult { + $parts = explode('.', $keyCode); + + // Try progressively shorter parent keys + for ($i = count($parts) - 1; $i > 0; $i--) { + $parentKey = implode('.', array_slice($parts, 0, $i)); + $subPath = implode('.', array_slice($parts, $i)); + + $workspaceId = $workspace?->id; + $channelId = $this->resolveChannelId($channel, $workspace); + + $resolved = ConfigResolved::lookup($parentKey, $workspaceId, $channelId); + + if ($resolved !== null && is_array($resolved->value)) { + $subValue = data_get($resolved->value, $subPath); + + if ($subValue !== null) { + $result = $resolved->toResult(); + + return new ConfigResult( + key: $keyCode, + value: $subValue, + type: $result->type, + found: true, + locked: $result->locked, + virtual: $result->virtual, + resolvedFrom: $result->resolvedFrom, + profileId: $result->profileId, + channelId: $result->channelId, + ); + } + } + } + + return ConfigResult::unconfigured($keyCode); + } + + /** + * Check if a key (or prefix) is configured. + * + * Uses current context set by middleware. + */ + public function isConfigured(string $keyOrPrefix): bool + { + $workspaceId = $this->workspace?->id; + $channelId = $this->channel?->id; + + // Check if it's a direct key + $resolved = ConfigResolved::lookup($keyOrPrefix, $workspaceId, $channelId); + if ($resolved !== null && $resolved->value !== null) { + return true; + } + + // Check as prefix - single EXISTS query + // Escape LIKE wildcards to prevent unintended pattern matching + $escapedPrefix = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $keyOrPrefix); + + return ConfigResolved::where('workspace_id', $workspaceId) + ->where('channel_id', $channelId) + ->where('key_code', 'LIKE', "{$escapedPrefix}.%") + ->whereNotNull('value') + ->exists(); + } + + /** + * Set a config value. + * + * Updates config_values (source of truth), then re-primes affected scope. + * Fires ConfigChanged event for invalidation hooks. + * + * @param string|Channel|null $channel Channel code or object + * + * @throws \InvalidArgumentException If key is unknown or value type is invalid + */ + public function set( + string $keyCode, + mixed $value, + ConfigProfile $profile, + bool $locked = false, + string|Channel|null $channel = null, + ): void { + // Get key from DB (only during set, not reads) + $key = ConfigKey::byCode($keyCode); + + if ($key === null) { + throw new \InvalidArgumentException("Unknown config key: {$keyCode}"); + } + + // Validate value type against schema + $this->validateValueType($value, $key->type, $keyCode); + + $channelId = $this->resolveChannelId($channel, null); + + // Capture previous value for event + $previousValue = ConfigValue::findValue($profile->id, $key->id, $channelId)?->value; + + // Update source of truth + ConfigValue::setValue($profile->id, $key->id, $value, $locked, null, $channelId); + + // Re-prime affected scope + $workspaceId = match ($profile->scope_type) { + Enums\ScopeType::WORKSPACE => $profile->scope_id, + default => null, + }; + + $this->primeKey($keyCode, $workspaceId, $channelId); + + // Fire event for module hooks + ConfigChanged::dispatch($keyCode, $value, $previousValue, $profile, $channelId); + } + + /** + * Lock a config value (FINAL - child cannot override). + * Fires ConfigLocked event. + */ + public function lock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void + { + // Get key from DB (only during lock, not reads) + $key = ConfigKey::byCode($keyCode); + + if ($key === null) { + throw new \InvalidArgumentException("Unknown config key: {$keyCode}"); + } + + $channelId = $this->resolveChannelId($channel, null); + $value = ConfigValue::findValue($profile->id, $key->id, $channelId); + + if ($value === null) { + throw new \InvalidArgumentException("No value set for {$keyCode} in profile {$profile->id}"); + } + + $value->update(['locked' => true]); + + // Re-prime - lock affects all child scopes + $this->primeKey($keyCode); + + // Fire event for module hooks + ConfigLocked::dispatch($keyCode, $profile, $channelId); + } + + /** + * Unlock a config value. + */ + public function unlock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void + { + // Get key from DB (only during unlock, not reads) + $key = ConfigKey::byCode($keyCode); + + if ($key === null) { + return; + } + + $channelId = $this->resolveChannelId($channel, null); + $value = ConfigValue::findValue($profile->id, $key->id, $channelId); + + if ($value === null) { + return; + } + + $value->update(['locked' => false]); + + // Re-prime + $this->primeKey($keyCode); + } + + /** + * Register a virtual provider. + * + * Virtual providers supply config values from module data + * without database storage. + * + * @param string $pattern Key pattern (supports * wildcard) + * @param callable $provider fn(string $key, ?Workspace, ?Channel): mixed + */ + public function virtual(string $pattern, callable $provider): void + { + $this->resolver->registerProvider($pattern, $provider); + } + + /** + * Get all config values for a workspace. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @return array + */ + public function all(?object $workspace = null, string|Channel|null $channel = null): array + { + $workspaceId = $workspace?->id; + $channelId = $this->resolveChannelId($channel, $workspace); + + $resolved = ConfigResolved::forScope($workspaceId, $channelId); + + $values = []; + foreach ($resolved as $row) { + $values[$row->key_code] = $row->getTypedValue(); + } + + return $values; + } + + /** + * Get all config values for a category. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @return array + */ + public function category( + string $category, + ?object $workspace = null, + string|Channel|null $channel = null, + ): array { + $workspaceId = $workspace?->id; + $channelId = $this->resolveChannelId($channel, $workspace); + + // Escape LIKE wildcards to prevent unintended pattern matching + $escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category); + + $resolved = ConfigResolved::where('workspace_id', $workspaceId) + ->where('channel_id', $channelId) + ->where('key_code', 'LIKE', "{$escapedCategory}.%") + ->get(); + + $values = []; + foreach ($resolved as $row) { + $values[$row->key_code] = $row->getTypedValue(); + } + + return $values; + } + + /** + * Prime the resolved table for a workspace. + * + * This is THE computation - runs full resolution and stores results. + * Call after workspace creation, config changes, or on schedule. + * + * Populates both hash (process-scoped) and database (persistent). + * + * ## When to Call Prime + * + * - After creating a new workspace + * - After bulk config changes (migrations, imports) + * - From a scheduled job (`config:prime` command) + * - After significant profile hierarchy changes + * + * ## What Prime Does + * + * 1. Clears existing resolved values (hash + DB) for the scope + * 2. Runs full resolution for all config keys + * 3. Stores results in both hash and database + * 4. Marks hash as "loaded" to prevent re-loading + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + public function prime(?object $workspace = null, string|Channel|null $channel = null): void + { + $workspaceId = $workspace?->id; + $channelId = $this->resolveChannelId($channel, $workspace); + + // Clear existing resolved values (hash + DB) + ConfigResolver::clearAll(); + ConfigResolved::clearScope($workspaceId, $channelId); + + // Run full resolution + $results = $this->resolver->resolveAll($workspace, $channel); + + // Store all resolved values (hash + DB) + foreach ($results as $code => $result) { + // Store in hash (process-scoped) + ConfigResolver::set($code, $result->value); + + // Store in database (persistent) + ConfigResolved::store( + keyCode: $code, + value: $result->value, + type: $result->type, + workspaceId: $workspaceId, + channelId: $channelId, + locked: $result->locked, + sourceProfileId: $result->profileId, + sourceChannelId: $result->channelId, + virtual: $result->virtual, + ); + } + + // Mark hash as loaded + ConfigResolver::markLoaded(); + } + + /** + * Prime a single key across all affected scopes. + * + * Clears and re-computes a specific key in both hash and database. + */ + public function primeKey(string $keyCode, ?int $workspaceId = null, ?int $channelId = null): void + { + // Clear from hash (pattern match) + ConfigResolver::clear($keyCode); + + // Clear from database + ConfigResolved::where('key_code', $keyCode) + ->when($workspaceId !== null, fn ($q) => $q->where('workspace_id', $workspaceId)) + ->when($channelId !== null, fn ($q) => $q->where('channel_id', $channelId)) + ->delete(); + + // Re-compute this key for the affected scope + $workspace = null; + if ($workspaceId !== null && class_exists(\Core\Tenant\Models\Workspace::class)) { + $workspace = \Core\Tenant\Models\Workspace::find($workspaceId); + } + $channel = $channelId ? Channel::find($channelId) : null; + + $result = $this->resolver->resolve($keyCode, $workspace, $channel); + + // Store in hash (process-scoped) + ConfigResolver::set($keyCode, $result->value); + + // Store in database (persistent) + ConfigResolved::store( + keyCode: $keyCode, + value: $result->value, + type: $result->type, + workspaceId: $workspaceId, + channelId: $channelId, + locked: $result->locked, + sourceProfileId: $result->profileId, + sourceChannelId: $result->channelId, + virtual: $result->virtual, + ); + } + + /** + * Prime cache for all workspaces. + * + * Run this from a scheduled command or queue job. + * Requires Core\Tenant module to prime workspace-level config. + */ + public function primeAll(): void + { + // Prime system config + $this->prime(null); + + // Prime each workspace (requires Tenant module) + if (class_exists(\Core\Tenant\Models\Workspace::class)) { + \Core\Tenant\Models\Workspace::chunk(100, function ($workspaces) { + foreach ($workspaces as $workspace) { + $this->prime($workspace); + } + }); + } + } + + /** + * Invalidate (clear) resolved config for a workspace. + * + * Clears both hash and database. Next read will lazy-prime. + * Fires ConfigInvalidated event. + * + * ## Cache Invalidation Behaviour + * + * This method performs a "soft" invalidation: + * - Clears the in-memory hash (immediate effect) + * - Clears the database resolved table (persistent effect) + * - Does NOT re-compute values immediately + * - Values are lazy-loaded on next read (lazy-prime) + * + * Use `prime()` instead if you need immediate re-computation. + * + * ## Listening for Invalidation + * + * ```php + * use Core\Config\Events\ConfigInvalidated; + * + * public function handle(ConfigInvalidated $event): void + * { + * if ($event->isFull()) { + * // Full invalidation - clear all module caches + * } elseif ($event->affectsKey('mymodule.setting')) { + * // Specific key was invalidated + * } + * } + * ``` + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + public function invalidateWorkspace(?object $workspace = null): void + { + $workspaceId = $workspace?->id; + + // Clear hash (process-scoped) + ConfigResolver::clearAll(); + + // Clear database (persistent) + ConfigResolved::clearWorkspace($workspaceId); + + ConfigInvalidated::dispatch(null, $workspaceId, null); + } + + /** + * Invalidate (clear) resolved config for a key. + * + * Clears both hash and database. Next read will lazy-prime. + * Fires ConfigInvalidated event. + */ + public function invalidateKey(string $key): void + { + // Clear hash (process-scoped) + ConfigResolver::clear($key); + + // Clear database (persistent) + ConfigResolved::clearKey($key); + + ConfigInvalidated::dispatch($key, null, null); + } + + /** + * Resolve channel to ID. + */ + protected function resolveChannelId(string|Channel|null $channel, ?Workspace $workspace): ?int + { + if ($channel === null) { + return null; + } + + if ($channel instanceof Channel) { + return $channel->id; + } + + $channelModel = Channel::byCode($channel, $workspace?->id); + + return $channelModel?->id; + } + + /** + * Ensure a config key exists (for dynamic registration). + */ + public function ensureKey( + string $code, + ConfigType $type, + string $category, + ?string $description = null, + mixed $default = null, + ): ConfigKey { + return ConfigKey::firstOrCreate( + ['code' => $code], + [ + 'type' => $type, + 'category' => $category, + 'description' => $description, + 'default_value' => $default, + ] + ); + } + + /** + * Register a config key if it doesn't exist. + * + * Convenience method for Boot.php files. + * Note: This persists to database. For runtime-only values, use provide(). + */ + public function register( + string $code, + string $type, + string $category, + ?string $description = null, + mixed $default = null, + ): void { + $this->ensureKey($code, ConfigType::from($type), $category, $description, $default); + } + + /** + * Provide a runtime value. + * + * Modules call this to share settings with other code in the process. + * Process-scoped, not persisted to database. Dies with the request. + * + * Usage in Boot.php: + * $config->provide('mymodule.api_key', env('MYMODULE_API_KEY')); + * $config->provide('mymodule.timeout', 30, 'int'); + * + * @param string $code Key code (e.g., 'mymodule.api_key') + * @param mixed $value The value + * @param string|ConfigType $type Value type for casting (currently unused, value stored as-is) + */ + public function provide(string $code, mixed $value, string|ConfigType $type = 'string'): void + { + // Runtime values just go in the hash (system scope) + ConfigResolver::set($code, $value); + } + + /** + * Check if a runtime value has been provided. + */ + public function hasProvided(string $code): bool + { + return ConfigResolver::has($code); + } + + /** + * Validate that a value matches the expected config type. + * + * @throws \InvalidArgumentException If value type is invalid + */ + protected function validateValueType(mixed $value, ConfigType $type, string $keyCode): void + { + // Null is allowed for any type (represents unset) + if ($value === null) { + return; + } + + $valid = match ($type) { + ConfigType::STRING => is_string($value) || is_numeric($value), + ConfigType::BOOL => is_bool($value) || in_array($value, [0, 1, '0', '1', 'true', 'false'], true), + ConfigType::INT => is_int($value) || (is_string($value) && ctype_digit(ltrim($value, '-'))), + ConfigType::FLOAT => is_float($value) || is_int($value) || is_numeric($value), + ConfigType::ARRAY, ConfigType::JSON => is_array($value), + }; + + if (! $valid) { + $actualType = get_debug_type($value); + throw new \InvalidArgumentException( + "Invalid value type for config key '{$keyCode}': expected {$type->value}, got {$actualType}" + ); + } + } +} diff --git a/app/Core/Config/ConfigVersioning.php b/app/Core/Config/ConfigVersioning.php new file mode 100644 index 0000000..325b9ee --- /dev/null +++ b/app/Core/Config/ConfigVersioning.php @@ -0,0 +1,355 @@ +createVersion($workspace, 'Before CDN migration'); + * + * // Make changes... + * $config->set('cdn.provider', 'bunny', $profile); + * + * // Rollback if needed + * $versioning->rollback($version->id, $workspace); + * + * // Compare versions + * $diff = $versioning->compare($workspace, $oldVersionId, $newVersionId); + * ``` + * + * ## Version Structure + * + * Each version stores: + * - Scope (workspace/system) + * - Timestamp + * - Label/description + * - Full snapshot of all config values + * - Author (if available) + * + * @see ConfigService For runtime config access + * @see ConfigExporter For import/export operations + */ +class ConfigVersioning +{ + /** + * Maximum versions to keep per scope (configurable). + */ + protected int $maxVersions; + + public function __construct( + protected ConfigService $config, + protected ConfigExporter $exporter, + ) { + $this->maxVersions = (int) config('core.config.max_versions', 50); + } + + /** + * Create a new config version (snapshot). + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param string $label Version label/description + * @param string|null $author Author identifier (user ID, email, etc.) + * @return ConfigVersion The created version + */ + public function createVersion( + ?object $workspace = null, + string $label = '', + ?string $author = null, + ): ConfigVersion { + $profile = $this->getOrCreateProfile($workspace); + + // Get current config as JSON snapshot + $snapshot = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false); + + $version = ConfigVersion::create([ + 'profile_id' => $profile->id, + 'workspace_id' => $workspace?->id, + 'label' => $label ?: 'Version '.now()->format('Y-m-d H:i:s'), + 'snapshot' => $snapshot, + 'author' => $author ?? $this->getCurrentAuthor(), + 'created_at' => now(), + ]); + + // Enforce retention policy + $this->pruneOldVersions($profile->id); + + return $version; + } + + /** + * Rollback to a specific version. + * + * @param int $versionId Version ID to rollback to + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $createBackup Create a backup version before rollback (default: true) + * @return ImportResult Import result with stats + * + * @throws \InvalidArgumentException If version not found or scope mismatch + */ + public function rollback( + int $versionId, + ?object $workspace = null, + bool $createBackup = true, + ): ImportResult { + $version = ConfigVersion::find($versionId); + + if ($version === null) { + throw new \InvalidArgumentException("Version not found: {$versionId}"); + } + + // Verify scope matches + $workspaceId = $workspace?->id; + if ($version->workspace_id !== $workspaceId) { + throw new \InvalidArgumentException('Version scope does not match target scope'); + } + + // Create backup before rollback + if ($createBackup) { + $this->createVersion($workspace, 'Backup before rollback to version '.$versionId); + } + + // Import the snapshot + return $this->exporter->importJson($version->snapshot, $workspace); + } + + /** + * Get all versions for a scope. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param int $limit Maximum versions to return + * @return Collection + */ + public function getVersions(?object $workspace = null, int $limit = 20): Collection + { + $workspaceId = $workspace?->id; + + return ConfigVersion::where('workspace_id', $workspaceId) + ->orderByDesc('created_at') + ->limit($limit) + ->get(); + } + + /** + * Get a specific version. + * + * @param int $versionId Version ID + */ + public function getVersion(int $versionId): ?ConfigVersion + { + return ConfigVersion::find($versionId); + } + + /** + * Compare two versions. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param int $oldVersionId Older version ID + * @param int $newVersionId Newer version ID + * @return VersionDiff Difference between versions + * + * @throws \InvalidArgumentException If versions not found + */ + public function compare(?object $workspace, int $oldVersionId, int $newVersionId): VersionDiff + { + $oldVersion = ConfigVersion::find($oldVersionId); + $newVersion = ConfigVersion::find($newVersionId); + + if ($oldVersion === null) { + throw new \InvalidArgumentException("Old version not found: {$oldVersionId}"); + } + + if ($newVersion === null) { + throw new \InvalidArgumentException("New version not found: {$newVersionId}"); + } + + // Parse snapshots + $oldData = json_decode($oldVersion->snapshot, true)['values'] ?? []; + $newData = json_decode($newVersion->snapshot, true)['values'] ?? []; + + return $this->computeDiff($oldData, $newData); + } + + /** + * Compare current state with a version. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param int $versionId Version ID to compare against + * @return VersionDiff Difference between version and current state + * + * @throws \InvalidArgumentException If version not found + */ + public function compareWithCurrent(?object $workspace, int $versionId): VersionDiff + { + $version = ConfigVersion::find($versionId); + + if ($version === null) { + throw new \InvalidArgumentException("Version not found: {$versionId}"); + } + + // Get current state + $currentJson = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false); + $currentData = json_decode($currentJson, true)['values'] ?? []; + + // Get version state + $versionData = json_decode($version->snapshot, true)['values'] ?? []; + + return $this->computeDiff($versionData, $currentData); + } + + /** + * Compute difference between two value arrays. + * + * @param array $oldValues + * @param array $newValues + */ + protected function computeDiff(array $oldValues, array $newValues): VersionDiff + { + $diff = new VersionDiff; + + // Index by key + $oldByKey = collect($oldValues)->keyBy('key'); + $newByKey = collect($newValues)->keyBy('key'); + + // Find added keys (in new but not in old) + foreach ($newByKey as $key => $newValue) { + if (! $oldByKey->has($key)) { + $diff->addAdded($key, $newValue['value']); + } + } + + // Find removed keys (in old but not in new) + foreach ($oldByKey as $key => $oldValue) { + if (! $newByKey->has($key)) { + $diff->addRemoved($key, $oldValue['value']); + } + } + + // Find changed keys (in both but different) + foreach ($oldByKey as $key => $oldValue) { + if ($newByKey->has($key)) { + $newValue = $newByKey[$key]; + if ($oldValue['value'] !== $newValue['value']) { + $diff->addChanged($key, $oldValue['value'], $newValue['value']); + } + if (($oldValue['locked'] ?? false) !== ($newValue['locked'] ?? false)) { + $diff->addLockChanged($key, $oldValue['locked'] ?? false, $newValue['locked'] ?? false); + } + } + } + + return $diff; + } + + /** + * Delete a version. + * + * @param int $versionId Version ID + * + * @throws \InvalidArgumentException If version not found + */ + public function deleteVersion(int $versionId): void + { + $version = ConfigVersion::find($versionId); + + if ($version === null) { + throw new \InvalidArgumentException("Version not found: {$versionId}"); + } + + $version->delete(); + } + + /** + * Prune old versions beyond retention limit. + * + * @param int $profileId Profile ID + */ + protected function pruneOldVersions(int $profileId): void + { + $versions = ConfigVersion::where('profile_id', $profileId) + ->orderByDesc('created_at') + ->get(); + + if ($versions->count() > $this->maxVersions) { + $toDelete = $versions->slice($this->maxVersions); + foreach ($toDelete as $version) { + $version->delete(); + } + } + } + + /** + * Get or create profile for a workspace (or system). + */ + protected function getOrCreateProfile(?object $workspace): ConfigProfile + { + if ($workspace !== null) { + return ConfigProfile::ensureWorkspace($workspace->id); + } + + return ConfigProfile::ensureSystem(); + } + + /** + * Get current author for version attribution. + */ + protected function getCurrentAuthor(): ?string + { + // Try to get authenticated user + if (function_exists('auth') && auth()->check()) { + $user = auth()->user(); + + return $user->email ?? $user->name ?? (string) $user->id; + } + + // Return null if no user context + return null; + } + + /** + * Set maximum versions to keep per scope. + * + * @param int $max Maximum versions + */ + public function setMaxVersions(int $max): void + { + $this->maxVersions = max(1, $max); + } + + /** + * Get maximum versions to keep per scope. + */ + public function getMaxVersions(): int + { + return $this->maxVersions; + } +} diff --git a/app/Core/Config/Console/CLAUDE.md b/app/Core/Config/Console/CLAUDE.md new file mode 100644 index 0000000..c2178f0 --- /dev/null +++ b/app/Core/Config/Console/CLAUDE.md @@ -0,0 +1,13 @@ +# Config/Console/ — Config Artisan Commands + +Artisan commands for managing the hierarchical configuration system. + +## Commands + +| Command | Signature | Purpose | +|---------|-----------|---------| +| `ConfigListCommand` | `config:list` | List config keys with resolved values. Filters by workspace, category, or configured-only. | +| `ConfigPrimeCommand` | `config:prime` | Materialise resolved config into the fast-read table. Primes system and/or specific workspace. | +| `ConfigExportCommand` | `config:export` | Export config to JSON or YAML file. Supports workspace scope and sensitive value inclusion. | +| `ConfigImportCommand` | `config:import` | Import config from JSON or YAML file. Supports dry-run mode and automatic version snapshots. | +| `ConfigVersionCommand` | `config:version` | Manage config versions — list, create snapshots, show, rollback, and compare versions. | diff --git a/app/Core/Config/Console/ConfigExportCommand.php b/app/Core/Config/Console/ConfigExportCommand.php new file mode 100644 index 0000000..2eb83b8 --- /dev/null +++ b/app/Core/Config/Console/ConfigExportCommand.php @@ -0,0 +1,114 @@ +argument('file'); + $workspaceSlug = $this->option('workspace'); + $category = $this->option('category'); + $includeSensitive = $this->option('include-sensitive'); + $includeKeys = ! $this->option('no-keys'); + + // Resolve workspace + $workspace = null; + if ($workspaceSlug) { + if (! class_exists(Workspace::class)) { + $this->components->error('Tenant module not installed. Cannot export workspace config.'); + + return self::FAILURE; + } + + $workspace = Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->components->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + } + + // Warn about sensitive data + if ($includeSensitive) { + $this->components->warn('WARNING: Export will include sensitive values. Handle the file securely!'); + + if (! $this->confirm('Are you sure you want to include sensitive values?')) { + $this->components->info('Export cancelled.'); + + return self::SUCCESS; + } + } + + // Determine format from extension + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $format = match ($extension) { + 'yaml', 'yml' => 'YAML', + default => 'JSON', + }; + + $this->components->task("Exporting {$format} config", function () use ($exporter, $file, $workspace, $includeSensitive, $includeKeys, $category) { + $content = match (strtolower(pathinfo($file, PATHINFO_EXTENSION))) { + 'yaml', 'yml' => $exporter->exportYaml($workspace, $includeSensitive, $includeKeys, $category), + default => $exporter->exportJson($workspace, $includeSensitive, $includeKeys, $category), + }; + + file_put_contents($file, $content); + }); + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + $this->components->info("Config exported to {$file} ({$scope})"); + + return self::SUCCESS; + } + + /** + * Get autocompletion suggestions. + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('workspace')) { + if (class_exists(Workspace::class)) { + $suggestions->suggestValues(Workspace::pluck('slug')->toArray()); + } + } + + if ($input->mustSuggestOptionValuesFor('category')) { + $suggestions->suggestValues(ConfigKey::distinct()->pluck('category')->toArray()); + } + } +} diff --git a/app/Core/Config/Console/ConfigImportCommand.php b/app/Core/Config/Console/ConfigImportCommand.php new file mode 100644 index 0000000..dc0ed83 --- /dev/null +++ b/app/Core/Config/Console/ConfigImportCommand.php @@ -0,0 +1,187 @@ +argument('file'); + $workspaceSlug = $this->option('workspace'); + $dryRun = $this->option('dry-run'); + $skipBackup = $this->option('no-backup'); + $force = $this->option('force'); + + // Check file exists + if (! file_exists($file)) { + $this->components->error("File not found: {$file}"); + + return self::FAILURE; + } + + // Resolve workspace + $workspace = null; + if ($workspaceSlug) { + if (! class_exists(Workspace::class)) { + $this->components->error('Tenant module not installed. Cannot import workspace config.'); + + return self::FAILURE; + } + + $workspace = Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->components->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + } + + // Read file content + $content = file_get_contents($file); + if ($content === false) { + $this->components->error("Failed to read file: {$file}"); + + return self::FAILURE; + } + + // Determine format from extension + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $format = match ($extension) { + 'yaml', 'yml' => 'YAML', + default => 'JSON', + }; + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + + if ($dryRun) { + $this->components->info("Dry-run import from {$file} ({$scope}):"); + } else { + if (! $force) { + $this->components->warn("This will import config from {$file} to {$scope}."); + + if (! $this->confirm('Are you sure you want to continue?')) { + $this->components->info('Import cancelled.'); + + return self::SUCCESS; + } + } + + // Create backup before import + if (! $skipBackup && ! $dryRun) { + $this->components->task('Creating backup version', function () use ($versioning, $workspace, $file) { + $versioning->createVersion( + $workspace, + 'Backup before import from '.basename($file) + ); + }); + } + } + + // Perform import + $result = null; + $this->components->task("Importing {$format} config", function () use ($exporter, $content, $extension, $workspace, $dryRun, &$result) { + $result = match ($extension) { + 'yaml', 'yml' => $exporter->importYaml($content, $workspace, $dryRun), + default => $exporter->importJson($content, $workspace, $dryRun), + }; + }); + + // Show results + $this->newLine(); + + if ($dryRun) { + $this->components->info('Dry-run results (no changes applied):'); + } + + // Display created items + if ($result->createdCount() > 0) { + $this->components->twoColumnDetail('Created', $result->createdCount().' items'); + foreach ($result->getCreated() as $item) { + $this->components->bulletList(["{$item['type']}: {$item['code']}"]); + } + } + + // Display updated items + if ($result->updatedCount() > 0) { + $this->components->twoColumnDetail('Updated', $result->updatedCount().' items'); + foreach ($result->getUpdated() as $item) { + $this->components->bulletList(["{$item['type']}: {$item['code']}"]); + } + } + + // Display skipped items + if ($result->skippedCount() > 0) { + $this->components->twoColumnDetail('Skipped', $result->skippedCount().' items'); + foreach ($result->getSkipped() as $reason) { + $this->components->bulletList([$reason]); + } + } + + // Display errors + if ($result->hasErrors()) { + $this->newLine(); + $this->components->error('Errors:'); + foreach ($result->getErrors() as $error) { + $this->components->bulletList(["{$error}"]); + } + + return self::FAILURE; + } + + $this->newLine(); + + if ($dryRun) { + $this->components->info("Dry-run complete: {$result->getSummary()}"); + } else { + $this->components->info("Import complete: {$result->getSummary()}"); + } + + return self::SUCCESS; + } + + /** + * Get autocompletion suggestions. + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('workspace')) { + if (class_exists(Workspace::class)) { + $suggestions->suggestValues(Workspace::pluck('slug')->toArray()); + } + } + } +} diff --git a/app/Core/Config/Console/ConfigListCommand.php b/app/Core/Config/Console/ConfigListCommand.php new file mode 100644 index 0000000..28f4398 --- /dev/null +++ b/app/Core/Config/Console/ConfigListCommand.php @@ -0,0 +1,107 @@ +option('workspace'); + $category = $this->option('category'); + $configuredOnly = $this->option('configured'); + + $workspace = null; + + if ($workspaceSlug) { + if (! class_exists(Workspace::class)) { + $this->error('Tenant module not installed. Cannot filter by workspace.'); + + return self::FAILURE; + } + + $workspace = Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + + $this->info("Config for workspace: {$workspace->slug}"); + } else { + $this->info('System config:'); + } + + $this->newLine(); + + $query = ConfigKey::query(); + + if ($category) { + $query->where('category', $category); + } + + $keys = $query->orderBy('category')->orderBy('code')->get(); + + $rows = []; + + foreach ($keys as $key) { + $result = $config->resolve($key->code, $workspace); + + if ($configuredOnly && ! $result->isConfigured()) { + continue; + } + + $value = $result->get(); + $displayValue = match (true) { + is_null($value) => 'null', + is_bool($value) => $value ? 'true' : 'false', + is_array($value) => '[array]', + is_string($value) && strlen($value) > 40 => substr($value, 0, 37).'...', + default => (string) $value, + }; + + $rows[] = [ + $key->code, + $key->category, + $key->type->value, + $displayValue, + $result->isLocked() ? 'LOCKED' : '', + $result->resolvedFrom?->value ?? 'default', + ]; + } + + if (empty($rows)) { + $this->warn('No config keys found.'); + + return self::SUCCESS; + } + + $this->table( + ['Key', 'Category', 'Type', 'Value', 'Status', 'Source'], + $rows + ); + + return self::SUCCESS; + } +} diff --git a/app/Core/Config/Console/ConfigPrimeCommand.php b/app/Core/Config/Console/ConfigPrimeCommand.php new file mode 100644 index 0000000..91bcc0c --- /dev/null +++ b/app/Core/Config/Console/ConfigPrimeCommand.php @@ -0,0 +1,80 @@ +argument('workspace'); + $systemOnly = $this->option('system'); + + if ($systemOnly) { + $this->info('Priming system config cache...'); + $config->prime(null); + $this->info('System config cached.'); + + return self::SUCCESS; + } + + if ($workspaceSlug) { + if (! class_exists(Workspace::class)) { + $this->error('Tenant module not installed. Cannot prime workspace config.'); + + return self::FAILURE; + } + + $workspace = Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + + $this->info("Priming config cache for workspace: {$workspace->slug}"); + $config->prime($workspace); + $this->info('Workspace config cached.'); + + return self::SUCCESS; + } + + $this->info('Priming config cache for all workspaces...'); + + if (! class_exists(Workspace::class)) { + $this->warn('Tenant module not installed. Only priming system config.'); + $config->prime(null); + $this->info('System config cached.'); + + return self::SUCCESS; + } + + $this->withProgressBar(Workspace::all(), function ($workspace) use ($config) { + $config->prime($workspace); + }); + + $this->newLine(); + $this->info('All config caches primed.'); + + return self::SUCCESS; + } +} diff --git a/app/Core/Config/Console/ConfigVersionCommand.php b/app/Core/Config/Console/ConfigVersionCommand.php new file mode 100644 index 0000000..027f6c2 --- /dev/null +++ b/app/Core/Config/Console/ConfigVersionCommand.php @@ -0,0 +1,420 @@ +argument('action'); + $arg1 = $this->argument('arg1'); + $arg2 = $this->argument('arg2'); + $workspaceSlug = $this->option('workspace'); + + // Resolve workspace + $workspace = null; + if ($workspaceSlug) { + if (! class_exists(Workspace::class)) { + $this->components->error('Tenant module not installed. Cannot manage workspace versions.'); + + return self::FAILURE; + } + + $workspace = Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->components->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + } + + return match ($action) { + 'list' => $this->listVersions($versioning, $workspace), + 'create' => $this->createVersion($versioning, $workspace, $arg1), + 'show' => $this->showVersion($versioning, $arg1), + 'rollback' => $this->rollbackVersion($versioning, $workspace, $arg1), + 'compare' => $this->compareVersions($versioning, $workspace, $arg1, $arg2), + 'diff' => $this->diffWithCurrent($versioning, $workspace, $arg1), + 'delete' => $this->deleteVersion($versioning, $arg1), + default => $this->invalidAction($action), + }; + } + + /** + * List versions. + */ + protected function listVersions(ConfigVersioning $versioning, ?object $workspace): int + { + $limit = (int) $this->option('limit'); + $versions = $versioning->getVersions($workspace, $limit); + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + $this->components->info("Config versions for {$scope}:"); + + if ($versions->isEmpty()) { + $this->components->warn('No versions found.'); + + return self::SUCCESS; + } + + $rows = $versions->map(fn (ConfigVersion $v) => [ + $v->id, + $v->label, + $v->author ?? '-', + $v->created_at->format('Y-m-d H:i:s'), + $v->created_at->diffForHumans(), + ])->toArray(); + + $this->table( + ['ID', 'Label', 'Author', 'Created', 'Age'], + $rows + ); + + return self::SUCCESS; + } + + /** + * Create a new version. + */ + protected function createVersion(ConfigVersioning $versioning, ?object $workspace, ?string $label): int + { + $label = $label ?? 'Manual snapshot'; + + $version = null; + $this->components->task("Creating version: {$label}", function () use ($versioning, $workspace, $label, &$version) { + $version = $versioning->createVersion($workspace, $label); + }); + + $this->components->info("Version created: ID {$version->id}"); + + return self::SUCCESS; + } + + /** + * Show version details. + */ + protected function showVersion(ConfigVersioning $versioning, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $version = $versioning->getVersion((int) $versionId); + + if ($version === null) { + $this->components->error("Version not found: {$versionId}"); + + return self::FAILURE; + } + + $this->components->info("Version #{$version->id}: {$version->label}"); + $this->components->twoColumnDetail('Created', $version->created_at->format('Y-m-d H:i:s')); + $this->components->twoColumnDetail('Author', $version->author ?? '-'); + $this->components->twoColumnDetail('Workspace ID', $version->workspace_id ?? 'system'); + + $values = $version->getValues(); + $this->newLine(); + $this->components->info('Values ('.count($values).' items):'); + + $rows = array_map(function ($v) { + $displayValue = match (true) { + is_array($v['value']) => '[array]', + is_null($v['value']) => 'null', + is_bool($v['value']) => $v['value'] ? 'true' : 'false', + is_string($v['value']) && strlen($v['value']) > 40 => substr($v['value'], 0, 37).'...', + default => (string) $v['value'], + }; + + return [ + $v['key'], + $displayValue, + $v['locked'] ?? false ? 'LOCKED' : '', + ]; + }, $values); + + $this->table(['Key', 'Value', 'Status'], $rows); + + return self::SUCCESS; + } + + /** + * Rollback to a version. + */ + protected function rollbackVersion(ConfigVersioning $versioning, ?object $workspace, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $version = $versioning->getVersion((int) $versionId); + + if ($version === null) { + $this->components->error("Version not found: {$versionId}"); + + return self::FAILURE; + } + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + + if (! $this->option('force')) { + $this->components->warn("This will restore config to version #{$version->id}: {$version->label}"); + $this->components->warn("Scope: {$scope}"); + + if (! $this->confirm('Are you sure you want to rollback?')) { + $this->components->info('Rollback cancelled.'); + + return self::SUCCESS; + } + } + + $createBackup = ! $this->option('no-backup'); + $result = null; + + $this->components->task('Rolling back config', function () use ($versioning, $workspace, $versionId, $createBackup, &$result) { + $result = $versioning->rollback((int) $versionId, $workspace, $createBackup); + }); + + $this->newLine(); + $this->components->info("Rollback complete: {$result->getSummary()}"); + + if ($createBackup) { + $this->components->info('A backup version was created before rollback.'); + } + + return self::SUCCESS; + } + + /** + * Compare two versions. + */ + protected function compareVersions(ConfigVersioning $versioning, ?object $workspace, ?string $oldId, ?string $newId): int + { + if ($oldId === null || $newId === null) { + $this->components->error('Two version IDs required for comparison.'); + + return self::FAILURE; + } + + $diff = $versioning->compare($workspace, (int) $oldId, (int) $newId); + + $this->components->info("Comparing version #{$oldId} to #{$newId}:"); + $this->newLine(); + + if ($diff->isEmpty()) { + $this->components->info('No differences found.'); + + return self::SUCCESS; + } + + $this->displayDiff($diff); + + return self::SUCCESS; + } + + /** + * Compare version with current state. + */ + protected function diffWithCurrent(ConfigVersioning $versioning, ?object $workspace, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $diff = $versioning->compareWithCurrent($workspace, (int) $versionId); + + $this->components->info("Comparing version #{$versionId} to current state:"); + $this->newLine(); + + if ($diff->isEmpty()) { + $this->components->info('No differences found. Current state matches the version.'); + + return self::SUCCESS; + } + + $this->displayDiff($diff); + + return self::SUCCESS; + } + + /** + * Display a diff. + */ + protected function displayDiff(VersionDiff $diff): void + { + $this->components->info("Summary: {$diff->getSummary()}"); + $this->newLine(); + + // Added + if (count($diff->getAdded()) > 0) { + $this->components->twoColumnDetail('Added', count($diff->getAdded()).' keys'); + foreach ($diff->getAdded() as $item) { + $this->line(" + {$item['key']}"); + } + $this->newLine(); + } + + // Removed + if (count($diff->getRemoved()) > 0) { + $this->components->twoColumnDetail('Removed', count($diff->getRemoved()).' keys'); + foreach ($diff->getRemoved() as $item) { + $this->line(" - {$item['key']}"); + } + $this->newLine(); + } + + // Changed + if (count($diff->getChanged()) > 0) { + $this->components->twoColumnDetail('Changed', count($diff->getChanged()).' keys'); + foreach ($diff->getChanged() as $item) { + $oldDisplay = $this->formatValue($item['old']); + $newDisplay = $this->formatValue($item['new']); + $this->line(" ~ {$item['key']}"); + $this->line(" old: {$oldDisplay}"); + $this->line(" new: {$newDisplay}"); + } + $this->newLine(); + } + + // Lock changes + if (count($diff->getLockChanged()) > 0) { + $this->components->twoColumnDetail('Lock Changed', count($diff->getLockChanged()).' keys'); + foreach ($diff->getLockChanged() as $item) { + $oldLock = $item['old'] ? 'LOCKED' : 'unlocked'; + $newLock = $item['new'] ? 'LOCKED' : 'unlocked'; + $this->line(" * {$item['key']}: {$oldLock} -> {$newLock}"); + } + } + } + + /** + * Format a value for display. + */ + protected function formatValue(mixed $value): string + { + return match (true) { + is_array($value) => '[array]', + is_null($value) => 'null', + is_bool($value) => $value ? 'true' : 'false', + is_string($value) && strlen($value) > 50 => '"'.substr($value, 0, 47).'..."', + default => (string) $value, + }; + } + + /** + * Delete a version. + */ + protected function deleteVersion(ConfigVersioning $versioning, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $version = $versioning->getVersion((int) $versionId); + + if ($version === null) { + $this->components->error("Version not found: {$versionId}"); + + return self::FAILURE; + } + + if (! $this->option('force')) { + $this->components->warn("This will permanently delete version #{$version->id}: {$version->label}"); + + if (! $this->confirm('Are you sure you want to delete this version?')) { + $this->components->info('Delete cancelled.'); + + return self::SUCCESS; + } + } + + $versioning->deleteVersion((int) $versionId); + $this->components->info("Version #{$versionId} deleted."); + + return self::SUCCESS; + } + + /** + * Handle invalid action. + */ + protected function invalidAction(string $action): int + { + $this->components->error("Invalid action: {$action}"); + $this->newLine(); + $this->components->info('Available actions:'); + $this->components->bulletList([ + 'list - List all versions', + 'create - Create a new version snapshot', + 'show - Show version details', + 'rollback - Restore config to a version', + 'compare - Compare two versions', + 'diff - Compare version with current state', + 'delete - Delete a version', + ]); + + return self::FAILURE; + } + + /** + * Get autocompletion suggestions. + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('action')) { + $suggestions->suggestValues(['list', 'create', 'show', 'rollback', 'compare', 'diff', 'delete']); + } + + if ($input->mustSuggestOptionValuesFor('workspace')) { + if (class_exists(Workspace::class)) { + $suggestions->suggestValues(Workspace::pluck('slug')->toArray()); + } + } + } +} diff --git a/app/Core/Config/Contracts/CLAUDE.md b/app/Core/Config/Contracts/CLAUDE.md new file mode 100644 index 0000000..94bd0a3 --- /dev/null +++ b/app/Core/Config/Contracts/CLAUDE.md @@ -0,0 +1,9 @@ +# Config/Contracts/ — Config System Interfaces + +## Interfaces + +| Interface | Purpose | +|-----------|---------| +| `ConfigProvider` | Virtual configuration provider. Supplies config values at runtime without database storage. Matched against key patterns (e.g., `bio.*`). Registered via `ConfigResolver::registerProvider()`. | + +Providers implement `pattern()` (wildcard key matching) and `resolve()` (returns the value for a given key, workspace, and channel context). Returns `null` to fall through to database resolution. diff --git a/app/Core/Config/Contracts/ConfigProvider.php b/app/Core/Config/Contracts/ConfigProvider.php new file mode 100644 index 0000000..26b7783 --- /dev/null +++ b/app/Core/Config/Contracts/ConfigProvider.php @@ -0,0 +1,108 @@ +registerProvider('bio.*', new BioConfigProvider()); + * ``` + * + * ## Example Implementation + * + * ```php + * class BioConfigProvider implements ConfigProvider + * { + * public function pattern(): string + * { + * return 'bio.*'; + * } + * + * public function resolve( + * string $keyCode, + * ?object $workspace, + * string|Channel|null $channel + * ): mixed { + * // Extract the specific key (e.g., "bio.theme" -> "theme") + * $subKey = substr($keyCode, 4); + * + * return match ($subKey) { + * 'theme' => $this->getTheme($workspace), + * 'layout' => $this->getLayout($workspace), + * default => null, + * }; + * } + * } + * ``` + * + * + * @see ConfigResolver::registerProvider() + */ +interface ConfigProvider +{ + /** + * Get the key pattern this provider handles. + * + * Supports wildcards: + * - `*` matches any characters + * - `bio.*` matches "bio.theme", "bio.colors.primary", etc. + * + * @return string The key pattern (e.g., 'bio.*', 'theme.colors.*') + */ + public function pattern(): string; + + /** + * Resolve a config value for the given key. + * + * Called when a key matches this provider's pattern. Return null if the + * provider cannot supply a value for this specific key, allowing other + * providers or the database to supply the value. + * + * @param string $keyCode The full config key being resolved + * @param object|null $workspace Workspace model instance or null for system scope + * @param string|Channel|null $channel Channel code or object + * @return mixed The config value, or null if not provided + */ + public function resolve( + string $keyCode, + ?object $workspace, + string|Channel|null $channel + ): mixed; +} diff --git a/app/Core/Config/Database/Seeders/CLAUDE.md b/app/Core/Config/Database/Seeders/CLAUDE.md new file mode 100644 index 0000000..70d58fe --- /dev/null +++ b/app/Core/Config/Database/Seeders/CLAUDE.md @@ -0,0 +1,9 @@ +# Config/Database/Seeders/ — Config Key Seeders + +## Files + +| File | Purpose | +|------|---------| +| `ConfigKeySeeder.php` | Seeds known configuration keys into the `config_keys` table. Defines CDN (Bunny), storage (Hetzner S3), social, analytics, and bio settings with their types, categories, and defaults. Uses `firstOrCreate` for idempotency. | + +Part of the Config subsystem's M1 layer (key definitions). These keys are then assigned values via `ConfigValue` at system or workspace scope. diff --git a/app/Core/Config/Database/Seeders/ConfigKeySeeder.php b/app/Core/Config/Database/Seeders/ConfigKeySeeder.php new file mode 100644 index 0000000..d47d3d5 --- /dev/null +++ b/app/Core/Config/Database/Seeders/ConfigKeySeeder.php @@ -0,0 +1,87 @@ + $key[0]], + [ + 'type' => $key[1], + 'category' => $key[2], + 'description' => $key[3] ?? null, + 'default_value' => $key[4] ?? null, + ] + ); + } + } +} diff --git a/app/Core/Config/Enums/CLAUDE.md b/app/Core/Config/Enums/CLAUDE.md new file mode 100644 index 0000000..1a5f3f8 --- /dev/null +++ b/app/Core/Config/Enums/CLAUDE.md @@ -0,0 +1,12 @@ +# Config/Enums/ — Config Type System + +Backed enums for the configuration system's type safety and scope hierarchy. + +## Enums + +| Enum | Values | Purpose | +|------|--------|---------| +| `ConfigType` | STRING, BOOL, INT, FLOAT, ARRAY, JSON | Determines how config values are cast and validated. Has `cast()` and `default()` methods. | +| `ScopeType` | SYSTEM, ORG, WORKSPACE | Defines the inheritance hierarchy. Resolution order: workspace (priority 20) > org (10) > system (0). | + +`ScopeType::resolutionOrder()` returns scopes from most specific to least specific for cascade resolution. diff --git a/app/Core/Config/Enums/ConfigType.php b/app/Core/Config/Enums/ConfigType.php new file mode 100644 index 0000000..9df22d1 --- /dev/null +++ b/app/Core/Config/Enums/ConfigType.php @@ -0,0 +1,60 @@ + (string) $value, + self::BOOL => filter_var($value, FILTER_VALIDATE_BOOLEAN), + self::INT => (int) $value, + self::FLOAT => (float) $value, + self::ARRAY => is_array($value) ? $value : json_decode($value, true) ?? [], + self::JSON => is_string($value) ? json_decode($value, true) : $value, + }; + } + + /** + * Get default value for this type. + */ + public function default(): mixed + { + return match ($this) { + self::STRING => '', + self::BOOL => false, + self::INT => 0, + self::FLOAT => 0.0, + self::ARRAY, self::JSON => [], + }; + } +} diff --git a/app/Core/Config/Enums/ScopeType.php b/app/Core/Config/Enums/ScopeType.php new file mode 100644 index 0000000..cca983d --- /dev/null +++ b/app/Core/Config/Enums/ScopeType.php @@ -0,0 +1,52 @@ + 0, + self::ORG => 10, + self::WORKSPACE => 20, + }; + } + + /** + * Get all scopes in resolution order (most specific first). + * + * @return array + */ + public static function resolutionOrder(): array + { + return [ + self::WORKSPACE, + self::ORG, + self::SYSTEM, + ]; + } +} diff --git a/app/Core/Config/Events/CLAUDE.md b/app/Core/Config/Events/CLAUDE.md new file mode 100644 index 0000000..31f4c1c --- /dev/null +++ b/app/Core/Config/Events/CLAUDE.md @@ -0,0 +1,13 @@ +# Config/Events/ — Config System Events + +Events dispatched by the configuration system for reactive integration. + +## Events + +| Event | Fired When | Key Properties | +|-------|-----------|----------------| +| `ConfigChanged` | A config value is set or updated via `ConfigService::set()` | `keyCode`, `value`, `previousValue`, `profile`, `channelId` | +| `ConfigInvalidated` | Config cache is manually cleared | `keyCode` (null = all), `workspaceId`, `channelId`. Has `isFull()` and `affectsKey()` helpers. | +| `ConfigLocked` | A config value is locked (FINAL) | `keyCode`, `profile`, `channelId` | + +Modules can listen to these events via the standard `$listens` pattern in their Boot class to react to config changes (e.g., refreshing CDN clients, flushing caches). diff --git a/app/Core/Config/Events/ConfigChanged.php b/app/Core/Config/Events/ConfigChanged.php new file mode 100644 index 0000000..5f23244 --- /dev/null +++ b/app/Core/Config/Events/ConfigChanged.php @@ -0,0 +1,89 @@ +keyCode === 'cdn.bunny.api_key') { + * // API key changed - refresh CDN client + * $this->cdnService->refreshClient(); + * } + * + * // Check for prefix matches + * if (str_starts_with($event->keyCode, 'mymodule.')) { + * Cache::tags(['mymodule'])->flush(); + * } + * } + * } + * ``` + * + * ## In Module Boot.php + * + * ```php + * use Core\Config\Events\ConfigChanged; + * + * class Boot + * { + * public static array $listens = [ + * ConfigChanged::class => 'onConfigChanged', + * ]; + * + * public function onConfigChanged(ConfigChanged $event): void + * { + * // Handle config changes + * } + * } + * ``` + * + * @see ConfigInvalidated For cache invalidation events + * @see ConfigLocked For when config values are locked + */ +class ConfigChanged +{ + use Dispatchable; + use InteractsWithSockets; + use SerializesModels; + + public function __construct( + public readonly string $keyCode, + public readonly mixed $value, + public readonly mixed $previousValue, + public readonly ConfigProfile $profile, + public readonly ?int $channelId = null, + ) {} +} diff --git a/app/Core/Config/Events/ConfigInvalidated.php b/app/Core/Config/Events/ConfigInvalidated.php new file mode 100644 index 0000000..df8be80 --- /dev/null +++ b/app/Core/Config/Events/ConfigInvalidated.php @@ -0,0 +1,96 @@ +affectsKey('mymodule.api_key')) { + * // Clear our module's cached API client + * Cache::forget('mymodule:api_client'); + * } + * + * // Or handle full invalidation + * if ($event->isFull()) { + * // Clear all module caches + * Cache::tags(['mymodule'])->flush(); + * } + * } + * } + * ``` + * + * ## Invalidation Sources + * + * This event is fired by: + * - `ConfigService::invalidateWorkspace()` - Clears workspace config + * - `ConfigService::invalidateKey()` - Clears a specific key + * + * @see ConfigChanged For changes to specific config values + * @see ConfigLocked For when config values are locked + */ +class ConfigInvalidated +{ + use Dispatchable; + use InteractsWithSockets; + use SerializesModels; + + public function __construct( + public readonly ?string $keyCode = null, + public readonly ?int $workspaceId = null, + public readonly ?int $channelId = null, + ) {} + + /** + * Is this a full invalidation? + */ + public function isFull(): bool + { + return $this->keyCode === null && $this->workspaceId === null; + } + + /** + * Does this invalidation affect a specific key? + */ + public function affectsKey(string $key): bool + { + if ($this->keyCode === null) { + return true; // Full invalidation affects all keys + } + + // Exact match or prefix match + return $this->keyCode === $key || str_starts_with($key, $this->keyCode.'.'); + } +} diff --git a/app/Core/Config/Events/ConfigLocked.php b/app/Core/Config/Events/ConfigLocked.php new file mode 100644 index 0000000..d98f1cb --- /dev/null +++ b/app/Core/Config/Events/ConfigLocked.php @@ -0,0 +1,35 @@ + + */ + protected array $created = []; + + /** + * Items updated during import. + * + * @var array + */ + protected array $updated = []; + + /** + * Items skipped during import. + * + * @var array + */ + protected array $skipped = []; + + /** + * Errors encountered during import. + * + * @var array + */ + protected array $errors = []; + + /** + * Add a created item. + * + * @param string $code The item code/identifier + * @param string $type The item type (key, value) + */ + public function addCreated(string $code, string $type): void + { + $this->created[] = ['code' => $code, 'type' => $type]; + } + + /** + * Add an updated item. + * + * @param string $code The item code/identifier + * @param string $type The item type (key, value) + */ + public function addUpdated(string $code, string $type): void + { + $this->updated[] = ['code' => $code, 'type' => $type]; + } + + /** + * Add a skipped item. + * + * @param string $reason Reason for skipping + */ + public function addSkipped(string $reason): void + { + $this->skipped[] = $reason; + } + + /** + * Add an error. + * + * @param string $message Error message + */ + public function addError(string $message): void + { + $this->errors[] = $message; + } + + /** + * Get created items. + * + * @return array + */ + public function getCreated(): array + { + return $this->created; + } + + /** + * Get updated items. + * + * @return array + */ + public function getUpdated(): array + { + return $this->updated; + } + + /** + * Get skipped items. + * + * @return array + */ + public function getSkipped(): array + { + return $this->skipped; + } + + /** + * Get errors. + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Check if import was successful (no errors). + */ + public function isSuccessful(): bool + { + return empty($this->errors); + } + + /** + * Check if any changes were made. + */ + public function hasChanges(): bool + { + return ! empty($this->created) || ! empty($this->updated); + } + + /** + * Check if there were any errors. + */ + public function hasErrors(): bool + { + return ! empty($this->errors); + } + + /** + * Get total count of created items. + */ + public function createdCount(): int + { + return count($this->created); + } + + /** + * Get total count of updated items. + */ + public function updatedCount(): int + { + return count($this->updated); + } + + /** + * Get total count of skipped items. + */ + public function skippedCount(): int + { + return count($this->skipped); + } + + /** + * Get total count of errors. + */ + public function errorCount(): int + { + return count($this->errors); + } + + /** + * Get summary string. + */ + public function getSummary(): string + { + $parts = []; + + if ($this->createdCount() > 0) { + $parts[] = "{$this->createdCount()} created"; + } + + if ($this->updatedCount() > 0) { + $parts[] = "{$this->updatedCount()} updated"; + } + + if ($this->skippedCount() > 0) { + $parts[] = "{$this->skippedCount()} skipped"; + } + + if ($this->errorCount() > 0) { + $parts[] = "{$this->errorCount()} errors"; + } + + if (empty($parts)) { + return 'No changes'; + } + + return implode(', ', $parts); + } + + /** + * Convert to array for JSON serialization. + * + * @return array + */ + public function toArray(): array + { + return [ + 'success' => $this->isSuccessful(), + 'summary' => $this->getSummary(), + 'created' => $this->created, + 'updated' => $this->updated, + 'skipped' => $this->skipped, + 'errors' => $this->errors, + 'counts' => [ + 'created' => $this->createdCount(), + 'updated' => $this->updatedCount(), + 'skipped' => $this->skippedCount(), + 'errors' => $this->errorCount(), + ], + ]; + } +} diff --git a/app/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php b/app/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php new file mode 100644 index 0000000..624c40c --- /dev/null +++ b/app/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php @@ -0,0 +1,122 @@ +id(); + $table->string('code')->unique(); + $table->foreignId('parent_id')->nullable() + ->constrained('config_keys') + ->nullOnDelete(); + $table->string('type')->default('string'); + $table->string('category')->index(); + $table->string('description')->nullable(); + $table->json('default_value')->nullable(); + $table->timestamps(); + + $table->index(['category', 'code']); + }); + + // 2. Config Profiles (scope containers) + Schema::create('config_profiles', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('scope_type')->index(); + $table->unsignedBigInteger('scope_id')->nullable()->index(); + $table->foreignId('parent_profile_id')->nullable() + ->constrained('config_profiles') + ->nullOnDelete(); + $table->integer('priority')->default(0); + $table->timestamps(); + + $table->index(['scope_type', 'scope_id']); + $table->unique(['scope_type', 'scope_id', 'priority']); + }); + + // 3. Config Values + Schema::create('config_values', function (Blueprint $table) { + $table->id(); + $table->foreignId('profile_id') + ->constrained('config_profiles') + ->cascadeOnDelete(); + $table->foreignId('key_id') + ->constrained('config_keys') + ->cascadeOnDelete(); + $table->json('value')->nullable(); + $table->boolean('locked')->default(false); + $table->foreignId('inherited_from')->nullable() + ->constrained('config_profiles') + ->nullOnDelete(); + $table->timestamps(); + + $table->unique(['profile_id', 'key_id']); + $table->index(['key_id', 'locked']); + }); + + // 4. Config Channels + Schema::create('config_channels', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('code')->unique(); + $table->string('type')->default('notification'); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->index(['type', 'is_active']); + }); + + // 5. Config Resolved Cache + Schema::create('config_resolved', function (Blueprint $table) { + $table->id(); + $table->string('scope_type'); + $table->unsignedBigInteger('scope_id'); + $table->string('key_code'); + $table->json('resolved_value')->nullable(); + $table->foreignId('source_profile_id')->nullable() + ->constrained('config_profiles') + ->nullOnDelete(); + $table->timestamp('resolved_at'); + $table->timestamps(); + + $table->unique(['scope_type', 'scope_id', 'key_code'], 'config_resolved_unique'); + $table->index(['scope_type', 'scope_id']); + $table->index('key_code'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('config_resolved'); + Schema::dropIfExists('config_channels'); + Schema::dropIfExists('config_values'); + Schema::dropIfExists('config_profiles'); + Schema::dropIfExists('config_keys'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/app/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php b/app/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php new file mode 100644 index 0000000..35cb829 --- /dev/null +++ b/app/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php @@ -0,0 +1,36 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('config_profiles', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/app/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php b/app/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php new file mode 100644 index 0000000..51159bd --- /dev/null +++ b/app/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php @@ -0,0 +1,39 @@ +boolean('is_sensitive')->default(false)->after('default_value'); + $table->index('is_sensitive'); + }); + } + + public function down(): void + { + Schema::table('config_keys', function (Blueprint $table) { + $table->dropIndex(['is_sensitive']); + $table->dropColumn('is_sensitive'); + }); + } +}; diff --git a/app/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php b/app/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php new file mode 100644 index 0000000..b5efe8a --- /dev/null +++ b/app/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('profile_id') + ->constrained('config_profiles') + ->cascadeOnDelete(); + $table->unsignedBigInteger('workspace_id')->nullable()->index(); + $table->string('label'); + $table->longText('snapshot'); // JSON snapshot of all config values + $table->string('author')->nullable(); + $table->timestamp('created_at'); + + $table->index(['profile_id', 'created_at']); + $table->index(['workspace_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('config_versions'); + } +}; diff --git a/app/Core/Config/Migrations/CLAUDE.md b/app/Core/Config/Migrations/CLAUDE.md new file mode 100644 index 0000000..9a986a3 --- /dev/null +++ b/app/Core/Config/Migrations/CLAUDE.md @@ -0,0 +1,14 @@ +# Config/Migrations/ — Config Schema Migrations + +Database migrations for the hierarchical configuration system. + +## Migrations + +| File | Purpose | +|------|---------| +| `0001_01_01_000001_create_config_tables.php` | Creates core config tables: `config_keys`, `config_profiles`, `config_values`, `config_channels`, `config_resolved`. | +| `0001_01_01_000002_add_soft_deletes_to_config_profiles.php` | Adds soft delete support to `config_profiles`. | +| `0001_01_01_000003_add_is_sensitive_to_config_keys.php` | Adds `is_sensitive` flag for automatic encryption of values. | +| `0001_01_01_000004_create_config_versions_table.php` | Creates `config_versions` table for point-in-time snapshots and rollback. | + +Uses early timestamps (`0001_01_01_*`) to run before application migrations. diff --git a/app/Core/Config/Models/CLAUDE.md b/app/Core/Config/Models/CLAUDE.md new file mode 100644 index 0000000..2be24c2 --- /dev/null +++ b/app/Core/Config/Models/CLAUDE.md @@ -0,0 +1,22 @@ +# Config/Models/ — Config Eloquent Models + +Eloquent models implementing the four-layer hierarchical configuration system. + +## Models + +| Model | Table | Purpose | +|-------|-------|---------| +| `ConfigKey` | `config_keys` | M1 layer — defines what keys exist. Dot-notation codes, typed (`ConfigType`), categorised. Supports sensitive flag for auto-encryption. Hierarchical parent/child grouping. | +| `ConfigProfile` | `config_profiles` | M2 layer — groups values at a scope level (system/org/workspace). Inherits from parent profiles. Soft-deletable. | +| `ConfigValue` | `config_values` | Junction table linking profiles to keys with actual values. `locked` flag implements FINAL (prevents child override). Auto-encrypts sensitive keys. Invalidates resolver hash on write. | +| `ConfigVersion` | `config_versions` | Point-in-time snapshots for version history and rollback. Immutable (no `updated_at`). Stores JSON snapshot of all values. | +| `Channel` | `config_channels` | Context dimension (web, api, mobile, instagram, etc.). Hierarchical inheritance chain with cycle detection. System or workspace-scoped. | +| `ConfigResolved` | `config_resolved` | Materialised READ table — all lookups hit this directly. No computation at read time. Populated by the `prime` operation. Composite key (workspace_id, channel_id, key_code). | + +## Resolution Flow + +``` +ConfigService::get() → ConfigResolved (fast lookup) + → miss: ConfigResolver computes from ConfigValue chain + → stores result back to ConfigResolved + in-memory hash +``` diff --git a/app/Core/Config/Models/Channel.php b/app/Core/Config/Models/Channel.php new file mode 100644 index 0000000..bd3dbc4 --- /dev/null +++ b/app/Core/Config/Models/Channel.php @@ -0,0 +1,211 @@ + 'array', + ]; + + /** + * Parent channel (for inheritance). + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * Child channels. + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * Workspace this channel belongs to (null = system channel). + * + * Requires Core\Tenant module to be installed. + */ + public function workspace(): BelongsTo + { + if (class_exists(Workspace::class)) { + return $this->belongsTo(Workspace::class); + } + + // Return a null relationship when Tenant module is not installed + return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0'); + } + + /** + * Config values for this channel. + */ + public function values(): HasMany + { + return $this->hasMany(ConfigValue::class, 'channel_id'); + } + + /** + * Find channel by code. + */ + public static function byCode(string $code, ?int $workspaceId = null): ?self + { + return static::where('code', $code) + ->where(function ($query) use ($workspaceId) { + $query->whereNull('workspace_id'); + if ($workspaceId !== null) { + $query->orWhere('workspace_id', $workspaceId); + } + }) + ->orderByRaw('workspace_id IS NULL') // Workspace-specific first + ->first(); + } + + /** + * Build inheritance chain (most specific to least). + * + * Includes cycle detection to prevent infinite loops from data corruption. + * + * @return Collection + */ + public function inheritanceChain(): Collection + { + $chain = new Collection([$this]); + $current = $this; + $seen = [$this->id => true]; + + while ($current->parent_id !== null) { + if (isset($seen[$current->parent_id])) { + Log::error('Circular reference detected in channel inheritance', [ + 'channel_id' => $this->id, + 'cycle_at' => $current->parent_id, + ]); + break; + } + + $parent = $current->parent; + if ($parent === null) { + break; + } + + $seen[$parent->id] = true; + $chain->push($parent); + $current = $parent; + } + + return $chain; + } + + /** + * Get all channel codes in inheritance chain. + * + * @return array + */ + public function inheritanceCodes(): array + { + return $this->inheritanceChain()->pluck('code')->all(); + } + + /** + * Check if this channel inherits from another. + */ + public function inheritsFrom(string $code): bool + { + return in_array($code, $this->inheritanceCodes(), true); + } + + /** + * Is this a system channel (available to all workspaces)? + */ + public function isSystem(): bool + { + return $this->workspace_id === null; + } + + /** + * Get metadata value. + */ + public function meta(string $key, mixed $default = null): mixed + { + return data_get($this->metadata, $key, $default); + } + + /** + * Ensure a channel exists. + */ + public static function ensure( + string $code, + string $name, + ?string $parentCode = null, + ?int $workspaceId = null, + ?array $metadata = null, + ): self { + $parentId = null; + if ($parentCode !== null) { + $parent = static::byCode($parentCode, $workspaceId); + $parentId = $parent?->id; + } + + return static::firstOrCreate( + [ + 'code' => $code, + 'workspace_id' => $workspaceId, + ], + [ + 'name' => $name, + 'parent_id' => $parentId, + 'metadata' => $metadata, + ] + ); + } +} diff --git a/app/Core/Config/Models/ConfigKey.php b/app/Core/Config/Models/ConfigKey.php new file mode 100644 index 0000000..ce8f403 --- /dev/null +++ b/app/Core/Config/Models/ConfigKey.php @@ -0,0 +1,119 @@ + ConfigType::class, + 'default_value' => 'json', + 'is_sensitive' => 'boolean', + ]; + + /** + * Check if this key contains sensitive data that should be encrypted. + */ + public function isSensitive(): bool + { + return $this->is_sensitive ?? false; + } + + /** + * Parent key (for hierarchical grouping). + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * Child keys. + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * Values assigned to this key across profiles. + */ + public function values(): HasMany + { + return $this->hasMany(ConfigValue::class, 'key_id'); + } + + /** + * Get typed default value. + */ + public function getTypedDefault(): mixed + { + if ($this->default_value === null) { + return $this->type->default(); + } + + return $this->type->cast($this->default_value); + } + + /** + * Find key by code. + */ + public static function byCode(string $code): ?self + { + return static::where('code', $code)->first(); + } + + /** + * Get all keys for a category. + * + * @return Collection + */ + public static function forCategory(string $category): Collection + { + return static::where('category', $category)->get(); + } +} diff --git a/app/Core/Config/Models/ConfigProfile.php b/app/Core/Config/Models/ConfigProfile.php new file mode 100644 index 0000000..57c65c1 --- /dev/null +++ b/app/Core/Config/Models/ConfigProfile.php @@ -0,0 +1,150 @@ + ScopeType::class, + 'priority' => 'integer', + ]; + + /** + * Parent profile (for profile-level inheritance). + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_profile_id'); + } + + /** + * Child profiles. + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_profile_id'); + } + + /** + * Config values in this profile. + */ + public function values(): HasMany + { + return $this->hasMany(ConfigValue::class, 'profile_id'); + } + + /** + * Get system profile. + */ + public static function system(): ?self + { + return static::where('scope_type', ScopeType::SYSTEM) + ->whereNull('scope_id') + ->orderByDesc('priority') + ->first(); + } + + /** + * Get profiles for a scope. + * + * @return Collection + */ + public static function forScope(ScopeType $type, ?int $scopeId = null): Collection + { + return static::where('scope_type', $type) + ->where('scope_id', $scopeId) + ->orderByDesc('priority') + ->get(); + } + + /** + * Get profile for workspace. + */ + public static function forWorkspace(int $workspaceId): ?self + { + return static::where('scope_type', ScopeType::WORKSPACE) + ->where('scope_id', $workspaceId) + ->orderByDesc('priority') + ->first(); + } + + /** + * Get or create system profile. + */ + public static function ensureSystem(): self + { + return static::firstOrCreate( + [ + 'scope_type' => ScopeType::SYSTEM, + 'scope_id' => null, + ], + [ + 'name' => 'System Default', + 'priority' => 0, + ] + ); + } + + /** + * Get or create workspace profile. + */ + public static function ensureWorkspace(int $workspaceId, ?int $parentProfileId = null): self + { + return static::firstOrCreate( + [ + 'scope_type' => ScopeType::WORKSPACE, + 'scope_id' => $workspaceId, + ], + [ + 'name' => "Workspace {$workspaceId}", + 'parent_profile_id' => $parentProfileId, + 'priority' => 0, + ] + ); + } +} diff --git a/app/Core/Config/Models/ConfigResolved.php b/app/Core/Config/Models/ConfigResolved.php new file mode 100644 index 0000000..dab2804 --- /dev/null +++ b/app/Core/Config/Models/ConfigResolved.php @@ -0,0 +1,239 @@ + 'json', + 'locked' => 'boolean', + 'virtual' => 'boolean', + 'computed_at' => 'datetime', + ]; + + /** + * Workspace this resolution is for (null = system). + * + * Requires Core\Tenant module to be installed. + */ + public function workspace(): BelongsTo + { + if (class_exists(Workspace::class)) { + return $this->belongsTo(Workspace::class); + } + + // Return a null relationship when Tenant module is not installed + return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0'); + } + + /** + * Channel this resolution is for (null = all channels). + */ + public function channel(): BelongsTo + { + return $this->belongsTo(Channel::class); + } + + /** + * Profile that provided this value. + */ + public function sourceProfile(): BelongsTo + { + return $this->belongsTo(ConfigProfile::class, 'source_profile_id'); + } + + /** + * Get the resolved value with proper type casting. + */ + public function getTypedValue(): mixed + { + $type = ConfigType::tryFrom($this->type) ?? ConfigType::STRING; + + return $type->cast($this->value); + } + + /** + * Convert to ConfigResult for API compatibility. + */ + public function toResult(): ConfigResult + { + $type = ConfigType::tryFrom($this->type) ?? ConfigType::STRING; + + if ($this->virtual) { + return ConfigResult::virtual( + key: $this->key_code, + value: $this->value, + type: $type, + ); + } + + // Determine scope type from source profile + $scopeType = null; + if ($this->source_profile_id !== null) { + $scopeType = $this->sourceProfile?->scope_type; + } + + return new ConfigResult( + key: $this->key_code, + value: $type->cast($this->value), + type: $type, + found: true, + locked: $this->locked, + virtual: $this->virtual, + resolvedFrom: $scopeType, + profileId: $this->source_profile_id, + channelId: $this->source_channel_id, + ); + } + + /** + * Look up a resolved config value. + * + * This is THE read path - single indexed lookup. + */ + public static function lookup(string $keyCode, ?int $workspaceId = null, ?int $channelId = null): ?self + { + return static::where('workspace_id', $workspaceId) + ->where('channel_id', $channelId) + ->where('key_code', $keyCode) + ->first(); + } + + /** + * Get all resolved config for a scope. + * + * @return Collection + */ + public static function forScope(?int $workspaceId = null, ?int $channelId = null): Collection + { + return static::where('workspace_id', $workspaceId) + ->where('channel_id', $channelId) + ->get(); + } + + /** + * Store a resolved config value. + */ + public static function store( + string $keyCode, + mixed $value, + ConfigType $type, + ?int $workspaceId = null, + ?int $channelId = null, + bool $locked = false, + ?int $sourceProfileId = null, + ?int $sourceChannelId = null, + bool $virtual = false, + ): self { + return static::updateOrCreate( + [ + 'workspace_id' => $workspaceId, + 'channel_id' => $channelId, + 'key_code' => $keyCode, + ], + [ + 'value' => $value, + 'type' => $type->value, + 'locked' => $locked, + 'source_profile_id' => $sourceProfileId, + 'source_channel_id' => $sourceChannelId, + 'virtual' => $virtual, + 'computed_at' => now(), + ] + ); + } + + /** + * Clear resolved config for a scope. + */ + public static function clearScope(?int $workspaceId = null, ?int $channelId = null): int + { + return static::where('workspace_id', $workspaceId) + ->where('channel_id', $channelId) + ->delete(); + } + + /** + * Clear all resolved config for a workspace (all channels). + */ + public static function clearWorkspace(?int $workspaceId = null): int + { + return static::where('workspace_id', $workspaceId)->delete(); + } + + /** + * Clear resolved config for a specific key across all scopes. + */ + public static function clearKey(string $keyCode): int + { + return static::where('key_code', $keyCode)->delete(); + } + + /** + * Composite key handling for Eloquent. + */ + protected function setKeysForSaveQuery($query) + { + $query->where('workspace_id', $this->workspace_id) + ->where('channel_id', $this->channel_id) + ->where('key_code', $this->key_code); + + return $query; + } +} diff --git a/app/Core/Config/Models/ConfigValue.php b/app/Core/Config/Models/ConfigValue.php new file mode 100644 index 0000000..0f783be --- /dev/null +++ b/app/Core/Config/Models/ConfigValue.php @@ -0,0 +1,275 @@ + 'boolean', + ]; + + /** + * Encrypted value marker prefix. + * + * Used to detect if a stored value is encrypted. + */ + protected const ENCRYPTED_PREFIX = 'encrypted:'; + + /** + * Get the value attribute with automatic decryption for sensitive keys. + */ + public function getValueAttribute(mixed $value): mixed + { + if ($value === null) { + return null; + } + + // Decode JSON first + $decoded = is_string($value) ? json_decode($value, true) : $value; + + // Check if this is an encrypted value + if (is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX)) { + try { + $encrypted = substr($decoded, strlen(self::ENCRYPTED_PREFIX)); + + return json_decode(Crypt::decryptString($encrypted), true); + } catch (DecryptException) { + // Return null if decryption fails (key rotation, corruption, etc.) + return null; + } + } + + return $decoded; + } + + /** + * Set the value attribute with automatic encryption for sensitive keys. + */ + public function setValueAttribute(mixed $value): void + { + // Check if the key is sensitive (need to load it if not already) + $key = $this->relationLoaded('key') + ? $this->getRelation('key') + : ($this->key_id ? ConfigKey::find($this->key_id) : null); + + if ($key?->isSensitive() && $value !== null) { + // Encrypt the value + $jsonValue = json_encode($value); + $encrypted = Crypt::encryptString($jsonValue); + $this->attributes['value'] = json_encode(self::ENCRYPTED_PREFIX.$encrypted); + } else { + // Store as regular JSON + $this->attributes['value'] = json_encode($value); + } + } + + /** + * Check if the current stored value is encrypted. + */ + public function isEncrypted(): bool + { + $raw = $this->attributes['value'] ?? null; + if ($raw === null) { + return false; + } + + $decoded = json_decode($raw, true); + + return is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX); + } + + /** + * The profile this value belongs to. + */ + public function profile(): BelongsTo + { + return $this->belongsTo(ConfigProfile::class, 'profile_id'); + } + + /** + * The key this value is for. + */ + public function key(): BelongsTo + { + return $this->belongsTo(ConfigKey::class, 'key_id'); + } + + /** + * Profile this value was inherited from (if any). + */ + public function inheritedFromProfile(): BelongsTo + { + return $this->belongsTo(ConfigProfile::class, 'inherited_from'); + } + + /** + * The channel this value is scoped to (null = all channels). + */ + public function channel(): BelongsTo + { + return $this->belongsTo(Channel::class, 'channel_id'); + } + + /** + * Get typed value. + */ + public function getTypedValue(): mixed + { + $key = $this->key; + + if ($key === null) { + return $this->value; + } + + return $key->type->cast($this->value); + } + + /** + * Check if this value is locked (FINAL). + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * Check if this value was inherited. + */ + public function isInherited(): bool + { + return $this->inherited_from !== null; + } + + /** + * Find value for a profile, key, and optional channel. + */ + public static function findValue(int $profileId, int $keyId, ?int $channelId = null): ?self + { + return static::where('profile_id', $profileId) + ->where('key_id', $keyId) + ->where('channel_id', $channelId) + ->first(); + } + + /** + * Set or update a value. + * + * Automatically invalidates the resolved hash so the next read + * will recompute the value with the new setting. + */ + public static function setValue( + int $profileId, + int $keyId, + mixed $value, + bool $locked = false, + ?int $inheritedFrom = null, + ?int $channelId = null, + ): self { + $configValue = static::updateOrCreate( + [ + 'profile_id' => $profileId, + 'key_id' => $keyId, + 'channel_id' => $channelId, + ], + [ + 'value' => $value, + 'locked' => $locked, + 'inherited_from' => $inheritedFrom, + ] + ); + + // Invalidate hash for this key (all scopes) + // The value will be recomputed on next access + $key = ConfigKey::find($keyId); + if ($key === null) { + return $configValue; + } + + ConfigResolver::clear($key->code); + + // Also clear from resolved table for this scope + $profile = ConfigProfile::find($profileId); + if ($profile === null) { + return $configValue; + } + + $workspaceId = $profile->scope_type === ScopeType::WORKSPACE + ? $profile->scope_id + : null; + + ConfigResolved::where('key_code', $key->code) + ->where('workspace_id', $workspaceId) + ->where('channel_id', $channelId) + ->delete(); + + return $configValue; + } + + /** + * Get all values for a key across profiles and channels. + * + * Used for batch resolution to avoid N+1. + * + * @param array $profileIds + * @param array|null $channelIds Include null for "all channels" values + * @return Collection + */ + public static function forKeyInProfiles(int $keyId, array $profileIds, ?array $channelIds = null): Collection + { + return static::where('key_id', $keyId) + ->whereIn('profile_id', $profileIds) + ->when($channelIds !== null, function ($query) use ($channelIds) { + $query->where(function ($q) use ($channelIds) { + $q->whereIn('channel_id', $channelIds) + ->orWhereNull('channel_id'); + }); + }) + ->get(); + } +} diff --git a/app/Core/Config/Models/ConfigVersion.php b/app/Core/Config/Models/ConfigVersion.php new file mode 100644 index 0000000..eb86863 --- /dev/null +++ b/app/Core/Config/Models/ConfigVersion.php @@ -0,0 +1,188 @@ + 'datetime', + ]; + + /** + * The profile this version belongs to. + */ + public function profile(): BelongsTo + { + return $this->belongsTo(ConfigProfile::class, 'profile_id'); + } + + /** + * Workspace this version is for (null = system). + * + * Requires Core\Tenant module to be installed. + */ + public function workspace(): BelongsTo + { + if (class_exists(Workspace::class)) { + return $this->belongsTo(Workspace::class); + } + + // Return a null relationship when Tenant module is not installed + return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0'); + } + + /** + * Get the parsed snapshot data. + * + * @return array + */ + public function getSnapshotData(): array + { + return json_decode($this->snapshot, true) ?? []; + } + + /** + * Get the config values from the snapshot. + * + * @return array + */ + public function getValues(): array + { + $data = $this->getSnapshotData(); + + return $data['values'] ?? []; + } + + /** + * Get a specific value from the snapshot. + * + * @param string $key Config key code + * @return mixed|null The value or null if not found + */ + public function getValue(string $key): mixed + { + $values = $this->getValues(); + + foreach ($values as $value) { + if ($value['key'] === $key) { + return $value['value']; + } + } + + return null; + } + + /** + * Check if a key exists in the snapshot. + * + * @param string $key Config key code + */ + public function hasKey(string $key): bool + { + $values = $this->getValues(); + + foreach ($values as $value) { + if ($value['key'] === $key) { + return true; + } + } + + return false; + } + + /** + * Get versions for a scope. + * + * @param int|null $workspaceId Workspace ID or null for system + * @return Collection + */ + public static function forScope(?int $workspaceId = null): Collection + { + return static::where('workspace_id', $workspaceId) + ->orderByDesc('created_at') + ->get(); + } + + /** + * Get the latest version for a scope. + * + * @param int|null $workspaceId Workspace ID or null for system + */ + public static function latest(?int $workspaceId = null): ?self + { + return static::where('workspace_id', $workspaceId) + ->orderByDesc('created_at') + ->first(); + } + + /** + * Get versions created by a specific author. + * + * @return Collection + */ + public static function byAuthor(string $author): Collection + { + return static::where('author', $author) + ->orderByDesc('created_at') + ->get(); + } + + /** + * Get versions created within a date range. + * + * @param Carbon $from Start date + * @param Carbon $to End date + * @return Collection + */ + public static function inDateRange(Carbon $from, Carbon $to): Collection + { + return static::whereBetween('created_at', [$from, $to]) + ->orderByDesc('created_at') + ->get(); + } +} diff --git a/app/Core/Config/Routes/CLAUDE.md b/app/Core/Config/Routes/CLAUDE.md new file mode 100644 index 0000000..cddbdd9 --- /dev/null +++ b/app/Core/Config/Routes/CLAUDE.md @@ -0,0 +1,7 @@ +# Config/Routes/ — Config Admin Routes + +## Files + +| File | Purpose | +|------|---------| +| `admin.php` | Admin route definitions for the configuration panel. Registers routes under the `admin` middleware group for the `ConfigPanel` and `WorkspaceConfig` Livewire components. | diff --git a/app/Core/Config/Routes/admin.php b/app/Core/Config/Routes/admin.php new file mode 100644 index 0000000..7863143 --- /dev/null +++ b/app/Core/Config/Routes/admin.php @@ -0,0 +1,19 @@ +prefix('admin') + ->group(function () { + Route::get('/config', ConfigPanel::class)->name('admin.config'); + }); diff --git a/app/Core/Config/Tests/Feature/CLAUDE.md b/app/Core/Config/Tests/Feature/CLAUDE.md new file mode 100644 index 0000000..08490c3 --- /dev/null +++ b/app/Core/Config/Tests/Feature/CLAUDE.md @@ -0,0 +1,11 @@ +# Config/Tests/Feature/ — Config Integration Tests + +Pest feature tests for the hierarchical configuration system. + +## Test Files + +| File | Purpose | +|------|---------| +| `ConfigServiceTest.php` | Full integration tests covering ConfigKey creation, ConfigProfile inheritance, ConfigResolver scope cascading, FINAL lock enforcement, ConfigService materialised reads/writes, ConfigResolved storage, and the single-hash lazy-load pattern. | + +Tests cover the complete config lifecycle: key definition, profile hierarchy (system/workspace), value resolution with inheritance, lock semantics, cache invalidation, and the prime/materialise flow. diff --git a/app/Core/Config/Tests/Feature/ConfigServiceTest.php b/app/Core/Config/Tests/Feature/ConfigServiceTest.php new file mode 100644 index 0000000..46d9ed9 --- /dev/null +++ b/app/Core/Config/Tests/Feature/ConfigServiceTest.php @@ -0,0 +1,414 @@ +systemProfile = ConfigProfile::ensureSystem(); + + // Create a test workspace + $this->workspace = Workspace::factory()->create(); + $this->workspaceProfile = ConfigProfile::ensureWorkspace($this->workspace->id, $this->systemProfile->id); + + // Create test config keys + $this->stringKey = ConfigKey::create([ + 'code' => 'test.string_key', + 'type' => ConfigType::STRING, + 'category' => 'test', + 'description' => 'A test string key', + 'default_value' => 'default_string', + ]); + + $this->boolKey = ConfigKey::create([ + 'code' => 'test.bool_key', + 'type' => ConfigType::BOOL, + 'category' => 'test', + 'description' => 'A test boolean key', + 'default_value' => false, + ]); + + $this->intKey = ConfigKey::create([ + 'code' => 'test.int_key', + 'type' => ConfigType::INT, + 'category' => 'test', + 'description' => 'A test integer key', + 'default_value' => 10, + ]); + + $this->service = app(ConfigService::class); + $this->resolver = app(ConfigResolver::class); +}); + +describe('ConfigKey model', function () { + it('creates keys with correct types', function () { + expect($this->stringKey->type)->toBe(ConfigType::STRING); + expect($this->boolKey->type)->toBe(ConfigType::BOOL); + expect($this->intKey->type)->toBe(ConfigType::INT); + }); + + it('returns typed defaults', function () { + expect($this->stringKey->getTypedDefault())->toBe('default_string'); + expect($this->boolKey->getTypedDefault())->toBe(false); + expect($this->intKey->getTypedDefault())->toBe(10); + }); + + it('finds keys by code', function () { + $found = ConfigKey::byCode('test.string_key'); + + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->stringKey->id); + }); +}); + +describe('ConfigProfile model', function () { + it('creates system profile', function () { + expect($this->systemProfile->scope_type)->toBe(ScopeType::SYSTEM); + expect($this->systemProfile->scope_id)->toBeNull(); + }); + + it('creates workspace profile', function () { + expect($this->workspaceProfile->scope_type)->toBe(ScopeType::WORKSPACE); + expect($this->workspaceProfile->scope_id)->toBe($this->workspace->id); + }); + + it('links workspace profile to system parent', function () { + expect($this->workspaceProfile->parent_profile_id)->toBe($this->systemProfile->id); + }); +}); + +describe('ConfigResolver', function () { + it('resolves to default when no value set', function () { + $result = $this->resolver->resolve('test.string_key', null); + + expect($result->found)->toBeFalse(); + expect($result->get())->toBe('default_string'); + }); + + it('resolves system value', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'system_value'); + + $result = $this->resolver->resolve('test.string_key', null); + + expect($result->found)->toBeTrue(); + expect($result->get())->toBe('system_value'); + expect($result->resolvedFrom)->toBe(ScopeType::SYSTEM); + }); + + it('workspace overrides system value', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'system_value'); + ConfigValue::setValue($this->workspaceProfile->id, $this->stringKey->id, 'workspace_value'); + + $result = $this->resolver->resolve('test.string_key', $this->workspace); + + expect($result->get())->toBe('workspace_value'); + expect($result->resolvedFrom)->toBe(ScopeType::WORKSPACE); + }); + + it('respects FINAL lock from system', function () { + // Set locked value at system level + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'locked_value', locked: true); + + // Try to override at workspace level + ConfigValue::setValue($this->workspaceProfile->id, $this->stringKey->id, 'workspace_value'); + + $result = $this->resolver->resolve('test.string_key', $this->workspace); + + // Should get the locked system value + expect($result->get())->toBe('locked_value'); + expect($result->isLocked())->toBeTrue(); + expect($result->resolvedFrom)->toBe(ScopeType::SYSTEM); + }); + + it('returns unconfigured for unknown keys', function () { + $result = $this->resolver->resolve('nonexistent.key', null); + + expect($result->found)->toBeFalse(); + expect($result->isConfigured())->toBeFalse(); + }); +}); + +describe('ConfigService with materialised resolution', function () { + it('gets config value with default', function () { + $value = $this->service->get('test.string_key', 'fallback'); + + expect($value)->toBe('default_string'); + }); + + it('gets config value from resolved table after prime', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'db_value'); + $this->service->prime(); + + $value = $this->service->get('test.string_key'); + + expect($value)->toBe('db_value'); + }); + + it('reads from materialised table not source', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'original'); + $this->service->prime(); + + // Update source directly (bypassing service) + ConfigValue::where('profile_id', $this->systemProfile->id) + ->where('key_id', $this->stringKey->id) + ->update(['value' => json_encode('changed')]); + + // Should still return materialised value + $value = $this->service->get('test.string_key'); + + expect($value)->toBe('original'); + }); + + it('updates materialised table on set', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'initial'); + $this->service->prime(); + + // Set new value via service + $this->service->set('test.string_key', 'updated', $this->systemProfile); + + // Should get new value + $value = $this->service->get('test.string_key'); + + expect($value)->toBe('updated'); + }); + + it('checks if configured', function () { + expect($this->service->isConfigured('test.string_key'))->toBeFalse(); + + $this->service->set('test.string_key', 'some_value', $this->systemProfile); + + expect($this->service->isConfigured('test.string_key'))->toBeTrue(); + }); + + it('checks if prefix is configured', function () { + expect($this->service->isConfigured('test'))->toBeFalse(); + + $this->service->set('test.string_key', 'value', $this->systemProfile); + + expect($this->service->isConfigured('test'))->toBeTrue(); + }); + + it('locks and unlocks values', function () { + $this->service->set('test.string_key', 'value', $this->systemProfile); + $this->service->lock('test.string_key', $this->systemProfile); + + $result = $this->service->resolve('test.string_key'); + expect($result->isLocked())->toBeTrue(); + + $this->service->unlock('test.string_key', $this->systemProfile); + $result = $this->service->resolve('test.string_key'); + expect($result->isLocked())->toBeFalse(); + }); + + it('gets all config values for scope', function () { + $this->service->set('test.string_key', 'string_val', $this->systemProfile); + $this->service->set('test.bool_key', true, $this->systemProfile); + $this->service->set('test.int_key', 42, $this->systemProfile); + $this->service->prime(); + + $all = $this->service->all(); + + expect($all['test.string_key'])->toBe('string_val'); + expect($all['test.bool_key'])->toBe(true); + expect($all['test.int_key'])->toBe(42); + }); + + it('primes materialised table for workspace', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'system'); + ConfigValue::setValue($this->workspaceProfile->id, $this->stringKey->id, 'workspace'); + + $this->service->prime(); + $this->service->prime($this->workspace); + + // Workspace context should get override + $this->service->setContext($this->workspace); + $wsValue = $this->service->get('test.string_key'); + expect($wsValue)->toBe('workspace'); + + // System context should get system value + $this->service->setContext(null); + $sysValue = $this->service->get('test.string_key'); + expect($sysValue)->toBe('system'); + }); +}); + +describe('ConfigResolved model', function () { + it('stores and retrieves resolved values', function () { + ConfigResolved::store( + keyCode: 'test.key', + value: 'test_value', + type: ConfigType::STRING, + workspaceId: null, + channelId: null, + ); + + $resolved = ConfigResolved::lookup('test.key'); + + expect($resolved)->not->toBeNull(); + expect($resolved->value)->toBe('test_value'); + }); + + it('clears scope correctly', function () { + ConfigResolved::store('key1', 'v1', ConfigType::STRING); + ConfigResolved::store('key2', 'v2', ConfigType::STRING, workspaceId: $this->workspace->id); + + ConfigResolved::clearScope(null, null); + + expect(ConfigResolved::lookup('key1'))->toBeNull(); + expect(ConfigResolved::lookup('key2', $this->workspace->id))->not->toBeNull(); + }); +}); + +describe('Single hash', function () { + it('loads scope into hash on first access', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'hash_test'); + $this->service->prime(); + + // Clear hash but keep DB + ConfigResolver::clearAll(); + + expect(ConfigResolver::isLoaded())->toBeFalse(); + expect(count(ConfigResolver::all()))->toBe(0); + + // First access should lazy-load entire scope + $this->service->get('test.string_key'); + + expect(ConfigResolver::isLoaded())->toBeTrue(); + expect(count(ConfigResolver::all()))->toBeGreaterThan(0); + }); + + it('subsequent reads hit hash not database', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'hash_read'); + $this->service->prime(); + + // Clear and reload + ConfigResolver::clearAll(); + + // First read loads scope + $this->service->get('test.string_key'); + + // Value is now in hash + expect(ConfigResolver::has('test.string_key'))->toBeTrue(); + + // Get the value directly from hash + $hashValue = ConfigResolver::get('test.string_key'); + expect($hashValue)->toBe('hash_read'); + }); + + it('lazy primes uncached keys into hash', function () { + // Set value but don't prime + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'lazy_prime'); + + // Clear everything + ConfigResolver::clearAll(); + + // Access should compute and store in hash + $value = $this->service->get('test.string_key'); + expect($value)->toBe('lazy_prime'); + + // Now it's in hash + expect(ConfigResolver::has('test.string_key'))->toBeTrue(); + }); + + it('invalidation clears hash and database', function () { + ConfigValue::setValue($this->systemProfile->id, $this->stringKey->id, 'to_invalidate'); + $this->service->prime(); + + // Verify in hash + expect(ConfigResolver::has('test.string_key'))->toBeTrue(); + + // Invalidate + $this->service->invalidateKey('test.string_key'); + + // Cleared from hash + expect(ConfigResolver::has('test.string_key'))->toBeFalse(); + }); +}); + +describe('ConfigResult', function () { + it('converts to array for serialisation', function () { + $result = ConfigResult::found( + key: 'test.key', + value: 'test_value', + type: ConfigType::STRING, + locked: true, + resolvedFrom: ScopeType::SYSTEM, + profileId: 1, + ); + + $array = $result->toArray(); + + expect($array['key'])->toBe('test.key'); + expect($array['value'])->toBe('test_value'); + expect($array['type'])->toBe('string'); + expect($array['locked'])->toBeTrue(); + }); + + it('reconstructs from array', function () { + $original = ConfigResult::found( + key: 'test.key', + value: 42, + type: ConfigType::INT, + locked: false, + resolvedFrom: ScopeType::WORKSPACE, + profileId: 5, + ); + + $reconstructed = ConfigResult::fromArray($original->toArray()); + + expect($reconstructed->key)->toBe($original->key); + expect($reconstructed->value)->toBe($original->value); + expect($reconstructed->type)->toBe($original->type); + expect($reconstructed->locked)->toBe($original->locked); + expect($reconstructed->resolvedFrom)->toBe($original->resolvedFrom); + }); + + it('provides typed accessors', function () { + $result = ConfigResult::found( + key: 'test.key', + value: '42', + type: ConfigType::STRING, + locked: false, + resolvedFrom: ScopeType::SYSTEM, + profileId: 1, + ); + + expect($result->string())->toBe('42'); + expect($result->int())->toBe(42); + }); + + it('supports virtual results', function () { + $result = ConfigResult::virtual( + key: 'bio.page.title', + value: 'My Bio Page', + type: ConfigType::STRING, + ); + + expect($result->isVirtual())->toBeTrue(); + expect($result->found)->toBeTrue(); + expect($result->get())->toBe('My Bio Page'); + }); +}); diff --git a/app/Core/Config/VersionDiff.php b/app/Core/Config/VersionDiff.php new file mode 100644 index 0000000..e3797f4 --- /dev/null +++ b/app/Core/Config/VersionDiff.php @@ -0,0 +1,221 @@ + + */ + protected array $added = []; + + /** + * Keys removed in the new version. + * + * @var array + */ + protected array $removed = []; + + /** + * Keys with changed values. + * + * @var array + */ + protected array $changed = []; + + /** + * Keys with changed lock status. + * + * @var array + */ + protected array $lockChanged = []; + + /** + * Add an added key. + * + * @param string $key The config key + * @param mixed $value The new value + */ + public function addAdded(string $key, mixed $value): void + { + $this->added[] = ['key' => $key, 'value' => $value]; + } + + /** + * Add a removed key. + * + * @param string $key The config key + * @param mixed $value The old value + */ + public function addRemoved(string $key, mixed $value): void + { + $this->removed[] = ['key' => $key, 'value' => $value]; + } + + /** + * Add a changed key. + * + * @param string $key The config key + * @param mixed $oldValue The old value + * @param mixed $newValue The new value + */ + public function addChanged(string $key, mixed $oldValue, mixed $newValue): void + { + $this->changed[] = ['key' => $key, 'old' => $oldValue, 'new' => $newValue]; + } + + /** + * Add a lock status change. + * + * @param string $key The config key + * @param bool $oldLocked Old lock status + * @param bool $newLocked New lock status + */ + public function addLockChanged(string $key, bool $oldLocked, bool $newLocked): void + { + $this->lockChanged[] = ['key' => $key, 'old' => $oldLocked, 'new' => $newLocked]; + } + + /** + * Get added keys. + * + * @return array + */ + public function getAdded(): array + { + return $this->added; + } + + /** + * Get removed keys. + * + * @return array + */ + public function getRemoved(): array + { + return $this->removed; + } + + /** + * Get changed keys. + * + * @return array + */ + public function getChanged(): array + { + return $this->changed; + } + + /** + * Get lock status changes. + * + * @return array + */ + public function getLockChanged(): array + { + return $this->lockChanged; + } + + /** + * Check if there are any differences. + */ + public function hasDifferences(): bool + { + return ! empty($this->added) + || ! empty($this->removed) + || ! empty($this->changed) + || ! empty($this->lockChanged); + } + + /** + * Check if there are no differences. + */ + public function isEmpty(): bool + { + return ! $this->hasDifferences(); + } + + /** + * Get total count of differences. + */ + public function count(): int + { + return count($this->added) + + count($this->removed) + + count($this->changed) + + count($this->lockChanged); + } + + /** + * Get summary string. + */ + public function getSummary(): string + { + if ($this->isEmpty()) { + return 'No differences'; + } + + $parts = []; + + if (count($this->added) > 0) { + $parts[] = count($this->added).' added'; + } + + if (count($this->removed) > 0) { + $parts[] = count($this->removed).' removed'; + } + + if (count($this->changed) > 0) { + $parts[] = count($this->changed).' changed'; + } + + if (count($this->lockChanged) > 0) { + $parts[] = count($this->lockChanged).' lock changes'; + } + + return implode(', ', $parts); + } + + /** + * Convert to array for JSON serialization. + * + * @return array + */ + public function toArray(): array + { + return [ + 'has_differences' => $this->hasDifferences(), + 'summary' => $this->getSummary(), + 'added' => $this->added, + 'removed' => $this->removed, + 'changed' => $this->changed, + 'lock_changed' => $this->lockChanged, + 'counts' => [ + 'added' => count($this->added), + 'removed' => count($this->removed), + 'changed' => count($this->changed), + 'lock_changed' => count($this->lockChanged), + 'total' => $this->count(), + ], + ]; + } +} diff --git a/app/Core/Config/View/Blade/admin/CLAUDE.md b/app/Core/Config/View/Blade/admin/CLAUDE.md new file mode 100644 index 0000000..6a981a5 --- /dev/null +++ b/app/Core/Config/View/Blade/admin/CLAUDE.md @@ -0,0 +1,12 @@ +# Config/View/Blade/admin/ — Config Admin Blade Templates + +Blade templates for the admin configuration panel. + +## Templates + +| File | Purpose | +|------|---------| +| `config-panel.blade.php` | Full config management panel — browse keys by category, edit values, toggle locks, manage system vs workspace scopes. Used by `ConfigPanel` Livewire component. | +| `workspace-config.blade.php` | Workspace-specific config panel — hierarchical namespace navigation, tab grouping, value editing with system inheritance display. Used by `WorkspaceConfig` Livewire component. | + +Both templates use the `hub::admin.layouts.app` layout and are rendered via the `core.config::admin.*` view namespace. diff --git a/app/Core/Config/View/Blade/admin/config-panel.blade.php b/app/Core/Config/View/Blade/admin/config-panel.blade.php new file mode 100644 index 0000000..a7af327 --- /dev/null +++ b/app/Core/Config/View/Blade/admin/config-panel.blade.php @@ -0,0 +1,145 @@ +
+ Configuration + +
+ + System + Workspace + + + @if ($scope === 'workspace') + + Select workspace + @foreach ($this->workspaces as $ws) + {{ $ws->name }} + @endforeach + + @endif + + + All categories + @foreach ($this->categories as $cat) + {{ $cat }} + @endforeach + + + +
+ + @if ($scope === 'workspace' && $this->selectedWorkspace) + + Editing configuration for workspace: {{ $this->selectedWorkspace->name }}. + Values inherit from system unless overridden. + + @endif + + + + Key + Type + Value + Status + + + + + @foreach ($this->keys as $key) + @php + $isInherited = $this->isInherited($key); + $isLockedByParent = $this->isLockedByParent($key); + $isLocked = $this->isLocked($key); + @endphp + + +
+
{{ $key->code }}
+ @if ($key->description) +
{{ $key->description }}
+ @endif +
+
+ + + {{ $key->type->value }} + + + + @if ($editingKeyId === $key->id) + @if ($key->type === \Core\Config\Enums\ConfigType::BOOL) + + @elseif ($key->type === \Core\Config\Enums\ConfigType::INT) + + @elseif ($key->type === \Core\Config\Enums\ConfigType::JSON || $key->type === \Core\Config\Enums\ConfigType::ARRAY) + + @else + + @endif + @else +
+ @if (is_array($this->getValue($key))) + {{ json_encode($this->getValue($key)) }} + @elseif (is_bool($this->getValue($key))) + {{ $this->getValue($key) ? 'true' : 'false' }} + @else + {{ $this->getValue($key) ?? '-' }} + @endif +
+ @endif +
+ + +
+ @if ($editingKeyId === $key->id && !$isLockedByParent) + + @else + @if ($isLockedByParent) + System locked + @elseif ($isLocked) + + @endif + + @if ($isInherited) + Inherited + @elseif ($scope === 'workspace' && $workspaceId) + Override + @endif + @endif +
+
+ + + @if ($editingKeyId === $key->id) +
+ Save + + Cancel +
+ @else +
+ @if (!$isLockedByParent) + Edit + + @endif + + @if ($scope === 'workspace' && $workspaceId && !$isInherited) + + Clear + + @endif +
+ @endif +
+
+ @endforeach +
+
+ + @if ($this->keys->isEmpty()) + + No configuration keys found. + + @endif +
diff --git a/app/Core/Config/View/Blade/admin/workspace-config.blade.php b/app/Core/Config/View/Blade/admin/workspace-config.blade.php new file mode 100644 index 0000000..db5fa23 --- /dev/null +++ b/app/Core/Config/View/Blade/admin/workspace-config.blade.php @@ -0,0 +1,119 @@ +
+ {{-- Sidebar --}} + + Settings + + + + + {{-- Main Content --}} + + @if (!$this->workspace) + + + No Workspace + Select a workspace from the menu above. + + @elseif ($path) +
+ {{ ucfirst(str_replace('/', ' / ', $path)) }} + + @if ($this->tabItems) + + @endif + + {{-- Settings list --}} +
+ @forelse ($this->currentKeys as $key) + +
+
+ {{ $this->settingName($key->code) }} + @if ($key->description) + {{ $key->description }} + @endif +
+
+ {{ $key->type->value }} + @if ($this->isLockedBySystem($key)) + Locked + @elseif ($this->isInherited($key)) + Inherited + @else + Custom + @endif +
+
+ + @if ($this->isLockedBySystem($key)) +
+ @php $val = $this->getValue($key); @endphp + @if (is_array($val)) + {{ json_encode($val, JSON_PRETTY_PRINT) }} + @elseif (is_bool($val)) + {{ $val ? 'true' : 'false' }} + @else + {{ $val ?? '-' }} + @endif +
+ @else +
+
+ @if ($key->type === \Core\Config\Enums\ConfigType::BOOL) + + @elseif ($key->type === \Core\Config\Enums\ConfigType::INT) + + @elseif ($key->type === \Core\Config\Enums\ConfigType::JSON || $key->type === \Core\Config\Enums\ConfigType::ARRAY) + {{ is_array($this->getValue($key)) ? json_encode($this->getValue($key), JSON_PRETTY_PRINT) : $this->getValue($key) }} + @else + + @endif +
+ @if (!$this->isInherited($key)) + + @endif +
+ + @if ($this->isInherited($key)) + @php $inherited = $this->getInheritedValue($key); @endphp + + Default: {{ is_array($inherited) ? json_encode($inherited) : ($inherited ?? 'none') }} + + @endif + @endif +
+ @empty + No settings in this group. + @endforelse +
+
+ @else +
+
+ + Select a category from the sidebar +
+
+ @endif +
+
diff --git a/app/Core/Config/View/Modal/Admin/CLAUDE.md b/app/Core/Config/View/Modal/Admin/CLAUDE.md new file mode 100644 index 0000000..f68ae4a --- /dev/null +++ b/app/Core/Config/View/Modal/Admin/CLAUDE.md @@ -0,0 +1,12 @@ +# Config/View/Modal/Admin/ — Config Admin Livewire Components + +Livewire components for the admin configuration interface. + +## Components + +| Component | Purpose | +|-----------|---------| +| `ConfigPanel` | Hades-only config management. Browse/search keys by category, edit values inline, toggle FINAL locks, manage system and workspace scopes. Respects parent lock enforcement. | +| `WorkspaceConfig` | Workspace-scoped settings. Hierarchical namespace navigation (cdn/bunny/storage), tab grouping by second-level prefix, value editing with inherited value display, system lock indicators. | + +Both require the Tenant module for workspace support and fall back gracefully without it. `ConfigPanel` requires Hades (super-admin) access. Values are persisted via `ConfigService`. diff --git a/app/Core/Config/View/Modal/Admin/ConfigPanel.php b/app/Core/Config/View/Modal/Admin/ConfigPanel.php new file mode 100644 index 0000000..01ddb92 --- /dev/null +++ b/app/Core/Config/View/Modal/Admin/ConfigPanel.php @@ -0,0 +1,282 @@ + $categories + * @property-read Collection $workspaces + */ +class ConfigPanel extends Component +{ + #[Url] + public string $category = ''; + + #[Url] + public string $search = ''; + + #[Url] + public string $scope = 'system'; + + #[Url] + public ?int $workspaceId = null; + + public ?int $editingKeyId = null; + + public mixed $editValue = null; + + public bool $editLocked = false; + + protected ConfigService $config; + + public function boot(ConfigService $config): void + { + $this->config = $config; + } + + public function mount(): void + { + $this->checkHadesAccess(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + #[Computed] + public function categories(): array + { + return ConfigKey::select('category') + ->distinct() + ->orderBy('category') + ->pluck('category') + ->toArray(); + } + + /** + * Get all workspaces (requires Tenant module). + */ + #[Computed] + public function workspaces(): Collection + { + if (! class_exists(Workspace::class)) { + return new Collection; + } + + return Workspace::orderBy('name')->get(); + } + + #[Computed] + public function keys(): Collection + { + return ConfigKey::query() + ->when($this->category, fn ($q) => $q->where('category', $this->category)) + ->when($this->search, fn ($q) => $q->where('code', 'LIKE', "%{$this->search}%")) + ->orderBy('category') + ->orderBy('code') + ->get(); + } + + #[Computed] + public function activeProfile(): ConfigProfile + { + if ($this->scope === 'workspace' && $this->workspaceId) { + $systemProfile = ConfigProfile::ensureSystem(); + + return ConfigProfile::ensureWorkspace($this->workspaceId, $systemProfile->id); + } + + return ConfigProfile::ensureSystem(); + } + + /** + * Get selected workspace (requires Tenant module). + * + * @return object|null Workspace model instance or null + */ + #[Computed] + public function selectedWorkspace(): ?object + { + if ($this->workspaceId && class_exists(Workspace::class)) { + return Workspace::find($this->workspaceId); + } + + return null; + } + + public function updatedScope(): void + { + if ($this->scope === 'system') { + $this->workspaceId = null; + } + $this->cancel(); + } + + public function updatedWorkspaceId(): void + { + $this->cancel(); + } + + public function getValue(ConfigKey $key): mixed + { + $value = ConfigValue::findValue($this->activeProfile->id, $key->id); + + return $value?->getTypedValue() ?? $key->getTypedDefault(); + } + + public function getInheritedValue(ConfigKey $key): mixed + { + if ($this->scope !== 'workspace') { + return null; + } + + $systemProfile = ConfigProfile::ensureSystem(); + $value = ConfigValue::findValue($systemProfile->id, $key->id); + + return $value?->getTypedValue(); + } + + public function isInherited(ConfigKey $key): bool + { + if ($this->scope !== 'workspace') { + return false; + } + + $workspaceValue = ConfigValue::findValue($this->activeProfile->id, $key->id); + + return $workspaceValue === null; + } + + public function isLocked(ConfigKey $key): bool + { + $value = ConfigValue::findValue($this->activeProfile->id, $key->id); + + return $value?->isLocked() ?? false; + } + + public function isLockedByParent(ConfigKey $key): bool + { + if ($this->scope !== 'workspace') { + return false; + } + + $systemProfile = ConfigProfile::ensureSystem(); + $value = ConfigValue::findValue($systemProfile->id, $key->id); + + return $value?->isLocked() ?? false; + } + + public function edit(int $keyId): void + { + $key = ConfigKey::find($keyId); + if ($key === null) { + return; + } + + $this->editingKeyId = $keyId; + $this->editValue = $this->getValue($key); + $this->editLocked = $this->isLocked($key); + } + + public function save(): void + { + if ($this->editingKeyId === null) { + return; + } + + $key = ConfigKey::find($this->editingKeyId); + if ($key === null) { + return; + } + + // Check if parent has locked this key + if ($this->isLockedByParent($key)) { + $this->dispatch('config-error', message: 'This key is locked at system level'); + + return; + } + + $this->config->set( + $key->code, + $this->editValue, + $this->activeProfile, + $this->editLocked, + ); + + $this->editingKeyId = null; + $this->editValue = null; + $this->editLocked = false; + + $this->dispatch('config-saved'); + } + + public function cancel(): void + { + $this->editingKeyId = null; + $this->editValue = null; + $this->editLocked = false; + } + + public function toggleLock(int $keyId): void + { + $key = ConfigKey::find($keyId); + if ($key === null) { + return; + } + + if ($this->isLocked($key)) { + $this->config->unlock($key->code, $this->activeProfile); + } else { + $this->config->lock($key->code, $this->activeProfile); + } + } + + public function clearOverride(int $keyId): void + { + if ($this->scope !== 'workspace') { + return; + } + + $key = ConfigKey::find($keyId); + if ($key === null) { + return; + } + + ConfigValue::where('profile_id', $this->activeProfile->id) + ->where('key_id', $key->id) + ->delete(); + + $this->dispatch('config-cleared'); + } + + public function render(): View + { + return view('core.config::admin.config-panel') + ->layout('hub::admin.layouts.app', ['title' => 'Configuration']); + } +} diff --git a/app/Core/Config/View/Modal/Admin/WorkspaceConfig.php b/app/Core/Config/View/Modal/Admin/WorkspaceConfig.php new file mode 100644 index 0000000..1a3cdda --- /dev/null +++ b/app/Core/Config/View/Modal/Admin/WorkspaceConfig.php @@ -0,0 +1,288 @@ + $namespaces + * @property-read ConfigProfile|null $workspaceProfile + * @property-read ConfigProfile $systemProfile + * @property-read object|null $workspace + * @property-read string $prefix + * @property-read array $tabs + */ +class WorkspaceConfig extends Component +{ + public ?string $path = null; + + protected ConfigService $config; + + /** + * Workspace service instance (from Tenant module when available). + */ + protected ?object $workspaceService = null; + + public function boot(ConfigService $config): void + { + $this->config = $config; + + // Try to resolve WorkspaceService if Tenant module is installed + if (class_exists(WorkspaceService::class)) { + $this->workspaceService = app(WorkspaceService::class); + } + } + + public function mount(?string $path = null): void + { + $this->path = $path; + } + + #[On('workspace-changed')] + public function workspaceChanged(): void + { + unset($this->workspace); + unset($this->workspaceProfile); + } + + public function navigate(string $path): void + { + $this->path = $path; + unset($this->prefix); + unset($this->depth); + unset($this->tabs); + unset($this->currentKeys); + } + + /** + * Get current workspace (requires Tenant module). + * + * @return object|null Workspace model instance or null + */ + #[Computed] + public function workspace(): ?object + { + return $this->workspaceService?->currentModel(); + } + + #[Computed] + public function prefix(): string + { + return $this->path ? str_replace('/', '.', $this->path) : ''; + } + + #[Computed] + public function depth(): int + { + return $this->path ? count(explode('/', $this->path)) : 0; + } + + #[Computed] + public function namespaces(): array + { + return ConfigKey::orderBy('code') + ->get() + ->map(fn ($key) => explode('.', $key->code)[0]) + ->unique() + ->values() + ->all(); + } + + #[Computed] + public function navItems(): array + { + return collect($this->namespaces)->map(fn ($ns) => [ + 'label' => ucfirst($ns), + 'action' => "navigate('{$ns}')", + 'current' => str_starts_with($this->path ?? '', $ns), + ])->all(); + } + + #[Computed] + public function tabs(): array + { + if ($this->depth !== 1) { + return []; + } + + $prefix = $this->prefix.'.'; + + return ConfigKey::where('code', 'like', $prefix.'%') + ->orderBy('code') + ->get() + ->filter(fn ($key) => count(explode('.', $key->code)) >= 3) + ->map(fn ($key) => explode('.', $key->code)[1]) + ->unique() + ->values() + ->all(); + } + + #[Computed] + public function tabItems(): array + { + return collect($this->tabs)->map(fn ($t) => [ + 'label' => ucfirst($t), + 'action' => "navigate('{$this->prefix}/{$t}')", + 'selected' => str_contains($this->path ?? '', '/'.$t), + ])->all(); + } + + #[Computed] + public function currentKeys(): array + { + if (! $this->path) { + return []; + } + + $prefix = $this->prefix.'.'; + + $allKeys = ConfigKey::where('code', 'like', $prefix.'%') + ->orderBy('code') + ->pluck('code') + ->all(); + + // Direct children: prefix + one segment (no dots in remainder) + $matches = array_filter($allKeys, fn ($code) => ! str_contains(substr($code, strlen($prefix)), '.')); + + return ConfigKey::whereIn('code', $matches) + ->orderBy('code') + ->get() + ->all(); + } + + #[Computed] + public function workspaceProfile(): ?ConfigProfile + { + if (! $this->workspace) { + return null; + } + + $systemProfile = ConfigProfile::ensureSystem(); + + return ConfigProfile::ensureWorkspace($this->workspace->id, $systemProfile->id); + } + + #[Computed] + public function systemProfile(): ConfigProfile + { + return ConfigProfile::ensureSystem(); + } + + public function settingName(string $code): string + { + $parts = explode('.', $code); + + return end($parts) ?: $code; + } + + public function getValue(ConfigKey $key): mixed + { + if (! $this->workspaceProfile) { + return $key->getTypedDefault(); + } + + $value = ConfigValue::findValue($this->workspaceProfile->id, $key->id); + + if ($value !== null) { + return $value->getTypedValue(); + } + + $systemValue = ConfigValue::findValue($this->systemProfile->id, $key->id); + + return $systemValue?->getTypedValue() ?? $key->getTypedDefault(); + } + + public function getInheritedValue(ConfigKey $key): mixed + { + $value = ConfigValue::findValue($this->systemProfile->id, $key->id); + + return $value?->getTypedValue() ?? $key->getTypedDefault(); + } + + public function isInherited(ConfigKey $key): bool + { + if (! $this->workspaceProfile) { + return true; + } + + $workspaceValue = ConfigValue::findValue($this->workspaceProfile->id, $key->id); + + return $workspaceValue === null; + } + + public function isLockedBySystem(ConfigKey $key): bool + { + $value = ConfigValue::findValue($this->systemProfile->id, $key->id); + + return $value?->isLocked() ?? false; + } + + public function toggleBool(int $keyId): void + { + $key = ConfigKey::find($keyId); + if (! $key || ! $this->workspaceProfile) { + return; + } + + if ($this->isLockedBySystem($key)) { + return; + } + + $currentValue = $this->getValue($key); + $this->config->set($key->code, ! $currentValue, $this->workspaceProfile, false); + } + + public function updateValue(int $keyId, mixed $value): void + { + $key = ConfigKey::find($keyId); + if (! $key || ! $this->workspaceProfile) { + return; + } + + if ($this->isLockedBySystem($key)) { + return; + } + + $this->config->set($key->code, $value, $this->workspaceProfile, false); + } + + public function clearOverride(int $keyId): void + { + if (! $this->workspaceProfile) { + return; + } + + ConfigValue::where('profile_id', $this->workspaceProfile->id) + ->where('key_id', $keyId) + ->delete(); + + $this->dispatch('config-cleared'); + } + + public function render(): View + { + return view('core.config::admin.workspace-config') + ->layout('hub::admin.layouts.app', ['title' => 'Settings']); + } +} diff --git a/app/Core/Console/Boot.php b/app/Core/Console/Boot.php new file mode 100644 index 0000000..05de287 --- /dev/null +++ b/app/Core/Console/Boot.php @@ -0,0 +1,35 @@ + 'onConsole', + ]; + + public function onConsole(ConsoleBooting $event): void + { + $event->command(Commands\InstallCommand::class); + $event->command(Commands\NewProjectCommand::class); + $event->command(Commands\MakeModCommand::class); + $event->command(Commands\MakePlugCommand::class); + $event->command(Commands\MakeWebsiteCommand::class); + $event->command(Commands\PruneEmailShieldStatsCommand::class); + $event->command(Commands\ScheduleSyncCommand::class); + } +} diff --git a/app/Core/Console/CLAUDE.md b/app/Core/Console/CLAUDE.md new file mode 100644 index 0000000..d26eff1 --- /dev/null +++ b/app/Core/Console/CLAUDE.md @@ -0,0 +1,32 @@ +# Core\Console + +Framework artisan commands registered via the `ConsoleBooting` lifecycle event. + +## Boot + +Uses the event-driven module loading pattern: + +```php +public static array $listens = [ + ConsoleBooting::class => 'onConsole', +]; +``` + +## Commands + +| Command | Signature | Purpose | +|---------|-----------|---------| +| `InstallCommand` | `core:install` | Framework setup wizard: env file, app config, migrations, app key, storage link. Supports `--dry-run` and `--force` | +| `NewProjectCommand` | `core:new` | Scaffold a new project | +| `MakeModCommand` | `make:mod {name}` | Generate a module in the `Mod` namespace with Boot.php. Flags: `--web`, `--admin`, `--api`, `--console`, `--all` | +| `MakePlugCommand` | `make:plug` | Generate a plugin scaffold | +| `MakeWebsiteCommand` | `make:website` | Generate a Website module scaffold | +| `PruneEmailShieldStatsCommand` | prunes `email_shield_stats` | Cleans old EmailShield validation stats | +| `ScheduleSyncCommand` | schedule sync | Schedule synchronisation | + +## Conventions + +- All commands use `declare(strict_types=1)` and the `Core\Console\Commands` namespace. +- `MakeModCommand` generates a complete module scaffold with optional handler stubs (web routes, admin panel, API, console). +- `InstallCommand` tracks progress via named installation steps and supports dry-run mode. +- Commands are registered via `$event->command()` on the `ConsoleBooting` event, not via a service provider's `$this->commands()`. diff --git a/app/Core/Console/Commands/CLAUDE.md b/app/Core/Console/Commands/CLAUDE.md new file mode 100644 index 0000000..52da328 --- /dev/null +++ b/app/Core/Console/Commands/CLAUDE.md @@ -0,0 +1,15 @@ +# Console/Commands/ — Core Framework Commands + +Artisan commands for framework scaffolding and maintenance. + +## Commands + +| Command | Signature | Purpose | +|---------|-----------|---------| +| `InstallCommand` | `core:install` | Framework installation wizard — sets up sensible defaults for new projects. | +| `MakeModCommand` | `core:make-mod` | Generates a new module scaffold in the `Mod` namespace with Boot.php event-driven loading pattern. | +| `MakePlugCommand` | `core:make-plug` | Generates a new plugin scaffold in the `Plug` namespace. | +| `MakeWebsiteCommand` | `core:make-website` | Generates a new website module scaffold in the `Website` namespace. | +| `NewProjectCommand` | `core:new` | Creates a complete new project from the Core PHP template. | +| `PruneEmailShieldStatsCommand` | `emailshield:prune` | Prunes old EmailShield validation statistics. | +| `ScheduleSyncCommand` | `core:schedule-sync` | Synchronises scheduled tasks across the application. | diff --git a/app/Core/Console/Commands/InstallCommand.php b/app/Core/Console/Commands/InstallCommand.php new file mode 100644 index 0000000..09aafba --- /dev/null +++ b/app/Core/Console/Commands/InstallCommand.php @@ -0,0 +1,495 @@ + + */ + protected array $installationSteps = [ + 'environment' => 'Setting up environment file', + 'application' => 'Configuring application settings', + 'migrations' => 'Running database migrations', + 'app_key' => 'Generating application key', + 'storage_link' => 'Creating storage symlink', + ]; + + /** + * Whether this is a dry run. + */ + protected bool $isDryRun = false; + + /** + * Track completed installation steps for rollback. + * + * @var array + */ + protected array $completedSteps = []; + + /** + * Original .env content for rollback. + */ + protected ?string $originalEnvContent = null; + + /** + * Execute the console command. + */ + public function handle(): int + { + $this->isDryRun = (bool) $this->option('dry-run'); + + $this->info(''); + $this->info(' '.__('core::core.installer.title')); + $this->info(' '.str_repeat('=', strlen(__('core::core.installer.title')))); + + if ($this->isDryRun) { + $this->warn(' [DRY RUN] No changes will be made'); + } + + $this->info(''); + + // Preserve original state for rollback (not needed in dry-run) + if (! $this->isDryRun) { + $this->preserveOriginalState(); + } + + try { + // Show progress bar for all steps + $this->info(' Installation Progress:'); + $this->info(''); + + $steps = $this->getInstallationSteps(); + $progressBar = $this->output->createProgressBar(count($steps)); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%'); + $progressBar->setMessage('Starting...'); + $progressBar->start(); + + // Step 1: Environment file + $progressBar->setMessage($this->installationSteps['environment']); + if (! $this->setupEnvironment()) { + $progressBar->finish(); + $this->newLine(); + + return self::FAILURE; + } + $progressBar->advance(); + + // Step 2: Application settings + $progressBar->setMessage($this->installationSteps['application']); + $progressBar->display(); + $this->newLine(); + $this->configureApplication(); + $progressBar->advance(); + + // Step 3: Database + $progressBar->setMessage($this->installationSteps['migrations']); + $progressBar->display(); + if ($this->option('no-interaction') || $this->isDryRun || $this->confirm(__('core::core.installer.prompts.run_migrations'), true)) { + $this->runMigrations(); + } + $progressBar->advance(); + + // Step 4: Generate app key if needed + $progressBar->setMessage($this->installationSteps['app_key']); + $this->generateAppKey(); + $progressBar->advance(); + + // Step 5: Create storage link + $progressBar->setMessage($this->installationSteps['storage_link']); + $this->createStorageLink(); + $progressBar->advance(); + + $progressBar->setMessage('Complete!'); + $progressBar->finish(); + $this->newLine(2); + + // Done! + if ($this->isDryRun) { + $this->info(' [DRY RUN] Installation preview complete. No changes were made.'); + } else { + $this->info(' '.__('core::core.installer.complete')); + } + $this->info(''); + $this->info(' '.__('core::core.installer.next_steps').':'); + $this->info(' 1. Run: valet link core'); + $this->info(' 2. Visit: http://core.test'); + $this->info(''); + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->newLine(); + $this->error(''); + $this->error(' Installation failed: '.$e->getMessage()); + $this->error(''); + + if (! $this->isDryRun) { + $this->rollback(); + } + + return self::FAILURE; + } + } + + /** + * Get the list of installation steps to execute. + * + * @return array + */ + protected function getInstallationSteps(): array + { + return array_keys($this->installationSteps); + } + + /** + * Log an action in dry-run mode or execute it. + */ + protected function dryRunOrExecute(string $description, callable $action): mixed + { + if ($this->isDryRun) { + $this->info(" [WOULD] {$description}"); + + return null; + } + + return $action(); + } + + /** + * Preserve original state for potential rollback. + */ + protected function preserveOriginalState(): void + { + $envPath = base_path('.env'); + if (File::exists($envPath)) { + $this->originalEnvContent = File::get($envPath); + } + } + + /** + * Rollback changes on installation failure. + */ + protected function rollback(): void + { + $this->warn(' Rolling back changes...'); + + // Restore original .env if we modified it + if (isset($this->completedSteps['env_created']) && $this->completedSteps['env_created']) { + $envPath = base_path('.env'); + if ($this->originalEnvContent !== null) { + File::put($envPath, $this->originalEnvContent); + $this->info(' [✓] Restored original .env file'); + } else { + File::delete($envPath); + $this->info(' [✓] Removed created .env file'); + } + } + + // Restore original .env content if we only modified values + if (isset($this->completedSteps['env_modified']) && $this->completedSteps['env_modified'] && $this->originalEnvContent !== null) { + File::put(base_path('.env'), $this->originalEnvContent); + $this->info(' [✓] Restored original .env configuration'); + } + + // Remove storage link if we created it + if (isset($this->completedSteps['storage_link']) && $this->completedSteps['storage_link']) { + $publicStorage = public_path('storage'); + if (File::exists($publicStorage) && is_link($publicStorage)) { + File::delete($publicStorage); + $this->info(' [✓] Removed storage symlink'); + } + } + + // Remove SQLite file if we created it + if (isset($this->completedSteps['sqlite_created']) && $this->completedSteps['sqlite_created']) { + $sqlitePath = database_path('database.sqlite'); + if (File::exists($sqlitePath)) { + File::delete($sqlitePath); + $this->info(' [✓] Removed SQLite database file'); + } + } + + $this->info(' Rollback complete.'); + } + + /** + * Set up the .env file. + */ + protected function setupEnvironment(): bool + { + $envPath = base_path('.env'); + $envExamplePath = base_path('.env.example'); + + if (File::exists($envPath) && ! $this->option('force')) { + $this->info(' [✓] '.__('core::core.installer.env_exists')); + + return true; + } + + if (! File::exists($envExamplePath)) { + $this->error(' [✗] '.__('core::core.installer.env_missing')); + + return false; + } + + if ($this->isDryRun) { + $this->info(' [WOULD] Copy .env.example to .env'); + } else { + File::copy($envExamplePath, $envPath); + $this->completedSteps['env_created'] = true; + } + $this->info(' [✓] '.__('core::core.installer.env_created')); + + return true; + } + + /** + * Configure application settings. + */ + protected function configureApplication(): void + { + if ($this->option('no-interaction')) { + $this->info(' [✓] '.__('core::core.installer.default_config')); + + return; + } + + if ($this->isDryRun) { + $this->info(' [WOULD] Prompt for app name, domain, and database settings'); + $this->info(' [WOULD] Update .env with configured values'); + $this->info(' [✓] '.__('core::core.installer.default_config').' (dry-run)'); + + return; + } + + // App name + $appName = $this->ask(__('core::core.installer.prompts.app_name'), __('core::core.brand.name')); + $this->updateEnv('APP_BRAND_NAME', $appName); + + // Domain + $domain = $this->ask(__('core::core.installer.prompts.domain'), 'core.test'); + $this->updateEnv('APP_DOMAIN', $domain); + $this->updateEnv('APP_URL', "http://{$domain}"); + + // Database + $this->info(''); + $this->info(' Database Configuration:'); + $dbConnection = $this->choice(__('core::core.installer.prompts.db_driver'), ['sqlite', 'mysql', 'pgsql'], 0); + + if ($dbConnection === 'sqlite') { + $this->updateEnv('DB_CONNECTION', 'sqlite'); + $this->updateEnv('DB_DATABASE', 'database/database.sqlite'); + + // Create SQLite file + $sqlitePath = database_path('database.sqlite'); + if (! File::exists($sqlitePath)) { + File::put($sqlitePath, ''); + $this->completedSteps['sqlite_created'] = true; + $this->info(' [✓] Created SQLite database'); + } + } else { + $this->updateEnv('DB_CONNECTION', $dbConnection); + $dbHost = $this->ask(__('core::core.installer.prompts.db_host'), '127.0.0.1'); + $dbPort = $this->ask(__('core::core.installer.prompts.db_port'), $dbConnection === 'mysql' ? '3306' : '5432'); + $dbName = $this->ask(__('core::core.installer.prompts.db_name'), 'core'); + $dbUser = $this->ask(__('core::core.installer.prompts.db_user'), 'root'); + $dbPass = $this->secret(__('core::core.installer.prompts.db_password')); + + $this->updateEnv('DB_HOST', $dbHost); + $this->updateEnv('DB_PORT', $dbPort); + $this->updateEnv('DB_DATABASE', $dbName); + $this->updateEnv('DB_USERNAME', $dbUser); + $this->updateEnv('DB_PASSWORD', $dbPass ?? ''); + + // Display masked confirmation (never show actual credentials) + $this->info(''); + $this->info(' Database settings configured:'); + $this->info(" Driver: {$dbConnection}"); + $this->info(" Host: {$dbHost}"); + $this->info(" Port: {$dbPort}"); + $this->info(" Database: {$dbName}"); + $this->info(' Username: '.$this->maskValue($dbUser)); + $this->info(' Password: '.$this->maskValue($dbPass ?? '', true)); + } + + $this->completedSteps['env_modified'] = true; + $this->info(' [✓] '.__('core::core.installer.config_saved')); + } + + /** + * Mask a sensitive value for display. + */ + protected function maskValue(string $value, bool $isPassword = false): string + { + if ($value === '') { + return $isPassword ? '[not set]' : '[empty]'; + } + + if ($isPassword) { + return str_repeat('*', min(strlen($value), 8)); + } + + $length = strlen($value); + if ($length <= 2) { + return str_repeat('*', $length); + } + + // Show first and last character with asterisks in between + return $value[0].str_repeat('*', $length - 2).$value[$length - 1]; + } + + /** + * Run database migrations. + */ + protected function runMigrations(): void + { + $this->info(''); + + if ($this->isDryRun) { + $this->info(' [WOULD] Run: php artisan migrate --force'); + $this->info(' [✓] '.__('core::core.installer.migrations_complete').' (dry-run)'); + + return; + } + + $this->info(' Running migrations...'); + + $this->call('migrate', ['--force' => true]); + + $this->info(' [✓] '.__('core::core.installer.migrations_complete')); + } + + /** + * Generate application key if not set. + */ + protected function generateAppKey(): void + { + $key = config('app.key'); + + if (empty($key) || $key === 'base64:') { + if ($this->isDryRun) { + $this->info(' [WOULD] Run: php artisan key:generate'); + $this->info(' [✓] '.__('core::core.installer.key_generated').' (dry-run)'); + } else { + $this->call('key:generate'); + $this->info(' [✓] '.__('core::core.installer.key_generated')); + } + } else { + $this->info(' [✓] '.__('core::core.installer.key_exists')); + } + } + + /** + * Create storage symlink. + */ + protected function createStorageLink(): void + { + $publicStorage = public_path('storage'); + + if (File::exists($publicStorage)) { + $this->info(' [✓] '.__('core::core.installer.storage_link_exists')); + + return; + } + + if ($this->isDryRun) { + $this->info(' [WOULD] Run: php artisan storage:link'); + $this->info(' [✓] '.__('core::core.installer.storage_link_created').' (dry-run)'); + + return; + } + + $this->call('storage:link'); + $this->completedSteps['storage_link'] = true; + $this->info(' [✓] '.__('core::core.installer.storage_link_created')); + } + + /** + * Update a value in the .env file. + */ + protected function updateEnv(string $key, string $value): void + { + $envPath = base_path('.env'); + + if (! File::exists($envPath)) { + return; + } + + $content = File::get($envPath); + + // Quote value if it contains spaces + if (str_contains($value, ' ')) { + $value = "\"{$value}\""; + } + + // Check if key exists (escape regex special chars in key) + $escapedKey = preg_quote($key, '/'); + if (preg_match("/^{$escapedKey}=/m", $content)) { + // Update existing key + $content = preg_replace( + "/^{$escapedKey}=.*/m", + "{$key}={$value}", + $content + ); + } else { + // Add new key + $content .= "\n{$key}={$value}"; + } + + File::put($envPath, $content); + } + + /** + * Get shell completion suggestions for options. + * + * This command has no option values that need completion hints, + * but implements the method for consistency with other commands. + */ + public function complete( + CompletionInput $input, + CompletionSuggestions $suggestions + ): void { + // No argument/option values need completion for this command + // All options are flags (--force, --no-interaction, --dry-run) + } +} diff --git a/app/Core/Console/Commands/MakeModCommand.php b/app/Core/Console/Commands/MakeModCommand.php new file mode 100644 index 0000000..d6ad61b --- /dev/null +++ b/app/Core/Console/Commands/MakeModCommand.php @@ -0,0 +1,529 @@ + + */ + protected array $createdFiles = []; + + /** + * Execute the console command. + */ + public function handle(): int + { + $name = Str::studly($this->argument('name')); + $modulePath = $this->getModulePath($name); + + if (File::isDirectory($modulePath) && ! $this->option('force')) { + $this->newLine(); + $this->components->error("Module [{$name}] already exists!"); + $this->newLine(); + $this->components->warn('Use --force to overwrite the existing module.'); + $this->newLine(); + + return self::FAILURE; + } + + $this->newLine(); + $this->components->info("Creating module: {$name}"); + $this->newLine(); + + // Create directory structure + $this->createDirectoryStructure($modulePath); + + // Create Boot.php + $this->createBootFile($modulePath, $name); + + // Create optional route files based on flags + $this->createOptionalFiles($modulePath, $name); + + // Show summary table of created files + $this->newLine(); + $this->components->twoColumnDetail('Created Files', 'Description'); + foreach ($this->createdFiles as $file) { + $this->components->twoColumnDetail( + "{$file['file']}", + "{$file['description']}" + ); + } + + $this->newLine(); + $this->components->info("Module [{$name}] created successfully!"); + $this->newLine(); + $this->components->twoColumnDetail('Location', "{$modulePath}"); + $this->newLine(); + + $this->components->info('Next steps:'); + $this->line(' 1. Add your module logic to the Boot.php event handlers'); + $this->line(' 2. Create Models, Views, and Controllers as needed'); + $this->newLine(); + + return self::SUCCESS; + } + + /** + * Get the path for the module. + */ + protected function getModulePath(string $name): string + { + // Check for packages structure first (monorepo) + $packagesPath = base_path("packages/core-php/src/Mod/{$name}"); + if (File::isDirectory(dirname($packagesPath))) { + return $packagesPath; + } + + // Fall back to app/Mod for consuming applications + return base_path("app/Mod/{$name}"); + } + + /** + * Create the directory structure for the module. + */ + protected function createDirectoryStructure(string $modulePath): void + { + $directories = [ + $modulePath, + "{$modulePath}/Models", + "{$modulePath}/View", + "{$modulePath}/View/Blade", + ]; + + if ($this->hasRoutes()) { + $directories[] = "{$modulePath}/Routes"; + } + + if ($this->option('console') || $this->option('all')) { + $directories[] = "{$modulePath}/Console"; + $directories[] = "{$modulePath}/Console/Commands"; + } + + foreach ($directories as $directory) { + File::ensureDirectoryExists($directory); + } + + $this->components->task('Creating directory structure', fn () => true); + } + + /** + * Check if any route handlers are requested. + */ + protected function hasRoutes(): bool + { + return $this->option('web') + || $this->option('admin') + || $this->option('api') + || $this->option('all'); + } + + /** + * Create the Boot.php file. + */ + protected function createBootFile(string $modulePath, string $name): void + { + $namespace = $this->resolveNamespace($modulePath, $name); + $listeners = $this->buildListenersArray(); + $handlers = $this->buildHandlerMethods($name); + + $content = <<buildUseStatements()} + +/** + * {$name} Module - Event-driven module registration. + * + * This module uses the lazy loading pattern where handlers + * are only invoked when their corresponding events fire. + */ +class Boot +{ + /** + * Events this module listens to for lazy loading. + * + * @var array + */ + public static array \$listens = [ +{$listeners} + ]; +{$handlers} +} + +PHP; + + File::put("{$modulePath}/Boot.php", $content); + $this->createdFiles[] = ['file' => 'Boot.php', 'description' => 'Event-driven module loader']; + $this->components->task('Creating Boot.php', fn () => true); + } + + /** + * Resolve the namespace for the module. + */ + protected function resolveNamespace(string $modulePath, string $name): string + { + if (str_contains($modulePath, 'packages/core-php/src/Mod')) { + return "Core\\Mod\\{$name}"; + } + + return "Mod\\{$name}"; + } + + /** + * Build the use statements for the Boot file. + */ + protected function buildUseStatements(): string + { + $statements = []; + + if ($this->option('web') || $this->option('all')) { + $statements[] = 'use Core\Events\WebRoutesRegistering;'; + } + + if ($this->option('admin') || $this->option('all')) { + $statements[] = 'use Core\Events\AdminPanelBooting;'; + } + + if ($this->option('api') || $this->option('all')) { + $statements[] = 'use Core\Events\ApiRoutesRegistering;'; + } + + if ($this->option('console') || $this->option('all')) { + $statements[] = 'use Core\Events\ConsoleBooting;'; + } + + if (empty($statements)) { + $statements[] = 'use Core\Events\WebRoutesRegistering;'; + } + + return implode("\n", $statements); + } + + /** + * Build the listeners array content. + */ + protected function buildListenersArray(): string + { + $listeners = []; + + if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) { + $listeners[] = " WebRoutesRegistering::class => 'onWebRoutes',"; + } + + if ($this->option('admin') || $this->option('all')) { + $listeners[] = " AdminPanelBooting::class => 'onAdminPanel',"; + } + + if ($this->option('api') || $this->option('all')) { + $listeners[] = " ApiRoutesRegistering::class => 'onApiRoutes',"; + } + + if ($this->option('console') || $this->option('all')) { + $listeners[] = " ConsoleBooting::class => 'onConsole',"; + } + + return implode("\n", $listeners); + } + + /** + * Check if any specific option was provided. + */ + protected function hasAnyOption(): bool + { + return $this->option('web') + || $this->option('admin') + || $this->option('api') + || $this->option('console') + || $this->option('all'); + } + + /** + * Build the handler methods. + */ + protected function buildHandlerMethods(string $name): string + { + $methods = []; + $moduleName = Str::snake($name); + + if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) { + $methods[] = <<views('{$moduleName}', __DIR__.'/View/Blade'); + + if (file_exists(__DIR__.'/Routes/web.php')) { + \$event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + } +PHP; + } + + if ($this->option('admin') || $this->option('all')) { + $methods[] = <<livewire('{$moduleName}.admin.index', View\Modal\Admin\Index::class); + + if (file_exists(__DIR__.'/Routes/admin.php')) { + \$event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } + } +PHP; + } + + if ($this->option('api') || $this->option('all')) { + $methods[] = <<<'PHP' + + /** + * Register API routes. + */ + public function onApiRoutes(ApiRoutesRegistering $event): void + { + if (file_exists(__DIR__.'/Routes/api.php')) { + $event->routes(fn () => require __DIR__.'/Routes/api.php'); + } + } +PHP; + } + + if ($this->option('console') || $this->option('all')) { + $methods[] = <<command(Console\Commands\ExampleCommand::class); + } +PHP; + } + + return implode("\n", $methods); + } + + /** + * Create optional files based on flags. + */ + protected function createOptionalFiles(string $modulePath, string $name): void + { + $moduleName = Str::snake($name); + + if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) { + $this->createWebRoutes($modulePath, $moduleName); + } + + if ($this->option('admin') || $this->option('all')) { + $this->createAdminRoutes($modulePath, $moduleName); + } + + if ($this->option('api') || $this->option('all')) { + $this->createApiRoutes($modulePath, $moduleName); + } + + // Create a sample view + $this->createSampleView($modulePath, $moduleName); + } + + /** + * Create web routes file. + */ + protected function createWebRoutes(string $modulePath, string $moduleName): void + { + $content = <<group(function () { + Route::get('/', function () { + return view('{$moduleName}::index'); + })->name('{$moduleName}.index'); +}); + +PHP; + + File::put("{$modulePath}/Routes/web.php", $content); + $this->createdFiles[] = ['file' => 'Routes/web.php', 'description' => 'Public web routes']; + $this->components->task('Creating Routes/web.php', fn () => true); + } + + /** + * Create admin routes file. + */ + protected function createAdminRoutes(string $modulePath, string $moduleName): void + { + $content = <<name('{$moduleName}.admin.')->group(function () { + Route::get('/', function () { + return view('{$moduleName}::admin.index'); + })->name('index'); +}); + +PHP; + + File::put("{$modulePath}/Routes/admin.php", $content); + $this->createdFiles[] = ['file' => 'Routes/admin.php', 'description' => 'Admin panel routes']; + $this->components->task('Creating Routes/admin.php', fn () => true); + } + + /** + * Create API routes file. + */ + protected function createApiRoutes(string $modulePath, string $moduleName): void + { + $content = <<name('api.{$moduleName}.')->group(function () { + Route::get('/', function () { + return response()->json(['module' => '{$moduleName}', 'status' => 'ok']); + })->name('index'); +}); + +PHP; + + File::put("{$modulePath}/Routes/api.php", $content); + $this->createdFiles[] = ['file' => 'Routes/api.php', 'description' => 'REST API routes']; + $this->components->task('Creating Routes/api.php', fn () => true); + } + + /** + * Create a sample view file. + */ + protected function createSampleView(string $modulePath, string $moduleName): void + { + $content = << + {$moduleName} + +
+

{$moduleName} Module

+

Welcome to the {$moduleName} module.

+
+ + +BLADE; + + File::put("{$modulePath}/View/Blade/index.blade.php", $content); + $this->createdFiles[] = ['file' => 'View/Blade/index.blade.php', 'description' => 'Sample index view']; + $this->components->task('Creating View/Blade/index.blade.php', fn () => true); + } + + /** + * Get shell completion suggestions for arguments. + */ + public function complete( + CompletionInput $input, + CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestArgumentValuesFor('name')) { + // Suggest common module naming patterns + $suggestions->suggestValues([ + 'Auth', + 'Blog', + 'Content', + 'Dashboard', + 'Media', + 'Settings', + 'Users', + ]); + } + } +} diff --git a/app/Core/Console/Commands/MakePlugCommand.php b/app/Core/Console/Commands/MakePlugCommand.php new file mode 100644 index 0000000..872c6b2 --- /dev/null +++ b/app/Core/Console/Commands/MakePlugCommand.php @@ -0,0 +1,631 @@ + + */ + protected array $createdOperations = []; + + /** + * Execute the console command. + */ + public function handle(): int + { + $name = Str::studly($this->argument('name')); + $category = Str::studly($this->option('category')); + + if (! in_array($category, self::CATEGORIES)) { + $this->newLine(); + $this->components->error("Invalid category [{$category}]."); + $this->newLine(); + $this->components->bulletList(self::CATEGORIES); + $this->newLine(); + + return self::FAILURE; + } + + $providerPath = $this->getProviderPath($category, $name); + + if (File::isDirectory($providerPath) && ! $this->option('force')) { + $this->newLine(); + $this->components->error("Provider [{$name}] already exists in [{$category}]!"); + $this->newLine(); + $this->components->warn('Use --force to overwrite the existing provider.'); + $this->newLine(); + + return self::FAILURE; + } + + $this->newLine(); + $this->components->info("Creating Plug provider: {$category}/{$name}"); + $this->newLine(); + + // Create directory structure + File::ensureDirectoryExists($providerPath); + $this->components->task('Creating provider directory', fn () => true); + + // Create operations based on flags + $this->createOperations($providerPath, $category, $name); + + // Show summary table of created operations + $this->newLine(); + $this->components->twoColumnDetail('Created Operations', 'Description'); + foreach ($this->createdOperations as $op) { + $this->components->twoColumnDetail( + "{$op['operation']}", + "{$op['description']}" + ); + } + + $this->newLine(); + $this->components->info("Plug provider [{$category}/{$name}] created successfully!"); + $this->newLine(); + $this->components->twoColumnDetail('Location', "{$providerPath}"); + $this->newLine(); + + $this->components->info('Usage example:'); + $this->line(" use Plug\\{$category}\\{$name}\\Auth;"); + $this->newLine(); + $this->line(' $auth = new Auth(\$clientId, \$clientSecret, \$redirectUrl);'); + $this->line(' $authUrl = \$auth->getAuthUrl();'); + $this->newLine(); + + return self::SUCCESS; + } + + /** + * Get the path for the provider. + */ + protected function getProviderPath(string $category, string $name): string + { + // Check for packages structure first (monorepo) + $packagesPath = base_path("packages/core-php/src/Plug/{$category}/{$name}"); + if (File::isDirectory(dirname(dirname($packagesPath)))) { + return $packagesPath; + } + + // Fall back to app/Plug for consuming applications + return base_path("app/Plug/{$category}/{$name}"); + } + + /** + * Resolve the namespace for the provider. + */ + protected function resolveNamespace(string $providerPath, string $category, string $name): string + { + if (str_contains($providerPath, 'packages/core-php/src/Plug')) { + return "Core\\Plug\\{$category}\\{$name}"; + } + + return "Plug\\{$category}\\{$name}"; + } + + /** + * Create operations based on flags. + */ + protected function createOperations(string $providerPath, string $category, string $name): void + { + $namespace = $this->resolveNamespace($providerPath, $category, $name); + + // Always create Auth if --auth or --all or no specific options + if ($this->option('auth') || $this->option('all') || ! $this->hasAnyOperation()) { + $this->createAuthOperation($providerPath, $namespace, $name); + } + + if ($this->option('post') || $this->option('all')) { + $this->createPostOperation($providerPath, $namespace, $name); + } + + if ($this->option('delete') || $this->option('all')) { + $this->createDeleteOperation($providerPath, $namespace, $name); + } + + if ($this->option('media') || $this->option('all')) { + $this->createMediaOperation($providerPath, $namespace, $name); + } + } + + /** + * Check if any operation option was provided. + */ + protected function hasAnyOperation(): bool + { + return $this->option('auth') + || $this->option('post') + || $this->option('delete') + || $this->option('media') + || $this->option('all'); + } + + /** + * Create the Auth operation. + */ + protected function createAuthOperation(string $providerPath, string $namespace, string $name): void + { + $content = <<clientId = \$clientId; + \$this->clientSecret = \$clientSecret; + \$this->redirectUrl = \$redirectUrl; + } + + /** + * Get the provider display name. + */ + public static function name(): string + { + return '{$name}'; + } + + /** + * Set OAuth scopes. + * + * @param string[] \$scopes + */ + public function withScopes(array \$scopes): static + { + \$this->scopes = \$scopes; + + return \$this; + } + + /** + * Get the authorization URL for user redirect. + */ + public function getAuthUrl(?string \$state = null): string + { + \$params = [ + 'client_id' => \$this->clientId, + 'redirect_uri' => \$this->redirectUrl, + 'response_type' => 'code', + 'scope' => implode(' ', \$this->scopes), + ]; + + if (\$state) { + \$params['state'] = \$state; + } + + // TODO: [USER] Replace with your provider's OAuth authorization URL + // Example: return 'https://api.twitter.com/oauth/authorize?' . http_build_query(\$params); + return 'https://example.com/oauth/authorize?'.http_build_query(\$params); + } + + /** + * Exchange authorization code for access token. + */ + public function exchangeCode(string \$code): Response + { + // TODO: [USER] Implement token exchange with your provider's API + // Make a POST request to the provider's token endpoint with the authorization code + return \$this->ok([ + 'access_token' => '', + 'refresh_token' => '', + 'expires_in' => 0, + ]); + } + + /** + * Refresh an expired access token. + */ + public function refreshToken(string \$refreshToken): Response + { + // TODO: [USER] Implement token refresh with your provider's API + // Use the refresh token to obtain a new access token + return \$this->ok([ + 'access_token' => '', + 'refresh_token' => '', + 'expires_in' => 0, + ]); + } + + /** + * Revoke an access token. + */ + public function revokeToken(string \$accessToken): Response + { + // TODO: [USER] Implement token revocation with your provider's API + // Call the provider's revocation endpoint to invalidate the token + return \$this->ok(['revoked' => true]); + } + + /** + * Get an HTTP client instance. + */ + protected function http(): PendingRequest + { + return Http::acceptJson() + ->timeout(30); + } +} + +PHP; + + File::put("{$providerPath}/Auth.php", $content); + $this->createdOperations[] = ['operation' => 'Auth.php', 'description' => 'OAuth 2.0 authentication']; + $this->components->task('Creating Auth.php', fn () => true); + } + + /** + * Create the Post operation. + */ + protected function createPostOperation(string $providerPath, string $namespace, string $name): void + { + $content = <<http() + // ->withToken(\$this->accessToken()) + // ->post('https://api.example.com/posts', [ + // 'text' => \$content, + // ...\$options, + // ]); + // + // return \$this->fromResponse(\$response); + + return \$this->ok([ + 'id' => '', + 'url' => '', + 'created_at' => now()->toIso8601String(), + ]); + } + + /** + * Schedule a post for later. + */ + public function schedule(string \$content, \DateTimeInterface \$publishAt, array \$options = []): Response + { + // TODO: [USER] Implement scheduled posting with your provider's API + return \$this->ok([ + 'id' => '', + 'scheduled_at' => \$publishAt->format('c'), + ]); + } + + /** + * Get an HTTP client instance. + */ + protected function http(): PendingRequest + { + return Http::acceptJson() + ->timeout(30); + } +} + +PHP; + + File::put("{$providerPath}/Post.php", $content); + $this->createdOperations[] = ['operation' => 'Post.php', 'description' => 'Content creation/publishing']; + $this->components->task('Creating Post.php', fn () => true); + } + + /** + * Create the Delete operation. + */ + protected function createDeleteOperation(string $providerPath, string $namespace, string $name): void + { + $content = <<http() + // ->withToken(\$this->accessToken()) + // ->delete("https://api.example.com/posts/{\$postId}"); + // + // return \$this->fromResponse(\$response); + + return \$this->ok(['deleted' => true]); + } + + /** + * Get an HTTP client instance. + */ + protected function http(): PendingRequest + { + return Http::acceptJson() + ->timeout(30); + } +} + +PHP; + + File::put("{$providerPath}/Delete.php", $content); + $this->createdOperations[] = ['operation' => 'Delete.php', 'description' => 'Content deletion']; + $this->components->task('Creating Delete.php', fn () => true); + } + + /** + * Create the Media operation. + */ + protected function createMediaOperation(string $providerPath, string $namespace, string $name): void + { + $content = <<http() + // ->withToken(\$this->accessToken()) + // ->attach('media', file_get_contents(\$filePath), basename(\$filePath)) + // ->post('https://api.example.com/media/upload', \$options); + // + // return \$this->fromResponse(\$response); + + return \$this->ok([ + 'media_id' => '', + 'url' => '', + ]); + } + + /** + * Upload media from a URL. + */ + public function uploadFromUrl(string \$url, array \$options = []): Response + { + // TODO: [USER] Implement URL-based media upload with your provider's API + return \$this->ok([ + 'media_id' => '', + 'url' => '', + ]); + } + + /** + * Get an HTTP client instance. + */ + protected function http(): PendingRequest + { + return Http::acceptJson() + ->timeout(60); // Longer timeout for uploads + } +} + +PHP; + + File::put("{$providerPath}/Media.php", $content); + $this->createdOperations[] = ['operation' => 'Media.php', 'description' => 'Media file uploads']; + $this->components->task('Creating Media.php', fn () => true); + } + + /** + * Get shell completion suggestions for arguments and options. + */ + public function complete( + CompletionInput $input, + CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestArgumentValuesFor('name')) { + // Suggest common social platform names + $suggestions->suggestValues([ + 'Twitter', + 'Instagram', + 'Facebook', + 'LinkedIn', + 'TikTok', + 'YouTube', + 'Mastodon', + 'Threads', + 'Bluesky', + ]); + } + + if ($input->mustSuggestOptionValuesFor('category')) { + $suggestions->suggestValues(self::CATEGORIES); + } + } +} diff --git a/app/Core/Console/Commands/MakeWebsiteCommand.php b/app/Core/Console/Commands/MakeWebsiteCommand.php new file mode 100644 index 0000000..2b34e3f --- /dev/null +++ b/app/Core/Console/Commands/MakeWebsiteCommand.php @@ -0,0 +1,605 @@ + + */ + protected array $createdFiles = []; + + /** + * Execute the console command. + */ + public function handle(): int + { + $name = Str::studly($this->argument('name')); + $domain = $this->option('domain') ?: Str::snake($name, '-').'.test'; + $websitePath = $this->getWebsitePath($name); + + if (File::isDirectory($websitePath) && ! $this->option('force')) { + $this->newLine(); + $this->components->error("Website [{$name}] already exists!"); + $this->newLine(); + $this->components->warn('Use --force to overwrite the existing website.'); + $this->newLine(); + + return self::FAILURE; + } + + $this->newLine(); + $this->components->info("Creating website: {$name}"); + $this->components->twoColumnDetail('Domain', "{$domain}"); + $this->newLine(); + + // Create directory structure + $this->createDirectoryStructure($websitePath); + + // Create Boot.php + $this->createBootFile($websitePath, $name, $domain); + + // Create optional route files + $this->createOptionalFiles($websitePath, $name); + + // Show summary table of created files + $this->newLine(); + $this->components->twoColumnDetail('Created Files', 'Description'); + foreach ($this->createdFiles as $file) { + $this->components->twoColumnDetail( + "{$file['file']}", + "{$file['description']}" + ); + } + + $this->newLine(); + $this->components->info("Website [{$name}] created successfully!"); + $this->newLine(); + $this->components->twoColumnDetail('Location', "{$websitePath}"); + $this->newLine(); + + $this->components->info('Next steps:'); + $this->line(" 1. Configure your local dev server to serve {$domain}"); + $this->line(' (e.g., valet link '.Str::snake($name, '-').')'); + $this->line(" 2. Visit http://{$domain} to see your website"); + $this->line(' 3. Add routes, views, and controllers as needed'); + $this->newLine(); + + return self::SUCCESS; + } + + /** + * Get the path for the website. + */ + protected function getWebsitePath(string $name): string + { + // Websites go in app/Website for consuming applications + return base_path("app/Website/{$name}"); + } + + /** + * Create the directory structure for the website. + */ + protected function createDirectoryStructure(string $websitePath): void + { + $directories = [ + $websitePath, + "{$websitePath}/View", + "{$websitePath}/View/Blade", + "{$websitePath}/View/Blade/layouts", + ]; + + if ($this->hasRoutes()) { + $directories[] = "{$websitePath}/Routes"; + } + + foreach ($directories as $directory) { + File::ensureDirectoryExists($directory); + } + + $this->components->task('Creating directory structure', fn () => true); + } + + /** + * Check if any route handlers are requested. + */ + protected function hasRoutes(): bool + { + return $this->option('web') + || $this->option('admin') + || $this->option('api') + || $this->option('all') + || ! $this->hasAnyOption(); + } + + /** + * Check if any specific option was provided. + */ + protected function hasAnyOption(): bool + { + return $this->option('web') + || $this->option('admin') + || $this->option('api') + || $this->option('all'); + } + + /** + * Create the Boot.php file. + */ + protected function createBootFile(string $websitePath, string $name, string $domain): void + { + $namespace = "Website\\{$name}"; + $domainPattern = $this->buildDomainPattern($domain); + $listeners = $this->buildListenersArray(); + $handlers = $this->buildHandlerMethods($name); + + $content = <<buildUseStatements()} +use Illuminate\Support\ServiceProvider; + +/** + * {$name} Website - Domain-isolated website provider. + * + * This website is loaded when the incoming HTTP host matches + * the domain pattern defined in \$domains. + */ +class Boot extends ServiceProvider +{ + /** + * Domain patterns this website responds to. + * + * Uses regex patterns. Common examples: + * - '/^example\\.test\$/' - exact match + * - '/^example\\.(com|test)\$/' - multiple TLDs + * - '/^(www\\.)?example\\.com\$/' - optional www + * + * @var array + */ + public static array \$domains = [ + '{$domainPattern}', + ]; + + /** + * Events this module listens to for lazy loading. + * + * @var array + */ + public static array \$listens = [ + DomainResolving::class => 'onDomainResolving', +{$listeners} + ]; + + /** + * Register any application services. + */ + public function register(): void + { + // + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + // + } + + /** + * Handle domain resolution - register if domain matches. + */ + public function onDomainResolving(DomainResolving \$event): void + { + foreach (self::\$domains as \$pattern) { + if (\$event->matches(\$pattern)) { + \$event->register(self::class); + + return; + } + } + } +{$handlers} +} + +PHP; + + File::put("{$websitePath}/Boot.php", $content); + $this->createdFiles[] = ['file' => 'Boot.php', 'description' => 'Domain-isolated website provider']; + $this->components->task('Creating Boot.php', fn () => true); + } + + /** + * Build the domain regex pattern. + */ + protected function buildDomainPattern(string $domain): string + { + // Escape dots and create a regex pattern + $escaped = preg_quote($domain, '/'); + + return '/^'.$escaped.'$/'; + } + + /** + * Build the use statements for the Boot file. + */ + protected function buildUseStatements(): string + { + $statements = []; + + if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) { + $statements[] = 'use Core\Events\WebRoutesRegistering;'; + } + + if ($this->option('admin') || $this->option('all')) { + $statements[] = 'use Core\Events\AdminPanelBooting;'; + } + + if ($this->option('api') || $this->option('all')) { + $statements[] = 'use Core\Events\ApiRoutesRegistering;'; + } + + return implode("\n", $statements); + } + + /** + * Build the listeners array content (excluding DomainResolving). + */ + protected function buildListenersArray(): string + { + $listeners = []; + + if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) { + $listeners[] = " WebRoutesRegistering::class => 'onWebRoutes',"; + } + + if ($this->option('admin') || $this->option('all')) { + $listeners[] = " AdminPanelBooting::class => 'onAdminPanel',"; + } + + if ($this->option('api') || $this->option('all')) { + $listeners[] = " ApiRoutesRegistering::class => 'onApiRoutes',"; + } + + return implode("\n", $listeners); + } + + /** + * Build the handler methods. + */ + protected function buildHandlerMethods(string $name): string + { + $methods = []; + $websiteName = Str::snake($name); + + if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) { + $methods[] = <<views('{$websiteName}', __DIR__.'/View/Blade'); + + if (file_exists(__DIR__.'/Routes/web.php')) { + \$event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + } +PHP; + } + + if ($this->option('admin') || $this->option('all')) { + $methods[] = <<<'PHP' + + /** + * Register admin panel routes. + */ + public function onAdminPanel(AdminPanelBooting $event): void + { + if (file_exists(__DIR__.'/Routes/admin.php')) { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } + } +PHP; + } + + if ($this->option('api') || $this->option('all')) { + $methods[] = <<<'PHP' + + /** + * Register API routes. + */ + public function onApiRoutes(ApiRoutesRegistering $event): void + { + if (file_exists(__DIR__.'/Routes/api.php')) { + $event->routes(fn () => require __DIR__.'/Routes/api.php'); + } + } +PHP; + } + + return implode("\n", $methods); + } + + /** + * Create optional files based on flags. + */ + protected function createOptionalFiles(string $websitePath, string $name): void + { + $websiteName = Str::snake($name); + + if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) { + $this->createWebRoutes($websitePath, $websiteName); + $this->createLayout($websitePath, $name); + $this->createHomepage($websitePath, $websiteName); + } + + if ($this->option('admin') || $this->option('all')) { + $this->createAdminRoutes($websitePath, $websiteName); + } + + if ($this->option('api') || $this->option('all')) { + $this->createApiRoutes($websitePath, $websiteName); + } + } + + /** + * Create web routes file. + */ + protected function createWebRoutes(string $websitePath, string $websiteName): void + { + $content = <<name('{$websiteName}.home'); + +PHP; + + File::put("{$websitePath}/Routes/web.php", $content); + $this->createdFiles[] = ['file' => 'Routes/web.php', 'description' => 'Public web routes']; + $this->components->task('Creating Routes/web.php', fn () => true); + } + + /** + * Create admin routes file. + */ + protected function createAdminRoutes(string $websitePath, string $websiteName): void + { + $content = <<name('{$websiteName}.admin.')->group(function () { + Route::get('/', function () { + return 'Admin dashboard for {$websiteName}'; + })->name('index'); +}); + +PHP; + + File::put("{$websitePath}/Routes/admin.php", $content); + $this->createdFiles[] = ['file' => 'Routes/admin.php', 'description' => 'Admin panel routes']; + $this->components->task('Creating Routes/admin.php', fn () => true); + } + + /** + * Create API routes file. + */ + protected function createApiRoutes(string $websitePath, string $websiteName): void + { + $content = <<name('api.{$websiteName}.')->group(function () { + Route::get('/health', function () { + return response()->json(['status' => 'ok', 'website' => '{$websiteName}']); + })->name('health'); +}); + +PHP; + + File::put("{$websitePath}/Routes/api.php", $content); + $this->createdFiles[] = ['file' => 'Routes/api.php', 'description' => 'REST API routes']; + $this->components->task('Creating Routes/api.php', fn () => true); + } + + /** + * Create a base layout file. + */ + protected function createLayout(string $websitePath, string $name): void + { + $content = << + + + + + + + {{ \$title ?? '{$name}' }} + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ + + + +
+ {{ \$slot }} +
+
+ + + +BLADE; + + File::put("{$websitePath}/View/Blade/layouts/app.blade.php", $content); + $this->createdFiles[] = ['file' => 'View/Blade/layouts/app.blade.php', 'description' => 'Base layout template']; + $this->components->task('Creating View/Blade/layouts/app.blade.php', fn () => true); + } + + /** + * Create a homepage view. + */ + protected function createHomepage(string $websitePath, string $websiteName): void + { + $name = Str::studly($websiteName); + + $content = << + Welcome - {$name} + +
+
+
+
+

Welcome to {$name}

+

+ This is your new website. Start building something amazing! +

+
+
+
+
+ + +BLADE; + + File::put("{$websitePath}/View/Blade/home.blade.php", $content); + $this->createdFiles[] = ['file' => 'View/Blade/home.blade.php', 'description' => 'Homepage view']; + $this->components->task('Creating View/Blade/home.blade.php', fn () => true); + } + + /** + * Get shell completion suggestions for arguments and options. + */ + public function complete( + CompletionInput $input, + CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestArgumentValuesFor('name')) { + // Suggest common website naming patterns + $suggestions->suggestValues([ + 'MarketingSite', + 'Blog', + 'Documentation', + 'LandingPage', + 'Portal', + 'Dashboard', + 'Support', + ]); + } + + if ($input->mustSuggestOptionValuesFor('domain')) { + // Suggest common development domains + $suggestions->suggestValues([ + 'example.test', + 'app.test', + 'site.test', + 'dev.test', + ]); + } + } +} diff --git a/app/Core/Console/Commands/NewProjectCommand.php b/app/Core/Console/Commands/NewProjectCommand.php new file mode 100644 index 0000000..8e75d34 --- /dev/null +++ b/app/Core/Console/Commands/NewProjectCommand.php @@ -0,0 +1,370 @@ +argument('name'); + $directory = getcwd().'/'.$name; + + // Validate project name + if (! $this->validateProjectName($name)) { + return self::FAILURE; + } + + // Check if directory exists + if (File::isDirectory($directory) && ! $this->option('force')) { + $this->newLine(); + $this->components->error("Directory [{$name}] already exists!"); + $this->newLine(); + $this->components->warn('Use --force to overwrite the existing directory.'); + $this->newLine(); + + return self::FAILURE; + } + + $this->newLine(); + $this->components->info(' ╔═══════════════════════════════════════════╗'); + $this->components->info(' ║ Core PHP Framework Project Creator ║'); + $this->components->info(' ╚═══════════════════════════════════════════╝'); + $this->newLine(); + + $template = $this->option('template') ?: $this->defaultTemplate; + $this->components->twoColumnDetail('Project Name', $name); + $this->components->twoColumnDetail('Template', $template); + $this->components->twoColumnDetail('Directory', $directory); + $this->newLine(); + + try { + // Step 1: Create project from template + $this->components->task('Creating project from template', function () use ($directory, $template, $name) { + return $this->createFromTemplate($directory, $template, $name); + }); + + // Step 2: Install dependencies + if (! $this->option('no-install')) { + $this->components->task('Installing Composer dependencies', function () use ($directory) { + return $this->installDependencies($directory); + }); + + // Step 3: Run core:install + $this->components->task('Running framework installation', function () use ($directory) { + return $this->runCoreInstall($directory); + }); + } + + // Success! + $this->newLine(); + $this->components->info(' ✓ Project created successfully!'); + $this->newLine(); + + $this->components->info(' Next steps:'); + $this->line(" 1. cd {$name}"); + if ($this->option('no-install')) { + $this->line(' 2. composer install'); + $this->line(' 3. php artisan core:install'); + $this->line(' 4. php artisan serve'); + } else { + $this->line(' 2. php artisan serve'); + } + $this->newLine(); + + $this->showPackageInfo(); + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->newLine(); + $this->components->error(' Project creation failed: '.$e->getMessage()); + $this->newLine(); + + // Cleanup on failure + if (File::isDirectory($directory)) { + $cleanup = $this->confirm('Remove failed project directory?', true); + if ($cleanup) { + File::deleteDirectory($directory); + $this->components->info(' Cleaned up project directory.'); + } + } + + return self::FAILURE; + } + } + + /** + * Validate project name. + */ + protected function validateProjectName(string $name): bool + { + if (empty($name)) { + $this->components->error('Project name cannot be empty'); + + return false; + } + + if (! preg_match('/^[a-z0-9_-]+$/i', $name)) { + $this->components->error('Project name can only contain letters, numbers, hyphens, and underscores'); + + return false; + } + + if (in_array(strtolower($name), ['vendor', 'app', 'test', 'tests', 'src', 'public'])) { + $this->components->error("Project name '{$name}' is reserved"); + + return false; + } + + return true; + } + + /** + * Create project from template repository. + */ + protected function createFromTemplate(string $directory, string $template, string $projectName): bool + { + $branch = $this->option('branch'); + + // If force, delete existing directory + if ($this->option('force') && File::isDirectory($directory)) { + File::deleteDirectory($directory); + } + + // Check if template is a URL or repo slug + $templateUrl = $this->resolveTemplateUrl($template); + + // Clone the template + $result = Process::run("git clone --branch {$branch} --single-branch --depth 1 {$templateUrl} {$directory}"); + + if (! $result->successful()) { + throw new \RuntimeException("Failed to clone template: {$result->errorOutput()}"); + } + + // Remove .git directory to make it a fresh repo + File::deleteDirectory("{$directory}/.git"); + + // Update composer.json with project name + $this->updateComposerJson($directory, $projectName); + + // Initialize new git repository + Process::run("cd {$directory} && git init"); + Process::run("cd {$directory} && git add ."); + Process::run("cd {$directory} && git commit -m \"Initial commit from Core PHP Framework template\""); + + return true; + } + + /** + * Resolve template to full git URL. + */ + protected function resolveTemplateUrl(string $template): string + { + // If already a URL, return as-is + if (str_starts_with($template, 'http://') || str_starts_with($template, 'https://')) { + return $template; + } + + // If contains .git, treat as SSH URL + if (str_contains($template, '.git')) { + return $template; + } + + // Otherwise, assume GitHub slug + return "https://github.com/{$template}.git"; + } + + /** + * Update composer.json with project name. + */ + protected function updateComposerJson(string $directory, string $projectName): void + { + $composerPath = "{$directory}/composer.json"; + if (! File::exists($composerPath)) { + return; + } + + $composer = json_decode(File::get($composerPath), true); + $composer['name'] = $this->generateComposerName($projectName); + $composer['description'] = "Core PHP Framework application - {$projectName}"; + + // Update namespace if using default App namespace + if (isset($composer['autoload']['psr-4']['App\\'])) { + $studlyName = Str::studly($projectName); + $composer['autoload']['psr-4']["{$studlyName}\\"] = 'app/'; + unset($composer['autoload']['psr-4']['App\\']); + } + + File::put($composerPath, json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"); + } + + /** + * Generate composer package name from project name. + */ + protected function generateComposerName(string $projectName): string + { + $vendor = $this->ask('Composer vendor name', 'my-company'); + $package = Str::slug($projectName); + + return "{$vendor}/{$package}"; + } + + /** + * Install composer dependencies. + */ + protected function installDependencies(string $directory): bool + { + $composerBin = $this->findComposer(); + + $command = $this->option('dev') + ? "{$composerBin} install --prefer-source" + : "{$composerBin} install"; + + $result = Process::run("cd {$directory} && {$command}"); + + if (! $result->successful()) { + throw new \RuntimeException("Composer install failed: {$result->errorOutput()}"); + } + + return true; + } + + /** + * Run core:install command. + */ + protected function runCoreInstall(string $directory): bool + { + $result = Process::run("cd {$directory} && php artisan core:install --no-interaction"); + + if (! $result->successful()) { + throw new \RuntimeException("core:install failed: {$result->errorOutput()}"); + } + + return true; + } + + /** + * Find the composer binary. + */ + protected function findComposer(): string + { + // Check if composer is in PATH + $result = Process::run('which composer'); + if ($result->successful()) { + return trim($result->output()); + } + + // Check common locations + $locations = [ + '/usr/local/bin/composer', + '/usr/bin/composer', + $_SERVER['HOME'].'/.composer/composer.phar', + ]; + + foreach ($locations as $location) { + if (File::exists($location)) { + return $location; + } + } + + return 'composer'; // Fallback, will fail if not in PATH + } + + /** + * Show package information. + */ + protected function showPackageInfo(): void + { + $this->components->info(' 📦 Installed Core PHP Packages:'); + $this->components->twoColumnDetail(' host-uk/core', 'Core framework components'); + $this->components->twoColumnDetail(' host-uk/core-admin', 'Admin panel & Livewire modals'); + $this->components->twoColumnDetail(' host-uk/core-api', 'REST API with scopes & webhooks'); + $this->components->twoColumnDetail(' host-uk/core-mcp', 'Model Context Protocol tools'); + $this->newLine(); + + $this->components->info(' 📚 Documentation:'); + $this->components->twoColumnDetail(' https://github.com/host-uk/core-php', 'GitHub Repository'); + $this->components->twoColumnDetail(' https://docs.core-php.dev', 'Official Docs (future)'); + $this->newLine(); + } + + /** + * Get shell completion suggestions. + */ + public function complete( + CompletionInput $input, + CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestArgumentValuesFor('name')) { + // Suggest common project naming patterns + $suggestions->suggestValues([ + 'my-app', + 'api-service', + 'admin-panel', + 'saas-platform', + ]); + } + + if ($input->mustSuggestOptionValuesFor('template')) { + // Suggest known templates + $suggestions->suggestValues([ + 'host-uk/core-template', + 'host-uk/core-api-template', + 'host-uk/core-admin-template', + ]); + } + } +} diff --git a/app/Core/Console/Commands/PruneEmailShieldStatsCommand.php b/app/Core/Console/Commands/PruneEmailShieldStatsCommand.php new file mode 100644 index 0000000..1ab5dac --- /dev/null +++ b/app/Core/Console/Commands/PruneEmailShieldStatsCommand.php @@ -0,0 +1,148 @@ +command('email-shield:prune')->daily(); + */ +class PruneEmailShieldStatsCommand extends Command +{ + /** + * The name and signature of the console command. + */ + protected $signature = 'email-shield:prune + {--days= : Number of days to retain (default: from config or 90)} + {--dry-run : Show what would be deleted without actually deleting}'; + + /** + * The console command description. + */ + protected $description = 'Prune old Email Shield statistics records'; + + /** + * Execute the console command. + */ + public function handle(): int + { + $days = $this->getRetentionDays(); + $dryRun = $this->option('dry-run'); + + $this->newLine(); + $this->components->info('Email Shield Stats Cleanup'); + $this->newLine(); + + // Get count of records that would be deleted + $cutoffDate = now()->subDays($days)->format('Y-m-d'); + $recordsToDelete = EmailShieldStat::query() + ->where('date', '<', $cutoffDate) + ->count(); + + // Show current state table + $this->components->twoColumnDetail('Configuration', ''); + $this->components->twoColumnDetail('Retention period', "{$days} days"); + $this->components->twoColumnDetail('Cutoff date', "{$cutoffDate}"); + $this->components->twoColumnDetail('Records to delete', $recordsToDelete > 0 + ? "{$recordsToDelete}" + : '0'); + $this->newLine(); + + if ($recordsToDelete === 0) { + $this->components->info('No records older than the retention period found.'); + $this->newLine(); + + return self::SUCCESS; + } + + if ($dryRun) { + $this->components->warn('Dry run mode - no records were deleted.'); + $this->newLine(); + + return self::SUCCESS; + } + + // Show progress for deletion + $this->components->task( + "Deleting {$recordsToDelete} old records", + function () use ($days) { + EmailShieldStat::pruneOldRecords($days); + + return true; + } + ); + + $this->newLine(); + $this->components->info("Successfully deleted {$recordsToDelete} records older than {$days} days."); + $this->newLine(); + + // Show remaining stats + $remaining = EmailShieldStat::getRecordCount(); + $oldest = EmailShieldStat::getOldestRecordDate(); + + $this->components->twoColumnDetail('Current State', ''); + $this->components->twoColumnDetail('Remaining records', "{$remaining}"); + if ($oldest) { + $this->components->twoColumnDetail('Oldest record', "{$oldest->format('Y-m-d')}"); + } + $this->newLine(); + + return self::SUCCESS; + } + + /** + * Get the retention period in days from option, config, or default. + */ + protected function getRetentionDays(): int + { + // First check command option + $days = $this->option('days'); + if ($days !== null) { + return (int) $days; + } + + // Then check config + $configDays = config('core.email_shield.retention_days'); + if ($configDays !== null) { + return (int) $configDays; + } + + // Default to 90 days + return 90; + } + + /** + * Get shell completion suggestions for options. + */ + public function complete( + CompletionInput $input, + CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestOptionValuesFor('days')) { + // Suggest common retention periods + $suggestions->suggestValues(['7', '14', '30', '60', '90', '180', '365']); + } + } +} diff --git a/app/Core/Console/Commands/ScheduleSyncCommand.php b/app/Core/Console/Commands/ScheduleSyncCommand.php new file mode 100644 index 0000000..e652e05 --- /dev/null +++ b/app/Core/Console/Commands/ScheduleSyncCommand.php @@ -0,0 +1,95 @@ +scan($paths); + + $added = 0; + $disabled = 0; + $unchanged = 0; + + // Upsert discovered actions + foreach ($discovered as $class => $attribute) { + $existing = ScheduledAction::where('action_class', $class)->first(); + + if ($existing) { + $unchanged++; + + continue; + } + + ScheduledAction::create([ + 'action_class' => $class, + 'frequency' => $attribute->frequency, + 'timezone' => $attribute->timezone, + 'without_overlapping' => $attribute->withoutOverlapping, + 'run_in_background' => $attribute->runInBackground, + 'is_enabled' => true, + ]); + + $added++; + } + + // Disable actions no longer in codebase + $discoveredClasses = array_keys($discovered); + + if (empty($discoveredClasses)) { + $this->warn('No scheduled actions discovered — skipping stale cleanup to avoid disabling all rows.'); + } else { + $stale = ScheduledAction::where('is_enabled', true) + ->whereNotIn('action_class', $discoveredClasses) + ->get(); + + foreach ($stale as $action) { + $action->update(['is_enabled' => false]); + $disabled++; + } + } + + $this->info("Schedule sync complete: {$added} added, {$disabled} disabled, {$unchanged} unchanged."); + + return Command::SUCCESS; + } +} diff --git a/app/Core/Crypt/CLAUDE.md b/app/Core/Crypt/CLAUDE.md new file mode 100644 index 0000000..d9abd41 --- /dev/null +++ b/app/Core/Crypt/CLAUDE.md @@ -0,0 +1,65 @@ +# Crypt + +Encryption utilities: encrypted Eloquent casts and LTHN QuasiHash identifier generator. + +## What It Does + +Two independent tools: + +1. **EncryptArrayObject** -- Eloquent cast that encrypts/decrypts array data transparently using Laravel's `Crypt` facade +2. **LthnHash** -- Deterministic identifier generator for workspace scoping, vBucket CDN paths, and consistent sharding + +## Key Classes + +| Class | Purpose | +|-------|---------| +| `EncryptArrayObject` | `CastsAttributes` implementation. Encrypts arrays as JSON+AES on write, decrypts on read. Fails gracefully (returns null + logs warning) | +| `LthnHash` | Static utility: `hash()`, `shortHash()`, `fastHash()`, `vBucketId()`, `toInt()`, `verify()`, `benchmark()`. Supports key rotation | + +## EncryptArrayObject Usage + +```php +class ApiCredential extends Model { + protected $casts = ['secrets' => EncryptArrayObject::class]; +} +$model->secrets['api_key'] = 'sk_live_xxx'; // encrypted in DB +``` + +## LthnHash API + +| Method | Output | Use Case | +|--------|--------|----------| +| `hash($input)` | 64 hex chars (SHA-256) | Default, high quality | +| `shortHash($input, $len)` | 16-32 hex chars | Space-constrained IDs | +| `fastHash($input)` | 8-16 hex chars (xxHash/CRC32) | High-throughput | +| `vBucketId($domain)` | 64 hex chars | CDN path isolation | +| `toInt($input, $max)` | int (60 bits) | Sharding/partitioning | +| `verify($input, $hash)` | bool | Constant-time comparison, tries all key maps | +| `benchmark($iterations)` | timing array | Performance measurement | + +## Algorithm + +1. Reverse input, apply character substitution map (key map) +2. Concatenate original + substituted string +3. Hash with SHA-256 (or xxHash/CRC32 for `fastHash`) + +## Key Rotation + +```php +LthnHash::addKeyMap('v2', $newMap, setActive: true); +// New hashes use v2, verify() tries v2 first then falls back to older maps +LthnHash::removeKeyMap('v1'); // after migration +``` + +## NOT For + +- Password hashing (use `password_hash()`) +- Security tokens (use `random_bytes()`) +- Cryptographic signatures + +## Integration + +- `CdnUrlBuilder::vBucketId()` delegates to `LthnHash::vBucketId()` +- `verify()` uses `hash_equals()` for timing-attack resistance +- `fastHash()` auto-selects xxh64 (PHP 8.1+) or CRC32b+CRC32c fallback +- `toInt()` uses GMP for safe large-integer modular arithmetic diff --git a/app/Core/Crypt/EncryptArrayObject.php b/app/Core/Crypt/EncryptArrayObject.php new file mode 100644 index 0000000..ee96379 --- /dev/null +++ b/app/Core/Crypt/EncryptArrayObject.php @@ -0,0 +1,87 @@ + $attributes + */ + public function get($model, string $key, $value, array $attributes): ?ArrayObject + { + if (isset($attributes[$key])) { + try { + $decrypted = Crypt::decryptString($attributes[$key]); + } catch (DecryptException $e) { + Log::warning('Failed to decrypt array object', ['key' => $key, 'error' => $e->getMessage()]); + + return null; + } + + $decoded = json_decode($decrypted, true); + + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + Log::warning('Failed to decode encrypted array', ['key' => $key, 'error' => json_last_error_msg()]); + + return null; + } + + return new ArrayObject($decoded ?? []); + } + + return null; + } + + /** + * Prepare the given value for storage. + * + * @param Model $model + * @param mixed $value + * @param array $attributes + * @return array|null + */ + public function set($model, string $key, $value, array $attributes): ?array + { + if (! is_null($value)) { + $encoded = json_encode($value); + + if ($encoded === false) { + throw new \RuntimeException( + "Failed to encode value for encryption [{$key}]: ".json_last_error_msg() + ); + } + + $encrypted = Crypt::encryptString($encoded); + + return [$key => $encrypted]; + } + + return null; + } +} diff --git a/app/Core/Crypt/LthnHash.php b/app/Core/Crypt/LthnHash.php new file mode 100644 index 0000000..03710c5 --- /dev/null +++ b/app/Core/Crypt/LthnHash.php @@ -0,0 +1,519 @@ + int | 60 bits | Sharding/partitioning | + * + * ## Security Properties + * + * This is a "QuasiHash" - a deterministic identifier generator, NOT a cryptographic hash. + * + * **What it provides:** + * - Deterministic output: same input always produces same output + * - Uniform distribution: outputs are evenly distributed across the hash space + * - Avalanche effect: small input changes produce significantly different outputs + * - Collision resistance proportional to output length (see table below) + * + * **What it does NOT provide:** + * - Pre-image resistance: attackers can potentially reverse the hash + * - Cryptographic security: the key map is not a secret + * - Protection against brute force: short hashes can be enumerated + * + * ## Collision Resistance by Length + * + * | Length | Bits | Collision Probability (10k items) | Use Case | + * |--------|------|-----------------------------------|----------| + * | 16 | 64 | ~1 in 3.4 billion | Internal IDs, low-volume | + * | 24 | 96 | ~1 in 79 quintillion | Cross-system IDs | + * | 32 | 128 | ~1 in 3.4e38 | Long-term storage | + * | 64 | 256 | Negligible | Maximum security | + * + * ## Performance Considerations + * + * For short inputs (< 64 bytes), the default SHA-256 implementation is suitable + * for most use cases. For extremely high-throughput scenarios with many short + * strings, consider using `fastHash()` which uses xxHash (when available) or + * a CRC32-based approach for better performance. + * + * Benchmark reference (typical values, YMMV): + * - SHA-256: ~300k hashes/sec for short strings + * - xxHash (via hash extension): ~2M hashes/sec for short strings + * - CRC32: ~1.5M hashes/sec for short strings + * + * Use `benchmark()` to measure actual performance on your system. + * + * ## Key Rotation + * + * The class supports multiple key maps for rotation. When verifying, all registered + * key maps are tried in order (newest first). This allows gradual migration: + * + * 1. Add new key map with `addKeyMap()` + * 2. New hashes use the new key map + * 3. Verification tries new key first, falls back to old + * 4. After migration period, remove old key map with `removeKeyMap()` + * + * ## Usage Examples + * + * ```php + * // Generate a vBucket ID for CDN path isolation + * $vbucket = LthnHash::vBucketId('workspace.example.com'); + * // => "a7b3c9d2e1f4g5h6..." + * + * // Generate a short ID for internal use + * $shortId = LthnHash::shortHash('user-12345', LthnHash::MEDIUM_LENGTH); + * // => "a7b3c9d2e1f4g5h6i8j9k1l2" + * + * // High-throughput scenario + * $fastId = LthnHash::fastHash('cache-key-123'); + * // => "1a2b3c4d5e6f7g8h" + * + * // Sharding: get consistent partition number + * $partition = LthnHash::toInt('user@example.com', 16); + * // => 7 (always 7 for this input) + * + * // Verify a hash + * $isValid = LthnHash::verify('user-12345', $shortId); + * // => true + * ``` + * + * ## NOT Suitable For + * + * - Password hashing (use `password_hash()` instead) + * - Security tokens (use `random_bytes()` instead) + * - Cryptographic signatures + * - Any security-sensitive operations + */ +class LthnHash +{ + /** + * Default output length for short hash (16 hex chars = 64 bits). + */ + public const SHORT_LENGTH = 16; + + /** + * Medium output length for improved collision resistance (24 hex chars = 96 bits). + */ + public const MEDIUM_LENGTH = 24; + + /** + * Long output length for high collision resistance (32 hex chars = 128 bits). + */ + public const LONG_LENGTH = 32; + + /** + * Default key map identifier. + */ + public const DEFAULT_KEY = 'default'; + + /** + * Character-swapping key maps for quasi-salting. + * Swaps pairs of characters during encoding. + * + * Multiple key maps can be registered for key rotation. + * The first key map is used for new hashes; all are tried during verification. + * + * @var array> + */ + protected static array $keyMaps = [ + 'default' => [ + 'a' => '7', 'b' => 'x', 'c' => '3', 'd' => 'w', + 'e' => '3', 'f' => 'v', 'g' => '2', 'h' => 'u', + 'i' => '8', 'j' => 't', 'k' => '1', 'l' => 's', + 'm' => '6', 'n' => 'r', 'o' => '4', 'p' => 'q', + '0' => 'z', '5' => 'y', + 's' => 'z', 't' => '7', + ], + ]; + + /** + * The currently active key map identifier for generating new hashes. + */ + protected static string $activeKey = self::DEFAULT_KEY; + + /** + * Generate a deterministic quasi-hash from input. + * + * Creates a salt by reversing the input and applying character + * substitution, then hashes input + salt with SHA-256. + * + * @param string $input The input string to hash + * @param string|null $keyId Key map identifier (null uses active key) + * @return string 64-character SHA-256 hex string + */ + public static function hash(string $input, ?string $keyId = null): string + { + $keyId ??= self::$activeKey; + + // Create salt by reversing input and applying substitution + $reversed = strrev($input); + $salt = self::applyKeyMap($reversed, $keyId); + + // Hash input + salt + return hash('sha256', $input.$salt); + } + + /** + * Generate a short hash (prefix of full hash). + * + * @param string $input The input string to hash + * @param int $length Output length in hex characters (default: SHORT_LENGTH) + */ + public static function shortHash(string $input, int $length = self::SHORT_LENGTH): string + { + if ($length < 1 || $length > 64) { + throw new \InvalidArgumentException('Hash length must be between 1 and 64'); + } + + return substr(self::hash($input), 0, $length); + } + + /** + * Generate a vBucket ID for a domain/workspace. + * + * Format: 64-character SHA-256 hex string + * + * @param string $domain The domain or workspace identifier + */ + public static function vBucketId(string $domain): string + { + // Normalize domain (lowercase, trim) + $normalized = strtolower(trim($domain)); + + return self::hash($normalized); + } + + /** + * Verify that a hash matches an input using constant-time comparison. + * + * Tries all registered key maps in order (active key first, then others). + * This supports key rotation: old hashes remain verifiable while new hashes + * use the current active key. + * + * SECURITY NOTE: This method uses hash_equals() for constant-time string + * comparison, which prevents timing attacks. Regular string comparison + * (== or ===) can leak information about the hash through timing differences. + * Always use this method for hash verification rather than direct comparison. + * + * @param string $input The original input + * @param string $hash The hash to verify + * @return bool True if the hash matches with any registered key map + */ + public static function verify(string $input, string $hash): bool + { + $hashLength = strlen($hash); + + // Try active key first + $computed = self::hash($input, self::$activeKey); + if ($hashLength < 64) { + $computed = substr($computed, 0, $hashLength); + } + if (hash_equals($computed, $hash)) { + return true; + } + + // Try other key maps for rotation support + foreach (array_keys(self::$keyMaps) as $keyId) { + if ($keyId === self::$activeKey) { + continue; + } + + $computed = self::hash($input, $keyId); + if ($hashLength < 64) { + $computed = substr($computed, 0, $hashLength); + } + if (hash_equals($computed, $hash)) { + return true; + } + } + + return false; + } + + /** + * Apply the key map character swapping. + * + * @param string $input The input string to transform + * @param string $keyId Key map identifier + */ + protected static function applyKeyMap(string $input, string $keyId): string + { + $keyMap = self::$keyMaps[$keyId] ?? self::$keyMaps[self::DEFAULT_KEY]; + $output = ''; + + for ($i = 0; $i < strlen($input); $i++) { + $char = $input[$i]; + $output .= $keyMap[$char] ?? $char; + } + + return $output; + } + + /** + * Get the current active key map. + * + * @return array + */ + public static function getKeyMap(): array + { + return self::$keyMaps[self::$activeKey] ?? self::$keyMaps[self::DEFAULT_KEY]; + } + + /** + * Get all registered key maps. + * + * @return array> + */ + public static function getKeyMaps(): array + { + return self::$keyMaps; + } + + /** + * Set a custom key map (replaces the active key map). + * + * @param array $keyMap Character substitution map + */ + public static function setKeyMap(array $keyMap): void + { + self::$keyMaps[self::$activeKey] = $keyMap; + } + + /** + * Add a new key map for rotation. + * + * @param string $keyId Unique identifier for this key map + * @param array $keyMap Character substitution map + * @param bool $setActive Whether to make this the active key for new hashes + */ + public static function addKeyMap(string $keyId, array $keyMap, bool $setActive = true): void + { + self::$keyMaps[$keyId] = $keyMap; + + if ($setActive) { + self::$activeKey = $keyId; + } + } + + /** + * Remove a key map. + * + * Cannot remove the default key map or the currently active key map. + * + * @param string $keyId Key map identifier to remove + * + * @throws \InvalidArgumentException If attempting to remove default or active key + */ + public static function removeKeyMap(string $keyId): void + { + if ($keyId === self::DEFAULT_KEY) { + throw new \InvalidArgumentException('Cannot remove the default key map'); + } + + if ($keyId === self::$activeKey) { + throw new \InvalidArgumentException('Cannot remove the active key map. Set a different active key first.'); + } + + unset(self::$keyMaps[$keyId]); + } + + /** + * Get the active key map identifier. + */ + public static function getActiveKey(): string + { + return self::$activeKey; + } + + /** + * Set the active key map for generating new hashes. + * + * @param string $keyId Key map identifier (must already be registered) + * + * @throws \InvalidArgumentException If key map does not exist + */ + public static function setActiveKey(string $keyId): void + { + if (! isset(self::$keyMaps[$keyId])) { + throw new \InvalidArgumentException("Key map '{$keyId}' does not exist"); + } + + self::$activeKey = $keyId; + } + + /** + * Reset to default state. + * + * Removes all custom key maps and resets to the default key map. + */ + public static function reset(): void + { + self::$keyMaps = [ + self::DEFAULT_KEY => [ + 'a' => '7', 'b' => 'x', 'c' => '3', 'd' => 'w', + 'e' => '3', 'f' => 'v', 'g' => '2', 'h' => 'u', + 'i' => '8', 'j' => 't', 'k' => '1', 'l' => 's', + 'm' => '6', 'n' => 'r', 'o' => '4', 'p' => 'q', + '0' => 'z', '5' => 'y', + 's' => 'z', 't' => '7', + ], + ]; + self::$activeKey = self::DEFAULT_KEY; + } + + /** + * Generate a deterministic integer from input. + * Useful for consistent sharding/partitioning. + * + * @param string $input The input string + * @param int $max Maximum value (exclusive) + */ + public static function toInt(string $input, int $max = PHP_INT_MAX): int + { + $hash = self::hash($input); + // Use first 15 hex chars (60 bits) for safe int conversion + $hex = substr($hash, 0, 15); + + return gmp_intval(gmp_mod(gmp_init($hex, 16), $max)); + } + + /** + * Generate a fast hash for performance-critical operations. + * + * Uses xxHash when available (via hash extension), falling back to a + * CRC32-based approach. This is significantly faster than SHA-256 for + * short inputs but provides less collision resistance. + * + * Best for: + * - High-throughput scenarios (millions of hashes) + * - Cache keys and temporary identifiers + * - Hash table bucketing + * + * NOT suitable for: + * - Long-term storage identifiers + * - Security-sensitive operations + * - Cases requiring strong collision resistance + * + * @param string $input The input string to hash + * @param int $length Output length in hex characters (max 16 for xxh64, 8 for crc32) + * @return string Hex hash string + */ + public static function fastHash(string $input, int $length = 16): string + { + // Apply key map for consistency with standard hash + $keyId = self::$activeKey; + $reversed = strrev($input); + $salted = $input.self::applyKeyMap($reversed, $keyId); + + // Use xxHash if available (PHP 8.1+ with hash extension) + if (in_array('xxh64', hash_algos(), true)) { + $hash = hash('xxh64', $salted); + + return substr($hash, 0, min($length, 16)); + } + + // Fallback: combine two CRC32 variants for 16 hex chars + $crc1 = hash('crc32b', $salted); + $crc2 = hash('crc32c', strrev($salted)); + $combined = $crc1.$crc2; + + return substr($combined, 0, min($length, 16)); + } + + /** + * Run a simple benchmark comparing hash algorithms. + * + * Returns timing data for hash(), shortHash(), and fastHash() to help + * choose the appropriate method for your use case. + * + * @param int $iterations Number of hash operations to run + * @param string|null $testInput Input string to hash (default: random 32 chars) + * @return array{ + * hash: array{iterations: int, total_ms: float, per_hash_us: float}, + * shortHash: array{iterations: int, total_ms: float, per_hash_us: float}, + * fastHash: array{iterations: int, total_ms: float, per_hash_us: float}, + * fastHash_algorithm: string + * } + */ + public static function benchmark(int $iterations = 10000, ?string $testInput = null): array + { + $testInput ??= bin2hex(random_bytes(16)); // 32 char test string + + // Benchmark hash() + $start = hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + self::hash($testInput.$i); + } + $hashTime = (hrtime(true) - $start) / 1e6; // Convert to ms + + // Benchmark shortHash() + $start = hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + self::shortHash($testInput.$i); + } + $shortHashTime = (hrtime(true) - $start) / 1e6; + + // Benchmark fastHash() + $start = hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + self::fastHash($testInput.$i); + } + $fastHashTime = (hrtime(true) - $start) / 1e6; + + // Determine which algorithm fastHash is using + $fastHashAlgo = in_array('xxh64', hash_algos(), true) ? 'xxh64' : 'crc32b+crc32c'; + + return [ + 'hash' => [ + 'iterations' => $iterations, + 'total_ms' => round($hashTime, 2), + 'per_hash_us' => round(($hashTime * 1000) / $iterations, 3), + ], + 'shortHash' => [ + 'iterations' => $iterations, + 'total_ms' => round($shortHashTime, 2), + 'per_hash_us' => round(($shortHashTime * 1000) / $iterations, 3), + ], + 'fastHash' => [ + 'iterations' => $iterations, + 'total_ms' => round($fastHashTime, 2), + 'per_hash_us' => round(($fastHashTime * 1000) / $iterations, 3), + ], + 'fastHash_algorithm' => $fastHashAlgo, + ]; + } +} diff --git a/app/Core/Database/Seeders/Attributes/CLAUDE.md b/app/Core/Database/Seeders/Attributes/CLAUDE.md new file mode 100644 index 0000000..0744a81 --- /dev/null +++ b/app/Core/Database/Seeders/Attributes/CLAUDE.md @@ -0,0 +1,27 @@ +# Database/Seeders/Attributes/ — Seeder Ordering Attributes + +PHP 8 attributes for controlling seeder execution order in the auto-discovery system. + +## Attributes + +| Attribute | Target | Purpose | +|-----------|--------|---------| +| `#[SeederAfter(...)]` | Class | This seeder must run after the specified seeders. Repeatable. | +| `#[SeederBefore(...)]` | Class | This seeder must run before the specified seeders. Repeatable. | +| `#[SeederPriority(n)]` | Class | Numeric priority (lower runs first, default 50). | + +## Priority Guidelines + +- 0-20: Foundation (features, configuration) +- 20-40: Core data (packages, workspaces) +- 40-60: Default (general seeders) +- 60-80: Content (pages, posts) +- 80-100: Demo/test data + +## Example + +```php +#[SeederAfter(FeatureSeeder::class)] +#[SeederPriority(30)] +class PackageSeeder extends Seeder { ... } +``` diff --git a/app/Core/Database/Seeders/Attributes/SeederAfter.php b/app/Core/Database/Seeders/Attributes/SeederAfter.php new file mode 100644 index 0000000..9917701 --- /dev/null +++ b/app/Core/Database/Seeders/Attributes/SeederAfter.php @@ -0,0 +1,60 @@ + + */ + public readonly array $seeders; + + /** + * Create a new dependency attribute. + * + * @param class-string ...$seeders Seeder classes that must run first + */ + public function __construct(string ...$seeders) + { + $this->seeders = $seeders; + } +} diff --git a/app/Core/Database/Seeders/Attributes/SeederBefore.php b/app/Core/Database/Seeders/Attributes/SeederBefore.php new file mode 100644 index 0000000..0b525dc --- /dev/null +++ b/app/Core/Database/Seeders/Attributes/SeederBefore.php @@ -0,0 +1,61 @@ + + */ + public readonly array $seeders; + + /** + * Create a new dependency attribute. + * + * @param class-string ...$seeders Seeder classes that must run after this one + */ + public function __construct(string ...$seeders) + { + $this->seeders = $seeders; + } +} diff --git a/app/Core/Database/Seeders/Attributes/SeederPriority.php b/app/Core/Database/Seeders/Attributes/SeederPriority.php new file mode 100644 index 0000000..c5b3e0b --- /dev/null +++ b/app/Core/Database/Seeders/Attributes/SeederPriority.php @@ -0,0 +1,61 @@ +register(FeatureSeeder::class, priority: 10) + ->register(PackageSeeder::class, after: [FeatureSeeder::class]); + } +} + +// CLI filtering +php artisan db:seed --exclude=DemoSeeder --only=FeatureSeeder +``` + +## Discovery Paths + +Scans `{path}/*/Database/Seeders/*Seeder.php` (module subdirs) and `{path}/Database/Seeders/*Seeder.php` (direct). Configured via `core.seeders.paths` or defaults to `app/Core`, `app/Mod`, `app/Website`. + +## Integration + +- Properties alternative to attributes: `public int $priority = 10;`, `public array $after = [...]`, `public array $before = [...]` +- Pattern matching for `--exclude`/`--only`: full class name, short name, or partial match +- Config: `core.seeders.auto_discover`, `core.seeders.paths`, `core.seeders.exclude` diff --git a/app/Core/Database/Seeders/CoreDatabaseSeeder.php b/app/Core/Database/Seeders/CoreDatabaseSeeder.php new file mode 100644 index 0000000..5987453 --- /dev/null +++ b/app/Core/Database/Seeders/CoreDatabaseSeeder.php @@ -0,0 +1,376 @@ +getSeedersToRun(); + + if (empty($seeders)) { + $this->info('No seeders found to run.'); + + return; + } + + $this->info(sprintf('Running %d seeders...', count($seeders))); + $this->newLine(); + + foreach ($seeders as $seeder) { + $shortName = $this->getShortName($seeder); + $this->info("Running: {$shortName}"); + + $this->call($seeder); + } + + $this->newLine(); + $this->info('Database seeding completed successfully.'); + } + + /** + * Get the list of seeders to run. + * + * @return array Ordered list of seeder class names + */ + protected function getSeedersToRun(): array + { + $seeders = $this->discoverSeeders(); + + // Apply filters + $seeders = $this->applyExcludeFilter($seeders); + $seeders = $this->applyOnlyFilter($seeders); + + return $seeders; + } + + /** + * Discover all seeders. + * + * @return array Ordered list of seeder class names + */ + protected function discoverSeeders(): array + { + // Check if auto-discovery is enabled + if (! $this->shouldAutoDiscover()) { + return $this->getManualSeeders(); + } + + $discovery = $this->getDiscovery(); + + return $discovery->discover(); + } + + /** + * Get manually registered seeders. + * + * @return array + */ + protected function getManualSeeders(): array + { + $registry = $this->getRegistry(); + + return $registry->getOrdered(); + } + + /** + * Get the seeder discovery instance. + */ + protected function getDiscovery(): SeederDiscovery + { + if ($this->discovery === null) { + $this->discovery = new SeederDiscovery( + $this->getSeederPaths(), + $this->getExcludedSeeders() + ); + } + + return $this->discovery; + } + + /** + * Get the seeder registry instance. + */ + protected function getRegistry(): SeederRegistry + { + if ($this->registry === null) { + $this->registry = new SeederRegistry; + $this->registerSeeders($this->registry); + } + + return $this->registry; + } + + /** + * Register seeders manually when auto-discovery is disabled. + * + * Override this method in subclasses to add seeders. + * + * @param SeederRegistry $registry The registry to add seeders to + */ + protected function registerSeeders(SeederRegistry $registry): void + { + // Override in subclasses + } + + /** + * Get paths to scan for seeders. + * + * Override this method to customize seeder paths. + * + * @return array + */ + protected function getSeederPaths(): array + { + // Use config if available, otherwise use defaults + $config = config('core.seeders.paths'); + + if (is_array($config) && ! empty($config)) { + return $config; + } + + return [ + app_path('Core'), + app_path('Mod'), + app_path('Website'), + ]; + } + + /** + * Get seeders to exclude. + * + * Override this method to customize excluded seeders. + * + * @return array + */ + protected function getExcludedSeeders(): array + { + return config('core.seeders.exclude', []); + } + + /** + * Check if auto-discovery should be used. + */ + protected function shouldAutoDiscover(): bool + { + if (! $this->autoDiscover) { + return false; + } + + return config('core.seeders.auto_discover', true); + } + + /** + * Apply the --exclude filter. + * + * @param array $seeders List of seeder classes + * @return array Filtered list + */ + protected function applyExcludeFilter(array $seeders): array + { + $excludes = $this->getCommandOption('exclude'); + + if (empty($excludes)) { + return $seeders; + } + + $excludePatterns = is_array($excludes) ? $excludes : [$excludes]; + + return array_filter($seeders, function ($seeder) use ($excludePatterns) { + foreach ($excludePatterns as $pattern) { + if ($this->matchesPattern($seeder, $pattern)) { + return false; + } + } + + return true; + }); + } + + /** + * Apply the --only filter. + * + * @param array $seeders List of seeder classes + * @return array Filtered list + */ + protected function applyOnlyFilter(array $seeders): array + { + $only = $this->getCommandOption('only'); + + if (empty($only)) { + return $seeders; + } + + $onlyPatterns = is_array($only) ? $only : [$only]; + + return array_values(array_filter($seeders, function ($seeder) use ($onlyPatterns) { + foreach ($onlyPatterns as $pattern) { + if ($this->matchesPattern($seeder, $pattern)) { + return true; + } + } + + return false; + })); + } + + /** + * Check if a seeder matches a pattern. + * + * Patterns can be: + * - Full class name: Core\Tenant\Database\Seeders\FeatureSeeder + * - Short name: FeatureSeeder + * - Partial match: Feature (matches FeatureSeeder) + * + * @param string $seeder Full class name + * @param string $pattern Pattern to match + */ + protected function matchesPattern(string $seeder, string $pattern): bool + { + // Exact match + if ($seeder === $pattern) { + return true; + } + + // Short name match + $shortName = $this->getShortName($seeder); + if ($shortName === $pattern) { + return true; + } + + // Partial match (contains) + if (str_contains($shortName, $pattern) || str_contains($seeder, $pattern)) { + return true; + } + + return false; + } + + /** + * Get a command option value. + * + * @param string $name Option name + */ + protected function getCommandOption(string $name): mixed + { + if (! $this->command instanceof Command) { + return null; + } + + // Check if the option exists before getting it + if (! $this->command->hasOption($name)) { + return null; + } + + return $this->command->option($name); + } + + /** + * Get the short (class only) name of a seeder. + * + * @param string $class Fully qualified class name + * @return string Class name without namespace + */ + protected function getShortName(string $class): string + { + $parts = explode('\\', $class); + + return end($parts); + } + + /** + * Output an info message. + * + * @param string $message Message to output + */ + protected function info(string $message): void + { + if ($this->command instanceof Command) { + $this->command->info($message); + } + } + + /** + * Output a newline. + */ + protected function newLine(): void + { + if ($this->command instanceof Command) { + $this->command->newLine(); + } + } +} diff --git a/app/Core/Database/Seeders/Exceptions/CLAUDE.md b/app/Core/Database/Seeders/Exceptions/CLAUDE.md new file mode 100644 index 0000000..e347f80 --- /dev/null +++ b/app/Core/Database/Seeders/Exceptions/CLAUDE.md @@ -0,0 +1,9 @@ +# Database/Seeders/Exceptions/ — Seeder Exception Types + +## Files + +| File | Purpose | +|------|---------| +| `CircularDependencyException.php` | Thrown when seeder dependency graph contains a cycle. Includes the `$cycle` array showing the loop path. Has `fromPath()` factory for building from a traversal path. | + +Part of the seeder auto-discovery system. The `CoreDatabaseSeeder` uses topological sorting on `#[SeederAfter]`/`#[SeederBefore]` attributes and throws this when a cycle is detected. diff --git a/app/Core/Database/Seeders/Exceptions/CircularDependencyException.php b/app/Core/Database/Seeders/Exceptions/CircularDependencyException.php new file mode 100644 index 0000000..758bf3a --- /dev/null +++ b/app/Core/Database/Seeders/Exceptions/CircularDependencyException.php @@ -0,0 +1,62 @@ + + */ + public readonly array $cycle; + + /** + * Create a new exception instance. + * + * @param array $cycle The seeders forming the dependency cycle + */ + public function __construct(array $cycle) + { + $this->cycle = $cycle; + + $cycleStr = implode(' -> ', $cycle); + + parent::__construct( + "Circular dependency detected in seeders: {$cycleStr}" + ); + } + + /** + * Create an exception from a dependency path. + * + * @param array $path The path of seeders leading to the cycle + * @param string $duplicate The seeder that was found again, completing the cycle + */ + public static function fromPath(array $path, string $duplicate): self + { + // Find where the cycle starts + $cycleStart = array_search($duplicate, $path, true); + $cycle = array_slice($path, $cycleStart); + $cycle[] = $duplicate; + + return new self($cycle); + } +} diff --git a/app/Core/Database/Seeders/SeederDiscovery.php b/app/Core/Database/Seeders/SeederDiscovery.php new file mode 100644 index 0000000..7ac116e --- /dev/null +++ b/app/Core/Database/Seeders/SeederDiscovery.php @@ -0,0 +1,546 @@ +, before: array}> + */ + private array $seeders = []; + + /** + * Paths to scan for seeders. + * + * @var array + */ + private array $paths = []; + + /** + * Seeder classes to exclude. + * + * @var array + */ + private array $excluded = []; + + /** + * Whether discovery has been performed. + */ + private bool $discovered = false; + + /** + * Create a new SeederDiscovery instance. + * + * @param array $paths Directories to scan for modules + * @param array $excluded Seeder classes to exclude + */ + public function __construct(array $paths = [], array $excluded = []) + { + $this->paths = $paths; + $this->excluded = $excluded; + } + + /** + * Add paths to scan for seeders. + * + * @param array $paths Directories to add + * @return $this + */ + public function addPaths(array $paths): self + { + $this->paths = array_merge($this->paths, $paths); + $this->discovered = false; + + return $this; + } + + /** + * Set paths to scan for seeders. + * + * @param array $paths Directories to scan + * @return $this + */ + public function setPaths(array $paths): self + { + $this->paths = $paths; + $this->discovered = false; + + return $this; + } + + /** + * Add seeder classes to exclude. + * + * @param array $classes Seeder class names to exclude + * @return $this + */ + public function exclude(array $classes): self + { + $this->excluded = array_merge($this->excluded, $classes); + + return $this; + } + + /** + * Discover and return ordered seeder classes. + * + * @return array Ordered list of seeder class names + * + * @throws CircularDependencyException If a circular dependency is detected + */ + public function discover(): array + { + if (! $this->discovered) { + $this->scanPaths(); + $this->discovered = true; + } + + return $this->sort(); + } + + /** + * Get all discovered seeders with their metadata. + * + * @return array, before: array}> + */ + public function getSeeders(): array + { + if (! $this->discovered) { + $this->scanPaths(); + $this->discovered = true; + } + + return $this->seeders; + } + + /** + * Reset the discovery cache. + * + * @return $this + */ + public function reset(): self + { + $this->seeders = []; + $this->discovered = false; + + return $this; + } + + /** + * Scan configured paths for seeder classes. + */ + private function scanPaths(): void + { + $this->seeders = []; + + foreach ($this->paths as $path) { + $this->scanPath($path); + } + } + + /** + * Scan a single path for seeder classes. + * + * @param string $path Directory to scan + */ + private function scanPath(string $path): void + { + if (! is_dir($path)) { + return; + } + + // Look for Database/Seeders directories in immediate subdirectories + $pattern = "{$path}/*/Database/Seeders/*Seeder.php"; + $files = glob($pattern) ?: []; + + // Also check for seeders directly in the path (for Core modules) + $directPattern = "{$path}/Database/Seeders/*Seeder.php"; + $directFiles = glob($directPattern) ?: []; + $files = array_merge($files, $directFiles); + + foreach ($files as $file) { + $class = $this->classFromFile($file); + + if ($class && class_exists($class) && ! in_array($class, $this->excluded, true)) { + $this->seeders[$class] = $this->extractMetadata($class); + } + } + } + + /** + * Derive class name from file path. + * + * @param string $file Path to the seeder file + * @return string|null Fully qualified class name, or null if not determinable + */ + private function classFromFile(string $file): ?string + { + $contents = file_get_contents($file); + if ($contents === false) { + return null; + } + + // Extract namespace + if (preg_match('/namespace\s+([^;]+);/', $contents, $nsMatch)) { + $namespace = $nsMatch[1]; + } else { + return null; + } + + // Extract class name + if (preg_match('/class\s+(\w+)/', $contents, $classMatch)) { + $className = $classMatch[1]; + } else { + return null; + } + + return $namespace.'\\'.$className; + } + + /** + * Extract ordering metadata from a seeder class. + * + * @param string $class Seeder class name + * @return array{priority: int, after: array, before: array} + */ + private function extractMetadata(string $class): array + { + $reflection = new ReflectionClass($class); + + return [ + 'priority' => $this->extractPriority($reflection), + 'after' => $this->extractAfter($reflection), + 'before' => $this->extractBefore($reflection), + ]; + } + + /** + * Extract priority from a seeder class. + * + * Checks for SeederPriority attribute first, then falls back to + * public $priority property. + * + * @param ReflectionClass $reflection Reflection of the seeder class + * @return int Priority value + */ + private function extractPriority(ReflectionClass $reflection): int + { + // Check for attribute first + $attributes = $reflection->getAttributes(SeederPriority::class); + if (! empty($attributes)) { + return $attributes[0]->newInstance()->priority; + } + + // Fall back to property + if ($reflection->hasProperty('priority')) { + $prop = $reflection->getProperty('priority'); + if ($prop->isPublic() && ! $prop->isStatic()) { + $defaultProps = $reflection->getDefaultProperties(); + if (isset($defaultProps['priority']) && is_int($defaultProps['priority'])) { + return $defaultProps['priority']; + } + } + } + + return self::DEFAULT_PRIORITY; + } + + /** + * Extract 'after' dependencies from a seeder class. + * + * Checks for SeederAfter attributes first, then falls back to + * public $after property. + * + * @param ReflectionClass $reflection Reflection of the seeder class + * @return array Seeder classes that must run before this one + */ + private function extractAfter(ReflectionClass $reflection): array + { + $after = []; + + // Check for attributes + $attributes = $reflection->getAttributes(SeederAfter::class); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $after = array_merge($after, $instance->seeders); + } + + // If no attributes, check for property + if (empty($after) && $reflection->hasProperty('after')) { + $prop = $reflection->getProperty('after'); + if ($prop->isPublic() && ! $prop->isStatic()) { + $defaultProps = $reflection->getDefaultProperties(); + if (isset($defaultProps['after']) && is_array($defaultProps['after'])) { + $after = $defaultProps['after']; + } + } + } + + return $after; + } + + /** + * Extract 'before' dependencies from a seeder class. + * + * Checks for SeederBefore attributes first, then falls back to + * public $before property. + * + * @param ReflectionClass $reflection Reflection of the seeder class + * @return array Seeder classes that must run after this one + */ + private function extractBefore(ReflectionClass $reflection): array + { + $before = []; + + // Check for attributes + $attributes = $reflection->getAttributes(SeederBefore::class); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $before = array_merge($before, $instance->seeders); + } + + // If no attributes, check for property + if (empty($before) && $reflection->hasProperty('before')) { + $prop = $reflection->getProperty('before'); + if ($prop->isPublic() && ! $prop->isStatic()) { + $defaultProps = $reflection->getDefaultProperties(); + if (isset($defaultProps['before']) && is_array($defaultProps['before'])) { + $before = $defaultProps['before']; + } + } + } + + return $before; + } + + /** + * Topologically sort seeders based on dependencies and priority. + * + * Lower priority values run first (e.g., priority 10 runs before priority 50). + * + * @return array Ordered seeder class names + * + * @throws CircularDependencyException If a circular dependency is detected + */ + private function sort(): array + { + // Build adjacency list (seeder -> seeders that must run before it) + $dependencies = []; + foreach ($this->seeders as $seeder => $meta) { + $dependencies[$seeder] = $meta['after']; + + // Process 'before' declarations (reverse dependencies) + foreach ($meta['before'] as $dependent) { + if (isset($this->seeders[$dependent])) { + $dependencies[$dependent][] = $seeder; + } + } + } + + // Normalize dependencies to unique values + foreach ($dependencies as $seeder => $deps) { + $dependencies[$seeder] = array_unique($deps); + } + + // Kahn's algorithm for topological sort + $inDegree = []; + $graph = []; + + // Initialize + foreach ($dependencies as $seeder => $deps) { + if (! isset($inDegree[$seeder])) { + $inDegree[$seeder] = 0; + } + if (! isset($graph[$seeder])) { + $graph[$seeder] = []; + } + + foreach ($deps as $dep) { + // Only count dependencies that exist in our discovered seeders + if (isset($this->seeders[$dep])) { + $inDegree[$seeder]++; + $graph[$dep][] = $seeder; + } + } + } + + // Start with seeders that have no dependencies + $queue = []; + foreach ($inDegree as $seeder => $degree) { + if ($degree === 0) { + $queue[] = $seeder; + } + } + + // Sort queue by priority (lower priority first - lower numbers run first) + usort($queue, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']); + + $sorted = []; + $processed = 0; + + while (! empty($queue)) { + $seeder = array_shift($queue); + $sorted[] = $seeder; + $processed++; + + // Collect dependents that become ready + $ready = []; + foreach ($graph[$seeder] ?? [] as $dependent) { + $inDegree[$dependent]--; + if ($inDegree[$dependent] === 0) { + $ready[] = $dependent; + } + } + + // Sort newly ready seeders by priority and add to queue + usort($ready, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']); + $queue = array_merge($ready, $queue); + + // Re-sort the entire queue to maintain priority order + usort($queue, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']); + } + + // Check for cycles + if ($processed < count($this->seeders)) { + $this->detectCycle($dependencies); + } + + return $sorted; + } + + /** + * Detect and report a cycle in the dependency graph. + * + * @param array> $dependencies Adjacency list + * + * @throws CircularDependencyException + */ + private function detectCycle(array $dependencies): void + { + $visited = []; + $recStack = []; + $path = []; + + foreach (array_keys($this->seeders) as $seeder) { + if ($this->dfsDetectCycle($seeder, $dependencies, $visited, $recStack, $path)) { + return; // Exception already thrown + } + } + + // If we get here, there's a cycle but we couldn't find it + throw new CircularDependencyException(['Unknown cycle detected']); + } + + /** + * DFS helper for cycle detection. + * + * @param string $seeder Current seeder being visited + * @param array> $dependencies Adjacency list + * @param array $visited Fully processed nodes + * @param array $recStack Nodes in current recursion stack + * @param array $path Current path for error reporting + * + * @throws CircularDependencyException If a cycle is detected + */ + private function dfsDetectCycle( + string $seeder, + array $dependencies, + array &$visited, + array &$recStack, + array &$path + ): bool { + if (! isset($this->seeders[$seeder])) { + return false; + } + + if (isset($recStack[$seeder])) { + throw CircularDependencyException::fromPath($path, $seeder); + } + + if (isset($visited[$seeder])) { + return false; + } + + $visited[$seeder] = true; + $recStack[$seeder] = true; + $path[] = $seeder; + + foreach ($dependencies[$seeder] ?? [] as $dep) { + if ($this->dfsDetectCycle($dep, $dependencies, $visited, $recStack, $path)) { + return true; + } + } + + array_pop($path); + unset($recStack[$seeder]); + + return false; + } +} diff --git a/app/Core/Database/Seeders/SeederRegistry.php b/app/Core/Database/Seeders/SeederRegistry.php new file mode 100644 index 0000000..b7e9a29 --- /dev/null +++ b/app/Core/Database/Seeders/SeederRegistry.php @@ -0,0 +1,189 @@ +register(FeatureSeeder::class, priority: 10) + * ->register(PackageSeeder::class, after: [FeatureSeeder::class]) + * ->register(WorkspaceSeeder::class, after: [PackageSeeder::class]); + * + * // Get ordered seeders + * $seeders = $registry->getOrdered(); + * ``` + * + * + * @see SeederDiscovery For auto-discovered seeders + */ +class SeederRegistry +{ + /** + * Registered seeder metadata. + * + * @var array, before: array}> + */ + private array $seeders = []; + + /** + * Register a seeder class. + * + * @param string $class Fully qualified seeder class name + * @param int $priority Priority (higher runs first, default 50) + * @param array $after Seeders that must run before this one + * @param array $before Seeders that must run after this one + * @return $this + */ + public function register( + string $class, + int $priority = SeederDiscovery::DEFAULT_PRIORITY, + array $after = [], + array $before = [] + ): self { + $this->seeders[$class] = [ + 'priority' => $priority, + 'after' => $after, + 'before' => $before, + ]; + + return $this; + } + + /** + * Register multiple seeders at once. + * + * @param array, before?: array}|int> $seeders + * Either [Class => priority] or [Class => ['priority' => n, 'after' => [], 'before' => []]] + * @return $this + */ + public function registerMany(array $seeders): self + { + foreach ($seeders as $class => $config) { + if (is_int($config)) { + $this->register($class, priority: $config); + } else { + $this->register( + $class, + priority: $config['priority'] ?? SeederDiscovery::DEFAULT_PRIORITY, + after: $config['after'] ?? [], + before: $config['before'] ?? [] + ); + } + } + + return $this; + } + + /** + * Remove a seeder from the registry. + * + * @param string $class Seeder class to remove + * @return $this + */ + public function remove(string $class): self + { + unset($this->seeders[$class]); + + return $this; + } + + /** + * Check if a seeder is registered. + * + * @param string $class Seeder class to check + */ + public function has(string $class): bool + { + return isset($this->seeders[$class]); + } + + /** + * Get all registered seeders. + * + * @return array, before: array}> + */ + public function all(): array + { + return $this->seeders; + } + + /** + * Get ordered seeder classes. + * + * @return array Ordered list of seeder class names + * + * @throws CircularDependencyException If a circular dependency is detected + */ + public function getOrdered(): array + { + // Use SeederDiscovery's sorting logic by creating a temporary instance + $discovery = new class extends SeederDiscovery + { + /** + * @param array, before: array}> $seeders + */ + public function setSeeders(array $seeders): void + { + $reflection = new \ReflectionClass(SeederDiscovery::class); + $prop = $reflection->getProperty('seeders'); + $prop->setValue($this, $seeders); + + $discovered = $reflection->getProperty('discovered'); + $discovered->setValue($this, true); + } + }; + + $discovery->setSeeders($this->seeders); + + return $discovery->discover(); + } + + /** + * Merge another registry into this one. + * + * @param SeederRegistry $registry Registry to merge + * @return $this + */ + public function merge(SeederRegistry $registry): self + { + foreach ($registry->all() as $class => $meta) { + if (! isset($this->seeders[$class])) { + $this->seeders[$class] = $meta; + } + } + + return $this; + } + + /** + * Clear all registered seeders. + * + * @return $this + */ + public function clear(): self + { + $this->seeders = []; + + return $this; + } +} diff --git a/app/Core/Events/AdminPanelBooting.php b/app/Core/Events/AdminPanelBooting.php new file mode 100644 index 0000000..4278671 --- /dev/null +++ b/app/Core/Events/AdminPanelBooting.php @@ -0,0 +1,58 @@ + 'onAdmin', + * ]; + * + * public function onAdmin(AdminPanelBooting $event): void + * { + * $event->views('commerce', __DIR__.'/Views/Admin'); + * $event->translations('commerce', __DIR__.'/Lang'); + * $event->livewire('commerce-dashboard', DashboardComponent::class); + * $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + * } + * ``` + * + * + * @see AdminMenuProvider For navigation registration + * @see WebRoutesRegistering For public web routes + */ +class AdminPanelBooting extends LifecycleEvent +{ + // +} diff --git a/app/Core/Events/ApiRoutesRegistering.php b/app/Core/Events/ApiRoutesRegistering.php new file mode 100644 index 0000000..f807933 --- /dev/null +++ b/app/Core/Events/ApiRoutesRegistering.php @@ -0,0 +1,53 @@ + 'onApi', + * ]; + * + * public function onApi(ApiRoutesRegistering $event): void + * { + * $event->routes(fn () => require __DIR__.'/Routes/api.php'); + * } + * ``` + * + * Note: API routes typically don't need views or Livewire components, but + * all LifecycleEvent methods are available if needed. + * + * + * @see WebRoutesRegistering For web routes with session state + */ +class ApiRoutesRegistering extends LifecycleEvent +{ + // +} diff --git a/app/Core/Events/CLAUDE.md b/app/Core/Events/CLAUDE.md new file mode 100644 index 0000000..37d7f29 --- /dev/null +++ b/app/Core/Events/CLAUDE.md @@ -0,0 +1,84 @@ +# Events + +Lifecycle events that drive the module loading system. + +## What It Does + +Defines the event classes that modules listen to via `static $listens` arrays in their Boot classes. Events use a request/collect pattern: modules call methods like `routes()`, `views()`, `livewire()` during event dispatch, and `LifecycleEventProvider` processes the collected requests afterwards. + +This is the **core of the module loading architecture**. Modules are never instantiated until their listened events fire. + +## Lifecycle Events (Mutually Exclusive by Context) + +| Event | Context | Middleware | Purpose | +|-------|---------|------------|---------| +| `WebRoutesRegistering` | Web requests | `web` | Public-facing routes, views | +| `AdminPanelBooting` | Admin requests | `admin` | Admin dashboard resources | +| `ApiRoutesRegistering` | API requests | `api` | REST API endpoints | +| `ClientRoutesRegistering` | Client dashboard | `client` | Authenticated SaaS user routes | +| `ConsoleBooting` | CLI | -- | Artisan commands | +| `QueueWorkerBooting` | Queue workers | -- | Job registration, queue init | +| `McpToolsRegistering` | MCP server | -- | MCP tool handlers | +| `McpRoutesRegistering` | MCP HTTP | `mcp` | MCP HTTP endpoints | +| `FrameworkBooted` | All contexts | -- | Late-stage cross-cutting init | + +## Capability Events (On-Demand) + +| Event | Purpose | +|-------|---------| +| `DomainResolving` | Multi-tenancy by domain. First provider to `register()` wins | +| `SearchRequested` | Lazy-load search: `searchable(Model::class)` | +| `MediaRequested` | Lazy-load media: `processor('image', ImageProcessor::class)` | +| `MailSending` | Lazy-load mail: `mailable(WelcomeEmail::class)` | + +## Base Class: LifecycleEvent + +All lifecycle events extend `LifecycleEvent`, which provides these request methods: + +| Method | Purpose | +|--------|---------| +| `routes(callable)` | Register route callback | +| `views(namespace, path)` | Register view namespace | +| `livewire(alias, class)` | Register Livewire component | +| `middleware(alias, class)` | Register middleware alias | +| `command(class)` | Register Artisan command | +| `translations(namespace, path)` | Register translation namespace | +| `bladeComponentPath(path, namespace)` | Register anonymous Blade components | +| `policy(model, policy)` | Register model policy | +| `navigation(item)` | Register nav item | + +Each has a corresponding `*Requests()` getter for `LifecycleEventProvider` to process. + +## Observability + +| Class | Purpose | +|-------|---------| +| `ListenerProfiler` | Measures execution time, memory, call count per listener. `enable()`, `getSlowListeners()`, `getSlowest(10)`, `getSummary()`, `export()` | +| `EventAuditLog` | Tracks success/failure of event handlers. `enable()`, `entries()`, `failures()`, `summary()` | + +## Event Versioning + +| Class | Purpose | +|-------|---------| +| `HasEventVersion` (trait) | Modules declare `$eventVersions` for compatibility checking | + +Events carry `VERSION` and `MIN_SUPPORTED_VERSION` constants. Handlers check `$event->version()` or `$event->supportsVersion(2)` for forward compatibility. + +## Integration + +```php +// Module Boot class +class Boot { + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + AdminPanelBooting::class => ['onAdmin', 10], // with priority + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void { + $event->views('mymod', __DIR__.'/Views'); + $event->routes(fn() => require __DIR__.'/web.php'); + } +} +``` + +Flow: `ModuleScanner` reads `$listens` -> `ModuleRegistry` registers `LazyModuleListener` with Laravel Events -> Event fires -> Module instantiated via container -> Method called with event -> Requests collected -> `LifecycleEventProvider` processes. diff --git a/app/Core/Events/ClientRoutesRegistering.php b/app/Core/Events/ClientRoutesRegistering.php new file mode 100644 index 0000000..3a29fe2 --- /dev/null +++ b/app/Core/Events/ClientRoutesRegistering.php @@ -0,0 +1,60 @@ + 'onClient', + * ]; + * + * public function onClient(ClientRoutesRegistering $event): void + * { + * $event->views('bio', __DIR__.'/Views/Client'); + * $event->livewire('bio-editor', BioEditorComponent::class); + * $event->routes(fn () => require __DIR__.'/Routes/client.php'); + * } + * ``` + * + * + * @see AdminPanelBooting For admin/staff routes + * @see WebRoutesRegistering For public-facing routes + */ +class ClientRoutesRegistering extends LifecycleEvent +{ + // +} diff --git a/app/Core/Events/Concerns/CLAUDE.md b/app/Core/Events/Concerns/CLAUDE.md new file mode 100644 index 0000000..54f9296 --- /dev/null +++ b/app/Core/Events/Concerns/CLAUDE.md @@ -0,0 +1,9 @@ +# Events/Concerns/ — Event Version Compatibility + +## Traits + +| Trait | Purpose | +|-------|---------| +| `HasEventVersion` | For Boot classes to declare which event API versions they support. Methods: `getRequiredEventVersion(eventClass)`, `isCompatibleWithEventVersion(eventClass, version)`, `getEventVersionRequirements()`. | + +Enables graceful handling of event API changes. Modules declare minimum versions via `$eventVersions` static property. The framework checks compatibility during bootstrap and logs warnings for version mismatches. diff --git a/app/Core/Events/Concerns/HasEventVersion.php b/app/Core/Events/Concerns/HasEventVersion.php new file mode 100644 index 0000000..8d06a40 --- /dev/null +++ b/app/Core/Events/Concerns/HasEventVersion.php @@ -0,0 +1,96 @@ + 'onWebRoutes', + * ]; + * + * // Declare minimum event versions this module requires + * protected static array $eventVersions = [ + * WebRoutesRegistering::class => 1, + * ]; + * + * public function onWebRoutes(WebRoutesRegistering $event): void + * { + * // Handle event + * } + * } + * ``` + * + * ## Version Checking + * + * During bootstrap, the framework checks version compatibility: + * - If a handler requires a version higher than available, a warning is logged + * - If a handler uses a deprecated version, a deprecation notice is raised + */ +trait HasEventVersion +{ + /** + * Get the required event version for a given event class. + * + * Returns the version number from $eventVersions if defined, + * or 1 (the baseline version) if not specified. + * + * @param string $eventClass The event class name + * @return int The required version number + */ + public static function getRequiredEventVersion(string $eventClass): int + { + if (property_exists(static::class, 'eventVersions')) { + return static::$eventVersions[$eventClass] ?? 1; + } + + return 1; + } + + /** + * Check if this module is compatible with an event version. + * + * @param string $eventClass The event class name + * @param int $availableVersion The available event API version + * @return bool True if the module can handle this event version + */ + public static function isCompatibleWithEventVersion(string $eventClass, int $availableVersion): bool + { + $required = static::getRequiredEventVersion($eventClass); + + return $availableVersion >= $required; + } + + /** + * Get all declared event version requirements. + * + * @return array Map of event class to required version + */ + public static function getEventVersionRequirements(): array + { + if (property_exists(static::class, 'eventVersions')) { + return static::$eventVersions; + } + + return []; + } +} diff --git a/app/Core/Events/ConsoleBooting.php b/app/Core/Events/ConsoleBooting.php new file mode 100644 index 0000000..356a792 --- /dev/null +++ b/app/Core/Events/ConsoleBooting.php @@ -0,0 +1,45 @@ + 'onConsole', + * ]; + * + * public function onConsole(ConsoleBooting $event): void + * { + * $event->command(ProcessOrdersCommand::class); + * $event->command(SyncInventoryCommand::class); + * } + * ``` + * + * + * @see QueueWorkerBooting For queue worker specific initialization + */ +class ConsoleBooting extends LifecycleEvent +{ + // +} diff --git a/app/Core/Events/DomainResolving.php b/app/Core/Events/DomainResolving.php new file mode 100644 index 0000000..7304fc0 --- /dev/null +++ b/app/Core/Events/DomainResolving.php @@ -0,0 +1,102 @@ + 'onDomain', + * ]; + * + * public function onDomain(DomainResolving $event): void + * { + * if ($event->matches('/^(www\.)?mysite\.com$/')) { + * $event->register(MySiteProvider::class); + * } + * } + * ``` + */ +class DomainResolving +{ + /** + * The matched provider class, if any. + */ + protected ?string $matchedProvider = null; + + /** + * Create a new DomainResolving event. + * + * @param string $host The incoming request hostname + */ + public function __construct( + public readonly string $host + ) {} + + /** + * Check if the incoming host matches a regex pattern. + * + * The host is normalized to lowercase before matching. + * + * @param string $pattern Regex pattern to match against (e.g., '/^example\.com$/') + * @return bool True if the pattern matches the host + */ + public function matches(string $pattern): bool + { + $normalised = strtolower(parse_url('http://'.$this->host, PHP_URL_HOST) ?? $this->host); + + return (bool) preg_match($pattern, $normalised); + } + + /** + * Register as the matching provider for this domain. + * + * Only the first provider to register wins. Subsequent registrations + * are ignored. + * + * @param string $providerClass Fully qualified provider class name + */ + public function register(string $providerClass): void + { + $this->matchedProvider = $providerClass; + } + + /** + * Get the matched provider class name. + * + * @return string|null Provider class name, or null if no match + */ + public function matchedProvider(): ?string + { + return $this->matchedProvider; + } +} diff --git a/app/Core/Events/EventAuditLog.php b/app/Core/Events/EventAuditLog.php new file mode 100644 index 0000000..28121c2 --- /dev/null +++ b/app/Core/Events/EventAuditLog.php @@ -0,0 +1,269 @@ + */ + private static array $entries = []; + + /** @var array */ + private static array $pendingEvents = []; + + /** + * Enable audit logging. + */ + public static function enable(): void + { + self::$enabled = true; + } + + /** + * Disable audit logging. + */ + public static function disable(): void + { + self::$enabled = false; + } + + /** + * Check if audit logging is enabled. + */ + public static function isEnabled(): bool + { + return self::$enabled; + } + + /** + * Enable writing audit entries to Laravel log. + */ + public static function enableLog(): void + { + self::$logEnabled = true; + } + + /** + * Disable writing audit entries to Laravel log. + */ + public static function disableLog(): void + { + self::$logEnabled = false; + } + + /** + * Record the start of event handling. + */ + public static function recordStart(string $eventClass, string $handlerClass): void + { + if (! self::$enabled) { + return; + } + + $key = "{$eventClass}:{$handlerClass}"; + self::$pendingEvents[$key] = microtime(true); + } + + /** + * Record successful completion of event handling. + */ + public static function recordSuccess(string $eventClass, string $handlerClass): void + { + if (! self::$enabled) { + return; + } + + $key = "{$eventClass}:{$handlerClass}"; + $startTime = self::$pendingEvents[$key] ?? microtime(true); + $duration = (microtime(true) - $startTime) * 1000; + + unset(self::$pendingEvents[$key]); + + $entry = [ + 'event' => $eventClass, + 'handler' => $handlerClass, + 'duration_ms' => round($duration, 2), + 'failed' => false, + 'timestamp' => microtime(true), + ]; + + self::$entries[] = $entry; + + if (self::$logEnabled) { + Log::debug('Lifecycle event handled', $entry); + } + } + + /** + * Record a failed event handler. + */ + public static function recordFailure(string $eventClass, string $handlerClass, \Throwable $error): void + { + if (! self::$enabled) { + return; + } + + $key = "{$eventClass}:{$handlerClass}"; + $startTime = self::$pendingEvents[$key] ?? microtime(true); + $duration = (microtime(true) - $startTime) * 1000; + + unset(self::$pendingEvents[$key]); + + $entry = [ + 'event' => $eventClass, + 'handler' => $handlerClass, + 'duration_ms' => round($duration, 2), + 'failed' => true, + 'error' => $error->getMessage(), + 'timestamp' => microtime(true), + ]; + + self::$entries[] = $entry; + + if (self::$logEnabled) { + Log::warning('Lifecycle event handler failed', $entry); + } + } + + /** + * Get all recorded entries. + * + * @return array + */ + public static function entries(): array + { + return self::$entries; + } + + /** + * Get entries for a specific event class. + * + * @return array + */ + public static function entriesFor(string $eventClass): array + { + return array_values( + array_filter(self::$entries, fn ($entry) => $entry['event'] === $eventClass) + ); + } + + /** + * Get only failed entries. + * + * @return array + */ + public static function failures(): array + { + return array_values( + array_filter(self::$entries, fn ($entry) => $entry['failed']) + ); + } + + /** + * Get summary statistics. + * + * @return array{total: int, failed: int, total_duration_ms: float, events: array} + */ + public static function summary(): array + { + $eventCounts = []; + $totalDuration = 0.0; + $failedCount = 0; + + foreach (self::$entries as $entry) { + $eventCounts[$entry['event']] = ($eventCounts[$entry['event']] ?? 0) + 1; + $totalDuration += $entry['duration_ms']; + + if ($entry['failed']) { + $failedCount++; + } + } + + return [ + 'total' => count(self::$entries), + 'failed' => $failedCount, + 'total_duration_ms' => round($totalDuration, 2), + 'events' => $eventCounts, + ]; + } + + /** + * Clear all recorded entries. + */ + public static function clear(): void + { + self::$entries = []; + self::$pendingEvents = []; + } + + /** + * Reset to initial state (disable and clear). + */ + public static function reset(): void + { + self::$enabled = false; + self::$logEnabled = false; + self::clear(); + } +} diff --git a/app/Core/Events/FrameworkBooted.php b/app/Core/Events/FrameworkBooted.php new file mode 100644 index 0000000..d94db86 --- /dev/null +++ b/app/Core/Events/FrameworkBooted.php @@ -0,0 +1,58 @@ +booted()` callback, after all service + * providers have completed their `boot()` methods. Use this for late-stage + * initialization that requires the full application context. + * + * ## When This Event Fires + * + * Fires after all service providers have booted, regardless of request type + * (web, API, console, queue). This is one of the last events in the bootstrap + * sequence. + * + * ## When to Use This Event + * + * Use FrameworkBooted sparingly. Most modules should prefer context-specific + * events that only fire when relevant: + * + * - **WebRoutesRegistering** - Web routes only + * - **AdminPanelBooting** - Admin requests only + * - **ApiRoutesRegistering** - API requests only + * - **ConsoleBooting** - CLI only + * + * Good use cases for FrameworkBooted: + * - Cross-cutting concerns that apply to all contexts + * - Initialization that depends on other modules being registered + * - Late-binding configuration that needs full container state + * + * ## Usage Example + * + * ```php + * public static array $listens = [ + * FrameworkBooted::class => 'onBooted', + * ]; + * + * public function onBooted(FrameworkBooted $event): void + * { + * // Late-stage initialization + * } + * ``` + */ +class FrameworkBooted extends LifecycleEvent +{ + // +} diff --git a/app/Core/Events/LifecycleEvent.php b/app/Core/Events/LifecycleEvent.php new file mode 100644 index 0000000..295619f --- /dev/null +++ b/app/Core/Events/LifecycleEvent.php @@ -0,0 +1,562 @@ + 'onWebRoutes', + * │ AdminPanelBooting::class => ['onAdmin', 10], + * │ ]; + * │ + * │ │ + * │◄──────────────────────────────────┤ ModuleScanner reads $listens + * │ │ without instantiation + * │ │ + * │ ▼ + * │ ┌─────────────────────┐ + * │ │ ModuleRegistry │ + * │ │ sorts by priority │ + * │ │ (10 runs before 0) │ + * │ └──────────┬──────────┘ + * │ │ + * │ ▼ + * │ ┌─────────────────────┐ + * │ │ LazyModuleListener │ + * │ │ registered with │ + * │ │ Laravel Events │ + * │ └──────────┬──────────┘ + * │ │ + * │ │ Event fires + * │ ▼ + * │ ┌─────────────────────┐ + * │ │ LazyModuleListener │ + * │◄───────────────────────────┤ instantiates module │ + * │ Module instantiated │ via container │ + * │ only when event fires └──────────┬──────────┘ + * │ │ + * ▼ │ + * ┌─────────────┐ │ + * │ onWebRoutes │◄──────────────────────────────┘ + * │ ($event) │ Method called with event + * └──────┬──────┘ + * │ + * ▼ + * ┌──────────────────────────────────────────────────────┐ + * │ $event->routes(fn () => require __DIR__.'/web.php'); │ + * │ $event->views('mymod', __DIR__.'/Views'); │ + * │ $event->livewire('my-comp', MyComponent::class); │ + * └──────────────────────────────────────────────────────┘ + * │ + * │ Requests collected in event + * ▼ + * ┌─────────────────────────────────────────────────────────────┐ + * │ LifecycleEventProvider processes requests: │ + * │ - Registers view namespaces │ + * │ - Registers Livewire components │ + * │ - Wraps routes with appropriate middleware │ + * │ - Refreshes route lookups │ + * └─────────────────────────────────────────────────────────────┘ + * ``` + * + * ## Request/Collect Pattern + * + * This class implements a "request/collect" pattern rather than direct mutation: + * + * 1. **Modules request** resources via methods like `routes()`, `views()`, etc. + * 2. **Requests are collected** in arrays during event dispatch + * 3. **LifecycleEventProvider processes** collected requests with validation + * + * This pattern ensures modules cannot directly mutate infrastructure and allows + * the framework to validate, sort, and process requests centrally. + * + * ## Available Request Methods + * + * | Method | Purpose | + * |--------|---------| + * | `routes()` | Register route files/callbacks | + * | `views()` | Register view namespaces | + * | `livewire()` | Register Livewire components | + * | `middleware()` | Register middleware aliases | + * | `command()` | Register Artisan commands | + * | `translations()` | Register translation namespaces | + * | `bladeComponentPath()` | Register anonymous Blade component paths | + * | `policy()` | Register model policies | + * | `navigation()` | Register navigation items | + * + * ## Event Versioning + * + * Events support versioning for backwards compatibility. The version number + * indicates the API contract version: + * + * - Version 1: Original API (current) + * - Future versions may add methods but maintain backwards compatibility + * + * Check version with `$event->version()` in your handlers to support multiple + * event versions during transitions. + * + * ## Usage Example + * + * ```php + * public function onWebRoutes(WebRoutesRegistering $event): void + * { + * $event->views('mymodule', __DIR__.'/Views'); + * $event->livewire('my-component', MyComponent::class); + * $event->routes(fn () => require __DIR__.'/Routes/web.php'); + * } + * ``` + * + * + * @method void navigation(array $item) Request a navigation item be added + * @method void routes(callable $callback) Request routes be registered + * @method void views(string $namespace, string $path) Request a view namespace be registered + * @method void middleware(string $alias, string $class) Request a middleware alias be registered + * @method void livewire(string $alias, string $class) Request a Livewire component be registered + * @method void command(string $class) Request an Artisan command be registered + * @method void translations(string $namespace, string $path) Request translations be loaded + * @method void bladeComponentPath(string $path, ?string $namespace = null) Request a Blade component path + * @method void policy(string $model, string $policy) Request a policy be registered + * @method array navigationRequests() Get collected navigation requests + * @method array routeRequests() Get collected route requests + * @method array viewRequests() Get collected view requests + * @method array middlewareRequests() Get collected middleware requests + * @method array livewireRequests() Get collected Livewire requests + * @method array commandRequests() Get collected command requests + * @method array translationRequests() Get collected translation requests + * @method array bladeComponentRequests() Get collected Blade component requests + * @method array policyRequests() Get collected policy requests + * + * @see LifecycleEventProvider For event processing + */ +abstract class LifecycleEvent +{ + /** + * Event API version. + * + * Increment this when making breaking changes to the event interface. + * Handlers can check this to maintain backwards compatibility. + * + * Version history: + * - 1: Initial release (Core PHP 1.0) + */ + public const VERSION = 1; + + /** + * Minimum supported handler version. + * + * Handlers declaring a version lower than this will receive a deprecation warning. + */ + public const MIN_SUPPORTED_VERSION = 1; + + /** @var array> Collected navigation item requests */ + protected array $navigationRequests = []; + + /** @var array Collected route registration callbacks */ + protected array $routeRequests = []; + + /** @var array Collected view namespace requests [namespace, path] */ + protected array $viewRequests = []; + + /** @var array Collected middleware alias requests [alias, class] */ + protected array $middlewareRequests = []; + + /** @var array Collected Livewire component requests [alias, class] */ + protected array $livewireRequests = []; + + /** @var array Collected Artisan command class names */ + protected array $commandRequests = []; + + /** @var array Collected translation namespace requests [namespace, path] */ + protected array $translationRequests = []; + + /** @var array Collected Blade component path requests [path, namespace] */ + protected array $bladeComponentRequests = []; + + /** @var array Collected policy requests [model, policy] */ + protected array $policyRequests = []; + + /** + * Request a navigation item be added. + * + * Navigation items are collected and processed by the admin menu system. + * Consider implementing AdminMenuProvider for more control over menu items. + * + * @param array $item Navigation item configuration + */ + public function navigation(array $item): void + { + $this->navigationRequests[] = $item; + } + + /** + * Request routes be registered. + * + * The callback is invoked within the appropriate middleware group + * (web, admin, api, client) depending on which event fired. + * + * ```php + * $event->routes(fn () => require __DIR__.'/Routes/web.php'); + * // or + * $event->routes(function () { + * Route::get('/example', ExampleController::class); + * }); + * ``` + * + * @param callable $callback Route registration callback + */ + public function routes(callable $callback): void + { + $this->routeRequests[] = $callback; + } + + /** + * Request a view namespace be registered. + * + * After registration, views can be referenced as `namespace::view.name`. + * + * ```php + * $event->views('commerce', __DIR__.'/Views'); + * // Later: view('commerce::products.index') + * ``` + * + * @param string $namespace The view namespace (e.g., 'commerce') + * @param string $path Absolute path to the views directory + */ + public function views(string $namespace, string $path): void + { + $this->viewRequests[] = [$namespace, $path]; + } + + /** + * Request a middleware alias be registered. + * + * @param string $alias The middleware alias (e.g., 'commerce.auth') + * @param string $class Fully qualified middleware class name + */ + public function middleware(string $alias, string $class): void + { + $this->middlewareRequests[] = [$alias, $class]; + } + + /** + * Request a Livewire component be registered. + * + * ```php + * $event->livewire('commerce-cart', CartComponent::class); + * // Later: + * ``` + * + * @param string $alias The component alias used in Blade templates + * @param string $class Fully qualified Livewire component class name + */ + public function livewire(string $alias, string $class): void + { + $this->livewireRequests[] = [$alias, $class]; + } + + /** + * Request an Artisan command be registered. + * + * Only processed during ConsoleBooting event. + * + * @param string $class Fully qualified command class name + */ + public function command(string $class): void + { + $this->commandRequests[] = $class; + } + + /** + * Request translations be loaded for a namespace. + * + * After registration, translations can be accessed as `namespace::key`. + * + * ```php + * $event->translations('commerce', __DIR__.'/Lang'); + * // Later: __('commerce::products.title') + * ``` + * + * @param string $namespace The translation namespace + * @param string $path Absolute path to the lang directory + */ + public function translations(string $namespace, string $path): void + { + $this->translationRequests[] = [$namespace, $path]; + } + + /** + * Request an anonymous Blade component path be registered. + * + * Anonymous components in this path can be used in templates. + * + * @param string $path Absolute path to the components directory + * @param string|null $namespace Optional prefix for component names + */ + public function bladeComponentPath(string $path, ?string $namespace = null): void + { + $this->bladeComponentRequests[] = [$path, $namespace]; + } + + /** + * Request a policy be registered for a model. + * + * @param string $model Fully qualified model class name + * @param string $policy Fully qualified policy class name + */ + public function policy(string $model, string $policy): void + { + $this->policyRequests[] = [$model, $policy]; + } + + /** + * Get all navigation requests for processing. + * + * @return array> + * + * @internal Used by LifecycleEventProvider + */ + public function navigationRequests(): array + { + return $this->navigationRequests; + } + + /** + * Get all route requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function routeRequests(): array + { + return $this->routeRequests; + } + + /** + * Get all view namespace requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function viewRequests(): array + { + return $this->viewRequests; + } + + /** + * Get all middleware alias requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function middlewareRequests(): array + { + return $this->middlewareRequests; + } + + /** + * Get all Livewire component requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function livewireRequests(): array + { + return $this->livewireRequests; + } + + /** + * Get all command requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function commandRequests(): array + { + return $this->commandRequests; + } + + /** + * Get all translation requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function translationRequests(): array + { + return $this->translationRequests; + } + + /** + * Get all Blade component path requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function bladeComponentRequests(): array + { + return $this->bladeComponentRequests; + } + + /** + * Get all policy requests for processing. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function policyRequests(): array + { + return $this->policyRequests; + } + + /** + * Get the event API version. + * + * Use this in your event handlers to check API compatibility: + * + * ```php + * public function onWebRoutes(WebRoutesRegistering $event): void + * { + * if ($event->version() >= 2) { + * // Use new v2 features + * } else { + * // Fallback to v1 behavior + * } + * } + * ``` + * + * @return int The event API version number + */ + public function version(): int + { + return static::VERSION; + } + + /** + * Check if this event supports a specific version. + * + * Returns true if the event's version is greater than or equal to the + * requested version. + * + * @param int $version The version to check against + * @return bool True if the event supports the specified version + */ + public function supportsVersion(int $version): bool + { + return static::VERSION >= $version; + } + + /** + * Get the event class name without namespace. + * + * Useful for logging and debugging. + * + * @return string The short class name (e.g., 'WebRoutesRegistering') + */ + public function eventName(): string + { + return class_basename(static::class); + } +} diff --git a/app/Core/Events/ListenerProfiler.php b/app/Core/Events/ListenerProfiler.php new file mode 100644 index 0000000..8c13cbe --- /dev/null +++ b/app/Core/Events/ListenerProfiler.php @@ -0,0 +1,552 @@ +50ms as slow + * ``` + * + * ## Retrieving Metrics + * + * ```php + * $profiles = ListenerProfiler::getProfiles(); // All listener profiles + * $slow = ListenerProfiler::getSlowListeners(); // Listeners exceeding threshold + * $sorted = ListenerProfiler::getSlowest(10); // Top 10 slowest listeners + * $byEvent = ListenerProfiler::getProfilesForEvent(WebRoutesRegistering::class); + * $summary = ListenerProfiler::getSummary(); // Overall statistics + * ``` + * + * ## Profile Structure + * + * Each profile contains: + * - `event` - Event class name + * - `handler` - Handler class name + * - `method` - Handler method name + * - `duration_ms` - Total execution time (milliseconds) + * - `memory_peak_bytes` - Peak memory usage during execution + * - `memory_delta_bytes` - Memory change during execution + * - `call_count` - Number of invocations + * - `avg_duration_ms` - Average time per call + * - `is_slow` - Whether any call exceeded slow threshold + * - `calls` - Array of individual call metrics + * + * ## Integration with LazyModuleListener + * + * Enable automatic profiling integration: + * + * ```php + * ListenerProfiler::enable(); + * // Profiling is automatically integrated via LazyModuleListener + * ``` + * + * + * @see EventAuditLog For simpler success/failure tracking + * @see LazyModuleListener For automatic profiling integration + */ +class ListenerProfiler +{ + private static bool $enabled = false; + + /** + * Threshold in milliseconds for flagging slow listeners. + */ + private static float $slowThreshold = 100.0; + + /** + * Collected profile data. + * + * @var array + * }> + */ + private static array $profiles = []; + + /** + * Active profiling contexts (for nested calls). + * + * @var array + */ + private static array $activeContexts = []; + + /** + * Enable listener profiling. + */ + public static function enable(): void + { + self::$enabled = true; + } + + /** + * Disable listener profiling. + */ + public static function disable(): void + { + self::$enabled = false; + } + + /** + * Check if profiling is enabled. + */ + public static function isEnabled(): bool + { + return self::$enabled; + } + + /** + * Set the threshold for flagging slow listeners. + * + * @param float $thresholdMs Threshold in milliseconds + */ + public static function setSlowThreshold(float $thresholdMs): void + { + self::$slowThreshold = $thresholdMs; + } + + /** + * Get the current slow listener threshold. + * + * @return float Threshold in milliseconds + */ + public static function getSlowThreshold(): float + { + return self::$slowThreshold; + } + + /** + * Start profiling a listener execution. + * + * Call this before invoking the listener. Returns a context key that must + * be passed to stop() to properly correlate the measurement. + * + * @param string $eventClass Event being handled + * @param string $handlerClass Handler class name + * @param string $method Handler method name + * @return string Context key for stop() + */ + public static function start(string $eventClass, string $handlerClass, string $method = '__invoke'): string + { + if (! self::$enabled) { + return ''; + } + + $contextKey = self::makeContextKey($eventClass, $handlerClass, $method); + + self::$activeContexts[$contextKey] = [ + 'start_time' => hrtime(true), + 'memory_before' => memory_get_usage(true), + ]; + + return $contextKey; + } + + /** + * Stop profiling and record the results. + * + * @param string $contextKey Key returned by start() + */ + public static function stop(string $contextKey): void + { + if (! self::$enabled || $contextKey === '' || ! isset(self::$activeContexts[$contextKey])) { + return; + } + + $context = self::$activeContexts[$contextKey]; + unset(self::$activeContexts[$contextKey]); + + $endTime = hrtime(true); + $memoryAfter = memory_get_usage(true); + $memoryPeak = memory_get_peak_usage(true); + + $durationNs = $endTime - $context['start_time']; + $durationMs = $durationNs / 1_000_000; + + // Parse context key to get event, handler, method + [$eventClass, $handlerClass, $method] = self::parseContextKey($contextKey); + + $profileKey = self::makeProfileKey($eventClass, $handlerClass); + + // Initialize profile if needed + if (! isset(self::$profiles[$profileKey])) { + self::$profiles[$profileKey] = [ + 'event' => $eventClass, + 'handler' => $handlerClass, + 'method' => $method, + 'duration_ms' => 0.0, + 'memory_peak_bytes' => 0, + 'memory_delta_bytes' => 0, + 'call_count' => 0, + 'avg_duration_ms' => 0.0, + 'is_slow' => false, + 'calls' => [], + ]; + } + + // Record this call + $callData = [ + 'duration_ms' => round($durationMs, 3), + 'memory_before' => $context['memory_before'], + 'memory_after' => $memoryAfter, + 'memory_peak' => $memoryPeak, + ]; + + self::$profiles[$profileKey]['calls'][] = $callData; + self::$profiles[$profileKey]['duration_ms'] += $durationMs; + self::$profiles[$profileKey]['call_count']++; + + // Update peak memory if this call used more + $memoryDelta = $memoryAfter - $context['memory_before']; + if ($memoryPeak > self::$profiles[$profileKey]['memory_peak_bytes']) { + self::$profiles[$profileKey]['memory_peak_bytes'] = $memoryPeak; + } + self::$profiles[$profileKey]['memory_delta_bytes'] += $memoryDelta; + + // Update average + self::$profiles[$profileKey]['avg_duration_ms'] = round( + self::$profiles[$profileKey]['duration_ms'] / self::$profiles[$profileKey]['call_count'], + 3 + ); + + // Check if slow + if ($durationMs >= self::$slowThreshold) { + self::$profiles[$profileKey]['is_slow'] = true; + } + } + + /** + * Profile a listener execution using a callback. + * + * Convenience method that handles start/stop automatically. + * + * ```php + * ListenerProfiler::profile( + * WebRoutesRegistering::class, + * MyModule::class, + * 'onWebRoutes', + * fn() => $handler->onWebRoutes($event) + * ); + * ``` + * + * @template T + * + * @param string $eventClass Event being handled + * @param string $handlerClass Handler class name + * @param string $method Handler method name + * @param callable(): T $callback The listener callback to profile + * @return T The callback's return value + */ + public static function profile(string $eventClass, string $handlerClass, string $method, callable $callback): mixed + { + $contextKey = self::start($eventClass, $handlerClass, $method); + + try { + return $callback(); + } finally { + self::stop($contextKey); + } + } + + /** + * Get all collected profiles. + * + * @return array + * }> + */ + public static function getProfiles(): array + { + return self::$profiles; + } + + /** + * Get profiles for a specific event. + * + * @param string $eventClass Event class name + * @return array + */ + public static function getProfilesForEvent(string $eventClass): array + { + return array_filter( + self::$profiles, + fn ($profile) => $profile['event'] === $eventClass + ); + } + + /** + * Get profiles for a specific handler. + * + * @param string $handlerClass Handler class name + * @return array + */ + public static function getProfilesForHandler(string $handlerClass): array + { + return array_filter( + self::$profiles, + fn ($profile) => $profile['handler'] === $handlerClass + ); + } + + /** + * Get listeners that exceeded the slow threshold. + * + * @return array + */ + public static function getSlowListeners(): array + { + return array_filter( + self::$profiles, + fn ($profile) => $profile['is_slow'] + ); + } + + /** + * Get the N slowest listeners by total duration. + * + * @param int $limit Maximum number of results + * @return array + */ + public static function getSlowest(int $limit = 10): array + { + $profiles = self::$profiles; + uasort($profiles, fn ($a, $b) => $b['duration_ms'] <=> $a['duration_ms']); + + return array_slice($profiles, 0, $limit, true); + } + + /** + * Get the N highest memory-consuming listeners. + * + * @param int $limit Maximum number of results + * @return array + */ + public static function getHighestMemory(int $limit = 10): array + { + $profiles = self::$profiles; + uasort($profiles, fn ($a, $b) => $b['memory_delta_bytes'] <=> $a['memory_delta_bytes']); + + return array_slice($profiles, 0, $limit, true); + } + + /** + * Get summary statistics for all profiled listeners. + * + * @return array{ + * total_listeners: int, + * total_calls: int, + * total_duration_ms: float, + * avg_duration_ms: float, + * slow_listeners: int, + * total_memory_delta_bytes: int, + * by_event: array + * } + */ + public static function getSummary(): array + { + $totalListeners = count(self::$profiles); + $totalCalls = 0; + $totalDuration = 0.0; + $slowCount = 0; + $totalMemoryDelta = 0; + $byEvent = []; + + foreach (self::$profiles as $profile) { + $totalCalls += $profile['call_count']; + $totalDuration += $profile['duration_ms']; + $totalMemoryDelta += $profile['memory_delta_bytes']; + + if ($profile['is_slow']) { + $slowCount++; + } + + $event = $profile['event']; + if (! isset($byEvent[$event])) { + $byEvent[$event] = [ + 'listeners' => 0, + 'duration_ms' => 0.0, + 'calls' => 0, + ]; + } + $byEvent[$event]['listeners']++; + $byEvent[$event]['duration_ms'] += $profile['duration_ms']; + $byEvent[$event]['calls'] += $profile['call_count']; + } + + return [ + 'total_listeners' => $totalListeners, + 'total_calls' => $totalCalls, + 'total_duration_ms' => round($totalDuration, 3), + 'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 3) : 0.0, + 'slow_listeners' => $slowCount, + 'total_memory_delta_bytes' => $totalMemoryDelta, + 'by_event' => $byEvent, + ]; + } + + /** + * Clear all collected profiles. + */ + public static function clear(): void + { + self::$profiles = []; + self::$activeContexts = []; + } + + /** + * Reset to initial state (disable and clear). + */ + public static function reset(): void + { + self::$enabled = false; + self::$slowThreshold = 100.0; + self::clear(); + } + + /** + * Export profiles to a format suitable for analysis tools. + * + * @return array{ + * timestamp: string, + * slow_threshold_ms: float, + * summary: array, + * profiles: array + * } + */ + public static function export(): array + { + return [ + 'timestamp' => date('c'), + 'slow_threshold_ms' => self::$slowThreshold, + 'summary' => self::getSummary(), + 'profiles' => self::$profiles, + ]; + } + + /** + * Create a unique context key for a listener execution. + */ + private static function makeContextKey(string $eventClass, string $handlerClass, string $method): string + { + $uniqueId = bin2hex(random_bytes(8)); + + return "{$eventClass}|{$handlerClass}|{$method}|{$uniqueId}"; + } + + /** + * Parse a context key back into its components. + * + * @return array{0: string, 1: string, 2: string} [event, handler, method] + */ + private static function parseContextKey(string $contextKey): array + { + $parts = explode('|', $contextKey); + + return [$parts[0] ?? '', $parts[1] ?? '', $parts[2] ?? '']; + } + + /** + * Create a profile key for aggregating calls to the same listener. + */ + private static function makeProfileKey(string $eventClass, string $handlerClass): string + { + return "{$eventClass}::{$handlerClass}"; + } +} diff --git a/app/Core/Events/MailSending.php b/app/Core/Events/MailSending.php new file mode 100644 index 0000000..968faf7 --- /dev/null +++ b/app/Core/Events/MailSending.php @@ -0,0 +1,66 @@ + 'onMail', + * ]; + * + * public function onMail(MailSending $event): void + * { + * $event->mailable(OrderConfirmationMail::class); + * $event->mailable(WelcomeEmail::class); + * } + * ``` + */ +class MailSending extends LifecycleEvent +{ + /** @var array Collected mailable class names */ + protected array $mailableRequests = []; + + /** + * Register a mailable class. + * + * @param string $class Fully qualified mailable class name + */ + public function mailable(string $class): void + { + $this->mailableRequests[] = $class; + } + + /** + * Get all registered mailable class names. + * + * @return array + * + * @internal Used by mail system + */ + public function mailableRequests(): array + { + return $this->mailableRequests; + } +} diff --git a/app/Core/Events/McpRoutesRegistering.php b/app/Core/Events/McpRoutesRegistering.php new file mode 100644 index 0000000..ddcbfd4 --- /dev/null +++ b/app/Core/Events/McpRoutesRegistering.php @@ -0,0 +1,54 @@ + 'onMcpRoutes', + * ]; + * + * public function onMcpRoutes(McpRoutesRegistering $event): void + * { + * $event->routes(fn () => Route::domain(config('mcp.domain')) + * ->middleware(McpApiKeyAuth::class) + * ->group(function () { + * Route::post('tools/call', [McpApiController::class, 'callTool']); + * }) + * ); + * } + * ``` + * + * @see ApiRoutesRegistering For REST API routes + * @see McpToolsRegistering For registering MCP tool handlers + */ +class McpRoutesRegistering extends LifecycleEvent +{ + // +} diff --git a/app/Core/Events/McpToolsRegistering.php b/app/Core/Events/McpToolsRegistering.php new file mode 100644 index 0000000..7a4a54f --- /dev/null +++ b/app/Core/Events/McpToolsRegistering.php @@ -0,0 +1,72 @@ + 'onMcp', + * ]; + * + * public function onMcp(McpToolsRegistering $event): void + * { + * $event->handler(ProductSearchHandler::class); + * $event->handler(InventoryQueryHandler::class); + * } + * ``` + */ +class McpToolsRegistering extends LifecycleEvent +{ + /** @var array Collected MCP tool handler class names */ + protected array $handlers = []; + + /** + * Register an MCP tool handler class. + * + * @param string $handlerClass Fully qualified class name implementing McpToolHandler + */ + public function handler(string $handlerClass): void + { + $this->handlers[] = $handlerClass; + } + + /** + * Get all registered handler class names. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function handlers(): array + { + return $this->handlers; + } +} diff --git a/app/Core/Events/MediaRequested.php b/app/Core/Events/MediaRequested.php new file mode 100644 index 0000000..3bdd424 --- /dev/null +++ b/app/Core/Events/MediaRequested.php @@ -0,0 +1,75 @@ + 'onMedia', + * ]; + * + * public function onMedia(MediaRequested $event): void + * { + * $event->processor('image', ImageProcessor::class); + * $event->processor('video', VideoProcessor::class); + * } + * ``` + */ +class MediaRequested extends LifecycleEvent +{ + /** @var array Collected processor registrations [type => class] */ + protected array $processorRequests = []; + + /** + * Register a media processor for a specific type. + * + * @param string $type Media type (e.g., 'image', 'video', 'audio') + * @param string $class Fully qualified processor class name + */ + public function processor(string $type, string $class): void + { + $this->processorRequests[$type] = $class; + } + + /** + * Get all registered processors. + * + * @return array [type => class] + * + * @internal Used by media system + */ + public function processorRequests(): array + { + return $this->processorRequests; + } +} diff --git a/app/Core/Events/QueueWorkerBooting.php b/app/Core/Events/QueueWorkerBooting.php new file mode 100644 index 0000000..d1aadc7 --- /dev/null +++ b/app/Core/Events/QueueWorkerBooting.php @@ -0,0 +1,74 @@ + 'onQueueWorker', + * ]; + * + * public function onQueueWorker(QueueWorkerBooting $event): void + * { + * $event->job(ProcessOrderJob::class); + * $event->job(SendNotificationJob::class); + * } + * ``` + * + * Note: Most Laravel jobs don't need explicit registration. This event + * is primarily for queue-specific initialization or custom job handling. + * + * + * @see ConsoleBooting For CLI-specific initialization + */ +class QueueWorkerBooting extends LifecycleEvent +{ + /** @var array Collected job class names */ + protected array $jobRequests = []; + + /** + * Register a job class. + * + * @param string $class Fully qualified job class name + */ + public function job(string $class): void + { + $this->jobRequests[] = $class; + } + + /** + * Get all registered job class names. + * + * @return array + * + * @internal Used by LifecycleEventProvider + */ + public function jobRequests(): array + { + return $this->jobRequests; + } +} diff --git a/app/Core/Events/SearchRequested.php b/app/Core/Events/SearchRequested.php new file mode 100644 index 0000000..51f7897 --- /dev/null +++ b/app/Core/Events/SearchRequested.php @@ -0,0 +1,66 @@ + 'onSearch', + * ]; + * + * public function onSearch(SearchRequested $event): void + * { + * $event->searchable(Product::class); + * $event->searchable(Article::class); + * } + * ``` + */ +class SearchRequested extends LifecycleEvent +{ + /** @var array Collected searchable model class names */ + protected array $searchableRequests = []; + + /** + * Register a searchable model. + * + * @param string $model Fully qualified model class name + */ + public function searchable(string $model): void + { + $this->searchableRequests[] = $model; + } + + /** + * Get all registered searchable model class names. + * + * @return array + * + * @internal Used by search system + */ + public function searchableRequests(): array + { + return $this->searchableRequests; + } +} diff --git a/app/Core/Events/WebRoutesRegistering.php b/app/Core/Events/WebRoutesRegistering.php new file mode 100644 index 0000000..eebfcef --- /dev/null +++ b/app/Core/Events/WebRoutesRegistering.php @@ -0,0 +1,58 @@ + 'onWebRoutes', + * ]; + * + * public function onWebRoutes(WebRoutesRegistering $event): void + * { + * $event->views('marketing', __DIR__.'/Views'); + * $event->routes(fn () => require __DIR__.'/Routes/web.php'); + * } + * ``` + * + * ## When to Use Other Events + * + * - **AdminPanelBooting** - For admin dashboard routes + * - **ClientRoutesRegistering** - For authenticated customer/namespace routes + * - **ApiRoutesRegistering** - For REST API endpoints + * + * + * @see AdminPanelBooting For admin routes + * @see ClientRoutesRegistering For client dashboard routes + */ +class WebRoutesRegistering extends LifecycleEvent +{ + // +} diff --git a/app/Core/Front/Admin/AdminMenuRegistry.php b/app/Core/Front/Admin/AdminMenuRegistry.php new file mode 100644 index 0000000..da3423b --- /dev/null +++ b/app/Core/Front/Admin/AdminMenuRegistry.php @@ -0,0 +1,740 @@ + + */ + protected array $providers = []; + + /** + * Registered dynamic menu providers. + * + * @var array + */ + protected array $dynamicProviders = []; + + /** + * Pre-defined menu groups with metadata. + * + * Groups with 'standalone' => true render items directly. + * Other groups become dropdown parents with items as children. + * + * @var array + */ + protected array $groups = [ + 'dashboard' => [ + 'standalone' => true, + ], + 'agents' => [ + 'standalone' => true, + ], + 'workspaces' => [ + 'label' => 'Workspaces', + 'icon' => 'layer-group', + 'color' => 'blue', + ], + 'services' => [ + 'standalone' => true, + ], + 'settings' => [ + 'label' => 'Account', + 'icon' => 'gear', + 'color' => 'zinc', + ], + 'admin' => [ + 'label' => 'Admin', + 'icon' => 'shield', + 'color' => 'amber', + ], + ]; + + /** + * Whether caching is enabled. + */ + protected bool $cachingEnabled = true; + + /** + * Cache TTL in seconds. + */ + protected int $cacheTtl; + + /** + * EntitlementService instance (Core\Tenant\Services\EntitlementService when available). + */ + protected ?object $entitlements = null; + + /** + * Icon validator instance. + */ + protected ?IconValidator $iconValidator = null; + + /** + * Whether icon validation is enabled. + */ + protected bool $validateIcons = true; + + public function __construct(?object $entitlements = null, ?IconValidator $iconValidator = null) + { + if ($entitlements === null && class_exists(EntitlementService::class)) { + $this->entitlements = app(EntitlementService::class); + } else { + $this->entitlements = $entitlements; + } + + $this->iconValidator = $iconValidator ?? new IconValidator; + $this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL); + $this->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true); + $this->validateIcons = (bool) config('core.admin_menu.validate_icons', true); + } + + /** + * Register a menu provider. + */ + public function register(AdminMenuProvider $provider): void + { + $this->providers[] = $provider; + + // Also register as dynamic provider if it implements the interface + if ($provider instanceof DynamicMenuProvider) { + $this->dynamicProviders[] = $provider; + } + } + + /** + * Register a dynamic menu provider. + */ + public function registerDynamic(DynamicMenuProvider $provider): void + { + $this->dynamicProviders[] = $provider; + } + + /** + * Enable or disable caching. + */ + public function setCachingEnabled(bool $enabled): void + { + $this->cachingEnabled = $enabled; + } + + /** + * Set cache TTL in seconds. + */ + public function setCacheTtl(int $seconds): void + { + $this->cacheTtl = $seconds; + } + + /** + * Build the complete menu structure. + * + * @param object|null $workspace Current workspace for entitlement checks (Workspace model instance) + * @param bool $isAdmin Whether user is admin (Hades) + * @param object|null $user The authenticated user for permission checks (User model instance) + * @return array + */ + public function build(?object $workspace, bool $isAdmin = false, ?object $user = null): array + { + // Get static items (potentially cached) + $staticItems = $this->getStaticItems($workspace, $isAdmin, $user); + + // Get dynamic items (never cached) + $dynamicItems = $this->getDynamicItems($workspace, $isAdmin, $user); + + // Merge static and dynamic items + $allItems = $this->mergeItems($staticItems, $dynamicItems); + + // Build the menu structure + return $this->buildMenuStructure($allItems, $workspace, $isAdmin); + } + + /** + * Get static menu items, using cache if enabled. + * + * @param object|null $workspace Workspace model instance + * @param object|null $user User model instance + * @return array> + */ + protected function getStaticItems(?object $workspace, bool $isAdmin, ?object $user): array + { + if (! $this->cachingEnabled) { + return $this->collectItems($workspace, $isAdmin, $user); + } + + $cacheKey = $this->buildCacheKey($workspace, $isAdmin, $user); + + return Cache::remember($cacheKey, $this->cacheTtl, function () use ($workspace, $isAdmin, $user) { + return $this->collectItems($workspace, $isAdmin, $user); + }); + } + + /** + * Get dynamic menu items from dynamic providers. + * + * @param object|null $workspace Workspace model instance + * @param object|null $user User model instance + * @return array> + */ + protected function getDynamicItems(?object $workspace, bool $isAdmin, ?object $user): array + { + $grouped = []; + + foreach ($this->dynamicProviders as $provider) { + $items = $provider->dynamicMenuItems($user, $workspace, $isAdmin); + + foreach ($items as $registration) { + $group = $registration['group'] ?? 'services'; + $entitlement = $registration['entitlement'] ?? null; + $requiresAdmin = $registration['admin'] ?? false; + $permissions = $registration['permissions'] ?? []; + + // Skip if requires admin and user isn't admin + if ($requiresAdmin && ! $isAdmin) { + continue; + } + + // Skip if entitlement check fails + if ($entitlement && $workspace && $this->entitlements !== null) { + if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { + continue; + } + } + + // Skip if no workspace and entitlement required + if ($entitlement && ! $workspace) { + continue; + } + + // Skip if permission check fails + if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) { + continue; + } + + $grouped[$group][] = [ + 'priority' => $registration['priority'] ?? 50, + 'item' => $registration['item'], + 'dynamic' => true, + ]; + } + } + + return $grouped; + } + + /** + * Merge static and dynamic items. + * + * @param array $static + * @param array $dynamic + * @return array + */ + protected function mergeItems(array $static, array $dynamic): array + { + foreach ($dynamic as $group => $items) { + if (! isset($static[$group])) { + $static[$group] = []; + } + $static[$group] = array_merge($static[$group], $items); + } + + return $static; + } + + /** + * Build the final menu structure from collected items. + * + * @param object|null $workspace Workspace model instance + */ + protected function buildMenuStructure(array $allItems, ?object $workspace, bool $isAdmin): array + { + // Build flat structure with dividers + $menu = []; + $firstGroup = true; + + foreach ($this->groups as $groupKey => $groupConfig) { + // Skip admin group unless user is admin AND on system workspace + if ($groupKey === 'admin' && (! $isAdmin || $workspace?->slug !== 'system')) { + continue; + } + + $groupItems = $allItems[$groupKey] ?? []; + + if (empty($groupItems)) { + continue; + } + + // Sort by priority + usort($groupItems, fn ($a, $b) => $a['priority'] <=> $b['priority']); + + // Evaluate closures and extract items + $evaluatedItems = []; + foreach ($groupItems as $item) { + $evaluated = ($item['item'])(); + if ($evaluated !== null) { + $evaluatedItems[] = $evaluated; + } + } + + if (empty($evaluatedItems)) { + continue; + } + + // Add divider before non-first groups + if (! $firstGroup) { + $menu[] = ['divider' => true]; + } + $firstGroup = false; + + // Standalone groups add items directly + if (! empty($groupConfig['standalone'])) { + foreach ($evaluatedItems as $item) { + $menu[] = $item; + } + + continue; + } + + // Other groups become dropdown parents + // Check if any item is active + $isActive = collect($evaluatedItems)->contains(fn ($item) => $item['active'] ?? false); + + // Flatten children: each item becomes a child entry + $children = []; + foreach ($evaluatedItems as $item) { + if (! empty($item['children'])) { + // Item has its own children - add label as section header then children + $children[] = [ + 'section' => $item['label'], + 'icon' => $item['icon'] ?? null, + 'color' => $item['color'] ?? null, + ]; + foreach ($item['children'] as $child) { + $children[] = $child; + } + } else { + // Item is a direct link - preserve icon and color + $children[] = [ + 'label' => $item['label'], + 'href' => $item['href'] ?? '#', + 'icon' => $item['icon'] ?? null, + 'color' => $item['color'] ?? null, + 'active' => $item['active'] ?? false, + 'badge' => $item['badge'] ?? null, + ]; + } + } + + $menu[] = [ + 'label' => $groupConfig['label'], + 'icon' => $groupConfig['icon'], + 'color' => $groupConfig['color'], + 'active' => $isActive, + 'children' => $children, + ]; + } + + return $menu; + } + + /** + * Build the cache key for menu items. + * + * @param object|null $workspace Workspace model instance + * @param object|null $user User model instance + */ + protected function buildCacheKey(?object $workspace, bool $isAdmin, ?object $user): string + { + $parts = [ + self::CACHE_PREFIX, + 'w'.($workspace?->id ?? 'null'), + 'a'.($isAdmin ? '1' : '0'), + 'u'.($user?->id ?? 'null'), + ]; + + // Add dynamic cache key modifiers + foreach ($this->dynamicProviders as $provider) { + $dynamicKey = $provider->dynamicCacheKey($user, $workspace); + if ($dynamicKey !== null) { + $parts[] = md5($dynamicKey); + } + } + + return implode(':', $parts); + } + + /** + * Collect items from all providers, filtering by entitlements and permissions. + * + * @param object|null $workspace Workspace model instance + * @param object|null $user User model instance + * @return array> + */ + protected function collectItems(?object $workspace, bool $isAdmin, ?object $user): array + { + $grouped = []; + + foreach ($this->providers as $provider) { + // Check provider-level permissions first + if (! $provider->canViewMenu($user, $workspace)) { + continue; + } + + foreach ($provider->adminMenuItems() as $registration) { + $group = $registration['group'] ?? 'services'; + $entitlement = $registration['entitlement'] ?? null; + $requiresAdmin = $registration['admin'] ?? false; + $permissions = $registration['permissions'] ?? []; + + // Skip if requires admin and user isn't admin + if ($requiresAdmin && ! $isAdmin) { + continue; + } + + // Skip if entitlement check fails + if ($entitlement && $workspace && $this->entitlements !== null) { + if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { + continue; + } + } + + // Skip if no workspace and entitlement required + if ($entitlement && ! $workspace) { + continue; + } + + // Skip if item-level permission check fails + if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) { + continue; + } + + $grouped[$group][] = [ + 'priority' => $registration['priority'] ?? 50, + 'item' => $registration['item'], + ]; + } + } + + return $grouped; + } + + /** + * Check if a user has all required permissions. + * + * @param object|null $user User model instance + * @param array $permissions + * @param object|null $workspace Workspace model instance + */ + protected function checkPermissions(?object $user, array $permissions, ?object $workspace): bool + { + if (empty($permissions)) { + return true; + } + + if ($user === null) { + return false; + } + + foreach ($permissions as $permission) { + // Check using Laravel's authorization + if (method_exists($user, 'can') && ! $user->can($permission, $workspace)) { + return false; + } + } + + return true; + } + + /** + * Invalidate cached menu for a specific context. + * + * @param object|null $workspace Workspace model instance + * @param object|null $user User model instance + */ + public function invalidateCache(?object $workspace = null, ?object $user = null): void + { + if ($workspace !== null && $user !== null) { + // Invalidate specific cache keys + foreach ([true, false] as $isAdmin) { + $cacheKey = $this->buildCacheKey($workspace, $isAdmin, $user); + Cache::forget($cacheKey); + } + } else { + // Flush all admin menu caches using tags if available + if (method_exists(Cache::getStore(), 'tags')) { + Cache::tags([self::CACHE_PREFIX])->flush(); + } + } + } + + /** + * Invalidate all cached menus for a workspace. + * + * @param object $workspace Workspace model instance + */ + public function invalidateWorkspaceCache(object $workspace): void + { + // We can't easily clear pattern-based cache keys with all drivers, + // so we rely on TTL expiration for non-tagged caches + if (method_exists(Cache::getStore(), 'tags')) { + Cache::tags([self::CACHE_PREFIX, 'workspace:'.$workspace->id])->flush(); + } + } + + /** + * Invalidate all cached menus for a user. + * + * @param object $user User model instance + */ + public function invalidateUserCache(object $user): void + { + if (method_exists(Cache::getStore(), 'tags')) { + Cache::tags([self::CACHE_PREFIX, 'user:'.$user->id])->flush(); + } + } + + /** + * Get available group keys. + * + * @return array + */ + public function getGroups(): array + { + return array_keys($this->groups); + } + + /** + * Get group configuration. + * + * @return array + */ + public function getGroupConfig(string $key): array + { + return $this->groups[$key] ?? []; + } + + /** + * Get the icon validator instance. + */ + public function getIconValidator(): IconValidator + { + return $this->iconValidator; + } + + /** + * Enable or disable icon validation. + */ + public function setIconValidation(bool $enabled): void + { + $this->validateIcons = $enabled; + } + + /** + * Validate an icon and return whether it's valid. + * + * @param string $icon The icon name to validate + * @return bool True if valid, false otherwise + */ + public function validateIcon(string $icon): bool + { + if (! $this->validateIcons || $this->iconValidator === null) { + return true; + } + + return $this->iconValidator->isValid($icon); + } + + /** + * Validate a menu item's icon. + * + * @param array $item The menu item array + * @return array Array of validation error messages (empty if valid) + */ + public function validateMenuItem(array $item): array + { + $errors = []; + + if (! $this->validateIcons || $this->iconValidator === null) { + return $errors; + } + + $icon = $item['icon'] ?? null; + if ($icon !== null && ! empty($icon)) { + $iconErrors = $this->iconValidator->validate($icon); + $errors = array_merge($errors, $iconErrors); + } + + // Validate children icons if present + if (! empty($item['children'])) { + foreach ($item['children'] as $index => $child) { + $childIcon = $child['icon'] ?? null; + if ($childIcon !== null && ! empty($childIcon)) { + $childErrors = $this->iconValidator->validate($childIcon); + foreach ($childErrors as $error) { + $errors[] = "Child item {$index}: {$error}"; + } + } + } + } + + return $errors; + } + + /** + * Get all service menu items indexed by service key. + * + * @param object|null $workspace Current workspace for entitlement checks (Workspace model instance) + * @param bool $isAdmin Whether user is admin (Hades) + * @param object|null $user The authenticated user for permission checks (User model instance) + * @return array Service items indexed by service key + */ + public function getAllServiceItems(?object $workspace, bool $isAdmin = false, ?object $user = null): array + { + $services = []; + + foreach ($this->providers as $provider) { + // Check provider-level permissions + if (! $provider->canViewMenu($user, $workspace)) { + continue; + } + + foreach ($provider->adminMenuItems() as $registration) { + if (($registration['group'] ?? 'services') !== 'services') { + continue; + } + + $serviceKey = $registration['service'] ?? null; + if (! $serviceKey) { + continue; + } + + $entitlement = $registration['entitlement'] ?? null; + $requiresAdmin = $registration['admin'] ?? false; + $permissions = $registration['permissions'] ?? []; + + // Skip if requires admin and user isn't admin + if ($requiresAdmin && ! $isAdmin) { + continue; + } + + // Skip if entitlement check fails + if ($entitlement && $workspace && $this->entitlements !== null) { + if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { + continue; + } + } + + // Skip if permission check fails + if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) { + continue; + } + + // Evaluate the closure and store by service key + $item = ($registration['item'])(); + if ($item) { + $services[$serviceKey] = array_merge($item, [ + 'priority' => $registration['priority'] ?? 50, + ]); + } + } + } + + // Sort by priority + uasort($services, fn ($a, $b) => ($a['priority'] ?? 50) <=> ($b['priority'] ?? 50)); + + return $services; + } + + /** + * Get a specific service's menu item including its children (tabs). + * + * @param string $serviceKey The service identifier (e.g., 'commerce', 'support') + * @param object|null $workspace Current workspace for entitlement checks (Workspace model instance) + * @param bool $isAdmin Whether user is admin (Hades) + * @param object|null $user The authenticated user for permission checks (User model instance) + * @return array|null The service menu item with children, or null if not found + */ + public function getServiceItem(string $serviceKey, ?object $workspace, bool $isAdmin = false, ?object $user = null): ?array + { + foreach ($this->providers as $provider) { + // Check provider-level permissions + if (! $provider->canViewMenu($user, $workspace)) { + continue; + } + + foreach ($provider->adminMenuItems() as $registration) { + // Only check services group items with matching service key + if (($registration['group'] ?? 'services') !== 'services') { + continue; + } + + if (($registration['service'] ?? null) !== $serviceKey) { + continue; + } + + $entitlement = $registration['entitlement'] ?? null; + $requiresAdmin = $registration['admin'] ?? false; + $permissions = $registration['permissions'] ?? []; + + // Skip if requires admin and user isn't admin + if ($requiresAdmin && ! $isAdmin) { + continue; + } + + // Skip if entitlement check fails + if ($entitlement && $workspace && $this->entitlements !== null) { + if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { + continue; + } + } + + // Skip if permission check fails + if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) { + continue; + } + + // Evaluate the closure and return the item + $item = ($registration['item'])(); + + return $item; + } + } + + return null; + } +} diff --git a/app/Core/Front/Admin/AdminTagCompiler.php b/app/Core/Front/Admin/AdminTagCompiler.php new file mode 100644 index 0000000..aad43b2 --- /dev/null +++ b/app/Core/Front/Admin/AdminTagCompiler.php @@ -0,0 +1,170 @@ + components. + * + * Enables flux-style syntax: + * + * Resolution order: + * 1. Check for registered class component (admin-xyz alias) + * 2. Fall back to anonymous component (admin::xyz namespace) + */ +class AdminTagCompiler extends ComponentTagCompiler +{ + /** + * Resolve component name - class-backed first, then anonymous. + */ + protected function resolveComponentName(string $name): string + { + // Convert dots/colons to hyphens for class alias lookup + $aliasName = 'admin-'.str_replace(['.', ':'], '-', $name); + + // Check if a class component is registered with this alias + $aliases = $this->blade->getClassComponentAliases(); + + if (isset($aliases[$aliasName])) { + return $aliasName; + } + + // Fall back to anonymous component namespace lookup + // Keep the original name format (hyphens) for blade file lookup + return 'admin::'.$name; + } + + /** + * Compile the opening tags within the given string. + */ + protected function compileOpeningTags(string $value): string + { + $pattern = "/ + < + \s* + admin[\:]([\w\-\:\.]*) + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + @(?:style)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + (\:\\\$)(\w+) + ) + | + (?: + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + (? + /x"; + + return preg_replace_callback($pattern, function (array $matches) { + $this->boundAttributes = []; + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); + + return $this->componentString($this->resolveComponentName($matches[1]), $attributes); + }, $value); + } + + /** + * Compile the self-closing tags within the given string. + */ + protected function compileSelfClosingTags(string $value): string + { + $pattern = "/ + < + \s* + admin[\:]([\w\-\:\.]*) + \s* + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + @(?:style)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + (\:\\\$)(\w+) + ) + | + (?: + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + \/> + /x"; + + return preg_replace_callback($pattern, function (array $matches) { + $this->boundAttributes = []; + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); + + return $this->componentString($this->resolveComponentName($matches[1]), $attributes)."\n@endComponentClass##END-COMPONENT-CLASS##"; + }, $value); + } + + /** + * Compile the closing tags within the given string. + */ + protected function compileClosingTags(string $value): string + { + return preg_replace("/<\/\s*admin[\:][\w\-\:\.]*\s*>/", ' @endComponentClass##END-COMPONENT-CLASS##', $value); + } +} diff --git a/app/Core/Front/Admin/Blade/components/CLAUDE.md b/app/Core/Front/Admin/Blade/components/CLAUDE.md new file mode 100644 index 0000000..29705d7 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/CLAUDE.md @@ -0,0 +1,38 @@ +# Front/Admin/Blade/components + +Anonymous Blade components for the admin panel. Used via `` tag syntax. + +## Components + +| Component | Purpose | +|-----------|---------| +| action-link | Styled link for table row actions | +| activity-feed | Template for ActivityFeed class component | +| activity-log | Template for ActivityLog class component | +| alert | Template for Alert class component | +| card-grid | Template for CardGrid class component | +| clear-filters | Template for ClearFilters class component | +| data-table | Template for DataTable class component | +| editable-table | Template for EditableTable class component | +| empty-state | Empty state placeholder with icon and message | +| entitlement-gate | Conditionally renders content based on workspace entitlements | +| filter / filter-bar | Template for Filter/FilterBar class components | +| flash | Session flash message display | +| header | Page header with breadcrumbs and actions | +| link-grid | Template for LinkGrid class component | +| manager-table | Template for ManagerTable class component | +| metric-card / metrics | Individual metric card and grid template | +| module | Module wrapper with loading states | +| nav-group / nav-item / nav-link / nav-menu / nav-panel | Sidebar navigation primitives | +| page-header | Page title bar with optional subtitle and actions | +| panel | Content panel with optional header/footer | +| progress-list | Template for ProgressList class component | +| search | Template for Search class component | +| service-card / service-cards | Service overview cards | +| sidebar / sidemenu | Sidebar shell and menu template | +| stat-card / stats | Individual stat card and grid template | +| status-cards | Template for StatusCards class component | +| tabs | Tab navigation wrapper using `` | +| workspace-card | Workspace overview card | + +Most are templates for the class-backed components in `View/Components/`. A few are standalone anonymous components (empty-state, entitlement-gate, flash, nav-*, page-header, panel, workspace-card). diff --git a/app/Core/Front/Admin/Blade/components/action-link.blade.php b/app/Core/Front/Admin/Blade/components/action-link.blade.php new file mode 100644 index 0000000..712465c --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/action-link.blade.php @@ -0,0 +1,29 @@ +@props([ + 'href', + 'title', + 'subtitle' => null, + 'icon' => 'arrow-right', + 'color' => 'violet', + 'wire' => true, +]) + +merge(['class' => 'flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition']) }} +> +
+ +
+
+
{{ $title }}
+ @if($subtitle) +
{{ $subtitle }}
+ @endif +
+ @if($slot->isNotEmpty()) + {{ $slot }} + @else + + @endif +
diff --git a/app/Core/Front/Admin/Blade/components/activity-feed.blade.php b/app/Core/Front/Admin/Blade/components/activity-feed.blade.php new file mode 100644 index 0000000..8a22cf5 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/activity-feed.blade.php @@ -0,0 +1,37 @@ + + @if(empty($items)) + + @else +
    + @foreach($items as $item) +
  • + {{-- Activity type indicator --}} +
    + {{-- Coloured status dot --}} +
    +
    + + {{-- Icon with background --}} +
    + +
    + + {{-- Content --}} +
    +

    {{ $item['message'] ?? '' }}

    + @if(isset($item['subtitle'])) +

    {{ $item['subtitle'] }}

    + @endif +
    + + {{-- Timestamp --}} + @if(isset($item['time'])) +
    + +
    + @endif +
  • + @endforeach +
+ @endif +
diff --git a/app/Core/Front/Admin/Blade/components/activity-log.blade.php b/app/Core/Front/Admin/Blade/components/activity-log.blade.php new file mode 100644 index 0000000..3105491 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/activity-log.blade.php @@ -0,0 +1,108 @@ +{{-- +Activity Log Component + +Full activity log display with avatars, diffs, and detailed timestamps. + +Each item in $items array: +- actor: { name: 'John', avatar?: 'url', initials?: 'J' } or null for system +- description: 'updated the post' +- subject: { type: 'Post', name: 'My Article', url?: 'link' } (optional) +- changes: { old: {field: 'value'}, new: {field: 'new_value'} } (optional) +- event: 'created' | 'updated' | 'deleted' | string +- timestamp: Carbon instance or string +--}} + +
+ @forelse($items as $item) +
+ {{-- Avatar --}} +
+ @if(isset($item['actor'])) + @if(isset($item['actor']['avatar'])) + {{ $item['actor']['name'] }} + @else +
+ {{ $item['actor']['initials'] ?? substr($item['actor']['name'] ?? 'U', 0, 1) }} +
+ @endif + @else +
+ +
+ @endif +
+ + {{-- Details --}} +
+
+ + {{ $item['actor']['name'] ?? 'System' }} + + + {{ $item['description'] }} + +
+ + @if(isset($item['subject'])) +
+ {{ $item['subject']['type'] }}: + @if(isset($item['subject']['url'])) + + {{ $item['subject']['name'] }} + + @else + {{ $item['subject']['name'] }} + @endif +
+ @endif + + @if(isset($item['changes']['old']) && isset($item['changes']['new'])) +
+
+ @foreach($item['changes']['new'] as $key => $newValue) + @if(($item['changes']['old'][$key] ?? null) !== $newValue) + {{ $formatValue($item['changes']['old'][$key] ?? null) }} + + {{ $formatValue($newValue) }} + @if(!$loop->last)|@endif + @endif + @endforeach +
+
+ @endif + +
+ {{ $formatTimestamp($item['timestamp'] ?? null)['relative'] }} + @if($formatTimestamp($item['timestamp'] ?? null)['absolute']) + · + {{ $formatTimestamp($item['timestamp'] ?? null)['absolute'] }} + @endif +
+
+ + {{-- Event Badge --}} +
+ + {{ $item['event'] ?? 'activity' }} + +
+
+ @empty +
+ +

+ {{ $empty }} +

+
+ @endforelse +
+ +@if($pagination && $pagination->hasPages()) +
+ {{ $pagination->links() }} +
+@endif diff --git a/app/Core/Front/Admin/Blade/components/alert.blade.php b/app/Core/Front/Admin/Blade/components/alert.blade.php new file mode 100644 index 0000000..d824d97 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/alert.blade.php @@ -0,0 +1,61 @@ +@php + // Explicit class mappings for Tailwind JIT purging + $containerClasses = match($bgColor) { + 'amber' => 'border-amber-500/50 bg-amber-50 dark:bg-amber-900/20', + 'green' => 'border-green-500/50 bg-green-50 dark:bg-green-900/20', + 'red' => 'border-red-500/50 bg-red-50 dark:bg-red-900/20', + default => 'border-blue-500/50 bg-blue-50 dark:bg-blue-900/20', + }; + $iconClass = match($bgColor) { + 'amber' => 'text-amber-500', + 'green' => 'text-green-500', + 'red' => 'text-red-500', + default => 'text-blue-500', + }; + $titleClass = match($bgColor) { + 'amber' => 'text-amber-700 dark:text-amber-300', + 'green' => 'text-green-700 dark:text-green-300', + 'red' => 'text-red-700 dark:text-red-300', + default => 'text-blue-700 dark:text-blue-300', + }; + $messageClass = match($bgColor) { + 'amber' => 'text-amber-600 dark:text-amber-400', + 'green' => 'text-green-600 dark:text-green-400', + 'red' => 'text-red-600 dark:text-red-400', + default => 'text-blue-600 dark:text-blue-400', + }; + $dismissClass = match($bgColor) { + 'amber' => 'text-amber-500 hover:text-amber-700', + 'green' => 'text-green-500 hover:text-green-700', + 'red' => 'text-red-500 hover:text-red-700', + default => 'text-blue-500 hover:text-blue-700', + }; +@endphp +
merge(['class' => "p-4 mb-6 rounded-lg border {$containerClasses}"]) }}> +
+ +
+ @if($title) +
{{ $title }}
+ @endif + @if($message) +
{{ $message }}
+ @endif + {{ $slot }} +
+ @if($action) + @if(isset($action['href'])) + + {{ $action['label'] }} + + @elseif(isset($action['click'])) + {{ $action['label'] }} + @endif + @endif + @if($dismissible) + + @endif +
+
diff --git a/app/Core/Front/Admin/Blade/components/card-grid.blade.php b/app/Core/Front/Admin/Blade/components/card-grid.blade.php new file mode 100644 index 0000000..6fb5a66 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/card-grid.blade.php @@ -0,0 +1,175 @@ +{{-- +Card Grid Component + +Each card in $cards array: +- id: unique identifier +- icon: icon name or null +- iconType: 'icon' (default), 'brand' (fa-brands), 'image' (url) +- iconColor: color name (violet, blue, green, etc) +- title: main title +- subtitle: subtitle text (optional) +- status: { label: 'Online', color: 'green' } (optional) +- stats: [{ label: 'CPU', value: '45%', progress: 45, progressColor: 'green' }] (optional) +- details: [{ label: 'Type', value: 'CMS' }] (optional) +- footer: [{ label: 'Visit', icon: 'arrow-up-right', href: 'url' }] (optional) +- menu: [{ label: 'Settings', icon: 'cog', href: 'url' or click: 'method' }] (optional) +--}} + +@if(empty($cards)) +
+ +

{{ $empty }}

+
+@else +
+ @foreach($cards as $card) +
+ {{-- Card Header --}} +
+
+ @if(isset($card['icon'])) +
+ @if(($card['iconType'] ?? 'icon') === 'brand') + + @elseif(($card['iconType'] ?? 'icon') === 'image') + + @else + + @endif +
+ @endif +
+

{{ $card['title'] }}

+ @if(isset($card['subtitle'])) +

{{ $card['subtitle'] }}

+ @endif +
+
+ + @if(isset($card['menu'])) +
+ +
+ @foreach($card['menu'] as $menuItem) + @if(isset($menuItem['divider'])) +
+ @elseif(isset($menuItem['href'])) + + @if(isset($menuItem['icon'])) + + @endif + {{ $menuItem['label'] }} + + @elseif(isset($menuItem['click'])) + + @endif + @endforeach +
+
+ @endif +
+ + {{-- Card Body --}} +
+ @if(isset($card['status'])) +
+ Status +
+
+ {{ $card['status']['label'] }} +
+
+ @endif + + @if(isset($card['details'])) + @foreach($card['details'] as $detail) +
+ {{ $detail['label'] }} + {{ $detail['value'] }} +
+ @endforeach + @endif + + @if(isset($card['stats'])) + @foreach($card['stats'] as $stat) +
+
+ {{ $stat['label'] }} + {{ $stat['value'] }} +
+ @if(isset($stat['progress'])) +
+
+
+ @endif +
+ @endforeach + @endif + + @if(isset($card['timestamp'])) +
+ {{ $card['timestamp']['label'] ?? 'Last updated' }} + {{ $card['timestamp']['value'] }} +
+ @endif +
+ + @if(isset($card['footer'])) +
+ @foreach($card['footer'] as $action) + @if(isset($action['href'])) + + @if(isset($action['icon'])) + + @endif + {{ $action['label'] }} + + @elseif(isset($action['click'])) + + @endif + @endforeach +
+ @endif +
+ @endforeach +
+@endif diff --git a/app/Core/Front/Admin/Blade/components/clear-filters.blade.php b/app/Core/Front/Admin/Blade/components/clear-filters.blade.php new file mode 100644 index 0000000..82eacce --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/clear-filters.blade.php @@ -0,0 +1,10 @@ +
+ +
diff --git a/app/Core/Front/Admin/Blade/components/data-table.blade.php b/app/Core/Front/Admin/Blade/components/data-table.blade.php new file mode 100644 index 0000000..9067d9a --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/data-table.blade.php @@ -0,0 +1,40 @@ + + @if(empty($rows)) + + @else +
+ + @if(count($columns)) + + + @foreach($processedColumns as $col) + + @endforeach + + + @endif + + @foreach($rows as $row) + + @foreach($row as $i => $cell) + + @endforeach + + @endforeach + +
{{ $col['label'] }}
+ @if(is_array($cell) && isset($cell['badge'])) + {{ $cell['badge'] }} + @elseif(is_array($cell) && isset($cell['mono'])) + {{ $cell['mono'] }} + @elseif(is_array($cell) && isset($cell['muted'])) + {{ $cell['muted'] }} + @elseif(is_array($cell) && isset($cell['bold'])) + {{ $cell['bold'] }} + @else + {{ $cell }} + @endif +
+
+ @endif +
diff --git a/app/Core/Front/Admin/Blade/components/editable-table.blade.php b/app/Core/Front/Admin/Blade/components/editable-table.blade.php new file mode 100644 index 0000000..1bdc6d9 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/editable-table.blade.php @@ -0,0 +1,219 @@ +{{-- +Editable Table Component + +Supports inline editing with these cell types: +- checkbox: { type: 'checkbox', model: 'selected', value: 1 } +- switch: { type: 'switch', click: 'toggle(1)', checked: true } +- input: { type: 'input', change: 'update(1, $event.target.value)', value: 0, inputType: 'number', class: 'w-20' } +- select: { type: 'select', model: 'category', options: [{value: 'a', label: 'A'}] } +- badge: { type: 'badge', label: 'Active', color: 'green' } +- text: { type: 'text', bold: true, value: 'Title' } or { type: 'text', muted: true, value: 'Subtitle' } +- preview: { type: 'preview', color: '#f3f4f6' } or { type: 'preview', image: 'url' } +- actions: { type: 'actions', items: [{icon, click, title, confirm?}] } +- menu: { type: 'menu', items: [{icon, click, label}] } +- html: { type: 'html', content: '...' } +--}} + +
+
+ + + + @if($selectable) + + @endif + @foreach($processedColumns as $column) + + @endforeach + + + + @forelse($rows as $rowIndex => $row) + + @if($selectable) + + @endif + @foreach($row['cells'] ?? $row as $cellIndex => $cell) + @if($cellIndex === '_id') @continue @endif + + @endforeach + + @empty + + + + @endforelse + +
+ + + {{ $column['label'] }} +
+ + + @if(is_array($cell) && isset($cell['type'])) + @switch($cell['type']) + @case('checkbox') + + @break + + @case('switch') + + @break + + @case('input') + + @break + + @case('select') + + @break + + @case('badge') + + @if(isset($cell['icon'])) + + @endif + {{ $cell['label'] }} + + @break + + @case('text') + @if($cell['bold'] ?? false) +
{{ $cell['value'] }}
+ @elseif($cell['muted'] ?? false) +
{{ $cell['value'] }}
+ @elseif($cell['mono'] ?? false) + {{ $cell['value'] }} + @else + {{ $cell['value'] }} + @endif + @if(isset($cell['subtitle'])) +
{{ $cell['subtitle'] }}
+ @endif + @break + + @case('preview') + @if(isset($cell['image'])) + + @elseif(isset($cell['color'])) +
+ @endif + @break + + @case('actions') +
+ @foreach($cell['items'] as $action) + + @endforeach +
+ @break + + @case('menu') +
+ +
+ @foreach($cell['items'] as $item) + + @endforeach +
+
+ @break + + @case('html') + {!! $cell['content'] !!} + @break + @endswitch + @elseif(is_array($cell)) + {{-- Legacy format support --}} + @if(isset($cell['bold'])) +
{{ $cell['bold'] }}
+ @elseif(isset($cell['muted'])) +
{{ $cell['muted'] }}
+ @elseif(isset($cell['badge'])) + + {{ $cell['badge'] }} + + @endif + @else + {{ $cell }} + @endif +
+ +

{{ $empty }}

+
+
+ + @if($pagination && $pagination->hasPages()) +
+ {{ $pagination->links() }} +
+ @endif +
diff --git a/app/Core/Front/Admin/Blade/components/empty-state.blade.php b/app/Core/Front/Admin/Blade/components/empty-state.blade.php new file mode 100644 index 0000000..2819e8c --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/empty-state.blade.php @@ -0,0 +1,9 @@ +@props([ + 'message', + 'icon' => 'inbox', +]) + +
merge(['class' => 'text-center py-8 text-gray-500 dark:text-gray-400']) }}> + + {{ $message }} +
diff --git a/app/Core/Front/Admin/Blade/components/entitlement-gate.blade.php b/app/Core/Front/Admin/Blade/components/entitlement-gate.blade.php new file mode 100644 index 0000000..464b052 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/entitlement-gate.blade.php @@ -0,0 +1,120 @@ +@props([ + 'entitled' => false, + 'authenticated' => false, + 'icon' => 'lock', + 'title' => null, + 'description' => null, + 'featureName' => 'this feature', + 'ctaUrl' => null, + 'ctaText' => null, + 'ctaIcon' => 'rocket', + 'variant' => 'upgrade', // upgrade, waitlist, boost, custom + 'showSignIn' => true, + 'blurAmount' => 'sm', // sm, md, lg, xl +]) + +@php + // Default titles based on variant + $defaultTitles = [ + 'upgrade' => $authenticated ? 'Upgrade to unlock' : 'Sign in to continue', + 'waitlist' => 'Join the waitlist', + 'boost' => 'Add a boost', + 'custom' => $title ?? 'Unlock this feature', + ]; + + // Default descriptions based on variant + $defaultDescriptions = [ + 'upgrade' => $authenticated + ? "{$featureName} requires a paid plan. Upgrade to unlock this feature and more." + : "Sign in to access {$featureName} and all your workspace features.", + 'waitlist' => "Get early access to {$featureName}. Join thousands of creators already on the waitlist.", + 'boost' => "Add a {$featureName} boost to your workspace to unlock this feature.", + 'custom' => $description ?? "You need access to use {$featureName}.", + ]; + + // Default CTAs based on variant + $defaultCtas = [ + 'upgrade' => ['url' => '/hub/settings', 'text' => 'View plans'], + 'waitlist' => ['url' => '/waitlist', 'text' => 'Join the waitlist'], + 'boost' => ['url' => '/hub/settings', 'text' => 'Add boost'], + 'custom' => ['url' => $ctaUrl ?? '#', 'text' => $ctaText ?? 'Get access'], + ]; + + // Default icons based on variant + $defaultIcons = [ + 'upgrade' => 'crown', + 'waitlist' => 'clock', + 'boost' => 'bolt', + 'custom' => $icon, + ]; + + $resolvedTitle = $title ?? $defaultTitles[$variant] ?? $defaultTitles['custom']; + $resolvedDescription = $description ?? $defaultDescriptions[$variant] ?? $defaultDescriptions['custom']; + $resolvedCtaUrl = $ctaUrl ?? $defaultCtas[$variant]['url'] ?? '#'; + $resolvedCtaText = $ctaText ?? $defaultCtas[$variant]['text'] ?? 'Get access'; + $resolvedIcon = $icon !== 'lock' ? $icon : ($defaultIcons[$variant] ?? 'lock'); + + $blurClasses = [ + 'sm' => 'blur-sm', + 'md' => 'blur-md', + 'lg' => 'blur-lg', + 'xl' => 'blur-xl', + ]; + $blurClass = $blurClasses[$blurAmount] ?? 'blur-sm'; +@endphp + +
merge(['class' => 'relative']) }}> + {{-- Content (blurred when not entitled) --}} +
!$entitled, + ])> + {{ $slot }} +
+ + {{-- Overlay when not entitled --}} + @unless($entitled) +
+
+
+ +
+ + + {{ $resolvedTitle }} + + + + {{ $resolvedDescription }} + + +
+ @if($authenticated || $variant === 'waitlist') + + + {{ $resolvedCtaText }} + + @else + + + Sign in + + @endif + + @if($showSignIn && !$authenticated && $variant !== 'waitlist') + + Don't have an account? Sign up + + @endif +
+ + @if($variant === 'waitlist') +

+ + No spam, ever. Unsubscribe anytime. +

+ @endif +
+
+ @endunless +
diff --git a/app/Core/Front/Admin/Blade/components/filter-bar.blade.php b/app/Core/Front/Admin/Blade/components/filter-bar.blade.php new file mode 100644 index 0000000..438a673 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/filter-bar.blade.php @@ -0,0 +1,3 @@ +
merge(['class' => "mb-6 grid grid-cols-1 gap-4 {$gridCols}"]) }}> + {{ $slot }} +
diff --git a/app/Core/Front/Admin/Blade/components/filter.blade.php b/app/Core/Front/Admin/Blade/components/filter.blade.php new file mode 100644 index 0000000..dc3da84 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/filter.blade.php @@ -0,0 +1,14 @@ +
+ @if($label) + + @endif + +
diff --git a/app/Core/Front/Admin/Blade/components/flash.blade.php b/app/Core/Front/Admin/Blade/components/flash.blade.php new file mode 100644 index 0000000..2c00dcd --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/flash.blade.php @@ -0,0 +1,16 @@ +@props([ + 'key' => 'message', + 'errorKey' => 'error', +]) + +@if (session()->has($key)) +
+ {{ session($key) }} +
+@endif + +@if (session()->has($errorKey)) +
+ {{ session($errorKey) }} +
+@endif diff --git a/app/Core/Front/Admin/Blade/components/header.blade.php b/app/Core/Front/Admin/Blade/components/header.blade.php new file mode 100644 index 0000000..2d26249 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/header.blade.php @@ -0,0 +1,39 @@ +@props([ + 'sticky' => true, +]) + +
+
+
+ + +
+ + + + {{ $left ?? '' }} +
+ + +
+ {{ $right ?? '' }} + {{ $slot }} +
+ +
+
+
diff --git a/app/Core/Front/Admin/Blade/components/link-grid.blade.php b/app/Core/Front/Admin/Blade/components/link-grid.blade.php new file mode 100644 index 0000000..68beb2a --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/link-grid.blade.php @@ -0,0 +1,10 @@ +
merge(['class' => "grid {$gridCols} gap-4"]) }}> + @foreach($items as $item) + +
+ + {{ $item['label'] }} +
+
+ @endforeach +
diff --git a/app/Core/Front/Admin/Blade/components/manager-table.blade.php b/app/Core/Front/Admin/Blade/components/manager-table.blade.php new file mode 100644 index 0000000..8737911 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/manager-table.blade.php @@ -0,0 +1,147 @@ +@php + // Badge color class mappings for Tailwind JIT purging + $badgeClasses = fn($color) => match($color) { + 'green' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'red' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + 'amber' => 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'blue' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'violet' => 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400', + 'purple' => 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + 'pink' => 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400', + 'cyan' => 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400', + 'emerald' => 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', + 'orange' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + default => 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + }; +@endphp +
+ {{-- Bulk action bar --}} + @if(isset($selectable) && $selectable && isset($selected) && count($selected) > 0) +
+ + {{ count($selected) }} {{ count($selected) === 1 ? 'item' : 'items' }} selected + +
+ {{ $bulkActions ?? '' }} +
+
+ @endif + +
+ + + + @if(isset($selectable) && $selectable) + + @endif + @foreach($processedColumns as $column) + + @endforeach + + + + @forelse($rows as $rowIndex => $row) + + @if(isset($selectable) && $selectable && isset($rowIds[$rowIndex])) + + @endif + @foreach($row as $index => $cell) + + @endforeach + + @empty + + + + @endforelse + +
+ + + {{ $column['label'] }} +
+ + + @if(is_array($cell)) + @if(isset($cell['lines'])) +
+ @foreach($cell['lines'] as $line) + @if(isset($line['bold'])) +
{{ $line['bold'] }}
+ @elseif(isset($line['muted'])) +
{{ $line['muted'] }}
+ @elseif(isset($line['mono'])) + {{ $line['mono'] }} + @elseif(isset($line['badge'])) + + @if(isset($line['icon'])) + + @endif + {{ $line['badge'] }} + + @endif + @endforeach +
+ @elseif(isset($cell['bold'])) +
{{ $cell['bold'] }}
+ @elseif(isset($cell['muted'])) +
{{ $cell['muted'] }}
+ @elseif(isset($cell['mono'])) + {{ $cell['mono'] }} + @elseif(isset($cell['badge'])) + + @if(isset($cell['icon'])) + + @endif + {{ $cell['badge'] }} + + @elseif(isset($cell['badges'])) +
+ @foreach($cell['badges'] as $badge) + + @if(isset($badge['icon'])) + + @endif + {{ $badge['label'] }} + + @endforeach +
+ @elseif(isset($cell['actions'])) +
+ @foreach($cell['actions'] as $action) + + @endforeach +
+ @elseif(isset($cell['link'])) + {{ $cell['link'] }} + @elseif(isset($cell['trustedHtml'])) + {{-- WARNING: Only use trustedHtml with already-sanitised server-rendered content --}} + {!! $cell['trustedHtml'] !!} + @endif + @else + {{ $cell }} + @endif +
+ +

{{ $empty }}

+
+
+ + @if($pagination && $pagination->hasPages()) +
+ {{ $pagination->links() }} +
+ @endif +
diff --git a/app/Core/Front/Admin/Blade/components/metric-card.blade.php b/app/Core/Front/Admin/Blade/components/metric-card.blade.php new file mode 100644 index 0000000..1d4d2d8 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/metric-card.blade.php @@ -0,0 +1,17 @@ +@props([ + 'title' => '', + 'value' => '', + 'icon' => 'chart-bar', + 'color' => 'gray', +]) + +
merge(['class' => 'bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6']) }}> +
+

{{ $title }}

+ +
+
{{ $value }}
+ @if($slot->isNotEmpty()) +
{{ $slot }}
+ @endif +
diff --git a/app/Core/Front/Admin/Blade/components/metrics.blade.php b/app/Core/Front/Admin/Blade/components/metrics.blade.php new file mode 100644 index 0000000..9e9a694 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/metrics.blade.php @@ -0,0 +1,10 @@ +
merge(['class' => "grid {$gridCols} gap-4 mb-8"]) }}> + @foreach($items as $item) + + @endforeach +
diff --git a/app/Core/Front/Admin/Blade/components/module.blade.php b/app/Core/Front/Admin/Blade/components/module.blade.php new file mode 100644 index 0000000..010d2ff --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/module.blade.php @@ -0,0 +1,12 @@ +@props([ + 'title', + 'subtitle' => null, +]) + +
+ + {{ $actions ?? '' }} + + + {{ $slot }} +
diff --git a/app/Core/Front/Admin/Blade/components/nav-group.blade.php b/app/Core/Front/Admin/Blade/components/nav-group.blade.php new file mode 100644 index 0000000..b135758 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/nav-group.blade.php @@ -0,0 +1,15 @@ +@props([ + 'title' => null, +]) + +
+ @if($title) +

+ + {{ $title }} +

+ @endif +
    + {{ $slot }} +
+
diff --git a/app/Core/Front/Admin/Blade/components/nav-item.blade.php b/app/Core/Front/Admin/Blade/components/nav-item.blade.php new file mode 100644 index 0000000..3ec3f50 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/nav-item.blade.php @@ -0,0 +1,27 @@ +@props([ + 'href', + 'icon' => null, + 'active' => false, + 'color' => 'gray', + 'badge' => null, +]) + +
  • + +
    +
    + @if($icon) + + @endif + {{ $slot }} +
    + @if($badge) + {{ $badge }} + @endif +
    +
    +
  • diff --git a/app/Core/Front/Admin/Blade/components/nav-link.blade.php b/app/Core/Front/Admin/Blade/components/nav-link.blade.php new file mode 100644 index 0000000..8c10411 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/nav-link.blade.php @@ -0,0 +1,21 @@ +@props([ + 'href', + 'active' => false, + 'badge' => null, + 'icon' => null, + 'color' => null, +]) + +
  • + + + @if($icon) + + @endif + {{ $slot }} + + @if($badge) + {{ $badge }} + @endif + +
  • diff --git a/app/Core/Front/Admin/Blade/components/nav-menu.blade.php b/app/Core/Front/Admin/Blade/components/nav-menu.blade.php new file mode 100644 index 0000000..8c30c19 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/nav-menu.blade.php @@ -0,0 +1,38 @@ +@props([ + 'title', + 'icon' => null, + 'active' => false, + 'color' => 'gray', + 'expanded' => null, +]) + +@php + $isExpanded = $expanded ?? $active; +@endphp + +
  • + +
    +
    + @if($icon) + + @endif + {{ $title }} +
    +
    + + + +
    +
    +
    +
    +
      + {{ $slot }} +
    +
    +
  • diff --git a/app/Core/Front/Admin/Blade/components/nav-panel.blade.php b/app/Core/Front/Admin/Blade/components/nav-panel.blade.php new file mode 100644 index 0000000..0e2bb36 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/nav-panel.blade.php @@ -0,0 +1,22 @@ +@props([ + 'title' => 'Quick Actions', + 'items' => [], // [{href, title, subtitle?, icon?, color?}] +]) + + +
    + @if(count($items)) + @foreach($items as $item) + + @endforeach + @else + {{ $slot }} + @endif +
    +
    diff --git a/app/Core/Front/Admin/Blade/components/page-header.blade.php b/app/Core/Front/Admin/Blade/components/page-header.blade.php new file mode 100644 index 0000000..c1db068 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/page-header.blade.php @@ -0,0 +1,26 @@ +@props([ + 'title', + 'description' => null, + 'subtitle' => null, +]) + +@php + $text = $description ?? $subtitle; +@endphp + +
    + {{-- Left: Title and description --}} +
    + {{ $title }} + @if($text) + {{ $text }} + @endif +
    + + {{-- Right: Actions slot --}} + @if($slot->isNotEmpty()) +
    + {{ $slot }} +
    + @endif +
    diff --git a/app/Core/Front/Admin/Blade/components/panel.blade.php b/app/Core/Front/Admin/Blade/components/panel.blade.php new file mode 100644 index 0000000..5c06e9d --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/panel.blade.php @@ -0,0 +1,21 @@ +@props([ + 'title' => null, + 'action' => null, + 'actionLabel' => 'View all', +]) + +
    merge(['class' => 'bg-white dark:bg-gray-800 rounded-lg shadow-sm']) }}> + @if($title || $action) +
    + @if($title) + {{ $title }} + @endif + @if($action) + {{ $actionLabel }} + @endif +
    + @endif +
    + {{ $slot }} +
    +
    diff --git a/app/Core/Front/Admin/Blade/components/progress-list.blade.php b/app/Core/Front/Admin/Blade/components/progress-list.blade.php new file mode 100644 index 0000000..bbabf2f --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/progress-list.blade.php @@ -0,0 +1,27 @@ + + @if(empty($items)) + + @else +
    + @foreach($items as $item) +
    +
    + {{ $item['label'] }} + {{ $formatValue($item['value'] ?? 0) }} +
    +
    +
    +
    + @if(isset($item['subtitle']) || isset($item['badge'])) +
    + {{ $item['subtitle'] ?? '' }} + @if(isset($item['badge'])) + {{ $item['badge'] }} + @endif +
    + @endif +
    + @endforeach +
    + @endif +
    diff --git a/app/Core/Front/Admin/Blade/components/search.blade.php b/app/Core/Front/Admin/Blade/components/search.blade.php new file mode 100644 index 0000000..87f0fc0 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/search.blade.php @@ -0,0 +1,11 @@ +
    +
    + +
    + merge(['class' => 'block w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-3 text-gray-900 placeholder-gray-500 focus:border-violet-500 focus:ring-violet-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400']) }} + placeholder="{{ $placeholder }}" + {!! $wireModel !!} + /> +
    diff --git a/app/Core/Front/Admin/Blade/components/service-card.blade.php b/app/Core/Front/Admin/Blade/components/service-card.blade.php new file mode 100644 index 0000000..e421a99 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/service-card.blade.php @@ -0,0 +1,129 @@ +@php + // Color class mappings for Tailwind JIT purging + $colorBgClass = match($color) { + 'violet' => 'bg-violet-500/20', + 'blue' => 'bg-blue-500/20', + 'green' => 'bg-green-500/20', + 'red' => 'bg-red-500/20', + 'amber' => 'bg-amber-500/20', + 'emerald' => 'bg-emerald-500/20', + 'cyan' => 'bg-cyan-500/20', + 'pink' => 'bg-pink-500/20', + default => 'bg-gray-500/20', + }; + $colorTextClass = match($color) { + 'violet' => 'text-violet-500', + 'blue' => 'text-blue-500', + 'green' => 'text-green-500', + 'red' => 'text-red-500', + 'amber' => 'text-amber-500', + 'emerald' => 'text-emerald-500', + 'cyan' => 'text-cyan-500', + 'pink' => 'text-pink-500', + default => 'text-gray-500', + }; + $colorHoverClass = match($color) { + 'violet' => 'hover:text-violet-500', + 'blue' => 'hover:text-blue-500', + 'green' => 'hover:text-green-500', + 'red' => 'hover:text-red-500', + 'amber' => 'hover:text-amber-500', + 'emerald' => 'hover:text-emerald-500', + 'cyan' => 'hover:text-cyan-500', + 'pink' => 'hover:text-pink-500', + default => 'hover:text-gray-500', + }; + $colorLinkClass = match($color) { + 'violet' => 'text-violet-500 hover:text-violet-600', + 'blue' => 'text-blue-500 hover:text-blue-600', + 'green' => 'text-green-500 hover:text-green-600', + 'red' => 'text-red-500 hover:text-red-600', + 'amber' => 'text-amber-500 hover:text-amber-600', + 'emerald' => 'text-emerald-500 hover:text-emerald-600', + 'cyan' => 'text-cyan-500 hover:text-cyan-600', + 'pink' => 'text-pink-500 hover:text-pink-600', + default => 'text-gray-500 hover:text-gray-600', + }; + $statusBgClass = match($statusColor) { + 'green' => 'bg-green-500', + 'red' => 'bg-red-500', + 'amber' => 'bg-amber-500', + 'blue' => 'bg-blue-500', + default => 'bg-gray-500', + }; + $statusTextClass = match($statusColor) { + 'green' => 'text-green-500', + 'red' => 'text-red-500', + 'amber' => 'text-amber-500', + 'blue' => 'text-blue-500', + default => 'text-gray-500', + }; +@endphp +
    merge(['class' => 'bg-white dark:bg-gray-800 shadow-xs rounded-xl overflow-hidden']) }}> + {{-- Header --}} +
    +
    +
    +
    + +
    +
    +

    {{ $name }}

    +

    {{ $description }}

    +
    +
    +
    +
    + {{ $status }} +
    +
    +
    + + {{-- Stats --}} +
    + @if(count($stats)) +
    + @foreach($stats as $stat) +
    +
    {{ $stat['value'] }}
    +
    {{ $stat['label'] }}
    +
    + @endforeach +
    + @endif + + @if(count($actions)) +
    + @foreach($actions as $action) + + + {{ $action['label'] }} + + @endforeach +
    + @endif +
    + + {{-- Footer --}} +
    +
    + + + {{ $domain }} + +
    + @if($adminRoute) + + + Admin + + @endif + @if($detailsRoute) + + View Details + + @endif +
    +
    +
    +
    diff --git a/app/Core/Front/Admin/Blade/components/service-cards.blade.php b/app/Core/Front/Admin/Blade/components/service-cards.blade.php new file mode 100644 index 0000000..05a99f9 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/service-cards.blade.php @@ -0,0 +1,9 @@ +@props([ + 'items' => [], +]) + +
    merge(['class' => 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 mb-8']) }}> + @foreach($items as $service) + + @endforeach +
    diff --git a/app/Core/Front/Admin/Blade/components/sidebar.blade.php b/app/Core/Front/Admin/Blade/components/sidebar.blade.php new file mode 100644 index 0000000..3a7e569 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/sidebar.blade.php @@ -0,0 +1,57 @@ +@props([ + 'logo' => null, + 'logoRoute' => '/', + 'logoText' => 'Admin', +]) + +
    + + + + + +
    diff --git a/app/Core/Front/Admin/Blade/components/sidemenu.blade.php b/app/Core/Front/Admin/Blade/components/sidemenu.blade.php new file mode 100644 index 0000000..7ef497d --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/sidemenu.blade.php @@ -0,0 +1,156 @@ +
      + @foreach($items as $item) + @if(!empty($item['divider'])) + {{-- Divider (with optional label) --}} +
    • + @if(!empty($item['label'])) +
      +
      + {{ $item['label'] }} +
      +
      + @else +
      + @endif +
    • + @elseif(!empty($item['separator'])) + {{-- Simple separator --}} +
    • +
      +
    • + @elseif(!empty($item['collapsible'])) + {{-- Collapsible group --}} + @php + $collapsibleId = 'menu-group-' . ($item['stateKey'] ?? \Illuminate\Support\Str::slug($item['label'])); + $isOpen = $item['open'] ?? true; + $groupColor = match($item['color'] ?? 'gray') { + 'violet' => 'text-violet-500', + 'blue' => 'text-blue-500', + 'green' => 'text-green-500', + 'red' => 'text-red-500', + 'amber' => 'text-amber-500', + 'emerald' => 'text-emerald-500', + 'cyan' => 'text-cyan-500', + 'pink' => 'text-pink-500', + default => 'text-gray-500', + }; + @endphp +
    • + +
        + @foreach($item['children'] ?? [] as $child) + @if(!empty($child['separator'])) +

      • + @elseif(!empty($child['section'])) + @php + $childColor = match($child['color'] ?? 'gray') { + 'violet' => 'text-violet-500', + 'blue' => 'text-blue-500', + 'green' => 'text-green-500', + 'red' => 'text-red-500', + 'amber' => 'text-amber-500', + 'emerald' => 'text-emerald-500', + 'cyan' => 'text-cyan-500', + 'pink' => 'text-pink-500', + default => 'text-gray-400 dark:text-gray-500', + }; + @endphp +
      • + @if(!empty($child['icon'])) + + @endif + {{ $child['section'] }} +
      • + @else + {{ $child['label'] }} + @endif + @endforeach +
      +
    • + @elseif(!empty($item['children'])) + {{-- Dropdown menu with children --}} +
    • + + @foreach($item['children'] as $child) + @if(!empty($child['separator'])) + {{-- Separator within dropdown --}} +
    • +
      +
    • + @elseif(!empty($child['section'])) + {{-- Section header within dropdown --}} + @php + $sectionIconClass = match($child['color'] ?? 'gray') { + 'violet' => 'text-violet-500', + 'blue' => 'text-blue-500', + 'green' => 'text-green-500', + 'red' => 'text-red-500', + 'amber' => 'text-amber-500', + 'emerald' => 'text-emerald-500', + 'cyan' => 'text-cyan-500', + 'pink' => 'text-pink-500', + default => 'text-gray-500', + }; + @endphp +
    • + @if(!empty($child['icon'])) + + @endif + + {{ $child['section'] }} + + @if(!empty($child['badge'])) + + {{ is_array($child['badge']) ? ($child['badge']['text'] ?? '') : $child['badge'] }} + + @endif +
    • + @else + {{ $child['label'] }} + @endif + @endforeach + + + @else + {{-- Single nav item --}} +
    • + {{ $item['label'] }} +
    • + @endif + @endforeach +
    diff --git a/app/Core/Front/Admin/Blade/components/stat-card.blade.php b/app/Core/Front/Admin/Blade/components/stat-card.blade.php new file mode 100644 index 0000000..1a67a68 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/stat-card.blade.php @@ -0,0 +1,48 @@ +@props([ + 'value', + 'label', + 'icon' => 'chart-bar', + 'color' => 'violet', + 'change' => null, + 'changeLabel' => null, +]) + +
    merge(['class' => 'relative bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden']) }}> + {{-- Coloured left border accent --}} +
    + +
    +
    +
    + {{-- Label first (smaller, secondary) --}} +

    {{ $label }}

    + + {{-- Value (larger, bolder, primary) --}} +

    {{ $value }}

    + + {{-- Optional change indicator --}} + @if($change !== null) +
    + @if($change > 0) + + +{{ $change }}% + @elseif($change < 0) + + {{ $change }}% + @else + No change + @endif + @if($changeLabel) + {{ $changeLabel }} + @endif +
    + @endif +
    + + {{-- Icon with background circle --}} +
    + +
    +
    +
    +
    diff --git a/app/Core/Front/Admin/Blade/components/stats.blade.php b/app/Core/Front/Admin/Blade/components/stats.blade.php new file mode 100644 index 0000000..a8fafe7 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/stats.blade.php @@ -0,0 +1,14 @@ +
    merge(['class' => "grid {$gridCols} gap-4 mb-8"]) }}> + @if(count($items)) + @foreach($items as $item) + + @endforeach + @else + {{ $slot }} + @endif +
    diff --git a/app/Core/Front/Admin/Blade/components/status-cards.blade.php b/app/Core/Front/Admin/Blade/components/status-cards.blade.php new file mode 100644 index 0000000..a16a29a --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/status-cards.blade.php @@ -0,0 +1,8 @@ +
    merge(['class' => "grid {$gridCols} gap-4 mb-8"]) }}> + @foreach($items as $item) +
    +
    {{ $item['value'] }}
    +
    {{ $item['label'] }}
    +
    + @endforeach +
    diff --git a/app/Core/Front/Admin/Blade/components/tabs.blade.php b/app/Core/Front/Admin/Blade/components/tabs.blade.php new file mode 100644 index 0000000..c58bf88 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/tabs.blade.php @@ -0,0 +1,16 @@ +@props([ + 'tabs' => [], + 'selected' => null, +]) + + + @foreach($tabs as $key => $config) + {{ $config['label'] }} + @endforeach + diff --git a/app/Core/Front/Admin/Blade/components/tabs/CLAUDE.md b/app/Core/Front/Admin/Blade/components/tabs/CLAUDE.md new file mode 100644 index 0000000..99968dc --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/tabs/CLAUDE.md @@ -0,0 +1,22 @@ +# Front/Admin/Blade/components/tabs + +Tab panel sub-component for admin tabs. + +## Files + +- **panel.blade.php** -- Individual tab panel that auto-detects selected state from `TabContext::$selected`. Wraps `` with automatic selection. + - Props: `name` (string, required) -- must match the tab key + - Reads `\Core\Front\Admin\TabContext::$selected` to determine visibility + +## Usage + +```blade + + + General settings content + + + Advanced settings content + + +``` diff --git a/app/Core/Front/Admin/Blade/components/tabs/panel.blade.php b/app/Core/Front/Admin/Blade/components/tabs/panel.blade.php new file mode 100644 index 0000000..44e74a7 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/tabs/panel.blade.php @@ -0,0 +1,10 @@ +@props(['name']) + +@php + // Auto-detect selected state from parent tabs component + $selected = \Core\Front\Admin\TabContext::$selected === $name; +@endphp + + + {{ $slot }} + diff --git a/app/Core/Front/Admin/Blade/components/workspace-card.blade.php b/app/Core/Front/Admin/Blade/components/workspace-card.blade.php new file mode 100644 index 0000000..6566df9 --- /dev/null +++ b/app/Core/Front/Admin/Blade/components/workspace-card.blade.php @@ -0,0 +1,92 @@ +@props([ + 'workspace', + 'services' => [], + 'subscription' => null, +]) + +@php + $hasServices = count($services) > 0; + $workspaceColor = $workspace->color ?? 'violet'; + $workspaceIcon = $workspace->icon ?? 'folder'; +@endphp + +
    merge(['class' => 'col-span-full sm:col-span-6 xl:col-span-4 bg-white dark:bg-gray-800 shadow-xs rounded-xl']) }}> +
    + {{-- Card top --}} +
    +
    + {{-- Icon + name --}} +
    +
    + +
    + +
    +
    +
    + +

    {{ $workspace->name }}

    +
    + @if($workspace->domain) +
    {{ $workspace->domain }}
    + @endif +
    +
    +
    + {{-- Subscription badge --}} + @if($subscription) + + {{ ucfirst($subscription->status) }} + + @endif +
    + + {{-- Services list --}} +
    + @if($hasServices) +
    {{ __('hub::hub.dashboard.enabled_services') }}
    +
    + @foreach($services as $serviceKey => $service) + @php + $serviceHref = $service['href'] ?? ($service['children'][0]['href'] ?? route('hub.services', ['service' => $serviceKey])); + @endphp + + @if($service['icon'] ?? null) + + @endif + {{ $service['label'] }} + + @endforeach +
    + @else +
    + + + +
    {{ __('hub::hub.dashboard.no_services') }}
    +
    + @endif +
    +
    + + {{-- Card footer --}} +
    + @if($hasServices) + + {{ __('hub::hub.dashboard.manage_workspace') }} + + @else + + {{ __('hub::hub.dashboard.add_services') }} + + @endif +
    +
    +
    diff --git a/app/Core/Front/Admin/Blade/layouts/CLAUDE.md b/app/Core/Front/Admin/Blade/layouts/CLAUDE.md new file mode 100644 index 0000000..22ff106 --- /dev/null +++ b/app/Core/Front/Admin/Blade/layouts/CLAUDE.md @@ -0,0 +1,10 @@ +# Front/Admin/Blade/layouts + +Layout templates for the admin panel. + +## Files + +- **app.blade.php** -- Full admin HTML shell with sidebar + content area layout. Includes dark mode (localStorage + cookie sync), FontAwesome Pro CSS, Vite assets (admin.css + app.js), Flux appearance/scripts, collapsible sidebar with `sidebarExpanded` Alpine state (persisted to localStorage), and light/dark mode toggle script. + - Props: `title` (string, default 'Admin') + - Slots: `$sidebar` (sidebar component), `$header` (top header), `$slot` (main content), `$head` (extra head content), `$scripts` (extra scripts) + - Responsive: sidebar hidden on mobile, 20px collapsed / 64px expanded on desktop. diff --git a/app/Core/Front/Admin/Blade/layouts/app.blade.php b/app/Core/Front/Admin/Blade/layouts/app.blade.php new file mode 100644 index 0000000..1a649bc --- /dev/null +++ b/app/Core/Front/Admin/Blade/layouts/app.blade.php @@ -0,0 +1,112 @@ +@props([ + 'title' => 'Admin', + 'sidebar' => null, + 'header' => null, +]) + +@php + $darkMode = request()->cookie('dark-mode') === 'true'; +@endphp + + + + + + + + {{ $title }} + + {{-- Critical CSS: Prevents white flash during page load/navigation --}} + + + + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/admin.css', 'resources/js/app.js']) + + + @fluxAppearance + + {{ $head ?? '' }} + + + + + + +
    + + + {{ $sidebar }} + + +
    + + + {{ $header }} + +
    + {{ $slot }} +
    + +
    + +
    + + +@fluxScripts + +{{ $scripts ?? '' }} + + + + diff --git a/app/Core/Front/Admin/Boot.php b/app/Core/Front/Admin/Boot.php new file mode 100644 index 0000000..2dc22f9 --- /dev/null +++ b/app/Core/Front/Admin/Boot.php @@ -0,0 +1,122 @@ +group('admin', [ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + ValidateCsrfToken::class, + SubstituteBindings::class, + SecurityHeaders::class, + 'auth', + ]); + } + + public function register(): void + { + $this->app->singleton(AdminMenuRegistry::class); + } + + public function boot(): void + { + // Register admin:: namespace for admin shell components + $this->loadViewsFrom(__DIR__.'/Blade', 'admin'); + Blade::anonymousComponentPath(__DIR__.'/Blade', 'admin'); + + // Register class-backed components + Blade::component('admin-activity-feed', ActivityFeed::class); + Blade::component('admin-activity-log', ActivityLog::class); + Blade::component('admin-alert', Alert::class); + Blade::component('admin-card-grid', CardGrid::class); + Blade::component('admin-clear-filters', ClearFilters::class); + Blade::component('admin-data-table', DataTable::class); + Blade::component('admin-editable-table', EditableTable::class); + Blade::component('admin-filter', Filter::class); + Blade::component('admin-filter-bar', FilterBar::class); + Blade::component('admin-link-grid', LinkGrid::class); + Blade::component('admin-manager-table', ManagerTable::class); + Blade::component('admin-metrics', Metrics::class); + Blade::component('admin-progress-list', ProgressList::class); + Blade::component('admin-search', Search::class); + Blade::component('admin-service-card', ServiceCard::class); + Blade::component('admin-sidemenu', Sidemenu::class); + Blade::component('admin-stats', Stats::class); + Blade::component('admin-status-cards', StatusCards::class); + + // Register tag compiler (like ) + $this->bootTagCompiler(); + + // Fire AdminPanelBooting event for lazy-loaded modules + LifecycleEventProvider::fireAdminBooting(); + } + + /** + * Register the custom tag compiler. + */ + protected function bootTagCompiler(): void + { + $compiler = new AdminTagCompiler( + app('blade.compiler')->getClassComponentAliases(), + app('blade.compiler')->getClassComponentNamespaces(), + app('blade.compiler') + ); + + app('blade.compiler')->precompiler(function (string $value) use ($compiler) { + return $compiler->compile($value); + }); + } +} diff --git a/app/Core/Front/Admin/CLAUDE.md b/app/Core/Front/Admin/CLAUDE.md new file mode 100644 index 0000000..5678908 --- /dev/null +++ b/app/Core/Front/Admin/CLAUDE.md @@ -0,0 +1,24 @@ +# Front/Admin + +Admin panel frontage. Service provider that configures the `admin` middleware group, registers `` Blade tag syntax, and boots 18 class-backed view components. + +## Files + +- **Boot.php** -- ServiceProvider configuring the `admin` middleware stack (separate from web -- includes `auth`). Registers `admin::` Blade namespace, class component aliases (e.g., `admin-data-table`), `` tag compiler, and fires `AdminPanelBooting` lifecycle event. Binds `AdminMenuRegistry` as singleton. +- **AdminMenuRegistry.php** -- Central registry for admin sidebar navigation. Modules register `AdminMenuProvider` implementations during boot. Handles entitlement checks, permission filtering, caching (5min TTL), priority sorting, and menu structure building with groups: dashboard, agents, workspaces, services, settings, admin. +- **AdminTagCompiler.php** -- Blade precompiler for `` tags. Resolves class-backed components first (via `admin-xyz` aliases), falls back to anonymous `admin::xyz` namespace. +- **TabContext.php** -- Static context for `` to communicate selected state to child `` components. + +## Middleware Stack + +The `admin` group: EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, ValidateCsrfToken, SubstituteBindings, SecurityHeaders, auth. + +## Tag Syntax + +```blade + + + + Content + +``` diff --git a/app/Core/Front/Admin/Concerns/CLAUDE.md b/app/Core/Front/Admin/Concerns/CLAUDE.md new file mode 100644 index 0000000..424dc3a --- /dev/null +++ b/app/Core/Front/Admin/Concerns/CLAUDE.md @@ -0,0 +1,7 @@ +# Front/Admin/Concerns + +Traits for admin panel functionality. + +## Files + +- **HasMenuPermissions.php** -- Default implementation of `AdminMenuProvider` permission methods. Provides `menuPermissions()` (returns empty array by default), `canViewMenu()` (checks all permissions from `menuPermissions()` against the user), and `userHasPermission()` (tries Laravel Gate `can()`, then `hasPermission()`, then Spatie's `hasPermissionTo()`, falls back to allow). Include this trait in classes implementing `AdminMenuProvider` to get sensible defaults; override methods for custom logic. diff --git a/app/Core/Front/Admin/Concerns/HasMenuPermissions.php b/app/Core/Front/Admin/Concerns/HasMenuPermissions.php new file mode 100644 index 0000000..149a725 --- /dev/null +++ b/app/Core/Front/Admin/Concerns/HasMenuPermissions.php @@ -0,0 +1,96 @@ + + */ + public function menuPermissions(): array + { + return []; + } + + /** + * Check if the user has permission to view menu items from this provider. + * + * By default, checks that the user has all permissions returned by + * menuPermissions(). Override for custom logic. + * + * @param object|null $user The authenticated user (User model instance) + * @param object|null $workspace The current workspace context (Workspace model instance) + */ + public function canViewMenu(?object $user, ?object $workspace): bool + { + // No user means no permission (unless we have no requirements) + $permissions = $this->menuPermissions(); + + if (empty($permissions)) { + return true; + } + + if ($user === null) { + return false; + } + + // Check each required permission + foreach ($permissions as $permission) { + if (! $this->userHasPermission($user, $permission, $workspace)) { + return false; + } + } + + return true; + } + + /** + * Check if a user has a specific permission. + * + * Override this method to customise how permission checks are performed. + * By default, uses Laravel's Gate/Authorization system. + * + * @param object $user User model instance + * @param object|null $workspace Workspace model instance + */ + protected function userHasPermission(object $user, string $permission, ?object $workspace): bool + { + // Check using Laravel's authorization + if (method_exists($user, 'can')) { + return $user->can($permission, $workspace); + } + + // Fallback: check for hasPermission method (common in permission packages) + if (method_exists($user, 'hasPermission')) { + return $user->hasPermission($permission); + } + + // Fallback: check for hasPermissionTo method (Spatie Permission) + if (method_exists($user, 'hasPermissionTo')) { + return $user->hasPermissionTo($permission); + } + + // No permission system found, allow by default + return true; + } +} diff --git a/app/Core/Front/Admin/Contracts/AdminMenuProvider.php b/app/Core/Front/Admin/Contracts/AdminMenuProvider.php new file mode 100644 index 0000000..7068076 --- /dev/null +++ b/app/Core/Front/Admin/Contracts/AdminMenuProvider.php @@ -0,0 +1,208 @@ + true]`) + * - **divider** - Divider with optional label (`['divider' => true, 'label' => 'More']`) + * - **section** - Section header (`['section' => 'Products', 'icon' => 'cube']`) + * - **collapsible** - Collapsible sub-group with state persistence + * + * Use `MenuItemGroup` helper for cleaner syntax: + * + * ```php + * use Core\Front\Admin\Support\MenuItemGroup; + * + * 'children' => [ + * MenuItemGroup::header('Products', 'cube'), + * ['label' => 'All Products', 'href' => '/products'], + * MenuItemGroup::separator(), + * MenuItemGroup::header('Orders', 'receipt'), + * ['label' => 'All Orders', 'href' => '/orders'], + * ], + * ``` + * + * ## Priority Constants (Ordering Specification) + * + * Use these priority ranges to ensure consistent menu ordering across modules: + * + * | Range | Constant | Description | + * |----------|-----------------------|---------------------------------------| + * | 0-9 | PRIORITY_FIRST | Reserved for system items | + * | 10-19 | PRIORITY_HIGH | Primary navigation items | + * | 20-39 | PRIORITY_ABOVE_NORMAL | Important but not primary items | + * | 40-60 | PRIORITY_NORMAL | Standard items (default: 50) | + * | 61-79 | PRIORITY_BELOW_NORMAL | Less important items | + * | 80-89 | PRIORITY_LOW | Rarely used items | + * | 90-99 | PRIORITY_LAST | Items that should appear at the end | + * + * Within the same priority, items are ordered by registration order. + * + * ## Icon Validation + * + * Icons should be valid FontAwesome icon names. The `IconValidator` class + * validates icons against known FontAwesome icons. Supported formats: + * + * - Shorthand: `home`, `user`, `gear` + * - Full class: `fas fa-home`, `fa-solid fa-user` + * - Brand icons: `fab fa-github`, `fa-brands fa-twitter` + * + * ## Lazy Evaluation + * + * The `item` closure is only called when the menu is rendered, after permission + * checks pass. This avoids unnecessary work for filtered items and allows + * route-dependent data (like `active` state) to be computed at render time. + * + * ## Registration + * + * Providers are typically registered via `AdminMenuRegistry::register()` during + * the AdminPanelBooting event or in a service provider's boot method. + * + * + * @see DynamicMenuProvider For uncached, real-time menu items + * @see \Core\Front\Admin\Validation\IconValidator For icon validation + */ +interface AdminMenuProvider +{ + /** + * Priority: Reserved for system items (0-9). + */ + public const PRIORITY_FIRST = 0; + + /** + * Priority: Primary navigation items (10-19). + */ + public const PRIORITY_HIGH = 10; + + /** + * Priority: Important but not primary items (20-39). + */ + public const PRIORITY_ABOVE_NORMAL = 20; + + /** + * Priority: Standard items, default (40-60). + */ + public const PRIORITY_NORMAL = 50; + + /** + * Priority: Less important items (61-79). + */ + public const PRIORITY_BELOW_NORMAL = 70; + + /** + * Priority: Rarely used items (80-89). + */ + public const PRIORITY_LOW = 80; + + /** + * Priority: Items that should appear at the end (90-99). + */ + public const PRIORITY_LAST = 90; + + /** + * Return admin menu items for this module. + * + * Each item should specify: + * - group: string (dashboard|workspaces|services|settings|admin) + * - priority: int (use PRIORITY_* constants for consistent ordering) + * - entitlement: string|null (feature code for access check) + * - permissions: array|null (required user permissions) + * - admin: bool (requires Hades/admin user) + * - item: Closure (lazy-evaluated menu item data) + * + * The item closure should return an array with: + * - label: string (display text) + * - icon: string (FontAwesome icon name, validated by IconValidator) + * - href: string (link URL) + * - active: bool (whether item is currently active) + * - color: string|null (optional color theme) + * - badge: string|array|null (optional badge text or config) + * - children: array|null (optional sub-menu items) + * + * Example: + * ```php + * return [ + * [ + * 'group' => 'services', + * 'priority' => self::PRIORITY_NORMAL, // 50 + * 'entitlement' => 'core.srv.bio', + * 'permissions' => ['bio.view', 'bio.manage'], + * 'item' => fn() => [ + * 'label' => 'BioHost', + * 'icon' => 'link', // Validated against FontAwesome icons + * 'href' => route('hub.bio.index'), + * 'active' => request()->routeIs('hub.bio.*'), + * 'children' => [...], + * ], + * ], + * ]; + * ``` + * + * @return array|null, + * admin?: bool, + * item: \Closure + * }> + * + * @see IconValidator For valid icon names + */ + public function adminMenuItems(): array; + + /** + * Get the permissions required to view any menu items from this provider. + * + * This provides a way to define global permission requirements for all + * menu items from this provider. Individual items can override with their + * own 'permissions' key in adminMenuItems(). + * + * Return an empty array if no global permissions are required. + * + * @return array + */ + public function menuPermissions(): array; + + /** + * Check if the user has permission to view menu items from this provider. + * + * Override this method to implement custom permission logic beyond + * simple permission key checks. + * + * @param object|null $user The authenticated user (User model instance) + * @param object|null $workspace The current workspace context (Workspace model instance) + */ + public function canViewMenu(?object $user, ?object $workspace): bool; +} diff --git a/app/Core/Front/Admin/Contracts/CLAUDE.md b/app/Core/Front/Admin/Contracts/CLAUDE.md new file mode 100644 index 0000000..f2ab40c --- /dev/null +++ b/app/Core/Front/Admin/Contracts/CLAUDE.md @@ -0,0 +1,17 @@ +# Front/Admin/Contracts + +Interfaces for the admin menu system. + +## Files + +- **AdminMenuProvider.php** -- Interface for modules contributing admin sidebar items. Defines priority constants (FIRST=0 through LAST=90), `adminMenuItems()` returning registration arrays with group/priority/entitlement/permissions/item closure, `menuPermissions()` for provider-level permission requirements, and `canViewMenu()` for custom access logic. Items are lazy-evaluated -- closures only called after permission checks pass. + +- **DynamicMenuProvider.php** -- Interface for providers supplying uncached, real-time menu items (e.g., notification counts, recent items). `dynamicMenuItems()` is called every request and merged after static cache retrieval. `dynamicCacheKey()` can invalidate static cache when dynamic state changes significantly. + +## Menu Groups + +`dashboard` | `workspaces` | `services` | `settings` | `admin` + +## Priority Constants + +PRIORITY_FIRST(0), PRIORITY_HIGH(10), PRIORITY_ABOVE_NORMAL(20), PRIORITY_NORMAL(50), PRIORITY_BELOW_NORMAL(70), PRIORITY_LOW(80), PRIORITY_LAST(90). diff --git a/app/Core/Front/Admin/Contracts/DynamicMenuProvider.php b/app/Core/Front/Admin/Contracts/DynamicMenuProvider.php new file mode 100644 index 0000000..534a01b --- /dev/null +++ b/app/Core/Front/Admin/Contracts/DynamicMenuProvider.php @@ -0,0 +1,83 @@ +|null, + * admin?: bool, + * dynamic?: bool, + * item: \Closure + * }> + */ + public function dynamicMenuItems(?object $user, ?object $workspace, bool $isAdmin): array; + + /** + * Get the cache key modifier for dynamic items. + * + * Dynamic items from this provider will invalidate menu cache when + * this key changes. Return null if dynamic items should never affect + * cache invalidation. + * + * @param object|null $user User model instance + * @param object|null $workspace Workspace model instance + */ + public function dynamicCacheKey(?object $user, ?object $workspace): ?string; +} diff --git a/app/Core/Front/Admin/Support/CLAUDE.md b/app/Core/Front/Admin/Support/CLAUDE.md new file mode 100644 index 0000000..9c11286 --- /dev/null +++ b/app/Core/Front/Admin/Support/CLAUDE.md @@ -0,0 +1,25 @@ +# Front/Admin/Support + +Builder utilities for constructing admin menu items. + +## Files + +- **MenuItemBuilder.php** -- Fluent builder for `AdminMenuProvider::adminMenuItems()` return arrays. Chainable API: `MenuItemBuilder::make('Label')->icon('cube')->href('/path')->inGroup('services')->entitlement('core.srv.x')->build()`. Supports route-based hrefs, active state callbacks (`activeOnRoute('hub.bio.*')`), children, badges, priority shortcuts (`->first()`, `->high()`, `->last()`), service keys, and custom attributes. + +- **MenuItemGroup.php** -- Static factory for structural menu elements within children arrays. Creates separators (`::separator()`), section headers (`::header('Products', 'cube')`), collapsible groups (`::collapsible('Orders', $children)`), and dividers (`::divider('More')`). Also provides type-check helpers: `isSeparator()`, `isHeader()`, `isCollapsible()`, `isDivider()`, `isStructural()`, `isLink()`. + +## Usage + +```php +MenuItemBuilder::make('Commerce') + ->icon('shopping-cart') + ->inServices() + ->entitlement('core.srv.commerce') + ->children([ + MenuItemGroup::header('Products', 'cube'), + MenuItemBuilder::child('All Products', '/products')->icon('list'), + MenuItemGroup::separator(), + MenuItemBuilder::child('Orders', '/orders')->icon('receipt'), + ]) + ->build(); +``` diff --git a/app/Core/Front/Admin/Support/MenuItemBuilder.php b/app/Core/Front/Admin/Support/MenuItemBuilder.php new file mode 100644 index 0000000..01b087b --- /dev/null +++ b/app/Core/Front/Admin/Support/MenuItemBuilder.php @@ -0,0 +1,841 @@ +icon('cube') + * ->href('/admin/products') + * ->inGroup('services') + * ->withPriority(AdminMenuProvider::PRIORITY_NORMAL) + * ->build(); + * ``` + * + * ## With Children + * + * ```php + * $item = MenuItemBuilder::make('Commerce') + * ->icon('shopping-cart') + * ->href('/admin/commerce') + * ->inGroup('services') + * ->entitlement('core.srv.commerce') + * ->children([ + * MenuItemBuilder::child('Products', '/products')->icon('cube'), + * MenuItemBuilder::child('Orders', '/orders')->icon('receipt'), + * ]) + * ->build(); + * ``` + * + * ## With Permissions + * + * ```php + * $item = MenuItemBuilder::make('Settings') + * ->icon('gear') + * ->href('/admin/settings') + * ->requireAdmin() + * ->permissions(['settings.view', 'settings.edit']) + * ->build(); + * ``` + * + * + * @see AdminMenuProvider For menu provider interface + * @see MenuItemGroup For grouping utilities + */ +class MenuItemBuilder +{ + /** + * The menu item label. + */ + protected string $label; + + /** + * The menu item icon (FontAwesome name). + */ + protected ?string $icon = null; + + /** + * The menu item URL/href. + */ + protected ?string $href = null; + + /** + * Route name for href generation. + */ + protected ?string $route = null; + + /** + * Route parameters for href generation. + * + * @var array + */ + protected array $routeParams = []; + + /** + * Menu group (dashboard, workspaces, services, settings, admin). + */ + protected string $group = 'services'; + + /** + * Priority within the group. + */ + protected int $priority = AdminMenuProvider::PRIORITY_NORMAL; + + /** + * Entitlement code for access control. + */ + protected ?string $entitlement = null; + + /** + * Required permissions array. + * + * @var array + */ + protected array $permissions = []; + + /** + * Whether admin access is required. + */ + protected bool $admin = false; + + /** + * Color theme for the item. + */ + protected ?string $color = null; + + /** + * Badge text or configuration. + * + * @var string|array|null + */ + protected string|array|null $badge = null; + + /** + * Child menu items. + * + * @var array + */ + protected array $children = []; + + /** + * Closure to determine active state. + */ + protected ?\Closure $activeCallback = null; + + /** + * Whether the item is currently active. + */ + protected ?bool $active = null; + + /** + * Service key for service-specific lookups. + */ + protected ?string $service = null; + + /** + * Additional custom attributes. + * + * @var array + */ + protected array $attributes = []; + + /** + * Create a new menu item builder. + * + * @param string $label The menu item display text + */ + public function __construct(string $label) + { + $this->label = $label; + } + + /** + * Create a new menu item builder (static factory). + * + * @param string $label The menu item display text + */ + public static function make(string $label): static + { + return new static($label); + } + + /** + * Create a child menu item builder. + * + * Convenience factory for creating sub-menu items with a relative href. + * + * @param string $label The child item label + * @param string $href The child item URL + */ + public static function child(string $label, string $href): static + { + return (new static($label))->href($href); + } + + /** + * Set the icon name (FontAwesome). + * + * @param string $icon Icon name (e.g., 'home', 'gear', 'fa-solid fa-user') + * @return $this + */ + public function icon(string $icon): static + { + $this->icon = $icon; + + return $this; + } + + /** + * Set the URL/href for the menu item. + * + * @param string $href The URL path + * @return $this + */ + public function href(string $href): static + { + $this->href = $href; + + return $this; + } + + /** + * Set the route name for href generation. + * + * The href will be generated using Laravel's route() helper at build time. + * + * @param string $route The route name + * @param array $params Optional route parameters + * @return $this + */ + public function route(string $route, array $params = []): static + { + $this->route = $route; + $this->routeParams = $params; + + return $this; + } + + /** + * Set the menu group. + * + * @param string $group Group key (dashboard, workspaces, services, settings, admin) + * @return $this + */ + public function inGroup(string $group): static + { + $this->group = $group; + + return $this; + } + + /** + * Place in the dashboard group. + * + * @return $this + */ + public function inDashboard(): static + { + return $this->inGroup('dashboard'); + } + + /** + * Place in the workspaces group. + * + * @return $this + */ + public function inWorkspaces(): static + { + return $this->inGroup('workspaces'); + } + + /** + * Place in the services group (default). + * + * @return $this + */ + public function inServices(): static + { + return $this->inGroup('services'); + } + + /** + * Place in the settings group. + * + * @return $this + */ + public function inSettings(): static + { + return $this->inGroup('settings'); + } + + /** + * Place in the admin group. + * + * @return $this + */ + public function inAdmin(): static + { + return $this->inGroup('admin'); + } + + /** + * Set the priority within the group. + * + * @param int $priority Use AdminMenuProvider::PRIORITY_* constants + * @return $this + */ + public function withPriority(int $priority): static + { + $this->priority = $priority; + + return $this; + } + + /** + * Alias for withPriority(). + * + * @param int $priority Priority value + * @return $this + */ + public function priority(int $priority): static + { + return $this->withPriority($priority); + } + + /** + * Set to highest priority (first in group). + * + * @return $this + */ + public function first(): static + { + return $this->withPriority(AdminMenuProvider::PRIORITY_FIRST); + } + + /** + * Set to high priority. + * + * @return $this + */ + public function high(): static + { + return $this->withPriority(AdminMenuProvider::PRIORITY_HIGH); + } + + /** + * Set to low priority. + * + * @return $this + */ + public function low(): static + { + return $this->withPriority(AdminMenuProvider::PRIORITY_LOW); + } + + /** + * Set to lowest priority (last in group). + * + * @return $this + */ + public function last(): static + { + return $this->withPriority(AdminMenuProvider::PRIORITY_LAST); + } + + /** + * Set the entitlement code for workspace-level access control. + * + * @param string $entitlement The feature code (e.g., 'core.srv.commerce') + * @return $this + */ + public function entitlement(string $entitlement): static + { + $this->entitlement = $entitlement; + + return $this; + } + + /** + * Alias for entitlement(). + * + * @param string $entitlement The feature code + * @return $this + */ + public function requiresEntitlement(string $entitlement): static + { + return $this->entitlement($entitlement); + } + + /** + * Set required permissions. + * + * @param array $permissions Array of permission keys + * @return $this + */ + public function permissions(array $permissions): static + { + $this->permissions = $permissions; + + return $this; + } + + /** + * Add a single required permission. + * + * @param string $permission The permission key + * @return $this + */ + public function permission(string $permission): static + { + $this->permissions[] = $permission; + + return $this; + } + + /** + * Alias for permissions(). + * + * @param array $permissions Array of permission keys + * @return $this + */ + public function requiresPermissions(array $permissions): static + { + return $this->permissions($permissions); + } + + /** + * Require admin access (Hades user). + * + * @param bool $required Whether admin is required + * @return $this + */ + public function requireAdmin(bool $required = true): static + { + $this->admin = $required; + + return $this; + } + + /** + * Alias for requireAdmin(). + * + * @return $this + */ + public function adminOnly(): static + { + return $this->requireAdmin(true); + } + + /** + * Set the color theme. + * + * @param string $color Color name (e.g., 'blue', 'green', 'amber') + * @return $this + */ + public function color(string $color): static + { + $this->color = $color; + + return $this; + } + + /** + * Set a text badge. + * + * @param string $text Badge text + * @param string|null $color Optional badge color + * @return $this + */ + public function badge(string $text, ?string $color = null): static + { + if ($color !== null) { + $this->badge = ['text' => $text, 'color' => $color]; + } else { + $this->badge = $text; + } + + return $this; + } + + /** + * Set a numeric badge with a count. + * + * @param int $count The count to display + * @param string|null $color Optional badge color + * @return $this + */ + public function badgeCount(int $count, ?string $color = null): static + { + return $this->badge((string) $count, $color); + } + + /** + * Set a configurable badge. + * + * @param array $config Badge configuration + * @return $this + */ + public function badgeConfig(array $config): static + { + $this->badge = $config; + + return $this; + } + + /** + * Set child menu items. + * + * @param array $children Child items or builders + * @return $this + */ + public function children(array $children): static + { + $this->children = $children; + + return $this; + } + + /** + * Add a child menu item. + * + * @param MenuItemBuilder|array $child Child item or builder + * @return $this + */ + public function addChild(MenuItemBuilder|array $child): static + { + $this->children[] = $child; + + return $this; + } + + /** + * Add a separator to children. + * + * @return $this + */ + public function separator(): static + { + $this->children[] = MenuItemGroup::separator(); + + return $this; + } + + /** + * Add a section header to children. + * + * @param string $label Section label + * @param string|null $icon Optional icon + * @return $this + */ + public function section(string $label, ?string $icon = null): static + { + $this->children[] = MenuItemGroup::header($label, $icon); + + return $this; + } + + /** + * Add a divider to children. + * + * @param string|null $label Optional divider label + * @return $this + */ + public function divider(?string $label = null): static + { + $this->children[] = MenuItemGroup::divider($label); + + return $this; + } + + /** + * Set whether the item is active. + * + * @param bool $active Active state + * @return $this + */ + public function active(bool $active = true): static + { + $this->active = $active; + + return $this; + } + + /** + * Set a callback to determine active state. + * + * The callback is evaluated at build time in the item closure. + * + * @param \Closure $callback Callback returning bool + * @return $this + */ + public function activeWhen(\Closure $callback): static + { + $this->activeCallback = $callback; + + return $this; + } + + /** + * Set active when the current route matches a pattern. + * + * @param string $pattern Route pattern (e.g., 'hub.commerce.*') + * @return $this + */ + public function activeOnRoute(string $pattern): static + { + return $this->activeWhen(fn () => request()->routeIs($pattern)); + } + + /** + * Set the service key for service-specific lookups. + * + * @param string $key Service key (e.g., 'commerce', 'bio') + * @return $this + */ + public function service(string $key): static + { + $this->service = $key; + + return $this; + } + + /** + * Set a custom attribute. + * + * @param string $key Attribute key + * @param mixed $value Attribute value + * @return $this + */ + public function with(string $key, mixed $value): static + { + $this->attributes[$key] = $value; + + return $this; + } + + /** + * Set multiple custom attributes. + * + * @param array $attributes Attributes array + * @return $this + */ + public function withAttributes(array $attributes): static + { + $this->attributes = array_merge($this->attributes, $attributes); + + return $this; + } + + /** + * Build the menu item registration array. + * + * Returns the structure expected by AdminMenuProvider::adminMenuItems(). + * + * @return array{ + * group: string, + * priority: int, + * entitlement?: string|null, + * permissions?: array, + * admin?: bool, + * service?: string, + * item: \Closure + * } + */ + public function build(): array + { + $registration = [ + 'group' => $this->group, + 'priority' => $this->priority, + 'item' => $this->buildItemClosure(), + ]; + + if ($this->entitlement !== null) { + $registration['entitlement'] = $this->entitlement; + } + + if (! empty($this->permissions)) { + $registration['permissions'] = $this->permissions; + } + + if ($this->admin) { + $registration['admin'] = true; + } + + if ($this->service !== null) { + $registration['service'] = $this->service; + } + + return $registration; + } + + /** + * Build the lazy-evaluated item closure. + */ + protected function buildItemClosure(): \Closure + { + return function () { + $item = [ + 'label' => $this->label, + ]; + + // Resolve href + if ($this->route !== null) { + $item['href'] = route($this->route, $this->routeParams); + } elseif ($this->href !== null) { + $item['href'] = $this->href; + } else { + $item['href'] = '#'; + } + + // Optional icon + if ($this->icon !== null) { + $item['icon'] = $this->icon; + } + + // Resolve active state + if ($this->activeCallback !== null) { + $item['active'] = ($this->activeCallback)(); + } elseif ($this->active !== null) { + $item['active'] = $this->active; + } else { + $item['active'] = false; + } + + // Optional color + if ($this->color !== null) { + $item['color'] = $this->color; + } + + // Optional badge + if ($this->badge !== null) { + $item['badge'] = $this->badge; + } + + // Build children + if (! empty($this->children)) { + $item['children'] = $this->buildChildren(); + } + + // Custom attributes + foreach ($this->attributes as $key => $value) { + if (! isset($item[$key])) { + $item[$key] = $value; + } + } + + return $item; + }; + } + + /** + * Build the children array. + * + * @return array + */ + protected function buildChildren(): array + { + $built = []; + + foreach ($this->children as $child) { + if ($child instanceof MenuItemBuilder) { + // Build the child item directly (not the registration) + $built[] = $child->buildChildItem(); + } else { + // Already an array (separator, header, etc.) + $built[] = $child; + } + } + + return $built; + } + + /** + * Build a child item array (without registration wrapper). + */ + public function buildChildItem(): array + { + $item = [ + 'label' => $this->label, + ]; + + if ($this->route !== null) { + $item['href'] = route($this->route, $this->routeParams); + } elseif ($this->href !== null) { + $item['href'] = $this->href; + } else { + $item['href'] = '#'; + } + + if ($this->icon !== null) { + $item['icon'] = $this->icon; + } + + if ($this->activeCallback !== null) { + $item['active'] = ($this->activeCallback)(); + } elseif ($this->active !== null) { + $item['active'] = $this->active; + } else { + $item['active'] = false; + } + + if ($this->color !== null) { + $item['color'] = $this->color; + } + + if ($this->badge !== null) { + $item['badge'] = $this->badge; + } + + foreach ($this->attributes as $key => $value) { + if (! isset($item[$key])) { + $item[$key] = $value; + } + } + + return $item; + } + + /** + * Get the label. + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * Get the group. + */ + public function getGroup(): string + { + return $this->group; + } + + /** + * Get the priority. + */ + public function getPriority(): int + { + return $this->priority; + } +} diff --git a/app/Core/Front/Admin/Support/MenuItemGroup.php b/app/Core/Front/Admin/Support/MenuItemGroup.php new file mode 100644 index 0000000..78583ae --- /dev/null +++ b/app/Core/Front/Admin/Support/MenuItemGroup.php @@ -0,0 +1,250 @@ + 'services', + * 'priority' => 50, + * 'item' => fn() => [ + * 'label' => 'Commerce', + * 'icon' => 'shopping-cart', + * 'href' => route('hub.commerce.index'), + * 'active' => request()->routeIs('hub.commerce.*'), + * // Define sub-groups within children + * 'children' => [ + * // Group header (section) + * MenuItemGroup::header('Products', 'cube'), + * ['label' => 'All Products', 'href' => '/products'], + * ['label' => 'Categories', 'href' => '/categories'], + * // Separator + * MenuItemGroup::separator(), + * // Another group + * MenuItemGroup::header('Orders', 'receipt'), + * ['label' => 'All Orders', 'href' => '/orders'], + * ['label' => 'Pending', 'href' => '/orders/pending'], + * ], + * ], + * ], + * ]; + * ``` + */ +class MenuItemGroup +{ + /** + * Type constant for separator items. + */ + public const TYPE_SEPARATOR = 'separator'; + + /** + * Type constant for section header items. + */ + public const TYPE_HEADER = 'header'; + + /** + * Type constant for collapsible group items. + */ + public const TYPE_COLLAPSIBLE = 'collapsible'; + + /** + * Create a separator element. + * + * Separators are visual dividers between groups of menu items. + * They render as a horizontal line in the menu. + * + * @return array{separator: true} + */ + public static function separator(): array + { + return ['separator' => true]; + } + + /** + * Create a section header element. + * + * Section headers provide a label for a group of related menu items. + * They appear as styled text (usually uppercase) with an optional icon. + * + * @param string $label The header text + * @param string|null $icon Optional FontAwesome icon name + * @param string|null $color Optional color theme (e.g., 'blue', 'green') + * @param string|array|null $badge Optional badge text or config + * @return array{section: string, icon?: string, color?: string, badge?: string|array} + */ + public static function header( + string $label, + ?string $icon = null, + ?string $color = null, + string|array|null $badge = null + ): array { + $item = ['section' => $label]; + + if ($icon !== null) { + $item['icon'] = $icon; + } + + if ($color !== null) { + $item['color'] = $color; + } + + if ($badge !== null) { + $item['badge'] = $badge; + } + + return $item; + } + + /** + * Create a collapsible group. + * + * Collapsible groups can be expanded/collapsed by clicking the header. + * They maintain state using localStorage when configured. + * + * @param string $label The group header text + * @param array $children Child menu items + * @param string|null $icon Optional FontAwesome icon name + * @param string|null $color Optional color theme + * @param bool $defaultOpen Whether the group is open by default + * @param string|null $stateKey Optional localStorage key for persisting state + * @return array{collapsible: true, label: string, children: array, icon?: string, color?: string, open?: bool, stateKey?: string} + */ + public static function collapsible( + string $label, + array $children, + ?string $icon = null, + ?string $color = null, + bool $defaultOpen = true, + ?string $stateKey = null + ): array { + $item = [ + 'collapsible' => true, + 'label' => $label, + 'children' => $children, + 'open' => $defaultOpen, + ]; + + if ($icon !== null) { + $item['icon'] = $icon; + } + + if ($color !== null) { + $item['color'] = $color; + } + + if ($stateKey !== null) { + $item['stateKey'] = $stateKey; + } + + return $item; + } + + /** + * Create a divider with an optional label. + * + * Dividers are similar to separators but can include centered text. + * + * @param string|null $label Optional centered label + * @return array{divider: true, label?: string} + */ + public static function divider(?string $label = null): array + { + $item = ['divider' => true]; + + if ($label !== null) { + $item['label'] = $label; + } + + return $item; + } + + /** + * Check if an item is a separator. + * + * @param array $item The menu item to check + */ + public static function isSeparator(array $item): bool + { + return ! empty($item['separator']); + } + + /** + * Check if an item is a section header. + * + * @param array $item The menu item to check + */ + public static function isHeader(array $item): bool + { + return ! empty($item['section']); + } + + /** + * Check if an item is a collapsible group. + * + * @param array $item The menu item to check + */ + public static function isCollapsible(array $item): bool + { + return ! empty($item['collapsible']); + } + + /** + * Check if an item is a divider. + * + * @param array $item The menu item to check + */ + public static function isDivider(array $item): bool + { + return ! empty($item['divider']); + } + + /** + * Check if an item is a structural element (separator, header, divider, or collapsible). + * + * @param array $item The menu item to check + */ + public static function isStructural(array $item): bool + { + return self::isSeparator($item) + || self::isHeader($item) + || self::isDivider($item) + || self::isCollapsible($item); + } + + /** + * Check if an item is a regular menu link. + * + * @param array $item The menu item to check + */ + public static function isLink(array $item): bool + { + return ! self::isStructural($item) && isset($item['label']); + } +} diff --git a/app/Core/Front/Admin/TabContext.php b/app/Core/Front/Admin/TabContext.php new file mode 100644 index 0000000..8c3a5b5 --- /dev/null +++ b/app/Core/Front/Admin/TabContext.php @@ -0,0 +1,23 @@ + to communicate selected state + * to child components without explicit prop passing. + */ +class TabContext +{ + public static ?string $selected = null; +} diff --git a/app/Core/Front/Admin/Validation/CLAUDE.md b/app/Core/Front/Admin/Validation/CLAUDE.md new file mode 100644 index 0000000..8eb27b6 --- /dev/null +++ b/app/Core/Front/Admin/Validation/CLAUDE.md @@ -0,0 +1,13 @@ +# Front/Admin/Validation + +Validation utilities for admin panel configuration. + +## Files + +- **IconValidator.php** -- Validates FontAwesome icon names used in admin menu items. Accepts shorthand (`home`), prefixed (`fa-home`), and full class (`fas fa-home`, `fa-solid fa-home`, `fab fa-github`) formats. Normalises all formats to base name. Contains built-in lists of ~200 solid icons and ~80 brand icons. Supports custom icons (`addCustomIcon()`), icon packs (`registerIconPack()`), and strict mode (config `core.admin_menu.strict_icon_validation`). Non-strict mode (default) allows unknown icons with optional warnings. Provides Levenshtein-based suggestions for misspelled icons (`getSuggestions()`). + +## Configuration + +- `core.admin_menu.strict_icon_validation` -- Reject unknown icons (default: false) +- `core.admin_menu.log_icon_warnings` -- Log warnings for unknown icons (default: true) +- `core.admin_menu.custom_icons` -- Array of additional valid icon names diff --git a/app/Core/Front/Admin/Validation/IconValidator.php b/app/Core/Front/Admin/Validation/IconValidator.php new file mode 100644 index 0000000..6ea78c9 --- /dev/null +++ b/app/Core/Front/Admin/Validation/IconValidator.php @@ -0,0 +1,466 @@ +isValid('home')) { + * // Icon is valid + * } + * + * // Get validation errors + * $errors = $validator->validate(['home', 'invalid-icon']); + * ``` + * + * ## Icon Formats + * + * The validator accepts multiple formats: + * - Shorthand: `home`, `user`, `cog` + * - Full class: `fas fa-home`, `fa-solid fa-home` + * - Brand icons: `fab fa-github`, `fa-brands fa-twitter` + * + * ## Extending + * + * Add custom icons via the `addCustomIcon()` method or register + * icon packs with `registerIconPack()`. + */ +class IconValidator +{ + /** + * Core FontAwesome solid icons (common subset). + * + * This is not exhaustive - FontAwesome has 2000+ icons. + * Configure additional icons via config or custom icon packs. + * + * @var array + */ + protected const SOLID_ICONS = [ + // Navigation & UI + 'home', 'house', 'bars', 'times', 'close', 'xmark', 'check', 'plus', 'minus', + 'arrow-left', 'arrow-right', 'arrow-up', 'arrow-down', 'chevron-left', + 'chevron-right', 'chevron-up', 'chevron-down', 'angle-left', 'angle-right', + 'angle-up', 'angle-down', 'caret-left', 'caret-right', 'caret-up', 'caret-down', + 'ellipsis', 'ellipsis-vertical', 'grip', 'grip-vertical', + + // Common Objects + 'user', 'users', 'user-plus', 'user-minus', 'user-gear', 'user-shield', + 'user-check', 'user-xmark', 'user-group', 'people-group', + 'gear', 'gears', 'cog', 'cogs', 'sliders', 'wrench', 'screwdriver', + 'file', 'file-lines', 'file-pdf', 'file-image', 'file-code', 'file-export', + 'file-import', 'folder', 'folder-open', 'folder-plus', 'folder-minus', + 'envelope', 'envelope-open', 'paper-plane', 'inbox', 'mailbox', + 'phone', 'mobile', 'tablet', 'laptop', 'desktop', 'computer', + 'calendar', 'calendar-days', 'calendar-check', 'calendar-plus', + 'clock', 'stopwatch', 'hourglass', 'timer', + 'bell', 'bell-slash', 'bell-concierge', + 'bookmark', 'bookmarks', 'flag', 'tag', 'tags', + 'star', 'star-half', 'heart', 'thumbs-up', 'thumbs-down', + 'comment', 'comments', 'message', 'quote-left', 'quote-right', + 'image', 'images', 'camera', 'video', 'film', 'photo-film', + 'music', 'headphones', 'microphone', 'volume-high', 'volume-low', 'volume-off', + 'play', 'pause', 'stop', 'forward', 'backward', 'circle-play', + 'link', 'link-slash', 'chain', 'chain-broken', 'unlink', + 'key', 'lock', 'lock-open', 'unlock', 'shield', 'shield-halved', + 'eye', 'eye-slash', 'glasses', + 'magnifying-glass', 'search', 'filter', 'sort', 'sort-up', 'sort-down', + + // E-commerce & Business + 'cart-shopping', 'basket-shopping', 'bag-shopping', 'store', 'shop', + 'credit-card', 'money-bill', 'money-bill-wave', 'coins', 'wallet', + 'receipt', 'barcode', 'qrcode', 'box', 'boxes', 'package', + 'truck', 'shipping-fast', 'dolly', 'warehouse', + 'chart-line', 'chart-bar', 'chart-pie', 'chart-area', 'chart-simple', + 'arrow-trend-up', 'arrow-trend-down', + 'briefcase', 'suitcase', 'building', 'buildings', 'city', 'industry', + 'handshake', 'handshake-angle', 'hands-holding', + + // Communication & Social + 'at', 'hashtag', 'share', 'share-nodes', 'share-from-square', + 'globe', 'earth-americas', 'earth-europe', 'earth-asia', + 'rss', 'wifi', 'signal', 'broadcast-tower', + 'bullhorn', 'megaphone', 'newspaper', + + // Content & Media + 'pen', 'pencil', 'pen-to-square', 'edit', 'eraser', 'highlighter', + 'palette', 'paintbrush', 'brush', 'spray-can', + 'align-left', 'align-center', 'align-right', 'align-justify', + 'bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', + 'list', 'list-ul', 'list-ol', 'list-check', 'table', 'table-cells', + 'code', 'terminal', 'code-branch', 'code-merge', 'code-pull-request', + 'cube', 'cubes', 'puzzle-piece', 'shapes', + + // Actions & States + 'plus', 'minus', 'times', 'check', 'xmark', + 'circle', 'circle-check', 'circle-xmark', 'circle-info', 'circle-question', + 'circle-exclamation', 'circle-notch', 'circle-dot', 'circle-half-stroke', + 'square', 'square-check', 'square-xmark', 'square-plus', 'square-minus', + 'triangle-exclamation', 'exclamation', 'question', 'info', + 'rotate', 'rotate-right', 'rotate-left', 'sync', 'refresh', 'redo', 'undo', + 'arrows-rotate', 'arrows-spin', 'spinner', 'circle-notch', + 'download', 'upload', 'cloud-download', 'cloud-upload', + 'save', 'floppy-disk', 'copy', 'paste', 'clipboard', 'trash', 'trash-can', + 'print', 'share', 'export', 'external-link', 'expand', 'compress', + + // Security & Privacy + 'shield', 'shield-halved', 'shield-check', 'user-shield', + 'fingerprint', 'id-card', 'id-badge', 'passport', + 'mask', 'ban', 'block', 'circle-stop', + + // Development & Tech + 'server', 'database', 'hdd', 'memory', 'microchip', + 'plug', 'power-off', 'bolt', 'battery-full', 'battery-half', 'battery-empty', + 'robot', 'brain', 'lightbulb', 'wand-magic', 'wand-magic-sparkles', + 'bug', 'bug-slash', 'vial', 'flask', 'microscope', + + // Locations & Maps + 'location-dot', 'location-pin', 'map', 'map-pin', 'map-marker', + 'compass', 'directions', 'route', 'road', + + // Nature & Weather + 'sun', 'moon', 'cloud', 'cloud-sun', 'cloud-moon', 'cloud-rain', + 'snowflake', 'wind', 'temperature-high', 'temperature-low', + 'tree', 'leaf', 'seedling', 'flower', 'mountain', + + // Misc + 'layer-group', 'layers', 'sitemap', 'network-wired', + 'grip-lines', 'grip-lines-vertical', 'border-all', + 'award', 'trophy', 'medal', 'crown', 'gem', + 'gift', 'cake-candles', 'champagne-glasses', + 'graduation-cap', 'book', 'book-open', 'bookmark', + 'hospital', 'stethoscope', 'heart-pulse', 'pills', 'syringe', + ]; + + /** + * Brand icons (FontAwesome brands). + * + * @var array + */ + protected const BRAND_ICONS = [ + // Social Media + 'facebook', 'facebook-f', 'twitter', 'x-twitter', 'instagram', 'linkedin', + 'linkedin-in', 'youtube', 'tiktok', 'snapchat', 'pinterest', 'reddit', + 'tumblr', 'whatsapp', 'telegram', 'discord', 'slack', 'twitch', + + // Development + 'github', 'gitlab', 'bitbucket', 'git', 'git-alt', 'docker', + 'npm', 'node', 'node-js', 'php', 'python', 'java', 'js', 'js-square', + 'html5', 'css3', 'css3-alt', 'sass', 'less', 'bootstrap', + 'react', 'vuejs', 'angular', 'laravel', 'symfony', + 'aws', 'digital-ocean', 'google-cloud', 'microsoft', 'azure', + + // Companies + 'apple', 'google', 'amazon', 'microsoft', 'meta', 'stripe', + 'paypal', 'cc-visa', 'cc-mastercard', 'cc-amex', 'cc-stripe', + 'shopify', 'wordpress', 'drupal', 'joomla', 'magento', + + // Services + 'dropbox', 'google-drive', 'trello', 'jira', 'confluence', + 'figma', 'sketch', 'invision', 'adobe', 'behance', 'dribbble', + 'vimeo', 'spotify', 'soundcloud', 'deezer', 'lastfm', + 'mailchimp', 'hubspot', 'salesforce', 'zendesk', + + // Misc + 'android', 'apple', 'windows', 'linux', 'ubuntu', 'fedora', 'chrome', + 'firefox', 'safari', 'edge', 'opera', 'internet-explorer', + 'bluetooth', 'usb', 'wifi', + ]; + + /** + * Custom registered icons. + * + * @var array + */ + protected array $customIcons = []; + + /** + * Icon packs (name => icons array). + * + * @var array> + */ + protected array $iconPacks = []; + + /** + * Whether to log validation warnings. + */ + protected bool $logWarnings = true; + + /** + * Whether strict validation is enabled. + */ + protected bool $strictMode = false; + + public function __construct() + { + $this->strictMode = (bool) config('core.admin_menu.strict_icon_validation', false); + $this->logWarnings = (bool) config('core.admin_menu.log_icon_warnings', true); + + // Load custom icons from config + $customIcons = config('core.admin_menu.custom_icons', []); + if (is_array($customIcons)) { + $this->customIcons = $customIcons; + } + } + + /** + * Check if an icon is valid. + */ + public function isValid(string $icon): bool + { + $normalized = $this->normalizeIcon($icon); + + return $this->isKnownIcon($normalized); + } + + /** + * Validate an icon and return errors if any. + * + * @return array Array of error messages + */ + public function validate(string $icon): array + { + $errors = []; + + if (empty($icon)) { + $errors[] = 'Icon name cannot be empty'; + + return $errors; + } + + $normalized = $this->normalizeIcon($icon); + + if (! $this->isKnownIcon($normalized)) { + $errors[] = sprintf( + "Unknown icon '%s'. Ensure it's a valid FontAwesome icon or add it to custom icons.", + $icon + ); + + if ($this->logWarnings) { + Log::warning("Unknown admin menu icon: {$icon}"); + } + } + + return $errors; + } + + /** + * Validate multiple icons at once. + * + * @param array $icons + * @return array> Icon => errors mapping + */ + public function validateMany(array $icons): array + { + $results = []; + + foreach ($icons as $icon) { + $errors = $this->validate($icon); + if (! empty($errors)) { + $results[$icon] = $errors; + } + } + + return $results; + } + + /** + * Normalize an icon name to its base form. + * + * Handles various formats: + * - 'home' => 'home' + * - 'fa-home' => 'home' + * - 'fas fa-home' => 'home' + * - 'fa-solid fa-home' => 'home' + * - 'fab fa-github' => 'github' (brand) + */ + public function normalizeIcon(string $icon): string + { + $icon = trim($icon); + + // Remove FontAwesome class prefixes + $icon = preg_replace('/^(fas?|far|fab|fa-solid|fa-regular|fa-brands)\s+/', '', $icon); + + // Remove 'fa-' prefix + $icon = preg_replace('/^fa-/', '', $icon ?? $icon); + + return strtolower($icon ?? ''); + } + + /** + * Check if the normalized icon name is in any known icon set. + */ + protected function isKnownIcon(string $normalizedIcon): bool + { + // Check core solid icons + if (in_array($normalizedIcon, self::SOLID_ICONS, true)) { + return true; + } + + // Check brand icons + if (in_array($normalizedIcon, self::BRAND_ICONS, true)) { + return true; + } + + // Check custom icons + if (in_array($normalizedIcon, $this->customIcons, true)) { + return true; + } + + // Check registered icon packs + foreach ($this->iconPacks as $icons) { + if (in_array($normalizedIcon, $icons, true)) { + return true; + } + } + + // If not strict mode, allow any icon (for extensibility) + if (! $this->strictMode) { + return true; + } + + return false; + } + + /** + * Add a custom icon to the validator. + */ + public function addCustomIcon(string $icon): self + { + $normalized = $this->normalizeIcon($icon); + if (! in_array($normalized, $this->customIcons, true)) { + $this->customIcons[] = $normalized; + } + + return $this; + } + + /** + * Add multiple custom icons. + * + * @param array $icons + */ + public function addCustomIcons(array $icons): self + { + foreach ($icons as $icon) { + $this->addCustomIcon($icon); + } + + return $this; + } + + /** + * Register a named icon pack. + * + * @param array $icons + */ + public function registerIconPack(string $name, array $icons): self + { + $this->iconPacks[$name] = array_map( + fn ($icon) => $this->normalizeIcon($icon), + $icons + ); + + return $this; + } + + /** + * Set strict mode. + * + * In strict mode, only known icons are valid. + * In non-strict mode (default), unknown icons generate warnings but are allowed. + */ + public function setStrictMode(bool $strict): self + { + $this->strictMode = $strict; + + return $this; + } + + /** + * Enable or disable warning logging. + */ + public function setLogWarnings(bool $log): self + { + $this->logWarnings = $log; + + return $this; + } + + /** + * Get all known solid icons. + * + * @return array + */ + public function getSolidIcons(): array + { + return self::SOLID_ICONS; + } + + /** + * Get all known brand icons. + * + * @return array + */ + public function getBrandIcons(): array + { + return self::BRAND_ICONS; + } + + /** + * Get all registered custom icons. + * + * @return array + */ + public function getCustomIcons(): array + { + return $this->customIcons; + } + + /** + * Get icon suggestions for a potentially misspelled icon. + * + * @return array + */ + public function getSuggestions(string $icon, int $maxSuggestions = 5): array + { + $normalized = $this->normalizeIcon($icon); + $allIcons = array_merge( + self::SOLID_ICONS, + self::BRAND_ICONS, + $this->customIcons + ); + + $suggestions = []; + foreach ($allIcons as $knownIcon) { + $distance = levenshtein($normalized, $knownIcon); + if ($distance <= 3) { // Allow up to 3 character differences + $suggestions[$knownIcon] = $distance; + } + } + + asort($suggestions); + + return array_slice(array_keys($suggestions), 0, $maxSuggestions); + } +} diff --git a/app/Core/Front/Admin/View/Components/ActivityFeed.php b/app/Core/Front/Admin/View/Components/ActivityFeed.php new file mode 100644 index 0000000..fee0977 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/ActivityFeed.php @@ -0,0 +1,42 @@ + 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'updated' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'deleted' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + 'restored' => 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'login' => 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400', + 'logout' => 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + default => 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + }; + } + + public function formatTimestamp(mixed $timestamp): array + { + if ($timestamp instanceof Carbon) { + return [ + 'relative' => $timestamp->diffForHumans(), + 'absolute' => $timestamp->format('d M Y H:i'), + ]; + } + + return [ + 'relative' => (string) $timestamp, + 'absolute' => null, + ]; + } + + public function formatValue(mixed $value): string + { + return is_array($value) ? json_encode($value) : (string) $value; + } + + public function render(): View + { + return view('admin::components.activity-log'); + } +} diff --git a/app/Core/Front/Admin/View/Components/Alert.php b/app/Core/Front/Admin/View/Components/Alert.php new file mode 100644 index 0000000..6f44693 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/Alert.php @@ -0,0 +1,50 @@ +resolveConfig(); + $this->bgColor = $config['bg']; + $this->iconName = $this->icon ?? $config['icon']; + } + + protected function resolveConfig(): array + { + return match ($this->type) { + 'warning' => ['bg' => 'amber', 'icon' => 'exclamation-triangle'], + 'success' => ['bg' => 'green', 'icon' => 'check-circle'], + 'error' => ['bg' => 'red', 'icon' => 'x-circle'], + default => ['bg' => 'blue', 'icon' => 'information-circle'], + }; + } + + public function render(): View + { + return view('admin::components.alert'); + } +} diff --git a/app/Core/Front/Admin/View/Components/CLAUDE.md b/app/Core/Front/Admin/View/Components/CLAUDE.md new file mode 100644 index 0000000..3228aa6 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/CLAUDE.md @@ -0,0 +1,28 @@ +# Front/Admin/View/Components + +Class-backed Blade components for the admin panel. Registered via `` tag syntax in Boot.php. + +## Components + +| Component | Tag | Purpose | +|-----------|-----|---------| +| ActivityFeed | `` | Timeline of recent activity items with icons and timestamps | +| ActivityLog | `` | Detailed activity log with filtering | +| Alert | `` | Dismissible alert/notification banners | +| CardGrid | `` | Responsive grid of cards | +| ClearFilters | `` | Button to reset active table/list filters | +| DataTable | `` | Table with columns, rows, title, empty state, and action link | +| EditableTable | `` | Inline-editable table rows | +| Filter | `` | Single filter control (select, input, etc.) | +| FilterBar | `` | Horizontal bar of filter controls | +| LinkGrid | `` | Grid of navigational link cards | +| ManagerTable | `` | CRUD management table with actions | +| Metrics | `` | Grid of metric cards (configurable columns: 2-4) | +| ProgressList | `` | List of items with progress indicators | +| Search | `` | Search input with Livewire integration | +| ServiceCard | `` | Card displaying a service's status, stats, and actions | +| Sidemenu | `` | Sidebar navigation built from AdminMenuRegistry | +| Stats | `` | Grid of stat cards (configurable columns: 2-6) | +| StatusCards | `` | Cards showing system/service status | + +All components extend `Illuminate\View\Component` and render via Blade templates in `Admin/Blade/components/`. diff --git a/app/Core/Front/Admin/View/Components/CardGrid.php b/app/Core/Front/Admin/View/Components/CardGrid.php new file mode 100644 index 0000000..936511b --- /dev/null +++ b/app/Core/Front/Admin/View/Components/CardGrid.php @@ -0,0 +1,60 @@ +colsClass = $this->resolveColsClass(); + } + + protected function resolveColsClass(): string + { + return match ($this->cols) { + 1 => 'grid-cols-1', + 2 => 'grid-cols-1 sm:grid-cols-2', + 3 => 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-3', + 4 => 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', + default => 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-3', + }; + } + + public function progressColor(array $stat): string + { + if (isset($stat['progressColor'])) { + return $stat['progressColor']; + } + + $progress = $stat['progress'] ?? 0; + + return match (true) { + $progress > 80 => 'red', + $progress > 50 => 'yellow', + default => 'green', + }; + } + + public function render(): View + { + return view('admin::components.card-grid'); + } +} diff --git a/app/Core/Front/Admin/View/Components/ClearFilters.php b/app/Core/Front/Admin/View/Components/ClearFilters.php new file mode 100644 index 0000000..2e26e7a --- /dev/null +++ b/app/Core/Front/Admin/View/Components/ClearFilters.php @@ -0,0 +1,34 @@ +clearStatements = collect($this->fields) + ->map(fn ($field) => "\$set('{$field}', '')") + ->implode('; '); + } + + public function render(): View + { + return view('admin::components.clear-filters'); + } +} diff --git a/app/Core/Front/Admin/View/Components/DataTable.php b/app/Core/Front/Admin/View/Components/DataTable.php new file mode 100644 index 0000000..8f17c93 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/DataTable.php @@ -0,0 +1,53 @@ +processedColumns = $this->processColumns(); + } + + protected function processColumns(): array + { + return array_map(fn ($col) => [ + 'label' => is_array($col) ? ($col['label'] ?? $col) : $col, + 'align' => is_array($col) ? ($col['align'] ?? 'left') : 'left', + ], $this->columns); + } + + public function cellAlignClass(int $index): string + { + $colDef = $this->columns[$index] ?? []; + $align = is_array($colDef) ? ($colDef['align'] ?? 'left') : 'left'; + + return $align === 'right' ? 'text-right' : ''; + } + + public function render(): View + { + return view('admin::components.data-table'); + } +} diff --git a/app/Core/Front/Admin/View/Components/EditableTable.php b/app/Core/Front/Admin/View/Components/EditableTable.php new file mode 100644 index 0000000..69c9362 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/EditableTable.php @@ -0,0 +1,82 @@ +processedColumns = $this->processColumns(); + } + + protected function processColumns(): array + { + return array_map(function ($column) { + if (is_array($column)) { + return [ + 'label' => $column['label'] ?? '', + 'align' => $column['align'] ?? 'left', + 'width' => $column['width'] ?? null, + 'alignClass' => $this->alignClass($column['align'] ?? 'left'), + ]; + } + + return [ + 'label' => $column, + 'align' => 'left', + 'width' => null, + 'alignClass' => 'text-left', + ]; + }, $this->columns); + } + + public function cellAlignClass(int $cellIndex): string + { + $columnDef = $this->columns[$cellIndex] ?? []; + $align = is_array($columnDef) ? ($columnDef['align'] ?? 'left') : 'left'; + + return $this->alignClass($align); + } + + protected function alignClass(string $align): string + { + return match ($align) { + 'center' => 'text-center', + 'right' => 'text-right', + default => 'text-left', + }; + } + + public function colspanCount(): int + { + return count($this->columns) + ($this->selectable ? 1 : 0); + } + + public function render(): View + { + return view('admin::components.editable-table'); + } +} diff --git a/app/Core/Front/Admin/View/Components/Filter.php b/app/Core/Front/Admin/View/Components/Filter.php new file mode 100644 index 0000000..fcb3470 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/Filter.php @@ -0,0 +1,57 @@ +wireModel = $this->model ? "wire:model.live=\"{$this->model}\"" : ''; + $this->placeholderText = $this->placeholder ?? ($this->label ? "All {$this->label}s" : 'All'); + $this->normalizedOptions = $this->normalizeOptions(); + } + + protected function normalizeOptions(): array + { + return collect($this->options)->map(function ($item, $key) { + if (is_object($item)) { + return ['value' => $item->{$this->valueKey}, 'label' => $item->{$this->labelKey}]; + } + if (is_array($item) && isset($item['value'])) { + return $item; + } + + return ['value' => $key, 'label' => $item]; + })->values()->all(); + } + + public function render(): View + { + return view('admin::components.filter'); + } +} diff --git a/app/Core/Front/Admin/View/Components/FilterBar.php b/app/Core/Front/Admin/View/Components/FilterBar.php new file mode 100644 index 0000000..7279631 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/FilterBar.php @@ -0,0 +1,42 @@ +gridCols = $this->resolveGridCols(); + } + + protected function resolveGridCols(): string + { + return match ($this->cols) { + 2 => 'sm:grid-cols-2', + 3 => 'sm:grid-cols-3', + 4 => 'sm:grid-cols-4', + 5 => 'sm:grid-cols-5', + default => 'sm:grid-cols-4', + }; + } + + public function render(): View + { + return view('admin::components.filter-bar'); + } +} diff --git a/app/Core/Front/Admin/View/Components/LinkGrid.php b/app/Core/Front/Admin/View/Components/LinkGrid.php new file mode 100644 index 0000000..63cd926 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/LinkGrid.php @@ -0,0 +1,52 @@ +gridCols = $this->resolveGridCols(); + } + + protected function resolveGridCols(): string + { + return match ($this->cols) { + 2 => 'grid-cols-1 sm:grid-cols-2', + 3 => 'grid-cols-1 sm:grid-cols-3', + 4 => 'grid-cols-2 md:grid-cols-4', + default => 'grid-cols-2 md:grid-cols-4', + }; + } + + public function itemColor(array $item): string + { + return $item['color'] ?? 'violet'; + } + + public function itemIcon(array $item): string + { + return $item['icon'] ?? 'arrow-right'; + } + + public function render(): View + { + return view('admin::components.link-grid'); + } +} diff --git a/app/Core/Front/Admin/View/Components/ManagerTable.php b/app/Core/Front/Admin/View/Components/ManagerTable.php new file mode 100644 index 0000000..e111418 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/ManagerTable.php @@ -0,0 +1,64 @@ +processedColumns = $this->processColumns(); + } + + protected function processColumns(): array + { + return array_map(fn ($column) => [ + 'label' => is_array($column) ? $column['label'] : $column, + 'align' => is_array($column) ? ($column['align'] ?? 'left') : 'left', + 'alignClass' => $this->alignClass( + is_array($column) ? ($column['align'] ?? 'left') : 'left' + ), + ], $this->columns); + } + + public function cellAlignClass(int $cellIndex): string + { + $columnDef = $this->columns[$cellIndex] ?? []; + $align = is_array($columnDef) ? ($columnDef['align'] ?? 'left') : 'left'; + + return $this->alignClass($align); + } + + protected function alignClass(string $align): string + { + return match ($align) { + 'center' => 'text-center', + 'right' => 'text-right', + default => 'text-left', + }; + } + + public function render(): View + { + return view('admin::components.manager-table'); + } +} diff --git a/app/Core/Front/Admin/View/Components/Metrics.php b/app/Core/Front/Admin/View/Components/Metrics.php new file mode 100644 index 0000000..49e7c88 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/Metrics.php @@ -0,0 +1,42 @@ +gridCols = $this->resolveGridCols(); + } + + protected function resolveGridCols(): string + { + return match ($this->cols) { + 2 => 'grid-cols-1 sm:grid-cols-2', + 3 => 'grid-cols-1 md:grid-cols-3', + 4 => 'grid-cols-2 md:grid-cols-4', + default => 'grid-cols-1 md:grid-cols-3', + }; + } + + public function render(): View + { + return view('admin::components.metrics'); + } +} diff --git a/app/Core/Front/Admin/View/Components/ProgressList.php b/app/Core/Front/Admin/View/Components/ProgressList.php new file mode 100644 index 0000000..08152af --- /dev/null +++ b/app/Core/Front/Admin/View/Components/ProgressList.php @@ -0,0 +1,60 @@ +maxValue = $this->calculateMaxValue(); + } + + protected function calculateMaxValue(): int|float + { + return collect($this->items)->max(fn ($item) => $item['max'] ?? $item['value']) ?? 0; + } + + public function itemPercentage(array $item): float + { + $value = $item['value'] ?? 0; + $max = $item['max'] ?? $this->maxValue; + + return $max > 0 ? ($value / $max) * 100 : 0; + } + + public function itemColor(array $item): string + { + return $item['color'] ?? $this->color; + } + + public function formatValue(mixed $value): string + { + return is_numeric($value) ? number_format($value) : (string) $value; + } + + public function render(): View + { + return view('admin::components.progress-list'); + } +} diff --git a/app/Core/Front/Admin/View/Components/Search.php b/app/Core/Front/Admin/View/Components/Search.php new file mode 100644 index 0000000..b641e33 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/Search.php @@ -0,0 +1,32 @@ +wireModel = $this->model ? "wire:model.live.debounce.300ms=\"{$this->model}\"" : ''; + } + + public function render(): View + { + return view('admin::components.search'); + } +} diff --git a/app/Core/Front/Admin/View/Components/ServiceCard.php b/app/Core/Front/Admin/View/Components/ServiceCard.php new file mode 100644 index 0000000..7f1bd4b --- /dev/null +++ b/app/Core/Front/Admin/View/Components/ServiceCard.php @@ -0,0 +1,60 @@ +name = $service['name'] ?? ''; + $this->description = $service['description'] ?? ''; + $this->icon = $service['icon'] ?? 'cube'; + $this->color = $service['color'] ?? 'violet'; + $this->domain = $service['domain'] ?? ''; + $this->status = $service['status'] ?? 'offline'; + $this->stats = $service['stats'] ?? []; + $this->actions = $service['actions'] ?? []; + $this->adminRoute = $service['adminRoute'] ?? null; + $this->detailsRoute = $service['detailsRoute'] ?? null; + $this->statusColor = $this->status === 'online' ? 'green' : 'red'; + } + + public function render(): View + { + return view('admin::components.service-card'); + } +} diff --git a/app/Core/Front/Admin/View/Components/Sidemenu.php b/app/Core/Front/Admin/View/Components/Sidemenu.php new file mode 100644 index 0000000..37d3d27 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/Sidemenu.php @@ -0,0 +1,61 @@ +items = $items; + } else { + // Registry mode: build from registered providers + $this->items = $this->buildFromRegistry(); + } + } + + protected function buildFromRegistry(): array + { + $user = Auth::user(); + + // Use current workspace from session, not default + $workspace = null; + if (class_exists(WorkspaceService::class)) { + $workspace = app(WorkspaceService::class)->currentModel(); + } + + $isAdmin = false; + if (class_exists(User::class) && $user instanceof User) { + $isAdmin = $user->isHades(); + } + + return app(AdminMenuRegistry::class)->build($workspace, $isAdmin); + } + + public function render(): View + { + return view('admin::components.sidemenu'); + } +} diff --git a/app/Core/Front/Admin/View/Components/Stats.php b/app/Core/Front/Admin/View/Components/Stats.php new file mode 100644 index 0000000..e76c83b --- /dev/null +++ b/app/Core/Front/Admin/View/Components/Stats.php @@ -0,0 +1,44 @@ +gridCols = $this->resolveGridCols(); + } + + protected function resolveGridCols(): string + { + return match ($this->cols) { + 2 => 'grid-cols-1 sm:grid-cols-2', + 3 => 'grid-cols-1 sm:grid-cols-3', + 4 => 'grid-cols-2 md:grid-cols-4', + 5 => 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5', + 6 => 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6', + default => 'grid-cols-2 md:grid-cols-4', + }; + } + + public function render(): View + { + return view('admin::components.stats'); + } +} diff --git a/app/Core/Front/Admin/View/Components/StatusCards.php b/app/Core/Front/Admin/View/Components/StatusCards.php new file mode 100644 index 0000000..9ba0b19 --- /dev/null +++ b/app/Core/Front/Admin/View/Components/StatusCards.php @@ -0,0 +1,47 @@ +gridCols = $this->resolveGridCols(); + } + + protected function resolveGridCols(): string + { + return match ($this->cols) { + 2 => 'grid-cols-1 sm:grid-cols-2', + 3 => 'grid-cols-1 sm:grid-cols-3', + 4 => 'grid-cols-2 md:grid-cols-4', + default => 'grid-cols-2 md:grid-cols-4', + }; + } + + public function itemColor(array $item): string + { + return $item['color'] ?? 'gray'; + } + + public function render(): View + { + return view('admin::components.status-cards'); + } +} diff --git a/app/Core/Front/Boot.php b/app/Core/Front/Boot.php new file mode 100644 index 0000000..d2409b1 --- /dev/null +++ b/app/Core/Front/Boot.php @@ -0,0 +1,67 @@ +group('api', [ + ThrottleRequests::class.':api', + SubstituteBindings::class, + ]); + $middleware->group('mcp', [ + ThrottleRequests::class.':api', + SubstituteBindings::class, + ]); + } +} diff --git a/app/Core/Front/CLAUDE.md b/app/Core/Front/CLAUDE.md new file mode 100644 index 0000000..7fe36b6 --- /dev/null +++ b/app/Core/Front/CLAUDE.md @@ -0,0 +1,82 @@ +# Front + +UI layer: admin panel, web frontage, Blade components, layouts, and tag compilers. + +## What It Does + +Three distinct frontages sharing a component library: + +1. **Admin** (`Admin/`) -- Admin dashboard with its own middleware group, menu registry, 50+ Blade components, and `` tag compiler +2. **Web** (`Web/`) -- Public-facing pages with `web` middleware, `` tag compiler, domain resolution +3. **Components** (`Components/`) -- Programmatic component library (Card, Heading, NavList, Layout, etc.) implementing `Htmlable` for use by MCP tools and agents + +## Directory Structure + +``` +Front/ + Controller.php -- Abstract base controller + Admin/ + Boot.php -- Admin ServiceProvider (middleware, components, tag compiler) + AdminMenuRegistry.php -- Menu builder with entitlements, permissions, caching + AdminTagCompiler.php -- Blade precompiler + TabContext.php -- Tab state management + Contracts/ -- AdminMenuProvider, DynamicMenuProvider interfaces + Support/ -- MenuItemBuilder, MenuItemGroup + Concerns/ -- HasMenuPermissions trait + Validation/ -- IconValidator (Font Awesome Pro validation) + View/Components/ -- 18 class-backed components (DataTable, Stats, Metrics, etc.) + Blade/ + components/ -- 30+ anonymous Blade components + layouts/app.blade.php + Web/ + Boot.php -- Web ServiceProvider (middleware, tag compiler, lifecycle fire) + WebTagCompiler.php -- Blade precompiler + Middleware/ + FindDomainRecord.php -- Resolves domain to workspace + ResilientSession.php -- Handles session issues gracefully + RedirectIfAuthenticated.php + Blade/ + components/ -- nav-item, page + layouts/app.blade.php + Components/ + Component.php -- Abstract base: fluent attr/class API, Htmlable + Card.php, Heading.php, NavList.php, Layout.php + CoreTagCompiler.php -- tag compiler + View/Blade/ -- 20+ component templates (forms, table, autocomplete, avatar, etc.) + Tests/Unit/ -- DeviceDetectionServiceTest +``` + +## Admin Menu System + +`AdminMenuRegistry` is the central hub: +- Modules implement `AdminMenuProvider` interface and register via `$registry->register($provider)` +- Items grouped into: `dashboard`, `agents`, `workspaces`, `services`, `settings`, `admin` +- Entitlement checks via `EntitlementService::can()` +- Permission checks via Laravel's `$user->can()` +- `DynamicMenuProvider` for runtime items (never cached) +- Cached with configurable TTL, invalidatable per workspace/user +- Icon validation against Font Awesome Pro + +## Middleware Groups + +**Admin** (`admin`): EncryptCookies, Session, CSRF, Bindings, SecurityHeaders, `auth` +**Web** (`web`): EncryptCookies, Session, ResilientSession, CSRF, Bindings, SecurityHeaders, FindDomainRecord + +## Tag Compilers + +Custom Blade precompilers enable ``, ``, and `` syntax (same pattern as ``). + +## Programmatic Components + +`Component` base class provides fluent API for building HTML without Blade: +```php +Card::make()->class('p-4')->attr('data-id', 42)->render() +``` +Used by MCP tools and agents to compose UIs programmatically. + +## Integration + +- Admin Boot fires `AdminPanelBooting` lifecycle event +- Web Boot fires `WebRoutesRegistering` via `$app->booted()` callback +- `livewire` aliased to `admin` for Flux Pro compatibility +- All admin components prefixed `admin-` (e.g., ``) diff --git a/app/Core/Front/COMPONENT_PATTERN.md b/app/Core/Front/COMPONENT_PATTERN.md new file mode 100644 index 0000000..20cfcd2 --- /dev/null +++ b/app/Core/Front/COMPONENT_PATTERN.md @@ -0,0 +1,225 @@ +# Core/Front Component Pattern + +**Rule: Blade templates render. PHP classes compute.** + +--- + +## The Pattern + +Every component that needs PHP logic gets a backing class. + +``` +Component = View/Components/Name.php + Blade/components/name.blade.php +``` + +### Component Class (`View/Components/Name.php`) + +```php +columns = $columns; + $this->rows = $rows; + $this->sortable = $sortable; + } + + // Computed properties / helpers for the view + public function hasData(): bool + { + return !empty($this->rows); + } + + public function render() + { + return view('admin::data-table'); + } +} +``` + +### Blade Template (`Blade/components/name.blade.php`) + +```blade +@if($hasData()) + + @foreach($columns as $col) + + @endforeach + @foreach($rows as $row) + ... + @endforeach +
    {{ $col['label'] }}
    +@else + +@endif +``` + +**No `@php` blocks. No business logic. Just loops and conditionals on data the class provides.** + +--- + +## What's Allowed in Blade + +| Allowed | Not Allowed | +|---------|-------------| +| `@foreach`, `@if`, `@unless` | `@php` blocks | +| `{{ $variable }}` | Database queries | +| `{{ $method() }}` | Service calls | +| `@props([...])` for anonymous components | Route checks (`request()->routeIs()`) | +| Slot composition | Auth checks | +| Calling other components | Entitlement checks | + +--- + +## Anonymous vs Class Components + +**Anonymous** (no PHP class) - for pure presentation: +- `` - just wraps content +- `` - just renders a link +- `` - just shows a message + +**Class-backed** - when you need logic: +- `` - iterates complex nested structure +- `` - sorting, filtering, pagination +- `` - date formatting, grouping + +--- + +## Registration + +In `Boot.php`: + +```php +use Core\Front\Admin\View\Components\DataTable; +use Core\Front\Admin\View\Components\Sidemenu; + +public function boot(): void +{ + // Anonymous components (just Blade files) + Blade::anonymousComponentPath(__DIR__.'/Blade', 'admin'); + + // Class-backed components + Blade::component('admin-sidemenu', Sidemenu::class); + Blade::component('admin-data-table', DataTable::class); +} +``` + +--- + +## Directory Structure + +``` +app/Core/Front/Admin/ +├── Boot.php # Registers components +├── AdminTagCompiler.php # syntax +├── Blade/ +│ ├── components/ +│ │ ├── panel.blade.php # Anonymous (pure presentation) +│ │ ├── nav-item.blade.php # Anonymous +│ │ ├── sidemenu.blade.php # Class-backed template +│ │ └── data-table.blade.php # Class-backed template +│ └── layouts/ +│ └── app.blade.php +└── View/ + └── Components/ + ├── Sidemenu.php # Sidemenu logic + └── DataTable.php # DataTable logic +``` + +--- + +## Audit: Completed Refactors + +All components now comply with the pattern. Class-backed components: + +| Component | Class | Notes | +|-----------|-------|-------| +| activity-feed | ActivityFeed.php | Icon/color extraction | +| activity-log | ActivityLog.php | Date formatting, event colors | +| alert | Alert.php | Type→config mapping | +| card-grid | CardGrid.php | Grid cols, progress colors | +| clear-filters | ClearFilters.php | Wire directive building | +| data-table | DataTable.php | Column processing | +| editable-table | EditableTable.php | Column/cell processing | +| filter | Filter.php | Wire model, options normalisation | +| filter-bar | FilterBar.php | Grid cols | +| link-grid | LinkGrid.php | Grid cols, item styling | +| manager-table | ManagerTable.php | Column processing | +| metrics | Metrics.php | Grid cols | +| progress-list | ProgressList.php | Percentage calculations | +| search | Search.php | Wire model building | +| service-card | ServiceCard.php | Service data extraction | +| sidemenu | Sidemenu.php | Menu data iteration | +| stats | Stats.php | Grid cols | +| status-cards | StatusCards.php | Grid cols, item colors | + +### Acceptable `@php` Usage + +Single-line prop defaults are OK in anonymous components: + +```blade +@php + $isExpanded = $expanded ?? $active; +@endphp +``` + +This is just a default value, not business logic. + +--- + +## Module Integration + +Modules provide **data**, Core provides **rendering**. + +```blade +{{-- Module's template --}} + + + +``` + +The SidebarService lives in the Module. It knows about: +- Entitlements +- Routes +- User permissions +- Badge counts + +The Core component knows about: +- HTML structure +- CSS classes +- Icon rendering +- Expand/collapse behaviour + +--- + +## Checklist for New Components + +1. Does it need PHP logic beyond prop defaults? + - Yes → Create class in `View/Components/` + - No → Anonymous component is fine + +2. Is it reusable across modules? + - Yes → Goes in Core/Front + - No → Goes in the Module + +3. Does it fetch data? + - Never in Core. Module passes data in. + +4. Register class components in Boot.php + +5. Zero `@php` blocks with business logic in Blade diff --git a/app/Core/Front/Cli/Boot.php b/app/Core/Front/Cli/Boot.php new file mode 100644 index 0000000..0a1ca2f --- /dev/null +++ b/app/Core/Front/Cli/Boot.php @@ -0,0 +1,80 @@ +app->runningInConsole()) { + return; + } + + $this->app->register(ScheduleServiceProvider::class); + + $this->fireConsoleBooting(); + } + + protected function fireConsoleBooting(): void + { + $event = new ConsoleBooting; + event($event); + + // Process commands + if (! empty($event->commandRequests())) { + $this->commands($event->commandRequests()); + } + + // Process translations + foreach ($event->translationRequests() as [$namespace, $path]) { + if (is_dir($path)) { + $this->loadTranslationsFrom($path, $namespace); + } + } + + // Process middleware aliases + $router = $this->app->make(Router::class); + foreach ($event->middlewareRequests() as [$alias, $class]) { + $router->aliasMiddleware($alias, $class); + } + + // Process policies + foreach ($event->policyRequests() as [$model, $policy]) { + Gate::policy($model, $policy); + } + + // Process blade component paths + foreach ($event->bladeComponentRequests() as [$path, $namespace]) { + if (is_dir($path)) { + Blade::anonymousComponentPath($path, $namespace); + } + } + } +} diff --git a/app/Core/Front/Cli/CLAUDE.md b/app/Core/Front/Cli/CLAUDE.md new file mode 100644 index 0000000..79ef688 --- /dev/null +++ b/app/Core/Front/Cli/CLAUDE.md @@ -0,0 +1,16 @@ +# Front/Cli + +CLI/Artisan frontage. Fires `ConsoleBooting` lifecycle event and processes module registrations. + +## Files + +- **Boot.php** -- ServiceProvider that only runs in console context. Registers the `ScheduleServiceProvider`, fires `ConsoleBooting` event, then processes module requests collected by the event: Artisan commands, translations, middleware aliases, Gate policies, and Blade component paths. This is how modules register CLI-specific resources without coupling to the console context directly. + +## Event-Driven Registration + +Modules listen for `ConsoleBooting` and call methods on the event to register: +- `commandRequests()` -- Artisan command classes +- `translationRequests()` -- `[namespace, path]` pairs +- `middlewareRequests()` -- `[alias, class]` pairs +- `policyRequests()` -- `[model, policy]` pairs +- `bladeComponentRequests()` -- `[path, namespace]` pairs diff --git a/app/Core/Front/Client/Boot.php b/app/Core/Front/Client/Boot.php new file mode 100644 index 0000000..fb1fe75 --- /dev/null +++ b/app/Core/Front/Client/Boot.php @@ -0,0 +1,36 @@ +group('client', [ + \Illuminate\Cookie\Middleware\EncryptCookies::class, + \Illuminate\Session\Middleware\StartSession::class, + ]); + } +} diff --git a/app/Core/Front/Components/Boot.php b/app/Core/Front/Components/Boot.php new file mode 100644 index 0000000..7794a7a --- /dev/null +++ b/app/Core/Front/Components/Boot.php @@ -0,0 +1,74 @@ + tag syntax + + * layouts:: Livewire layouts (->layout('layouts::app')) + * front:: Front-end components () + * errors:: Error pages (404, 500, 503) + */ +class Boot extends ServiceProvider +{ + public function register(): void + { + // + } + + public function boot(): void + { + $blade = __DIR__.'/View/Blade'; + + // Add to view paths for Livewire's layout resolution + // Makes ->layout('layouts.app') find layouts/app.blade.php + $this->app['view']->addLocation($blade); + + // Register core:: namespace ( + ) + $this->loadViewsFrom($blade, 'core'); + Blade::anonymousComponentPath($blade, 'core'); + + // Register layouts:: namespace + $this->loadViewsFrom($blade.'/layouts', 'layouts'); + Blade::anonymousComponentPath($blade.'/layouts', 'layouts'); + + // Register front:: namespace for front-end components + Blade::anonymousComponentPath($blade.'/components', 'front'); + + // Register error views + $this->loadViewsFrom($blade.'/errors', 'errors'); + + // Register tag compiler (like ) + $this->bootTagCompiler(); + } + + /** + * Register the custom tag compiler. + */ + protected function bootTagCompiler(): void + { + $compiler = new CoreTagCompiler( + app('blade.compiler')->getClassComponentAliases(), + app('blade.compiler')->getClassComponentNamespaces(), + app('blade.compiler') + ); + + app('blade.compiler')->precompiler(function (string $value) use ($compiler) { + return $compiler->compile($value); + }); + } +} diff --git a/app/Core/Front/Components/Button.php b/app/Core/Front/Components/Button.php new file mode 100644 index 0000000..fcedc0f --- /dev/null +++ b/app/Core/Front/Components/Button.php @@ -0,0 +1,220 @@ +label('Save')->primary() + * Button::make()->label('Cancel')->secondary()->href('/back') + */ +class Button extends Component +{ + protected string $label = ''; + + protected ?string $href = null; + + protected string $type = 'button'; + + protected string $variant = 'primary'; + + protected string $size = 'md'; + + protected bool $disabled = false; + + /** + * Set the button label. + */ + public function label(string $label): static + { + $this->label = $label; + + return $this; + } + + /** + * Make this a link button. + */ + public function href(string $href): static + { + $this->href = $href; + + return $this; + } + + /** + * Set the button type (button, submit, reset). + */ + public function type(string $type): static + { + $this->type = $type; + + return $this; + } + + /** + * Set to submit type. + */ + public function submit(): static + { + return $this->type('submit'); + } + + /** + * Primary variant (default). + */ + public function primary(): static + { + $this->variant = 'primary'; + + return $this; + } + + /** + * Secondary variant. + */ + public function secondary(): static + { + $this->variant = 'secondary'; + + return $this; + } + + /** + * Danger variant. + */ + public function danger(): static + { + $this->variant = 'danger'; + + return $this; + } + + /** + * Ghost variant (minimal styling). + */ + public function ghost(): static + { + $this->variant = 'ghost'; + + return $this; + } + + /** + * Set size (sm, md, lg). + */ + public function size(string $size): static + { + $this->size = $size; + + return $this; + } + + /** + * Small size. + */ + public function sm(): static + { + return $this->size('sm'); + } + + /** + * Large size. + */ + public function lg(): static + { + return $this->size('lg'); + } + + /** + * Disable the button. + */ + public function disabled(bool $disabled = true): static + { + $this->disabled = $disabled; + + return $this; + } + + /** + * Get variant CSS classes. + */ + protected function variantClasses(): string + { + return match ($this->variant) { + 'primary' => 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100', + 'secondary' => 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600', + 'danger' => 'bg-red-600 text-white hover:bg-red-700', + 'ghost' => 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800', + default => '', + }; + } + + /** + * Get size CSS classes. + */ + protected function sizeClasses(): string + { + return match ($this->size) { + 'sm' => 'px-2.5 py-1.5 text-sm', + 'lg' => 'px-5 py-3 text-lg', + default => 'px-4 py-2', + }; + } + + /** + * Render the button to HTML. + */ + public function render(): string + { + $baseClasses = [ + 'inline-flex', + 'items-center', + 'justify-center', + 'gap-2', + 'rounded-md', + 'font-medium', + 'transition-colors', + 'focus:outline-none', + 'focus:ring-2', + 'focus:ring-offset-2', + ]; + + if ($this->disabled) { + $baseClasses[] = 'opacity-50'; + $baseClasses[] = 'cursor-not-allowed'; + } + + $classes = array_merge( + $baseClasses, + explode(' ', $this->variantClasses()), + explode(' ', $this->sizeClasses()) + ); + + // Link button + if ($this->href !== null) { + $attrs = $this->buildAttributes($classes); + + return ''.e($this->label).''; + } + + // Button element + if ($this->disabled) { + $this->attr('disabled', true); + } + $this->attr('type', $this->type); + $attrs = $this->buildAttributes($classes); + + return ''.e($this->label).''; + } +} diff --git a/app/Core/Front/Components/CLAUDE.md b/app/Core/Front/Components/CLAUDE.md new file mode 100644 index 0000000..faf5106 --- /dev/null +++ b/app/Core/Front/Components/CLAUDE.md @@ -0,0 +1,32 @@ +# Front/Components + +Core UI component system. Provides `` Blade tag syntax and data-driven PHP component builders for programmatic UI composition. + +## Files + +- **Boot.php** -- ServiceProvider registering multiple Blade namespaces: `core::` (core components + `` tags), `layouts::` (Livewire layout resolution), `front::` (front-end satellite components), `errors::` (error pages). Adds blade view paths for Livewire's `->layout()` resolution. +- **CoreTagCompiler.php** -- Blade precompiler for `` tag syntax. Compiles to `core::` anonymous components. +- **Component.php** -- Abstract base for data-driven UI components. Fluent interface with `attr()`, `class()`, `id()`, `buildAttributes()`. Implements `Htmlable`. Used by MCP tools and agents to compose UIs without Blade templates. +- **Button.php** -- Button builder. Variants: primary, secondary, danger, ghost. Sizes: sm, md, lg. Supports link buttons (`href()`), disabled state, submit type. +- **Card.php** -- Card builder with title, description, body content, and action buttons in footer. +- **Heading.php** -- Heading builder (h1-h6) with optional description subtitle. Size classes auto-mapped from level. +- **Layout.php** -- HLCRF Layout Compositor. Data-driven layout builder where H=Header, L=Left, C=Content, R=Right, F=Footer. Variant string defines which slots exist (e.g., `'HLCF'`, `'HCF'`, `'HC'`). Supports nesting and hierarchical path tracking. +- **NavList.php** -- Navigation list builder with heading, items (label + href + icon + active), and dividers. +- **Text.php** -- Text builder. Tags: span, p, div. Variants: default, muted, success, warning, error. + +## Tag Syntax + +```blade + +... +``` + +## Programmatic Usage + +```php +Layout::make('HLCF') + ->h('') + ->l(NavList::make()->item('Dashboard', '/hub')) + ->c(Card::make()->title('Settings')->body('Content')) + ->f('
    Links
    '); +``` diff --git a/app/Core/Front/Components/Card.php b/app/Core/Front/Components/Card.php new file mode 100644 index 0000000..6b6a24b --- /dev/null +++ b/app/Core/Front/Components/Card.php @@ -0,0 +1,120 @@ +title('Settings') + * ->body('Configure your preferences') + * ->action(Button::make()->label('Save')) + */ +class Card extends Component +{ + protected mixed $title = null; + + protected mixed $description = null; + + protected array $body = []; + + protected array $actions = []; + + /** + * Set the card title. + */ + public function title(mixed $title): static + { + $this->title = $title; + + return $this; + } + + /** + * Set the card description (subtitle under title). + */ + public function description(mixed $description): static + { + $this->description = $description; + + return $this; + } + + /** + * Add content to the card body. + */ + public function body(mixed ...$items): static + { + foreach ($items as $item) { + $this->body[] = $item; + } + + return $this; + } + + /** + * Add action buttons/links to the card footer. + */ + public function actions(mixed ...$items): static + { + foreach ($items as $item) { + $this->actions[] = $item; + } + + return $this; + } + + /** + * Render the card to HTML. + */ + public function render(): string + { + $attrs = $this->buildAttributes(['card', 'rounded-lg', 'border', 'bg-white', 'dark:bg-zinc-800']); + + $html = ''; + + // Header (title + description) + if ($this->title !== null || $this->description !== null) { + $html .= '
    '; + if ($this->title !== null) { + $html .= '

    '.$this->resolve($this->title).'

    '; + } + if ($this->description !== null) { + $html .= '

    '.$this->resolve($this->description).'

    '; + } + $html .= '
    '; + } + + // Body + if (! empty($this->body)) { + $html .= '
    '; + foreach ($this->body as $item) { + $html .= $this->raw($item); + } + $html .= '
    '; + } + + // Actions + if (! empty($this->actions)) { + $html .= '
    '; + foreach ($this->actions as $action) { + $html .= $this->raw($action); + } + $html .= '
    '; + } + + $html .= ''; + + return $html; + } +} diff --git a/app/Core/Front/Components/Component.php b/app/Core/Front/Components/Component.php new file mode 100644 index 0000000..9e3bb9e --- /dev/null +++ b/app/Core/Front/Components/Component.php @@ -0,0 +1,168 @@ +attributes[$key] = $value; + + return $this; + } + + /** + * Set multiple attributes. + */ + public function attributes(array $attributes): static + { + $this->attributes = array_merge($this->attributes, $attributes); + + return $this; + } + + /** + * Add CSS classes. + */ + public function class(string ...$classes): static + { + $this->classes = array_merge($this->classes, $classes); + + return $this; + } + + /** + * Set the ID attribute. + */ + public function id(string $id): static + { + return $this->attr('id', $id); + } + + /** + * Build the attributes string for HTML output. + */ + protected function buildAttributes(array $extraClasses = []): string + { + $attrs = $this->attributes; + $allClasses = array_merge($this->classes, $extraClasses); + + if (! empty($allClasses)) { + $existing = $attrs['class'] ?? ''; + $attrs['class'] = trim($existing.' '.implode(' ', array_unique($allClasses))); + } + + $parts = []; + foreach ($attrs as $key => $value) { + if ($value === true) { + $parts[] = e($key); + } elseif ($value !== false && $value !== null && $value !== '') { + $parts[] = e($key).'="'.e($value).'"'; + } + } + + return $parts ? ' '.implode(' ', $parts) : ''; + } + + /** + * Resolve content to string. + */ + protected function resolve(mixed $content): string + { + if ($content === null) { + return ''; + } + + if ($content instanceof Htmlable) { + return $content->toHtml(); + } + + if ($content instanceof Closure) { + return $this->resolve($content()); + } + + if (is_array($content)) { + return implode('', array_map(fn ($item) => $this->resolve($item), $content)); + } + + return e((string) $content); + } + + /** + * Resolve content without escaping (for raw HTML). + */ + protected function raw(mixed $content): string + { + if ($content === null) { + return ''; + } + + if ($content instanceof Htmlable) { + return $content->toHtml(); + } + + if ($content instanceof Closure) { + return $this->raw($content()); + } + + if (is_array($content)) { + return implode('', array_map(fn ($item) => $this->raw($item), $content)); + } + + return (string) $content; + } + + /** + * Render the component to HTML. + */ + abstract public function render(): string; + + /** + * Get the HTML string (Htmlable interface). + */ + public function toHtml(): string + { + return $this->render(); + } + + /** + * Convert to string. + */ + public function __toString(): string + { + return $this->render(); + } +} diff --git a/app/Core/Front/Components/CoreTagCompiler.php b/app/Core/Front/Components/CoreTagCompiler.php new file mode 100644 index 0000000..9535bc4 --- /dev/null +++ b/app/Core/Front/Components/CoreTagCompiler.php @@ -0,0 +1,147 @@ + components. + * + * Enables flux-style syntax: + * Compiles to anonymous components in the 'core' namespace. + */ +class CoreTagCompiler extends ComponentTagCompiler +{ + /** + * Compile the opening tags within the given string. + */ + protected function compileOpeningTags(string $value): string + { + $pattern = "/ + < + \s* + core[\:]([\w\-\:\.]*) + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + @(?:style)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + (\:\\\$)(\w+) + ) + | + (?: + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + (? + /x"; + + return preg_replace_callback($pattern, function (array $matches) { + $this->boundAttributes = []; + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); + + return $this->componentString('core::'.$matches[1], $attributes); + }, $value); + } + + /** + * Compile the self-closing tags within the given string. + */ + protected function compileSelfClosingTags(string $value): string + { + $pattern = "/ + < + \s* + core[\:]([\w\-\:\.]*) + \s* + (? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + @(?:style)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + (\:\\\$)(\w+) + ) + | + (?: + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + ) + \/> + /x"; + + return preg_replace_callback($pattern, function (array $matches) { + $this->boundAttributes = []; + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); + + return $this->componentString('core::'.$matches[1], $attributes)."\n@endComponentClass##END-COMPONENT-CLASS##"; + }, $value); + } + + /** + * Compile the closing tags within the given string. + */ + protected function compileClosingTags(string $value): string + { + return preg_replace("/<\/\s*core[\:][\w\-\:\.]*\s*>/", ' @endComponentClass##END-COMPONENT-CLASS##', $value); + } +} diff --git a/app/Core/Front/Components/Heading.php b/app/Core/Front/Components/Heading.php new file mode 100644 index 0000000..4ee5843 --- /dev/null +++ b/app/Core/Front/Components/Heading.php @@ -0,0 +1,140 @@ +h1() + * Heading::make('Settings')->h2()->description('Configure your account') + */ +class Heading extends Component +{ + protected string $text = ''; + + protected int $level = 2; + + protected ?string $description = null; + + public function __construct(string $text = '') + { + $this->text = $text; + } + + /** + * Create with initial text. + */ + public static function make(string $text = ''): static + { + return new static($text); + } + + /** + * Set the heading text. + */ + public function text(string $text): static + { + $this->text = $text; + + return $this; + } + + /** + * Set heading level (1-6). + */ + public function level(int $level): static + { + $this->level = max(1, min(6, $level)); + + return $this; + } + + /** + * H1 heading. + */ + public function h1(): static + { + return $this->level(1); + } + + /** + * H2 heading. + */ + public function h2(): static + { + return $this->level(2); + } + + /** + * H3 heading. + */ + public function h3(): static + { + return $this->level(3); + } + + /** + * H4 heading. + */ + public function h4(): static + { + return $this->level(4); + } + + /** + * Add a description/subtitle. + */ + public function description(string $description): static + { + $this->description = $description; + + return $this; + } + + /** + * Get size classes based on level. + */ + protected function sizeClasses(): array + { + return match ($this->level) { + 1 => ['text-3xl', 'font-bold', 'tracking-tight'], + 2 => ['text-2xl', 'font-semibold', 'tracking-tight'], + 3 => ['text-xl', 'font-semibold'], + 4 => ['text-lg', 'font-medium'], + 5 => ['text-base', 'font-medium'], + 6 => ['text-sm', 'font-medium', 'uppercase', 'tracking-wider'], + default => ['text-xl', 'font-semibold'], + }; + } + + /** + * Render the heading to HTML. + */ + public function render(): string + { + $tag = 'h'.$this->level; + $classes = array_merge( + $this->sizeClasses(), + ['text-zinc-900', 'dark:text-zinc-100'] + ); + $attrs = $this->buildAttributes($classes); + + $html = '<'.$tag.$attrs.'>'.e($this->text).''; + + if ($this->description !== null) { + $html .= '

    '.e($this->description).'

    '; + } + + return $html; + } +} diff --git a/app/Core/Front/Components/Layout.php b/app/Core/Front/Components/Layout.php new file mode 100644 index 0000000..bbc4a2a --- /dev/null +++ b/app/Core/Front/Components/Layout.php @@ -0,0 +1,327 @@ +h('') + * ->l(Sidebar::make()->items(['Dashboard', 'Settings'])) + * ->c('
    Content
    ') + * ->f('
    Links
    ') + * ->render(); + */ +class Layout implements Htmlable, Renderable +{ + protected string $variant; + + protected array $attributes = []; + + protected string $path = ''; // Hierarchical path (e.g., "L-" for nested in Left) + + protected array $header = []; + + protected array $left = []; + + protected array $content = []; + + protected array $right = []; + + protected array $footer = []; + + public function __construct(string $variant = 'HCF', string $path = '') + { + $this->variant = strtoupper($variant); + $this->path = $path; + } + + /** + * Create a new layout instance + */ + public static function make(string $variant = 'HCF', string $path = ''): static + { + return new static($variant, $path); + } + + /** + * Get the slot ID for a given slot letter + */ + protected function slotId(string $slot): string + { + return $this->path.$slot; + } + + /** + * Add to the Header slot + */ + public function h(mixed ...$items): static + { + foreach ($items as $item) { + $this->header[] = $item; + } + + return $this; + } + + /** + * Add to the Left slot + */ + public function l(mixed ...$items): static + { + foreach ($items as $item) { + $this->left[] = $item; + } + + return $this; + } + + /** + * Add to the Content slot + */ + public function c(mixed ...$items): static + { + foreach ($items as $item) { + $this->content[] = $item; + } + + return $this; + } + + /** + * Add to the Right slot + */ + public function r(mixed ...$items): static + { + foreach ($items as $item) { + $this->right[] = $item; + } + + return $this; + } + + /** + * Add to the Footer slot + */ + public function f(mixed ...$items): static + { + foreach ($items as $item) { + $this->footer[] = $item; + } + + return $this; + } + + /** + * Alias methods for readability (variadic) + */ + public function addHeader(mixed ...$items): static + { + return $this->h(...$items); + } + + public function addLeft(mixed ...$items): static + { + return $this->l(...$items); + } + + public function addContent(mixed ...$items): static + { + return $this->c(...$items); + } + + public function addRight(mixed ...$items): static + { + return $this->r(...$items); + } + + public function addFooter(mixed ...$items): static + { + return $this->f(...$items); + } + + /** + * Set HTML attributes on the layout container + */ + public function attributes(array $attributes): static + { + $this->attributes = array_merge($this->attributes, $attributes); + + return $this; + } + + /** + * Add a CSS class + */ + public function class(string $class): static + { + $existing = $this->attributes['class'] ?? ''; + $this->attributes['class'] = trim($existing.' '.$class); + + return $this; + } + + /** + * Check if variant includes a slot + */ + protected function has(string $slot): bool + { + return str_contains($this->variant, strtoupper($slot)); + } + + /** + * Render all items in a slot with indexed data attributes + */ + protected function renderSlot(array $items, string $slot): string + { + $html = ''; + foreach ($items as $index => $item) { + $itemId = $this->slotId($slot).'-'.$index; + $resolved = $this->resolveItem($item, $slot); + $html .= '
    '.$resolved.'
    '; + } + + return $html; + } + + /** + * Resolve a single item to string, passing path context to nested layouts + */ + protected function resolveItem(mixed $content, string $slot): string + { + if ($content === null) { + return ''; + } + + // Nested Layout - inject the path context + if ($content instanceof Layout) { + $content->path = $this->slotId($slot).'-'; + + return $content->render(); + } + + if ($content instanceof Htmlable) { + return $content->toHtml(); + } + + if ($content instanceof Renderable) { + return $content->render(); + } + + if ($content instanceof View) { + return $content->render(); + } + + if (is_callable($content)) { + return $this->resolveItem($content(), $slot); + } + + return (string) $content; + } + + /** + * Build attributes string + */ + protected function buildAttributes(): string + { + $attrs = $this->attributes; + $attrs['class'] = trim('hlcrf-layout '.($attrs['class'] ?? '')); + + $parts = []; + foreach ($attrs as $key => $value) { + if ($value === true) { + $parts[] = $key; + } elseif ($value !== false && $value !== null) { + $parts[] = $key.'="'.e($value).'"'; + } + } + + return implode(' ', $parts); + } + + /** + * Render the layout to HTML + */ + public function render(): string + { + $layoutId = $this->path ? rtrim($this->path, '-') : 'root'; + $html = '
    buildAttributes().' data-layout="'.e($layoutId).'">'; + + // Header + if ($this->has('H') && ! empty($this->header)) { + $id = $this->slotId('H'); + $html .= '
    '.$this->renderSlot($this->header, 'H').'
    '; + } + + // Body (L, C, R) + if ($this->has('L') || $this->has('C') || $this->has('R')) { + $html .= '
    '; + + if ($this->has('L') && ! empty($this->left)) { + $id = $this->slotId('L'); + $html .= ''; + } + + if ($this->has('C')) { + $id = $this->slotId('C'); + $html .= '
    '.$this->renderSlot($this->content, 'C').'
    '; + } + + if ($this->has('R') && ! empty($this->right)) { + $id = $this->slotId('R'); + $html .= ''; + } + + $html .= '
    '; + } + + // Footer + if ($this->has('F') && ! empty($this->footer)) { + $id = $this->slotId('F'); + $html .= '
    '.$this->renderSlot($this->footer, 'F').'
    '; + } + + $html .= '
    '; + + return $html; + } + + /** + * Get the HTML string + */ + public function toHtml(): string + { + return $this->render(); + } + + /** + * Cast to string + */ + public function __toString(): string + { + return $this->render(); + } +} diff --git a/app/Core/Front/Components/NavList.php b/app/Core/Front/Components/NavList.php new file mode 100644 index 0000000..39c79c9 --- /dev/null +++ b/app/Core/Front/Components/NavList.php @@ -0,0 +1,130 @@ +heading('Menu') + * ->item('Dashboard', '/hub') + * ->item('Settings', '/hub/settings', active: true) + * ->divider() + * ->item('Logout', '/logout') + */ +class NavList extends Component +{ + protected ?string $heading = null; + + protected array $items = []; + + /** + * Set the navigation list heading. + */ + public function heading(string $heading): static + { + $this->heading = $heading; + + return $this; + } + + /** + * Add a navigation item. + */ + public function item(string $label, string $href = '#', bool $active = false, ?string $icon = null): static + { + $this->items[] = [ + 'type' => 'item', + 'label' => $label, + 'href' => $href, + 'active' => $active, + 'icon' => $icon, + ]; + + return $this; + } + + /** + * Add a divider between items. + */ + public function divider(): static + { + $this->items[] = ['type' => 'divider']; + + return $this; + } + + /** + * Add multiple items at once. + * + * @param array $items + */ + public function items(array $items): static + { + foreach ($items as $item) { + if (is_string($item)) { + $this->item($item); + } else { + $this->item( + label: $item['label'], + href: $item['href'] ?? '#', + active: $item['active'] ?? false, + icon: $item['icon'] ?? null + ); + } + } + + return $this; + } + + /** + * Render the navigation list to HTML. + */ + public function render(): string + { + $attrs = $this->buildAttributes(['navlist']); + + $html = ''; + + if ($this->heading !== null) { + $html .= '

    '.e($this->heading).'

    '; + } + + $html .= ''; + $html .= ''; + + return $html; + } +} diff --git a/app/Core/Front/Components/Text.php b/app/Core/Front/Components/Text.php new file mode 100644 index 0000000..7f02214 --- /dev/null +++ b/app/Core/Front/Components/Text.php @@ -0,0 +1,155 @@ +muted() + * Text::make()->content('Paragraph text')->p() + */ +class Text extends Component +{ + protected string $content = ''; + + protected string $tag = 'span'; + + protected string $variant = 'default'; + + public function __construct(string $content = '') + { + $this->content = $content; + } + + /** + * Create with initial content. + */ + public static function make(string $content = ''): static + { + return new static($content); + } + + /** + * Set the text content. + */ + public function content(string $content): static + { + $this->content = $content; + + return $this; + } + + /** + * Render as a paragraph. + */ + public function p(): static + { + $this->tag = 'p'; + + return $this; + } + + /** + * Render as a span. + */ + public function span(): static + { + $this->tag = 'span'; + + return $this; + } + + /** + * Render as a div. + */ + public function div(): static + { + $this->tag = 'div'; + + return $this; + } + + /** + * Default text styling. + */ + public function default(): static + { + $this->variant = 'default'; + + return $this; + } + + /** + * Muted/subtle text. + */ + public function muted(): static + { + $this->variant = 'muted'; + + return $this; + } + + /** + * Success text (green). + */ + public function success(): static + { + $this->variant = 'success'; + + return $this; + } + + /** + * Warning text (amber). + */ + public function warning(): static + { + $this->variant = 'warning'; + + return $this; + } + + /** + * Error text (red). + */ + public function error(): static + { + $this->variant = 'error'; + + return $this; + } + + /** + * Get variant CSS classes. + */ + protected function variantClasses(): array + { + return match ($this->variant) { + 'muted' => ['text-zinc-500', 'dark:text-zinc-400'], + 'success' => ['text-green-600', 'dark:text-green-400'], + 'warning' => ['text-amber-600', 'dark:text-amber-400'], + 'error' => ['text-red-600', 'dark:text-red-400'], + default => ['text-zinc-900', 'dark:text-zinc-100'], + }; + } + + /** + * Render the text to HTML. + */ + public function render(): string + { + $attrs = $this->buildAttributes($this->variantClasses()); + + return '<'.$this->tag.$attrs.'>'.e($this->content).'tag.'>'; + } +} diff --git a/app/Core/Front/Components/View/Blade/CLAUDE.md b/app/Core/Front/Components/View/Blade/CLAUDE.md new file mode 100644 index 0000000..99afa9a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/CLAUDE.md @@ -0,0 +1,62 @@ +# Components/View/Blade + +Root directory for all core anonymous Blade components. Registered under the `core::` namespace and accessible via `` tag syntax. + +## Top-Level Components (48 files) + +Each `.blade.php` file is the parent component. Sub-components live in matching subdirectories. + +| Component | Description | +|-----------|-------------| +| accordion | Collapsible content sections | +| autocomplete | Typeahead search input | +| avatar | User/entity avatar display | +| badge | Status/count badge | +| button | Action button (primary, secondary, danger, ghost) | +| calendar | Calendar date display | +| callout | Notice/alert box | +| card | Content card container | +| chart | SVG chart container | +| checkbox | Checkbox input | +| command | Command palette (Cmd+K) | +| composer | Content composer/editor wrapper | +| context | Context menu | +| date-picker | Date selection input | +| description | Description list/text | +| dropdown | Dropdown menu trigger | +| editor | Rich text editor | +| error | Inline error message | +| field | Form field wrapper (label + input + error) | +| file-item | File list item display | +| file-upload | File upload input | +| heading | Section heading (h1-h6) | +| icon | FontAwesome icon renderer | +| input | Text input | +| kanban | Kanban board | +| label | Form label | +| layout | HLCRF layout container | +| main | Main content area | +| menu | Dropdown menu panel | +| modal | Modal dialog | +| navbar | Navigation bar | +| navlist | Navigation list (sidebar) | +| navmenu | Navigation menu | +| pillbox | Tag/chip multi-select input | +| popover | Popover tooltip/panel | +| radio | Radio button input | +| select | Dropdown select | +| separator | Visual divider | +| slider | Range slider input | +| subheading | Secondary heading text | +| switch | Toggle switch | +| tab | Tab trigger | +| table | Data table | +| tabs | Tab container with panels | +| text | Body text | +| textarea | Multi-line text input | +| time-picker | Time selection input | +| tooltip | Hover tooltip | + +## Subdirectories + +Each subdirectory contains sub-components (e.g., `table/row.blade.php` = ``). See individual `CLAUDE.md` files in each subdirectory. diff --git a/app/Core/Front/Components/View/Blade/accordion.blade.php b/app/Core/Front/Components/View/Blade/accordion.blade.php new file mode 100644 index 0000000..4228d05 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/accordion.blade.php @@ -0,0 +1,8 @@ +@props([ + 'transition' => null, // Enable transition animations + 'exclusive' => null, // Only one item open at a time +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/accordion/CLAUDE.md b/app/Core/Front/Components/View/Blade/accordion/CLAUDE.md new file mode 100644 index 0000000..c2297e9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/accordion/CLAUDE.md @@ -0,0 +1,20 @@ +# Blade/accordion + +Accordion (collapsible section) components. + +## Files + +- **content.blade.php** -- Collapsible content panel of an accordion item. Hidden/shown based on accordion state. +- **heading.blade.php** -- Clickable header that toggles the accordion item's content visibility. +- **item.blade.php** -- Single accordion item wrapping a heading + content pair. + +## Usage + +```blade + + + Section Title + Hidden content here + + +``` diff --git a/app/Core/Front/Components/View/Blade/accordion/content.blade.php b/app/Core/Front/Components/View/Blade/accordion/content.blade.php new file mode 100644 index 0000000..8c25bdf --- /dev/null +++ b/app/Core/Front/Components/View/Blade/accordion/content.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/accordion/heading.blade.php b/app/Core/Front/Components/View/Blade/accordion/heading.blade.php new file mode 100644 index 0000000..f6ee0ec --- /dev/null +++ b/app/Core/Front/Components/View/Blade/accordion/heading.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/accordion/item.blade.php b/app/Core/Front/Components/View/Blade/accordion/item.blade.php new file mode 100644 index 0000000..2890b6b --- /dev/null +++ b/app/Core/Front/Components/View/Blade/accordion/item.blade.php @@ -0,0 +1,7 @@ +@props([ + 'expanded' => false, // Default expanded state +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/autocomplete.blade.php b/app/Core/Front/Components/View/Blade/autocomplete.blade.php new file mode 100644 index 0000000..474a9d8 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/autocomplete.blade.php @@ -0,0 +1,3 @@ +{{-- Core Autocomplete - Flux Pro component. Props: label, description, placeholder, size, disabled, invalid, clearable --}} +@php(\Core\Pro::requireFluxPro('core:autocomplete')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/autocomplete/CLAUDE.md b/app/Core/Front/Components/View/Blade/autocomplete/CLAUDE.md new file mode 100644 index 0000000..8e397c2 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/autocomplete/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/autocomplete + +Autocomplete sub-components. + +## Files + +- **item.blade.php** -- Individual autocomplete suggestion item. Rendered within an autocomplete dropdown list. diff --git a/app/Core/Front/Components/View/Blade/autocomplete/item.blade.php b/app/Core/Front/Components/View/Blade/autocomplete/item.blade.php new file mode 100644 index 0000000..0509832 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/autocomplete/item.blade.php @@ -0,0 +1,7 @@ +@props([ + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/avatar.blade.php b/app/Core/Front/Components/View/Blade/avatar.blade.php new file mode 100644 index 0000000..8ad5ee7 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/avatar.blade.php @@ -0,0 +1,2 @@ +{{-- Core Avatar - Thin wrapper around flux:avatar. Props: src, initials, alt, size (xs|sm|default|lg|xl) --}} + diff --git a/app/Core/Front/Components/View/Blade/badge.blade.php b/app/Core/Front/Components/View/Blade/badge.blade.php new file mode 100644 index 0000000..272b370 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/badge.blade.php @@ -0,0 +1,18 @@ +@props([ + 'color' => null, // zinc, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose + 'size' => null, // sm, base, lg + 'variant' => null, // solid, outline, pill + 'inset' => null, // top, bottom, left, right + 'icon' => null, // Icon name (Font Awesome) + 'iconTrailing' => null, // Icon name (right side, Font Awesome) +]) + +except(['icon', 'iconTrailing']) }}> + @if($icon) + + @endif + {{ $slot }} + @if($iconTrailing) + + @endif + diff --git a/app/Core/Front/Components/View/Blade/button.blade.php b/app/Core/Front/Components/View/Blade/button.blade.php new file mode 100644 index 0000000..d32655d --- /dev/null +++ b/app/Core/Front/Components/View/Blade/button.blade.php @@ -0,0 +1,34 @@ +{{-- + Core Button - Wrapper around flux:button with Font Awesome icon support + + Props: variant (primary|filled|outline|ghost|danger|subtle), size (xs|sm|base|lg|xl), + icon, iconTrailing, href, type, disabled, loading, square, inset +--}} +@props([ + 'icon' => null, + 'iconTrailing' => null, + 'size' => 'base', + 'square' => null, +]) + +@php + // Determine if this is an icon-only (square) button + $isSquare = $square ?? $slot->isEmpty(); + + // Icon sizes based on button size (matching Flux's sizing) + $iconSize = match($size) { + 'xs' => 'size-3', + 'sm' => $isSquare ? 'size-4' : 'size-3.5', + default => $isSquare ? 'size-5' : 'size-4', + }; +@endphp + +except(['icon', 'iconTrailing', 'icon:trailing'])->merge(['size' => $size, 'square' => $square]) }}> + @if($icon) + + @endif + {{ $slot }} + @if($iconTrailing) + + @endif + diff --git a/app/Core/Front/Components/View/Blade/button/CLAUDE.md b/app/Core/Front/Components/View/Blade/button/CLAUDE.md new file mode 100644 index 0000000..289cf44 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/button/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/button + +Button sub-components. + +## Files + +- **group.blade.php** -- Button group container. Renders multiple buttons inline with shared styling (connected borders, uniform spacing). diff --git a/app/Core/Front/Components/View/Blade/button/group.blade.php b/app/Core/Front/Components/View/Blade/button/group.blade.php new file mode 100644 index 0000000..e827c4c --- /dev/null +++ b/app/Core/Front/Components/View/Blade/button/group.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/calendar.blade.php b/app/Core/Front/Components/View/Blade/calendar.blade.php new file mode 100644 index 0000000..207a8f1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/calendar.blade.php @@ -0,0 +1,3 @@ +{{-- Core Calendar - Flux Pro component. Props: value, mode, min, max, size, months, navigation, static, multiple, locale --}} +@php(\Core\Pro::requireFluxPro('core:calendar')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/callout.blade.php b/app/Core/Front/Components/View/Blade/callout.blade.php new file mode 100644 index 0000000..f96410e --- /dev/null +++ b/app/Core/Front/Components/View/Blade/callout.blade.php @@ -0,0 +1,2 @@ +{{-- Core Callout - Thin wrapper around flux:callout. Props: variant (default|warning|danger|success), icon, inline --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/callout/CLAUDE.md b/app/Core/Front/Components/View/Blade/callout/CLAUDE.md new file mode 100644 index 0000000..7bc636f --- /dev/null +++ b/app/Core/Front/Components/View/Blade/callout/CLAUDE.md @@ -0,0 +1,17 @@ +# Blade/callout + +Callout (notice/alert box) sub-components. + +## Files + +- **heading.blade.php** -- Callout title/heading text +- **text.blade.php** -- Callout body text/description + +## Usage + +```blade + + Note + Important information here. + +``` diff --git a/app/Core/Front/Components/View/Blade/callout/heading.blade.php b/app/Core/Front/Components/View/Blade/callout/heading.blade.php new file mode 100644 index 0000000..b93f187 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/callout/heading.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/callout/text.blade.php b/app/Core/Front/Components/View/Blade/callout/text.blade.php new file mode 100644 index 0000000..10d94d1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/callout/text.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/card.blade.php b/app/Core/Front/Components/View/Blade/card.blade.php new file mode 100644 index 0000000..25540ae --- /dev/null +++ b/app/Core/Front/Components/View/Blade/card.blade.php @@ -0,0 +1,7 @@ +@props([ + 'class' => null, // Additional CSS classes +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/chart.blade.php b/app/Core/Front/Components/View/Blade/chart.blade.php new file mode 100644 index 0000000..d30898b --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart.blade.php @@ -0,0 +1,3 @@ +{{-- Core Chart - Flux Pro component. Props: value, curve (smooth|none) --}} +@php(\Core\Pro::requireFluxPro('core:chart')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/chart/CLAUDE.md b/app/Core/Front/Components/View/Blade/chart/CLAUDE.md new file mode 100644 index 0000000..5c537d5 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/CLAUDE.md @@ -0,0 +1,33 @@ +# Blade/chart + +SVG chart components for data visualisation. + +## Files + +- **area.blade.php** -- Area chart (filled line chart) +- **axis.blade.php** -- Chart axis container +- **cursor.blade.php** -- Interactive cursor/crosshair overlay +- **legend.blade.php** -- Chart legend +- **line.blade.php** -- Line chart series +- **point.blade.php** -- Data point marker +- **summary.blade.php** -- Chart summary/stats display +- **svg.blade.php** -- SVG container wrapper +- **tooltip.blade.php** -- Hover tooltip container +- **viewport.blade.php** -- Viewable chart area with coordinate system + +## Subdirectories + +- **tooltip/** -- `heading.blade.php` (tooltip title), `value.blade.php` (tooltip data value) +- **axis/** -- `grid.blade.php` (grid lines), `line.blade.php` (axis line), `mark.blade.php` (axis label), `tick.blade.php` (tick mark) + +## Usage + +```blade + + + + + + + +``` diff --git a/app/Core/Front/Components/View/Blade/chart/area.blade.php b/app/Core/Front/Components/View/Blade/chart/area.blade.php new file mode 100644 index 0000000..a255420 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/area.blade.php @@ -0,0 +1,5 @@ +@props([ + 'field' => null, +]) + + diff --git a/app/Core/Front/Components/View/Blade/chart/axis.blade.php b/app/Core/Front/Components/View/Blade/chart/axis.blade.php new file mode 100644 index 0000000..5f42af9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/axis.blade.php @@ -0,0 +1,7 @@ +@props([ + 'axis' => null, // x, y +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/chart/axis/CLAUDE.md b/app/Core/Front/Components/View/Blade/chart/axis/CLAUDE.md new file mode 100644 index 0000000..7c30c76 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/axis/CLAUDE.md @@ -0,0 +1,12 @@ +# Blade/chart/axis + +Chart axis sub-components for rendering axis elements. + +## Files + +- **grid.blade.php** -- Background grid lines aligned to axis ticks +- **line.blade.php** -- The axis line itself (horizontal or vertical) +- **mark.blade.php** -- Axis label/mark text (e.g., "Jan", "Feb", "100", "200") +- **tick.blade.php** -- Small tick marks along the axis + +Used within `` to compose axis rendering. diff --git a/app/Core/Front/Components/View/Blade/chart/axis/grid.blade.php b/app/Core/Front/Components/View/Blade/chart/axis/grid.blade.php new file mode 100644 index 0000000..08af16a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/axis/grid.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/chart/axis/line.blade.php b/app/Core/Front/Components/View/Blade/chart/axis/line.blade.php new file mode 100644 index 0000000..c91eeb2 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/axis/line.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/chart/axis/mark.blade.php b/app/Core/Front/Components/View/Blade/chart/axis/mark.blade.php new file mode 100644 index 0000000..822cd7a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/axis/mark.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/chart/axis/tick.blade.php b/app/Core/Front/Components/View/Blade/chart/axis/tick.blade.php new file mode 100644 index 0000000..c9c66ca --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/axis/tick.blade.php @@ -0,0 +1,7 @@ +@props([ + 'format' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/chart/cursor.blade.php b/app/Core/Front/Components/View/Blade/chart/cursor.blade.php new file mode 100644 index 0000000..3d26b85 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/cursor.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/chart/legend.blade.php b/app/Core/Front/Components/View/Blade/chart/legend.blade.php new file mode 100644 index 0000000..3e61552 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/legend.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/chart/line.blade.php b/app/Core/Front/Components/View/Blade/chart/line.blade.php new file mode 100644 index 0000000..eef4db5 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/line.blade.php @@ -0,0 +1,5 @@ +@props([ + 'field' => null, +]) + + diff --git a/app/Core/Front/Components/View/Blade/chart/point.blade.php b/app/Core/Front/Components/View/Blade/chart/point.blade.php new file mode 100644 index 0000000..9981dc2 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/point.blade.php @@ -0,0 +1,5 @@ +@props([ + 'field' => null, +]) + + diff --git a/app/Core/Front/Components/View/Blade/chart/summary.blade.php b/app/Core/Front/Components/View/Blade/chart/summary.blade.php new file mode 100644 index 0000000..20842fa --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/summary.blade.php @@ -0,0 +1,7 @@ +@props([ + 'field' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/chart/svg.blade.php b/app/Core/Front/Components/View/Blade/chart/svg.blade.php new file mode 100644 index 0000000..bea4fcd --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/svg.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/chart/tooltip.blade.php b/app/Core/Front/Components/View/Blade/chart/tooltip.blade.php new file mode 100644 index 0000000..08b7504 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/tooltip.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/chart/tooltip/CLAUDE.md b/app/Core/Front/Components/View/Blade/chart/tooltip/CLAUDE.md new file mode 100644 index 0000000..7e9dc49 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/tooltip/CLAUDE.md @@ -0,0 +1,10 @@ +# Blade/chart/tooltip + +Chart tooltip sub-components. + +## Files + +- **heading.blade.php** -- Tooltip title/heading text (e.g., date or category label) +- **value.blade.php** -- Tooltip data value display (e.g., "1,234 visitors") + +Used within `` to structure hover information. diff --git a/app/Core/Front/Components/View/Blade/chart/tooltip/heading.blade.php b/app/Core/Front/Components/View/Blade/chart/tooltip/heading.blade.php new file mode 100644 index 0000000..cfd7c5f --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/tooltip/heading.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/chart/tooltip/value.blade.php b/app/Core/Front/Components/View/Blade/chart/tooltip/value.blade.php new file mode 100644 index 0000000..fbf709a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/tooltip/value.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/chart/viewport.blade.php b/app/Core/Front/Components/View/Blade/chart/viewport.blade.php new file mode 100644 index 0000000..8857b15 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/chart/viewport.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/checkbox.blade.php b/app/Core/Front/Components/View/Blade/checkbox.blade.php new file mode 100644 index 0000000..c061094 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/checkbox.blade.php @@ -0,0 +1,2 @@ +{{-- Core Checkbox - Thin wrapper around flux:checkbox. Props: label, description, value, disabled, checked, indeterminate --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/checkbox/CLAUDE.md b/app/Core/Front/Components/View/Blade/checkbox/CLAUDE.md new file mode 100644 index 0000000..b19ffe7 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/checkbox/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/checkbox + +Checkbox sub-components. + +## Files + +- **group.blade.php** -- Checkbox group container. Wraps multiple checkbox inputs with shared layout and optional label. diff --git a/app/Core/Front/Components/View/Blade/checkbox/group.blade.php b/app/Core/Front/Components/View/Blade/checkbox/group.blade.php new file mode 100644 index 0000000..ab2e543 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/checkbox/group.blade.php @@ -0,0 +1,11 @@ +@props([ + 'label' => null, // Group label + 'description' => null, // Help text + 'variant' => null, // cards, segmented + 'wire:model' => null, + 'wire:model.live' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/command.blade.php b/app/Core/Front/Components/View/Blade/command.blade.php new file mode 100644 index 0000000..6d4eabc --- /dev/null +++ b/app/Core/Front/Components/View/Blade/command.blade.php @@ -0,0 +1,3 @@ +{{-- Core Command - Flux Pro component --}} +@php(\Core\Pro::requireFluxPro('core:command')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/command/CLAUDE.md b/app/Core/Front/Components/View/Blade/command/CLAUDE.md new file mode 100644 index 0000000..7bdc365 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/command/CLAUDE.md @@ -0,0 +1,23 @@ +# Blade/command + +Command palette (Cmd+K) sub-components. + +## Files + +- **empty.blade.php** -- Empty state shown when no results match the search query +- **input.blade.php** -- Search input field within the command palette +- **item.blade.php** -- Individual command/action item in the results list +- **items.blade.php** -- Results list container wrapping command items + +## Usage + +```blade + + + + Go to Dashboard + Create New... + + No results found + +``` diff --git a/app/Core/Front/Components/View/Blade/command/empty.blade.php b/app/Core/Front/Components/View/Blade/command/empty.blade.php new file mode 100644 index 0000000..16c457e --- /dev/null +++ b/app/Core/Front/Components/View/Blade/command/empty.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/command/input.blade.php b/app/Core/Front/Components/View/Blade/command/input.blade.php new file mode 100644 index 0000000..4102b9b --- /dev/null +++ b/app/Core/Front/Components/View/Blade/command/input.blade.php @@ -0,0 +1,2 @@ +{{-- Core Command Input - Thin wrapper around flux:command.input. Props: clearable, closable, icon, placeholder --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/command/item.blade.php b/app/Core/Front/Components/View/Blade/command/item.blade.php new file mode 100644 index 0000000..63799f8 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/command/item.blade.php @@ -0,0 +1,8 @@ +@props([ + 'icon' => null, + 'kbd' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/command/items.blade.php b/app/Core/Front/Components/View/Blade/command/items.blade.php new file mode 100644 index 0000000..3f8112d --- /dev/null +++ b/app/Core/Front/Components/View/Blade/command/items.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/components/satellite/CLAUDE.md b/app/Core/Front/Components/View/Blade/components/satellite/CLAUDE.md new file mode 100644 index 0000000..07fa734 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/components/satellite/CLAUDE.md @@ -0,0 +1,10 @@ +# Blade/components/satellite + +Satellite site layout components for workspace-branded pages (e.g., bio links, landing pages). + +## Files + +- **footer-custom.blade.php** -- Custom footer for satellite sites with workspace branding, social links, custom links, contact info, and copyright. Supports configurable footer settings (show_default_links, position, custom_content). +- **layout.blade.php** -- Full HTML shell for satellite/workspace sites. Includes dark mode, meta tags, workspace branding, configurable footer. Used for public workspace pages served on custom domains or subdomains. + +These are registered under the `front::` namespace via ``. diff --git a/app/Core/Front/Components/View/Blade/components/satellite/footer-custom.blade.php b/app/Core/Front/Components/View/Blade/components/satellite/footer-custom.blade.php new file mode 100644 index 0000000..f431c6c --- /dev/null +++ b/app/Core/Front/Components/View/Blade/components/satellite/footer-custom.blade.php @@ -0,0 +1,114 @@ +{{-- + Custom Footer Content Partial + + Variables: + $customContent - Raw HTML content + $customLinks - Array of ['label' => '', 'url' => '', 'icon' => ''] links + $socialLinks - Array of ['platform' => '', 'url' => '', 'icon' => ''] social links + $contactEmail - Email address + $contactPhone - Phone number + $showCopyright - Whether to show copyright (for replace mode) + $copyrightText - Custom copyright text + $workspaceName - Workspace name for default copyright + $appName - App name for default copyright + $appIcon - App icon path +--}} +@php + $showCopyright = $showCopyright ?? false; + $copyrightText = $copyrightText ?? null; + $workspaceName = $workspaceName ?? null; + $appName = $appName ?? config('core.app.name', 'Core PHP'); + $appIcon = $appIcon ?? config('core.app.icon', '/images/icon.svg'); +@endphp + +
    + {{-- Raw HTML custom content --}} + @if(!empty($customContent)) + + @endif + + {{-- Structured content grid --}} + @if(!empty($customLinks) || !empty($socialLinks) || $contactEmail || $contactPhone) +
    + + {{-- Contact information --}} + @if($contactEmail || $contactPhone) +
    + @if($contactEmail) + + + {{ $contactEmail }} + + @endif + @if($contactPhone) + + + {{ $contactPhone }} + + @endif +
    + @endif + + {{-- Custom links --}} + @if(!empty($customLinks)) +
    + @foreach($customLinks as $link) + + @if(!empty($link['icon'])) + + @endif + {{ $link['label'] }} + + @endforeach +
    + @endif + + {{-- Social links --}} + @if(!empty($socialLinks)) +
    + @foreach($socialLinks as $social) + @php + // Get icon from the social link or generate based on platform + $socialIcon = $social['icon'] ?? match(strtolower($social['platform'] ?? '')) { + 'twitter', 'x' => 'fa-brands fa-x-twitter', + 'facebook' => 'fa-brands fa-facebook', + 'instagram' => 'fa-brands fa-instagram', + 'linkedin' => 'fa-brands fa-linkedin', + 'youtube' => 'fa-brands fa-youtube', + 'tiktok' => 'fa-brands fa-tiktok', + 'github' => 'fa-brands fa-github', + 'discord' => 'fa-brands fa-discord', + 'mastodon' => 'fa-brands fa-mastodon', + 'bluesky' => 'fa-brands fa-bluesky', + 'threads' => 'fa-brands fa-threads', + 'pinterest' => 'fa-brands fa-pinterest', + default => 'fa-solid fa-link', + }; + @endphp + + + + @endforeach +
    + @endif +
    + @endif + + {{-- Copyright for replace mode --}} + @if($showCopyright) +
    + {{ $appName }} + + {!! $copyrightText ?? '© '.date('Y').' '.e($workspaceName ?? $appName) !!} + +
    + @endif +
    diff --git a/app/Core/Front/Components/View/Blade/components/satellite/layout.blade.php b/app/Core/Front/Components/View/Blade/components/satellite/layout.blade.php new file mode 100644 index 0000000..de39db1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/components/satellite/layout.blade.php @@ -0,0 +1,223 @@ +@php + $appName = config('core.app.name', 'Core PHP'); + $appIcon = config('core.app.icon', '/images/icon.svg'); + $appUrl = config('app.url', 'https://core.test'); + $privacyUrl = config('core.urls.privacy', '/privacy'); + $termsUrl = config('core.urls.terms', '/terms'); + + // Footer settings - can be passed as array or FooterSettings object + $footer = $footer ?? null; + $footerShowDefault = $footer['show_default_links'] ?? $footer?->showDefaultLinks ?? true; + $footerPosition = $footer['position'] ?? $footer?->position ?? 'above_default'; + $footerCustomContent = $footer['custom_content'] ?? $footer?->customContent ?? null; + $footerCustomLinks = $footer['custom_links'] ?? $footer?->customLinks ?? []; + $footerSocialLinks = $footer['social_links'] ?? $footer?->socialLinks ?? []; + $footerCopyright = $footer['copyright_text'] ?? $footer?->copyrightText ?? null; + $footerContactEmail = $footer['contact_email'] ?? $footer?->contactEmail ?? null; + $footerContactPhone = $footer['contact_phone'] ?? $footer?->contactPhone ?? null; + $footerHasCustom = $footerCustomContent || !empty($footerCustomLinks) || !empty($footerSocialLinks) || $footerContactEmail || $footerContactPhone; +@endphp + + + + + + + + {{ $meta['title'] ?? $workspace?->name ?? $appName }} + + + @if(isset($meta['image'])) + + + @endif + + + + + + + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + {{-- Prevent flash of wrong theme --}} + + + + + + + + + + +
    + + +
    +
    + + +
    + + + + {{ $slot }} +
    + + +
    + +
    + + {{-- Custom footer content (above default) --}} + @if($footerHasCustom && $footerPosition === 'above_default') + @include('front::components.satellite.footer-custom', [ + 'customContent' => $footerCustomContent, + 'customLinks' => $footerCustomLinks, + 'socialLinks' => $footerSocialLinks, + 'contactEmail' => $footerContactEmail, + 'contactPhone' => $footerContactPhone, + ]) + @endif + + {{-- Custom footer content (replace default) --}} + @if($footerHasCustom && $footerPosition === 'replace_default') + @include('front::components.satellite.footer-custom', [ + 'customContent' => $footerCustomContent, + 'customLinks' => $footerCustomLinks, + 'socialLinks' => $footerSocialLinks, + 'contactEmail' => $footerContactEmail, + 'contactPhone' => $footerContactPhone, + 'showCopyright' => true, + 'copyrightText' => $footerCopyright, + 'workspaceName' => $workspace?->name, + 'appName' => $appName, + 'appIcon' => $appIcon, + ]) + @endif + + {{-- Default footer --}} + @if($footerShowDefault && $footerPosition !== 'replace_default') +
    +
    +
    + {{ $appName }} + + {!! $footerCopyright ?? '© '.date('Y').' '.e($workspace?->name ?? $appName) !!} + +
    + +
    +
    + @endif + + {{-- Custom footer content (below default) --}} + @if($footerHasCustom && $footerPosition === 'below_default') + @include('front::components.satellite.footer-custom', [ + 'customContent' => $footerCustomContent, + 'customLinks' => $footerCustomLinks, + 'socialLinks' => $footerSocialLinks, + 'contactEmail' => $footerContactEmail, + 'contactPhone' => $footerContactPhone, + ]) + @endif +
    + + + diff --git a/app/Core/Front/Components/View/Blade/composer.blade.php b/app/Core/Front/Components/View/Blade/composer.blade.php new file mode 100644 index 0000000..e58444d --- /dev/null +++ b/app/Core/Front/Components/View/Blade/composer.blade.php @@ -0,0 +1,3 @@ +{{-- Core Composer - Flux Pro component. Props: name, placeholder, label, rows, submit, disabled, invalid --}} +@php(\Core\Pro::requireFluxPro('core:composer')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/context.blade.php b/app/Core/Front/Components/View/Blade/context.blade.php new file mode 100644 index 0000000..c98a3e1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/context.blade.php @@ -0,0 +1,3 @@ +{{-- Core Context Menu - Flux Pro component. Props: position, gap, offset, target, disabled --}} +@php(\Core\Pro::requireFluxPro('core:context')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/date-picker.blade.php b/app/Core/Front/Components/View/Blade/date-picker.blade.php new file mode 100644 index 0000000..0030fa2 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/date-picker.blade.php @@ -0,0 +1,3 @@ +{{-- Core Date Picker - Flux Pro component. Props: value, mode, min, max, months, label, placeholder, size, clearable, disabled, locale --}} +@php(\Core\Pro::requireFluxPro('core:date-picker')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/date-picker/CLAUDE.md b/app/Core/Front/Components/View/Blade/date-picker/CLAUDE.md new file mode 100644 index 0000000..d96083c --- /dev/null +++ b/app/Core/Front/Components/View/Blade/date-picker/CLAUDE.md @@ -0,0 +1,8 @@ +# Blade/date-picker + +Date picker sub-components. + +## Files + +- **button.blade.php** -- Trigger button that opens the date picker calendar popover. +- **input.blade.php** -- Text input field displaying the selected date value, with date picker integration. diff --git a/app/Core/Front/Components/View/Blade/date-picker/button.blade.php b/app/Core/Front/Components/View/Blade/date-picker/button.blade.php new file mode 100644 index 0000000..b628cf1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/date-picker/button.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/date-picker/input.blade.php b/app/Core/Front/Components/View/Blade/date-picker/input.blade.php new file mode 100644 index 0000000..42feb24 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/date-picker/input.blade.php @@ -0,0 +1,7 @@ +@props([ + 'label' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/description.blade.php b/app/Core/Front/Components/View/Blade/description.blade.php new file mode 100644 index 0000000..a03a5d2 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/description.blade.php @@ -0,0 +1,7 @@ +@props([ + 'trailing' => false, // Show below input instead of above +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/dropdown.blade.php b/app/Core/Front/Components/View/Blade/dropdown.blade.php new file mode 100644 index 0000000..e8cc257 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/dropdown.blade.php @@ -0,0 +1,10 @@ +@props([ + 'position' => null, // top, right, bottom, left + 'align' => null, // start, center, end + 'offset' => null, // pixels + 'gap' => null, // pixels +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/editor.blade.php b/app/Core/Front/Components/View/Blade/editor.blade.php new file mode 100644 index 0000000..310f778 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/editor.blade.php @@ -0,0 +1,3 @@ +{{-- Core Editor - Flux Pro component. Props: value, label, description, placeholder, toolbar, disabled, invalid --}} +@php(\Core\Pro::requireFluxPro('core:editor')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/editor/CLAUDE.md b/app/Core/Front/Components/View/Blade/editor/CLAUDE.md new file mode 100644 index 0000000..7d313b1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/editor/CLAUDE.md @@ -0,0 +1,22 @@ +# Blade/editor + +Rich text editor sub-components. + +## Files + +- **button.blade.php** -- Toolbar action button (bold, italic, link, etc.) +- **content.blade.php** -- Editable content area (the actual rich text editing surface) +- **toolbar.blade.php** -- Editor toolbar container wrapping action buttons + +## Usage + +```blade + + + + + + + + +``` diff --git a/app/Core/Front/Components/View/Blade/editor/button.blade.php b/app/Core/Front/Components/View/Blade/editor/button.blade.php new file mode 100644 index 0000000..19362c1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/editor/button.blade.php @@ -0,0 +1,10 @@ +@props([ + 'icon' => null, + 'iconVariant' => null, // icon-variant + 'tooltip' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/editor/content.blade.php b/app/Core/Front/Components/View/Blade/editor/content.blade.php new file mode 100644 index 0000000..9cefcb5 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/editor/content.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/editor/toolbar.blade.php b/app/Core/Front/Components/View/Blade/editor/toolbar.blade.php new file mode 100644 index 0000000..72863ba --- /dev/null +++ b/app/Core/Front/Components/View/Blade/editor/toolbar.blade.php @@ -0,0 +1,7 @@ +@props([ + 'items' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/error.blade.php b/app/Core/Front/Components/View/Blade/error.blade.php new file mode 100644 index 0000000..a6414a5 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/error.blade.php @@ -0,0 +1,7 @@ +@props([ + 'name' => null, // Form field name for validation error +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/errors/404.blade.php b/app/Core/Front/Components/View/Blade/errors/404.blade.php new file mode 100644 index 0000000..2899fda --- /dev/null +++ b/app/Core/Front/Components/View/Blade/errors/404.blade.php @@ -0,0 +1,99 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appIcon = config('core.app.icon', '/images/icon.svg'); +@endphp + + + + + + {{ __('core::core.errors.404.title') }} - {{ $appName }} + + + + +
    + 404 illustration +
    404
    +

    {{ __('core::core.errors.404.heading') }}

    +

    {{ __('core::core.errors.404.message') }}

    + +
    + + diff --git a/app/Core/Front/Components/View/Blade/errors/500.blade.php b/app/Core/Front/Components/View/Blade/errors/500.blade.php new file mode 100644 index 0000000..9f234e9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/errors/500.blade.php @@ -0,0 +1,101 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appIcon = config('core.app.icon', '/images/icon.svg'); +@endphp + + + + + + {{ __('core::core.errors.500.title') }} - {{ $appName }} + + + + +
    + 500 illustration +
    500
    +

    {{ __('core::core.errors.500.heading') }}

    +

    {{ __('core::core.errors.500.message') }}

    +
    + + {{ __('core::core.errors.500.back_home') }} +
    +
    + + diff --git a/app/Core/Front/Components/View/Blade/errors/503.blade.php b/app/Core/Front/Components/View/Blade/errors/503.blade.php new file mode 100644 index 0000000..0223bf0 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/errors/503.blade.php @@ -0,0 +1,101 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appIcon = config('core.app.icon', '/images/icon.svg'); + $statusUrl = config('core.urls.status'); +@endphp + + + + + + {{ __('core::core.errors.503.title') }} - {{ $appName }} + + + + +
    + Maintenance illustration +
    503
    +

    {{ __('core::core.errors.503.heading') }}

    +

    {{ __('core::core.errors.503.message') }}

    + @if($statusUrl) + + + + + + {{ __('core::core.errors.503.check_status') }} + + @endif +

    {{ __('core::core.errors.503.auto_refresh') }}

    +
    + + + diff --git a/app/Core/Front/Components/View/Blade/errors/CLAUDE.md b/app/Core/Front/Components/View/Blade/errors/CLAUDE.md new file mode 100644 index 0000000..112f184 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/errors/CLAUDE.md @@ -0,0 +1,11 @@ +# Blade/errors + +HTTP error page templates. Registered under the `errors::` namespace. + +## Files + +- **404.blade.php** -- Not Found error page +- **500.blade.php** -- Internal Server Error page +- **503.blade.php** -- Service Unavailable / Maintenance Mode page + +These override Laravel's default error views when the `errors::` namespace is registered. diff --git a/app/Core/Front/Components/View/Blade/examples/CLAUDE.md b/app/Core/Front/Components/View/Blade/examples/CLAUDE.md new file mode 100644 index 0000000..b158508 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/examples/CLAUDE.md @@ -0,0 +1,13 @@ +# Blade/examples + +Example page templates demonstrating the component system. + +## Files + +- **blog-post.blade.php** -- Blog post page layout example +- **checkout.blade.php** -- Checkout/payment page layout example +- **guide.blade.php** -- Documentation/guide page layout example +- **help-centre.blade.php** -- Help centre page with sidebar navigation example +- **hlcrf-test.blade.php** -- Test page for the HLCRF layout compositor (Header-Left-Content-Right-Footer) + +These are reference implementations showing how to compose pages using the core component library and layout system. diff --git a/app/Core/Front/Components/View/Blade/examples/blog-post.blade.php b/app/Core/Front/Components/View/Blade/examples/blog-post.blade.php new file mode 100644 index 0000000..86a8044 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/examples/blog-post.blade.php @@ -0,0 +1,92 @@ +{{-- +Example: Blog Post using Content Layout +Route: /examples/blog-post +--}} + + + +

    + Building an audience takes time, but with the right tools and strategies, you can accelerate your growth + and create a community that genuinely engages with your content. +

    + +

    Start with your niche

    + +

    + The most successful creators focus on a specific niche before expanding. This doesn't mean you're limited + forever, but starting focused helps you build authority and attract a dedicated audience who knows exactly + what to expect from you. +

    + +

    + Consider what makes you unique. Your perspective, experience, or approach to topics can differentiate you + from others in your space. +

    + +
    +

    "The riches are in the niches. Find your specific audience first, then expand from a position of strength."

    +
    + +

    Consistency beats perfection

    + +

    + One of the biggest mistakes new creators make is waiting for perfect conditions. The algorithm rewards + consistency, and your audience builds habits around your posting schedule. +

    + +
      +
    • Post at regular intervals your audience can rely on
    • +
    • Batch create content to maintain consistency during busy periods
    • +
    • Use scheduling tools like Host Social to automate posting
    • +
    • Track what works and iterate on successful formats
    • +
    + +

    Engage authentically

    + +

    + Social media is a two-way conversation. The creators who build the strongest communities are those who + genuinely engage with their audience, respond to comments, and create content based on feedback. +

    + +

    Respond to comments quickly

    + +

    + The first hour after posting is crucial. Being present to respond to early comments signals to the algorithm + that your content is generating engagement, which can boost its reach. +

    + +

    Ask questions

    + +

    + End your posts with questions that invite discussion. This transforms passive viewers into active participants + and helps you understand what your audience wants. +

    + +
    + +

    Tools that help

    + +

    + Host Social makes managing multiple platforms simple. Schedule your content once and let it publish across + all your channels automatically. This frees up time for what matters most: creating great content and + engaging with your community. +

    + + +
    +

    Ready to grow your audience?

    +

    Start scheduling your content with Host Social today.

    + + Get Started Free + +
    +
    + +
    diff --git a/app/Core/Front/Components/View/Blade/examples/checkout.blade.php b/app/Core/Front/Components/View/Blade/examples/checkout.blade.php new file mode 100644 index 0000000..530a147 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/examples/checkout.blade.php @@ -0,0 +1,115 @@ +{{-- +Example: Checkout Form using Focused Layout +Route: /examples/checkout +--}} + + + +
    + @csrf + + +
    +
    +
    Creator Pro
    +
    Monthly billing
    +
    +
    +
    £29
    +
    /month
    +
    +
    + + +
    +

    Billing details

    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + + +
    +
    + + +
    +

    Payment method

    + +
    + +
    + +
    + + +
    +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + + +
    + + + + + +
    + + Secured by Stripe. Your payment info is encrypted. +
    +
    + + +

    Have a promo code? Apply it here

    +
    + +
    diff --git a/app/Core/Front/Components/View/Blade/examples/guide.blade.php b/app/Core/Front/Components/View/Blade/examples/guide.blade.php new file mode 100644 index 0000000..1e2f518 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/examples/guide.blade.php @@ -0,0 +1,159 @@ +{{-- +Example: Guide with TOC using Sidebar Right Layout +Route: /examples/guide +--}} + + + + + Introduction + Connecting accounts + Creating posts + Scheduling + Analytics + Best practices + Troubleshooting + + +

    Introduction

    + +

    + Host Social is your all-in-one social media management platform. Schedule posts, track performance, + and manage all your social accounts from a single dashboard. +

    + +

    + This guide will walk you through everything you need to know to get started and make the most of + Host Social's features. +

    + +

    Connecting your social accounts

    + +

    + Before you can start scheduling posts, you'll need to connect your social media accounts. + Host Social supports the following platforms: +

    + +
      +
    • Instagram - Business and Creator accounts
    • +
    • Twitter/X - Personal and Business accounts
    • +
    • Facebook - Pages and Groups
    • +
    • LinkedIn - Personal and Company pages
    • +
    • TikTok - Creator and Business accounts
    • +
    • YouTube - Channels with upload permission
    • +
    + +

    To connect an account:

    + +
      +
    1. Navigate to Settings → Connected Accounts
    2. +
    3. Click the platform you want to connect
    4. +
    5. Authorise Host Social to access your account
    6. +
    7. Select which profile or page to use
    8. +
    + +
    +

    Tip: Connect all your accounts at once to enable cross-posting from day one.

    +
    + +

    Creating posts

    + +

    + Host Social makes it easy to create content that works across multiple platforms. The composer + automatically adapts your content to each platform's requirements. +

    + +

    Using the post composer

    + +

    + Click Create Post to open the composer. You can write your content once and + customise it for each platform if needed. +

    + +
    Example post structure:
    +- Hook (first line that grabs attention)
    +- Value (the main content)
    +- Call to action (what you want readers to do)
    + +

    Adding media

    + +

    + Drag and drop images or videos into the composer, or click the media button to upload. + Host Social will automatically resize and optimise your media for each platform. +

    + +

    Scheduling your posts

    + +

    + Once you've created your post, you can either publish immediately or schedule it for later. + Use the calendar view to plan your content strategy across the week or month. +

    + +

    Best times to post

    + +

    + Host Social analyses your audience engagement and suggests optimal posting times. + Look for the green indicators on the calendar for + high-engagement windows. +

    + +

    Understanding your analytics

    + +

    + Track your performance across all platforms from a single dashboard. Key metrics include: +

    + +
      +
    • Reach - How many people saw your content
    • +
    • Engagement - Likes, comments, shares, and saves
    • +
    • Click-through rate - For posts with links
    • +
    • Follower growth - Net new followers over time
    • +
    + +

    Best practices

    + +

    + To get the most out of Host Social, follow these recommendations: +

    + +
      +
    1. Batch your content - Create a week's worth of posts in one session
    2. +
    3. Use the preview - Always check how your post looks on each platform
    4. +
    5. Mix content types - Alternate between images, videos, and text posts
    6. +
    7. Engage after posting - Set reminders to respond to comments
    8. +
    9. Review analytics weekly - Adjust your strategy based on performance
    10. +
    + +

    Troubleshooting

    + +

    Post failed to publish

    + +

    + If a scheduled post fails, check the following: +

    + +
      +
    • Your account connection is still active (re-authorise if needed)
    • +
    • The content meets platform guidelines
    • +
    • Media files are under the size limit
    • +
    + +

    Account disconnected

    + +

    + Social platforms occasionally require re-authorisation. If your account shows as disconnected, + simply click Reconnect and authorise again. +

    + +
    + +

    + Need more help? Visit the Help Centre or + contact support. +

    + +
    diff --git a/app/Core/Front/Components/View/Blade/examples/help-centre.blade.php b/app/Core/Front/Components/View/Blade/examples/help-centre.blade.php new file mode 100644 index 0000000..09f6ae7 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/examples/help-centre.blade.php @@ -0,0 +1,117 @@ +{{-- +Example: Help Centre using Sidebar Left Layout +Route: /examples/help-centre +--}} + + + + + + General + + + Billing + + + Account + + + Host Social + + + Host Link + + + API + + + + +
    +
    +

    Frequently Asked Questions

    + +
    + +
    + +
    +

    Getting started is simple. Create an account, verify your email, and you'll have immediate access to your Host Hub dashboard. From there, you can set up your bio pages, connect your social accounts, and start scheduling content.

    +
    +
    + + +
    + +
    +

    Host UK accepts all major credit and debit cards (Visa, Mastercard, American Express) as well as PayPal. All payments are processed securely through Stripe.

    +
    +
    + + +
    + +
    +

    Yes, you can cancel your subscription at any time from your account settings. Your access will continue until the end of your current billing period, and you won't be charged again.

    +
    +
    + + +
    + +
    +

    Host UK offers a 14-day money-back guarantee for new subscriptions. If you're not satisfied within the first 14 days, contact the support team for a full refund.

    +
    +
    + + +
    + +
    +

    Navigate to Host Social in your dashboard and click "Connect Account". You'll be guided through the OAuth process for each platform. Host Social supports Instagram, Twitter/X, Facebook, LinkedIn, TikTok, and YouTube.

    +
    +
    +
    +
    + + +
    +
    + +
    +

    Still need help?

    +

    The support team is available Monday to Friday, 9am to 6pm GMT.

    + + Contact Support + +
    +
    + +
    diff --git a/app/Core/Front/Components/View/Blade/examples/hlcrf-test.blade.php b/app/Core/Front/Components/View/Blade/examples/hlcrf-test.blade.php new file mode 100644 index 0000000..0bca6ee --- /dev/null +++ b/app/Core/Front/Components/View/Blade/examples/hlcrf-test.blade.php @@ -0,0 +1,143 @@ +{{-- HLCRF Layout Test Page --}} +{{-- Tests: HCF, HLCF, HLCRF, LCR, CF variants --}} + +
    + + {{-- Test 0: Two layouts side by side - slot isolation test --}} +
    +

    Test 0: Slot Isolation (Two Layouts Side by Side)

    +
    + + +
    Layout A Header
    +
    +
    Layout A Content
    + +
    Layout A Footer
    +
    +
    + + + +
    Layout B Header
    +
    +
    Layout B Content
    + +
    Layout B Footer
    +
    +
    +
    +
    + + {{-- Test 1: HCF (Header, Content, Footer) - most common --}} +
    +

    Test 1: HCF (Default)

    + + +
    Header
    +
    + +
    Content (default slot)
    + + +
    Footer
    +
    +
    +
    + + {{-- Test 2: HLCF (Header, Left, Content, Footer) - admin dashboards --}} +
    +

    Test 2: HLCF (Admin Dashboard)

    + + +
    Header
    +
    + + +
    Left Sidebar
    +
    + +
    Main Content
    + + +
    Footer
    +
    +
    +
    + + {{-- Test 3: HLCRF (Full layout) --}} +
    +

    Test 3: HLCRF (Full)

    + + +
    Header
    +
    + + +
    Left
    +
    + +
    Content
    + + +
    Right
    +
    + + +
    Footer
    +
    +
    +
    + + {{-- Test 4: LCR (No header/footer - app body) --}} +
    +

    Test 4: LCR (App Body)

    + + +
    Nav
    +
    + +
    Content
    + + +
    Aside
    +
    +
    +
    + + {{-- Test 5: C (Content only - minimal) --}} +
    +

    Test 5: C (Content Only)

    + +
    Just content, nothing else
    +
    +
    + + {{-- Test 6: Nested HLCRF --}} +
    +

    Test 6: Nested (LCR inside HCF)

    + + +
    App Header
    +
    + + {{-- Nested layout in content --}} + + +
    Sidebar
    +
    + +
    Nested Content
    + + +
    Panel
    +
    +
    + + +
    App Footer
    +
    +
    +
    + +
    diff --git a/app/Core/Front/Components/View/Blade/field.blade.php b/app/Core/Front/Components/View/Blade/field.blade.php new file mode 100644 index 0000000..b48c606 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/field.blade.php @@ -0,0 +1,7 @@ +@props([ + 'variant' => null, // inline, stacked +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/file-item.blade.php b/app/Core/Front/Components/View/Blade/file-item.blade.php new file mode 100644 index 0000000..b69b531 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/file-item.blade.php @@ -0,0 +1,12 @@ +@props([ + 'heading' => null, + 'text' => null, + 'image' => null, + 'size' => null, + 'icon' => null, + 'invalid' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/file-item/CLAUDE.md b/app/Core/Front/Components/View/Blade/file-item/CLAUDE.md new file mode 100644 index 0000000..f6fd790 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/file-item/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/file-item + +File item sub-components for file list displays. + +## Files + +- **remove.blade.php** -- Remove/delete button for a file item. Typically renders an X icon button to remove a file from an upload list or file manager. diff --git a/app/Core/Front/Components/View/Blade/file-item/remove.blade.php b/app/Core/Front/Components/View/Blade/file-item/remove.blade.php new file mode 100644 index 0000000..cc53cd6 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/file-item/remove.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/file-upload.blade.php b/app/Core/Front/Components/View/Blade/file-upload.blade.php new file mode 100644 index 0000000..f94225d --- /dev/null +++ b/app/Core/Front/Components/View/Blade/file-upload.blade.php @@ -0,0 +1,3 @@ +{{-- Core File Upload - Flux Pro component. Props: name, multiple, label, description, error, disabled --}} +@php(\Core\Pro::requireFluxPro('core:file-upload')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/file-upload/CLAUDE.md b/app/Core/Front/Components/View/Blade/file-upload/CLAUDE.md new file mode 100644 index 0000000..1f9cf81 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/file-upload/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/file-upload + +File upload sub-components. + +## Files + +- **dropzone.blade.php** -- Drag-and-drop file upload zone. Provides a styled drop target area with upload icon, instructions, and file type/size hints. Integrates with Livewire file upload. diff --git a/app/Core/Front/Components/View/Blade/file-upload/dropzone.blade.php b/app/Core/Front/Components/View/Blade/file-upload/dropzone.blade.php new file mode 100644 index 0000000..c83fdf0 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/file-upload/dropzone.blade.php @@ -0,0 +1,11 @@ +@props([ + 'heading' => null, + 'text' => null, + 'icon' => null, + 'inline' => false, + 'withProgress' => false, // with-progress +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/forms/CLAUDE.md b/app/Core/Front/Components/View/Blade/forms/CLAUDE.md new file mode 100644 index 0000000..9db8dbc --- /dev/null +++ b/app/Core/Front/Components/View/Blade/forms/CLAUDE.md @@ -0,0 +1,14 @@ +# Blade/forms + +Form element components. Provide styled, accessible form controls. + +## Files + +- **button.blade.php** -- Form submit/action button +- **checkbox.blade.php** -- Checkbox input with label +- **input.blade.php** -- Text input field +- **select.blade.php** -- Dropdown select field +- **textarea.blade.php** -- Multi-line text input +- **toggle.blade.php** -- Toggle switch (boolean input) + +All components follow Flux Pro styling conventions and integrate with Livewire `wire:model`. diff --git a/app/Core/Front/Components/View/Blade/forms/button.blade.php b/app/Core/Front/Components/View/Blade/forms/button.blade.php new file mode 100644 index 0000000..27de20d --- /dev/null +++ b/app/Core/Front/Components/View/Blade/forms/button.blade.php @@ -0,0 +1,34 @@ +{{-- +Authorization-aware button component. + +Wraps flux:button with built-in authorization checking. + +Usage: +Save +Save +Save +--}} + +@props([ + 'canGate' => null, + 'canResource' => null, + 'variant' => 'primary', + 'type' => 'submit', +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } +@endphp + +except(['disabled', 'variant', 'type'])->merge([ + 'type' => $type, + 'variant' => $variant, + 'disabled' => $disabled, + ]) }} +> + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/forms/checkbox.blade.php b/app/Core/Front/Components/View/Blade/forms/checkbox.blade.php new file mode 100644 index 0000000..a441fe3 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/forms/checkbox.blade.php @@ -0,0 +1,54 @@ +{{-- +Authorization-aware checkbox component. + +Wraps flux:checkbox with built-in authorization checking. + +Usage: + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + except(['disabled', 'wire:model', 'wire:model.live'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + > + @if($label) + {{ $label }} + @endif + + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/app/Core/Front/Components/View/Blade/forms/input.blade.php b/app/Core/Front/Components/View/Blade/forms/input.blade.php new file mode 100644 index 0000000..bcf15dc --- /dev/null +++ b/app/Core/Front/Components/View/Blade/forms/input.blade.php @@ -0,0 +1,54 @@ +{{-- +Authorization-aware input component. + +Wraps flux:input with built-in authorization checking. + +Usage: + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live.debounce.500ms="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + /> + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/app/Core/Front/Components/View/Blade/forms/select.blade.php b/app/Core/Front/Components/View/Blade/forms/select.blade.php new file mode 100644 index 0000000..bbba146 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/forms/select.blade.php @@ -0,0 +1,65 @@ +{{-- +Authorization-aware select component. + +Wraps flux:select with built-in authorization checking. + +Usage: + + + + + + + Light + Dark + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, + 'placeholder' => null, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live', 'placeholder'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + 'placeholder' => $placeholder, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + > + {{ $slot }} + + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/app/Core/Front/Components/View/Blade/forms/textarea.blade.php b/app/Core/Front/Components/View/Blade/forms/textarea.blade.php new file mode 100644 index 0000000..540aa82 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/forms/textarea.blade.php @@ -0,0 +1,56 @@ +{{-- +Authorization-aware textarea component. + +Wraps flux:textarea with built-in authorization checking. + +Usage: + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, + 'rows' => 3, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live', 'rows'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + 'rows' => $rows, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live.debounce.500ms="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + /> + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/app/Core/Front/Components/View/Blade/forms/toggle.blade.php b/app/Core/Front/Components/View/Blade/forms/toggle.blade.php new file mode 100644 index 0000000..689fae7 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/forms/toggle.blade.php @@ -0,0 +1,57 @@ +{{-- +Authorization-aware toggle/switch component. + +Wraps flux:switch with built-in authorization checking. + +Usage: + + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + +
    + @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + /> +
    + + @if($helper) + {{ $helper }} + @endif + + +
    diff --git a/app/Core/Front/Components/View/Blade/heading.blade.php b/app/Core/Front/Components/View/Blade/heading.blade.php new file mode 100644 index 0000000..edd87fb --- /dev/null +++ b/app/Core/Front/Components/View/Blade/heading.blade.php @@ -0,0 +1,2 @@ +{{-- Core Heading - Thin wrapper around flux:heading. Props: level (1-6), size (xs|sm|base|lg|xl|2xl) --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/icon.blade.php b/app/Core/Front/Components/View/Blade/icon.blade.php new file mode 100644 index 0000000..97ac773 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon.blade.php @@ -0,0 +1,154 @@ +{{-- + Core Icon - FontAwesome Implementation with Pro/Free Detection + + Uses FontAwesome with automatic brand/jelly detection: + - Brand icons (github, twitter, etc.) → fa-brands + - Jelly icons (globe, clock, etc.) → fa-jelly (Pro) or fa-solid (Free fallback) + - All others → fa-solid (or explicit style override) + + Pro styles: solid, regular, light, thin, duotone, brands, sharp, jelly + Free styles: solid, regular, brands (others fall back automatically) + + Props: name, style (solid|regular|light|thin|duotone|brands|jelly), + size (xs|sm|lg|xl|2xl), spin, pulse, flip, rotate, fw +--}} +@props([ + 'name', + 'style' => null, // Override: solid, regular, light, thin, duotone, brands, jelly + 'size' => null, // Size class: xs, sm, lg, xl, 2xl, etc. + 'spin' => false, // Animate spinning + 'pulse' => false, // Animate pulsing + 'flip' => null, // horizontal, vertical, both + 'rotate' => null, // 90, 180, 270 + 'fw' => false, // Fixed width +]) + +@php + use Core\Pro; + + // Brand icons - always use fa-brands (available in Free) + $brandIcons = [ + // Social + 'facebook', 'facebook-f', 'facebook-messenger', 'instagram', 'twitter', 'x-twitter', + 'tiktok', 'youtube', 'linkedin', 'linkedin-in', 'pinterest', 'pinterest-p', + 'snapchat', 'snapchat-ghost', 'whatsapp', 'telegram', 'telegram-plane', + 'discord', 'twitch', 'reddit', 'reddit-alien', 'threads', 'mastodon', 'bluesky', + // Media + 'spotify', 'soundcloud', 'apple', 'itunes', 'itunes-note', 'bandcamp', + 'deezer', 'napster', 'audible', 'vimeo', 'vimeo-v', 'dailymotion', + // Dev/Tech + 'github', 'github-alt', 'gitlab', 'bitbucket', 'dribbble', 'behance', + 'figma', 'sketch', 'codepen', 'jsfiddle', 'stack-overflow', + 'npm', 'node', 'node-js', 'js', 'php', 'python', 'java', 'rust', + 'react', 'vuejs', 'angular', 'laravel', 'symfony', 'docker', + 'aws', 'google', 'microsoft', + // Commerce + 'shopify', 'etsy', 'amazon', 'ebay', 'paypal', 'stripe', 'cc-stripe', + 'cc-visa', 'cc-mastercard', 'cc-amex', 'cc-paypal', 'cc-apple-pay', + 'bitcoin', 'btc', 'ethereum', 'monero', + // Communication + 'slack', 'slack-hash', 'skype', 'viber', 'line', 'wechat', 'qq', + // Other + 'wordpress', 'wordpress-simple', 'medium', 'blogger', 'tumblr', + 'patreon', 'kickstarter', 'product-hunt', 'airbnb', 'uber', 'lyft', + 'yelp', 'tripadvisor', + ]; + + // Jelly style icons - full list from FA Pro+ metadata + // Generated from ~/Code/lib/fontawesome/metadata/icon-families.json + $jellyIcons = [ + 'address-card', 'alarm-clock', 'anchor', 'angle-down', 'angle-left', + 'angle-right', 'angle-up', 'arrow-down', 'arrow-down-to-line', + 'arrow-down-wide-short', 'arrow-left', 'arrow-right', + 'arrow-right-arrow-left', 'arrow-right-from-bracket', + 'arrow-right-to-bracket', 'arrow-rotate-left', 'arrow-rotate-right', + 'arrow-up', 'arrow-up-from-bracket', 'arrow-up-from-line', + 'arrow-up-right-from-square', 'arrow-up-wide-short', 'arrows-rotate', + 'at', 'backward', 'backward-step', 'bag-shopping', 'bars', + 'battery-bolt', 'battery-empty', 'battery-half', 'battery-low', + 'battery-three-quarters', 'bed', 'bell', 'block-quote', 'bold', 'bolt', + 'bomb', 'book', 'book-open', 'bookmark', 'box', 'box-archive', 'bug', + 'building', 'bus', 'cake-candles', 'calendar', 'camera', 'camera-slash', + 'car', 'cart-shopping', 'chart-bar', 'chart-pie', 'check', 'circle', + 'circle-check', 'circle-half-stroke', 'circle-info', 'circle-plus', + 'circle-question', 'circle-user', 'circle-xmark', 'city', 'clipboard', + 'clock', 'clone', 'cloud', 'code', 'command', 'comment', 'comment-dots', + 'comments', 'compact-disc', 'compass', 'compress', 'credit-card', + 'crown', 'database', 'desktop', 'door-closed', 'droplet', 'ellipsis', + 'envelope', 'equals', 'expand', 'eye', 'eye-slash', 'face-frown', + 'face-grin', 'face-meh', 'face-smile', 'file', 'files', 'film', + 'filter', 'fire', 'fish', 'flag', 'flower', 'folder', 'folders', 'font', + 'font-awesome', 'font-case', 'forward', 'forward-step', 'gamepad', + 'gauge', 'gear', 'gift', 'globe', 'grid', 'hand', 'headphones', 'heart', + 'heart-half', 'hourglass', 'house', 'image', 'images', 'inbox', + 'italic', 'key', 'landmark', 'language', 'laptop', 'layer-group', + 'leaf', 'life-ring', 'lightbulb', 'link', 'list', 'list-ol', + 'location-arrow', 'location-dot', 'lock', 'lock-open', + 'magnifying-glass', 'magnifying-glass-minus', 'magnifying-glass-plus', + 'map', 'martini-glass', 'microphone', 'microphone-slash', 'minus', + 'mobile', 'money-bill', 'moon', 'mug-hot', 'music', 'newspaper', + 'notdef', 'palette', 'paper-plane', 'paperclip', 'pause', 'paw', + 'pencil', 'percent', 'person-biking', 'phone', 'phone-slash', 'plane', + 'play', 'play-pause', 'plus', 'print', 'question', 'quote-left', + 'rectangle', 'rectangle-tall', 'rectangle-vertical', 'rectangle-wide', + 'scissors', 'share-nodes', 'shield', 'shield-halved', 'ship', 'shirt', + 'shop', 'sidebar', 'sidebar-flip', 'signal-bars', 'signal-bars-fair', + 'signal-bars-good', 'signal-bars-slash', 'signal-bars-weak', 'skull', + 'sliders', 'snowflake', 'sort', 'sparkles', 'square', 'square-code', + 'star', 'star-half', 'stop', 'stopwatch', 'strikethrough', 'suitcase', + 'sun', 'tag', 'terminal', 'thumbs-down', 'thumbs-up', 'thumbtack', + 'ticket', 'train', 'trash', 'tree', 'triangle', 'triangle-exclamation', + 'trophy', 'truck', 'tv-retro', 'umbrella', 'universal-access', 'user', + 'users', 'utensils', 'video', 'video-slash', 'volume', 'volume-low', + 'volume-off', 'volume-slash', 'volume-xmark', 'wand-magic-sparkles', + 'wheelchair-move', 'wifi', 'wifi-fair', 'wifi-slash', 'wifi-weak', + 'wrench', 'xmark', + ]; + + // Pro-only style fallbacks (when FA Pro not available) + $proStyleFallbacks = [ + 'light' => 'regular', + 'thin' => 'regular', + 'duotone' => 'solid', + 'sharp' => 'solid', + 'jelly' => 'solid', + ]; + + $hasFaPro = Pro::hasFontAwesomePro(); + + // Determine raw style + if ($style) { + $rawStyle = match($style) { + 'brands', 'brand' => 'brands', + default => $style, + }; + } elseif (in_array($name, $brandIcons)) { + $rawStyle = 'brands'; + } elseif (in_array($name, $jellyIcons)) { + $rawStyle = 'jelly'; + } else { + $rawStyle = 'solid'; + } + + // Apply fallback if Pro not available + $finalStyle = $rawStyle; + if (!$hasFaPro && isset($proStyleFallbacks[$rawStyle])) { + $finalStyle = $proStyleFallbacks[$rawStyle]; + } + + $iconStyle = "fa-{$finalStyle}"; + + // Build classes + $classes = collect([ + $iconStyle, + "fa-{$name}", + $size ? "fa-{$size}" : null, + $spin ? 'fa-spin' : null, + $pulse ? 'fa-pulse' : null, + $flip ? "fa-flip-{$flip}" : null, + $rotate ? "fa-rotate-{$rotate}" : null, + $fw ? 'fa-fw' : null, + ])->filter()->implode(' '); +@endphp + +class($classes) }} aria-hidden="true"> diff --git a/app/Core/Front/Components/View/Blade/icon/CLAUDE.md b/app/Core/Front/Components/View/Blade/icon/CLAUDE.md new file mode 100644 index 0000000..5150242 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/CLAUDE.md @@ -0,0 +1,19 @@ +# Blade/icon + +Inline SVG icon components. These are Heroicon-style SVG icons used as fallbacks or within specific components. + +## Files + +- **check.blade.php** -- Checkmark icon +- **check-circle.blade.php** -- Checkmark in circle icon +- **clipboard.blade.php** -- Clipboard icon +- **clock.blade.php** -- Clock/time icon +- **code-bracket.blade.php** -- Code brackets icon +- **code-bracket-square.blade.php** -- Code brackets in square icon +- **document-text.blade.php** -- Document with text icon +- **key.blade.php** -- Key icon +- **lock-closed.blade.php** -- Locked padlock icon +- **x-circle.blade.php** -- X in circle icon (error/close) +- **x-mark.blade.php** -- X mark icon (close/dismiss) + +Note: The primary icon system uses Font Awesome Pro via ``. These SVG components are supplementary. diff --git a/app/Core/Front/Components/View/Blade/icon/check-circle.blade.php b/app/Core/Front/Components/View/Blade/icon/check-circle.blade.php new file mode 100644 index 0000000..62ebec9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/check-circle.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/check.blade.php b/app/Core/Front/Components/View/Blade/icon/check.blade.php new file mode 100644 index 0000000..c4c8b69 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/check.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/clipboard.blade.php b/app/Core/Front/Components/View/Blade/icon/clipboard.blade.php new file mode 100644 index 0000000..b6d9ebe --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/clipboard.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/clock.blade.php b/app/Core/Front/Components/View/Blade/icon/clock.blade.php new file mode 100644 index 0000000..21d1ed5 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/clock.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/code-bracket-square.blade.php b/app/Core/Front/Components/View/Blade/icon/code-bracket-square.blade.php new file mode 100644 index 0000000..149c60e --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/code-bracket-square.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/code-bracket.blade.php b/app/Core/Front/Components/View/Blade/icon/code-bracket.blade.php new file mode 100644 index 0000000..5aa3917 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/code-bracket.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/document-text.blade.php b/app/Core/Front/Components/View/Blade/icon/document-text.blade.php new file mode 100644 index 0000000..e60f75c --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/document-text.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/key.blade.php b/app/Core/Front/Components/View/Blade/icon/key.blade.php new file mode 100644 index 0000000..91b0e73 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/key.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/lock-closed.blade.php b/app/Core/Front/Components/View/Blade/icon/lock-closed.blade.php new file mode 100644 index 0000000..7172fa5 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/lock-closed.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/x-circle.blade.php b/app/Core/Front/Components/View/Blade/icon/x-circle.blade.php new file mode 100644 index 0000000..431143a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/x-circle.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/icon/x-mark.blade.php b/app/Core/Front/Components/View/Blade/icon/x-mark.blade.php new file mode 100644 index 0000000..b2d2a22 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/icon/x-mark.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/input.blade.php b/app/Core/Front/Components/View/Blade/input.blade.php new file mode 100644 index 0000000..131187e --- /dev/null +++ b/app/Core/Front/Components/View/Blade/input.blade.php @@ -0,0 +1,2 @@ +{{-- Core Input - Thin wrapper around flux:input. Props: type, name, placeholder, value, label, description, icon, iconTrailing, badge, size, variant, disabled, readonly, required, autocomplete, clearable, viewable, copyable --}} + diff --git a/app/Core/Front/Components/View/Blade/input/CLAUDE.md b/app/Core/Front/Components/View/Blade/input/CLAUDE.md new file mode 100644 index 0000000..f864e1f --- /dev/null +++ b/app/Core/Front/Components/View/Blade/input/CLAUDE.md @@ -0,0 +1,11 @@ +# Blade/input + +Input sub-components. + +## Files + +- **group.blade.php** -- Input group container for combining an input with prefix/suffix addons. + +## Subdirectories + +- **group/** -- Contains `prefix.blade.php` for input group prefix addon (icon, text, or custom content before the input). diff --git a/app/Core/Front/Components/View/Blade/input/group.blade.php b/app/Core/Front/Components/View/Blade/input/group.blade.php new file mode 100644 index 0000000..7841558 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/input/group.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/input/group/CLAUDE.md b/app/Core/Front/Components/View/Blade/input/group/CLAUDE.md new file mode 100644 index 0000000..896e05b --- /dev/null +++ b/app/Core/Front/Components/View/Blade/input/group/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/input/group + +Input group addon components. + +## Files + +- **prefix.blade.php** -- Prefix addon for input groups. Renders content (icon, text, symbol) to the left of the input field within an input group container. diff --git a/app/Core/Front/Components/View/Blade/input/group/prefix.blade.php b/app/Core/Front/Components/View/Blade/input/group/prefix.blade.php new file mode 100644 index 0000000..fb59e9c --- /dev/null +++ b/app/Core/Front/Components/View/Blade/input/group/prefix.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/kanban.blade.php b/app/Core/Front/Components/View/Blade/kanban.blade.php new file mode 100644 index 0000000..c4370ab --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban.blade.php @@ -0,0 +1,3 @@ +{{-- Core Kanban - Flux Pro component --}} +@php(\Core\Pro::requireFluxPro('core:kanban')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/kanban/CLAUDE.md b/app/Core/Front/Components/View/Blade/kanban/CLAUDE.md new file mode 100644 index 0000000..e0bc084 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban/CLAUDE.md @@ -0,0 +1,26 @@ +# Blade/kanban + +Kanban board components. + +## Files + +- **card.blade.php** -- Individual kanban card (draggable item within a column) +- **column.blade.php** -- Kanban column container + +## Subdirectories + +- **column/** -- `cards.blade.php` (card list container), `footer.blade.php` (column footer with add action), `header.blade.php` (column title and count) + +## Usage + +```blade + + + To Do + + Task 1 + + + + +``` diff --git a/app/Core/Front/Components/View/Blade/kanban/card.blade.php b/app/Core/Front/Components/View/Blade/kanban/card.blade.php new file mode 100644 index 0000000..810203a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban/card.blade.php @@ -0,0 +1,8 @@ +@props([ + 'heading' => null, + 'as' => null, // button, div +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/kanban/column.blade.php b/app/Core/Front/Components/View/Blade/kanban/column.blade.php new file mode 100644 index 0000000..7560a61 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban/column.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/kanban/column/CLAUDE.md b/app/Core/Front/Components/View/Blade/kanban/column/CLAUDE.md new file mode 100644 index 0000000..79dd5b1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban/column/CLAUDE.md @@ -0,0 +1,9 @@ +# Blade/kanban/column + +Kanban column sub-components. + +## Files + +- **cards.blade.php** -- Card list container within a column. Provides the droppable area for kanban cards. +- **footer.blade.php** -- Column footer, typically containing an "Add card" action button. +- **header.blade.php** -- Column header displaying the column title, item count, and optional actions. diff --git a/app/Core/Front/Components/View/Blade/kanban/column/cards.blade.php b/app/Core/Front/Components/View/Blade/kanban/column/cards.blade.php new file mode 100644 index 0000000..11ceb35 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban/column/cards.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/kanban/column/footer.blade.php b/app/Core/Front/Components/View/Blade/kanban/column/footer.blade.php new file mode 100644 index 0000000..e5a673e --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban/column/footer.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/kanban/column/header.blade.php b/app/Core/Front/Components/View/Blade/kanban/column/header.blade.php new file mode 100644 index 0000000..73abe5b --- /dev/null +++ b/app/Core/Front/Components/View/Blade/kanban/column/header.blade.php @@ -0,0 +1,10 @@ +@props([ + 'heading' => null, + 'subheading' => null, + 'count' => null, + 'badge' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/label.blade.php b/app/Core/Front/Components/View/Blade/label.blade.php new file mode 100644 index 0000000..6bbef16 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/label.blade.php @@ -0,0 +1,7 @@ +@props([ + 'for' => null, // Associated input ID +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/layout.blade.php b/app/Core/Front/Components/View/Blade/layout.blade.php new file mode 100644 index 0000000..0317537 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layout.blade.php @@ -0,0 +1,41 @@ +@props(['variant' => 'HCF']) + +@php + $has = fn($slot) => str_contains($variant, $slot); +@endphp + +
    merge(['class' => 'hlcrf-layout']) }}> + @if($has('H') && isset($header)) +
    + {{ $header }} +
    + @endif + + @if($has('L') || $has('C') || $has('R')) +
    + @if($has('L') && isset($left)) + + @endif + + @if($has('C')) +
    + {{ $slot }} +
    + @endif + + @if($has('R') && isset($right)) + + @endif +
    + @endif + + @if($has('F') && isset($footer)) +
    + {{ $footer }} +
    + @endif +
    diff --git a/app/Core/Front/Components/View/Blade/layout/CLAUDE.md b/app/Core/Front/Components/View/Blade/layout/CLAUDE.md new file mode 100644 index 0000000..43c0a28 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layout/CLAUDE.md @@ -0,0 +1,13 @@ +# Blade/layout + +Structural layout slot components for page composition. + +## Files + +- **content.blade.php** -- Main content area wrapper +- **footer.blade.php** -- Page footer section +- **header.blade.php** -- Page header section +- **left.blade.php** -- Left sidebar/aside section +- **right.blade.php** -- Right sidebar/aside section + +These map to the HLCRF (Header-Left-Content-Right-Footer) layout system. Used as named slots within layout templates. diff --git a/app/Core/Front/Components/View/Blade/layout/content.blade.php b/app/Core/Front/Components/View/Blade/layout/content.blade.php new file mode 100644 index 0000000..dadb254 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layout/content.blade.php @@ -0,0 +1,3 @@ +
    merge(['class' => 'hlcrf-content']) }}> + {{ $slot }} +
    diff --git a/app/Core/Front/Components/View/Blade/layout/footer.blade.php b/app/Core/Front/Components/View/Blade/layout/footer.blade.php new file mode 100644 index 0000000..fa4a394 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layout/footer.blade.php @@ -0,0 +1,3 @@ +
    merge(['class' => 'hlcrf-footer']) }}> + {{ $slot }} +
    diff --git a/app/Core/Front/Components/View/Blade/layout/header.blade.php b/app/Core/Front/Components/View/Blade/layout/header.blade.php new file mode 100644 index 0000000..9651997 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layout/header.blade.php @@ -0,0 +1,3 @@ +
    merge(['class' => 'hlcrf-header']) }}> + {{ $slot }} +
    diff --git a/app/Core/Front/Components/View/Blade/layout/left.blade.php b/app/Core/Front/Components/View/Blade/layout/left.blade.php new file mode 100644 index 0000000..33ee0b9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layout/left.blade.php @@ -0,0 +1,3 @@ + diff --git a/app/Core/Front/Components/View/Blade/layout/right.blade.php b/app/Core/Front/Components/View/Blade/layout/right.blade.php new file mode 100644 index 0000000..34610f9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layout/right.blade.php @@ -0,0 +1,3 @@ + diff --git a/app/Core/Front/Components/View/Blade/layouts/CLAUDE.md b/app/Core/Front/Components/View/Blade/layouts/CLAUDE.md new file mode 100644 index 0000000..8e52c69 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/CLAUDE.md @@ -0,0 +1,27 @@ +# Blade/layouts + +Page layout templates registered under the `layouts::` namespace. Used by Livewire components via `->layout('layouts::app')`. + +## Files + +- **app.blade.php** -- Marketing/sales layout with particle animation. For landing pages, pricing, about, services. +- **content.blade.php** -- Blog posts, guides, legal pages. Centred prose layout. +- **focused.blade.php** -- Checkout, forms, onboarding. Minimal, distraction-free layout. +- **minimal.blade.php** -- Bare minimum layout with no navigation. +- **sidebar-left.blade.php** -- Help centre, FAQ, documentation. Left nav + content. +- **sidebar-right.blade.php** -- Long guides with TOC. Content + right sidebar. +- **workspace.blade.php** -- Authenticated SaaS workspace layout. + +## Subdirectories + +- **partials/** -- Shared layout fragments (base HTML shell, fonts, header, footer) + +## Usage + +```php +// In Livewire component +public function layout(): string +{ + return 'layouts::app'; +} +``` diff --git a/app/Core/Front/Components/View/Blade/layouts/app.blade.php b/app/Core/Front/Components/View/Blade/layouts/app.blade.php new file mode 100644 index 0000000..d4196ba --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/app.blade.php @@ -0,0 +1,32 @@ +@props([ + 'title' => null, +]) + +{{-- +Marketing/Sales Layout (app.blade.php) +Use for: Landing pages, pricing, about, services - anything with particle animation and sales focus + +Other layouts available: +- content.blade.php → Blog posts, guides, legal pages (centred prose) +- sidebar-left.blade.php → Help centre, FAQ, documentation (left nav + content) +- sidebar-right.blade.php → Long guides with TOC (content + right sidebar) +- focused.blade.php → Checkout, forms, onboarding (minimal, focused) +--}} + + + +
    + + + + +
    +
    + {{ $slot }} +
    +
    + + + +
    +
    diff --git a/app/Core/Front/Components/View/Blade/layouts/content.blade.php b/app/Core/Front/Components/View/Blade/layouts/content.blade.php new file mode 100644 index 0000000..50078fd --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/content.blade.php @@ -0,0 +1,123 @@ +@props([ + 'title' => null, + 'description' => null, + 'author' => null, + 'date' => null, + 'category' => null, + 'image' => null, + 'backLink' => null, + 'backLabel' => 'Back', +]) + + + +
    + + + + + + + +
    + + + @if($title) +
    + + + +
    + + @if($backLink) + + + {{ $backLabel }} + + @endif + + @if($category) +
    + + {{ $category }} + +
    + @endif + +

    + {{ $title }} +

    + + @if($description) +

    + {{ $description }} +

    + @endif + + @if($author || $date) +
    + @if($author) +
    +
    + {{ substr($author, 0, 1) }} +
    + {{ $author }} +
    + @endif + @if($author && $date) + · + @endif + @if($date) + + @endif +
    + @endif +
    + + @if($image) +
    +
    + {{ $title }} +
    +
    + @endif +
    + @endif + + +
    +
    +
    + {{ $slot }} +
    +
    +
    + + + @isset($after) +
    +
    + {{ $after }} +
    +
    + @endisset + +
    + + + +
    +
    diff --git a/app/Core/Front/Components/View/Blade/layouts/focused.blade.php b/app/Core/Front/Components/View/Blade/layouts/focused.blade.php new file mode 100644 index 0000000..d6dcc28 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/focused.blade.php @@ -0,0 +1,71 @@ +@props([ + 'title' => null, + 'description' => null, + 'step' => null, + 'totalSteps' => null, + 'showProgress' => false, +]) + + + +
    + + + + +
    + + + @if($showProgress && $step && $totalSteps) +
    +
    +
    + Step {{ $step }} of {{ $totalSteps }} + {{ round(($step / $totalSteps) * 100) }}% complete +
    +
    +
    +
    +
    +
    + @endif + +
    +
    + + + @if($title) +
    +

    + {{ $title }} +

    + @if($description) +

    + {{ $description }} +

    + @endif +
    + @endif + + +
    + {{ $slot }} +
    + + + @isset($helper) +
    + {{ $helper }} +
    + @endisset + +
    +
    + +
    + + + +
    +
    diff --git a/app/Core/Front/Components/View/Blade/layouts/minimal.blade.php b/app/Core/Front/Components/View/Blade/layouts/minimal.blade.php new file mode 100644 index 0000000..1605d0f --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/minimal.blade.php @@ -0,0 +1,25 @@ + + + + + + + + {{ $title ?? config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + + +
    +
    +
    +
    + + {{ $slot }} + + @livewireScripts + + + diff --git a/app/Core/Front/Components/View/Blade/layouts/partials/CLAUDE.md b/app/Core/Front/Components/View/Blade/layouts/partials/CLAUDE.md new file mode 100644 index 0000000..7258e5e --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/partials/CLAUDE.md @@ -0,0 +1,12 @@ +# Blade/layouts/partials + +Shared layout fragments included by the layout templates. + +## Files + +- **base.blade.php** -- Base HTML document shell. Handles ``, ``, ``, OG meta tags, CSRF token, Vite assets, and optional particle animation canvas. All layout templates extend this. + - Props: `title`, `description`, `ogImage`, `ogType`, `particles` (bool) +- **fonts.blade.php** -- External font loading (Google Fonts link tags) +- **fonts-inline.blade.php** -- Inline font declarations (for critical rendering path) +- **footer.blade.php** -- Shared site footer with navigation links, social links, and copyright +- **header.blade.php** -- Shared site header/navigation bar with logo, menu items, and auth links diff --git a/app/Core/Front/Components/View/Blade/layouts/partials/base.blade.php b/app/Core/Front/Components/View/Blade/layouts/partials/base.blade.php new file mode 100644 index 0000000..809b03d --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/partials/base.blade.php @@ -0,0 +1,140 @@ +@props([ + 'title' => null, + 'description' => null, + 'ogImage' => null, + 'ogType' => 'website', + 'particles' => false, +]) + +@php + $appName = config('core.app.name', 'Core PHP'); + $appTagline = config('core.app.tagline', 'Modular Monolith Framework'); + $defaultDescription = config('core.app.description', "{$appName} - {$appTagline}"); + $contactEmail = config('core.contact.email', 'hello@' . config('core.domain.base', 'core.test')); + + $pageTitle = $title ? $title . ' - ' . $appName : $appName . ' - ' . $appTagline; + $pageDescription = $description ?? $defaultDescription; + $pageOgImage = $ogImage ?? asset('images/og-default.jpg'); + $pageUrl = url()->current(); +@endphp + + + + + + + + + {{ $pageTitle }} + + + + + + + + + + + + + + + + + + + + + + + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + @fluxAppearance + + {{ $head ?? '' }} + + @stack('styles') + + + + + Skip to main content + + +@if($particles) + + +@endif + +{{ $slot }} + + +@include('hub::admin.components.developer-bar') + + +@fluxScripts + +{{ $scripts ?? '' }} + +@stack('scripts') + + + diff --git a/app/Core/Front/Components/View/Blade/layouts/partials/fonts-inline.blade.php b/app/Core/Front/Components/View/Blade/layouts/partials/fonts-inline.blade.php new file mode 100644 index 0000000..250e895 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/partials/fonts-inline.blade.php @@ -0,0 +1,10 @@ +{{-- Self-hosted Inter variable font (inline for standalone pages) --}} + diff --git a/app/Core/Front/Components/View/Blade/layouts/partials/fonts.blade.php b/app/Core/Front/Components/View/Blade/layouts/partials/fonts.blade.php new file mode 100644 index 0000000..0dd4d23 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/partials/fonts.blade.php @@ -0,0 +1,10 @@ +{{-- Self-hosted Inter variable font --}} + diff --git a/app/Core/Front/Components/View/Blade/layouts/partials/footer.blade.php b/app/Core/Front/Components/View/Blade/layouts/partials/footer.blade.php new file mode 100644 index 0000000..5da12e0 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/partials/footer.blade.php @@ -0,0 +1,139 @@ +@props([ + 'minimal' => false, +]) + +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appLogo = config('core.app.logo', '/images/logo.svg'); + $appIcon = config('core.app.icon', '/images/icon.svg'); + $socialTwitter = config('core.social.twitter'); + $socialGithub = config('core.social.github'); +@endphp + +@if($minimal) + + +@else + + +@endif diff --git a/app/Core/Front/Components/View/Blade/layouts/partials/header.blade.php b/app/Core/Front/Components/View/Blade/layouts/partials/header.blade.php new file mode 100644 index 0000000..2946588 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/partials/header.blade.php @@ -0,0 +1,362 @@ +@props([ + 'minimal' => false, + 'transparent' => false, +]) + +
    +
    +
    + + + + + @unless($minimal) + + + + + + @auth + + + Dashboard + + + + + Hub Home + + + + + Services + + +
    + + Bio Pages +
    +
    + + +
    + + Scheduling +
    +
    + + +
    + + Coming Soon +
    +
    + + +
    + + Coming Soon +
    +
    + + +
    + + Coming Soon +
    +
    + + +
    + + Coming Soon +
    +
    + + + + + Profile Settings + +
    +
    + + + Logout + + @else + + Login + + + {{-- VI_DONE: waitlist CTA, queue-based early access, 50% launch bonus --}} + + Get early access + + @endauth +
    + + +
    + + + + +
    + @else + + + @endunless + +
    +
    +
    diff --git a/app/Core/Front/Components/View/Blade/layouts/sidebar-left.blade.php b/app/Core/Front/Components/View/Blade/layouts/sidebar-left.blade.php new file mode 100644 index 0000000..e2d16cb --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/sidebar-left.blade.php @@ -0,0 +1,70 @@ +@props([ + 'title' => null, + 'description' => null, +]) + + + +
    + + + + + + + +
    +
    + + + @if($title) +
    +

    + {{ $title }} +

    + @if($description) +

    + {{ $description }} +

    + @endif +
    + @endif + + +
    + + + + + +
    + {{ $slot }} +
    + +
    +
    +
    + + + +
    +
    diff --git a/app/Core/Front/Components/View/Blade/layouts/sidebar-right.blade.php b/app/Core/Front/Components/View/Blade/layouts/sidebar-right.blade.php new file mode 100644 index 0000000..fb4be46 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/sidebar-right.blade.php @@ -0,0 +1,124 @@ +@props([ + 'title' => null, + 'description' => null, + 'backLink' => null, + 'backLabel' => 'Back', +]) + + + + + + + +
    + + + + + + + +
    +
    + + + @if($title) +
    + @if($backLink) + + + {{ $backLabel }} + + @endif + +

    + {{ $title }} +

    + @if($description) +

    + {{ $description }} +

    + @endif +
    + @endif + + +
    + + +
    +
    + {{ $slot }} +
    +
    + + + @isset($toc) + + @endisset + +
    +
    +
    + + + +
    + + + + +
    diff --git a/app/Core/Front/Components/View/Blade/layouts/workspace.blade.php b/app/Core/Front/Components/View/Blade/layouts/workspace.blade.php new file mode 100644 index 0000000..274b9e7 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/layouts/workspace.blade.php @@ -0,0 +1,137 @@ +@props(['title' => null, 'workspace' => []]) + +@php + $appName = config('core.app.name', 'Core PHP'); + $baseDomain = config('core.domain.base', 'core.test'); + $hubUrl = 'https://hub.' . $baseDomain; + $wsName = $workspace['name'] ?? $appName; + $wsColor = $workspace['color'] ?? 'violet'; + $wsIcon = $workspace['icon'] ?? 'globe'; + $wsSlug = $workspace['slug'] ?? 'main'; +@endphp + + + + + + + + + {{ $title ?? $wsName . ' | ' . $appName }} + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + @fluxAppearance + + + + +
    + + +
    +
    +
    + + + + + + + + + + +
    +
    +
    + + +
    + {{ $slot }} +
    + + + + +
    + + + @fluxScripts + + diff --git a/app/Core/Front/Components/View/Blade/main.blade.php b/app/Core/Front/Components/View/Blade/main.blade.php new file mode 100644 index 0000000..7ca7366 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/main.blade.php @@ -0,0 +1,7 @@ +@props([ + 'container' => false, // Apply max-width container +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/menu.blade.php b/app/Core/Front/Components/View/Blade/menu.blade.php new file mode 100644 index 0000000..a14cf44 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu.blade.php @@ -0,0 +1,7 @@ +@props([ + 'keepOpen' => false, // Keep menu open after selection +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/menu/CLAUDE.md b/app/Core/Front/Components/View/Blade/menu/CLAUDE.md new file mode 100644 index 0000000..2348921 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu/CLAUDE.md @@ -0,0 +1,25 @@ +# Blade/menu + +Dropdown menu sub-components. + +## Files + +- **checkbox.blade.php** -- Menu item with checkbox toggle +- **group.blade.php** -- Menu item group with optional label +- **item.blade.php** -- Standard menu item (link or action) +- **radio.blade.php** -- Menu item with radio button selection +- **separator.blade.php** -- Visual separator/divider between menu groups +- **submenu.blade.php** -- Nested submenu that opens on hover/click + +## Usage + +```blade + + + Edit + Duplicate + + + Delete + +``` diff --git a/app/Core/Front/Components/View/Blade/menu/checkbox.blade.php b/app/Core/Front/Components/View/Blade/menu/checkbox.blade.php new file mode 100644 index 0000000..cf52655 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu/checkbox.blade.php @@ -0,0 +1,9 @@ +@props([ + 'wire:model' => null, + 'value' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/menu/group.blade.php b/app/Core/Front/Components/View/Blade/menu/group.blade.php new file mode 100644 index 0000000..fb555ea --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu/group.blade.php @@ -0,0 +1,7 @@ +@props([ + 'heading' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/menu/item.blade.php b/app/Core/Front/Components/View/Blade/menu/item.blade.php new file mode 100644 index 0000000..267079a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu/item.blade.php @@ -0,0 +1,16 @@ +@props([ + 'href' => null, // URL for link items + 'icon' => null, // Icon name (left side) + 'iconTrailing' => null, // Icon name (right side) + 'iconVariant' => null, // outline, solid, mini, micro + 'kbd' => null, // Keyboard shortcut hint + 'suffix' => null, // Suffix text + 'variant' => null, // default, danger + 'disabled' => false, + 'keepOpen' => false, + 'wire:click' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/menu/radio.blade.php b/app/Core/Front/Components/View/Blade/menu/radio.blade.php new file mode 100644 index 0000000..ced3be1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu/radio.blade.php @@ -0,0 +1,9 @@ +@props([ + 'wire:model' => null, + 'value' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/menu/separator.blade.php b/app/Core/Front/Components/View/Blade/menu/separator.blade.php new file mode 100644 index 0000000..a50cbab --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu/separator.blade.php @@ -0,0 +1 @@ + diff --git a/app/Core/Front/Components/View/Blade/menu/submenu.blade.php b/app/Core/Front/Components/View/Blade/menu/submenu.blade.php new file mode 100644 index 0000000..a839eaf --- /dev/null +++ b/app/Core/Front/Components/View/Blade/menu/submenu.blade.php @@ -0,0 +1,7 @@ +@props([ + 'heading' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/modal.blade.php b/app/Core/Front/Components/View/Blade/modal.blade.php new file mode 100644 index 0000000..0035de2 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/modal.blade.php @@ -0,0 +1,2 @@ +{{-- Core Modal - Thin wrapper around flux:modal. Props: name, maxWidth (sm|md|lg|xl|2xl), variant (default|flyout), position (left|right), closeable --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/navbar.blade.php b/app/Core/Front/Components/View/Blade/navbar.blade.php new file mode 100644 index 0000000..e7be1c4 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navbar.blade.php @@ -0,0 +1,7 @@ +@props([ + 'class' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/navbar/CLAUDE.md b/app/Core/Front/Components/View/Blade/navbar/CLAUDE.md new file mode 100644 index 0000000..ce6f951 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navbar/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/navbar + +Navbar sub-components for the `` component. + +## Files + +- **item.blade.php** -- Individual navbar item. Renders as a navigation link within a navbar container. diff --git a/app/Core/Front/Components/View/Blade/navbar/item.blade.php b/app/Core/Front/Components/View/Blade/navbar/item.blade.php new file mode 100644 index 0000000..698acba --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navbar/item.blade.php @@ -0,0 +1,14 @@ +@props([ + 'href' => null, // URL + 'current' => null, // boolean, auto-detected if null + 'icon' => null, // Icon name (left side) + 'iconTrailing' => null, // Icon name (right side) + 'badge' => null, // Badge text/slot + 'badgeColor' => null, // Badge colour + 'badgeVariant' => null, // solid, outline + 'wire:navigate' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/navlist.blade.php b/app/Core/Front/Components/View/Blade/navlist.blade.php new file mode 100644 index 0000000..5d7780f --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navlist.blade.php @@ -0,0 +1,23 @@ +@props([ + 'variant' => null, // outline + 'items' => [], // [{label, href?, action?, current?, icon?, badge?}] +]) + +except('items') }}> + @if(count($items) > 0) + @foreach($items as $item) + + {{ $item['label'] ?? '' }} + + @endforeach + @else + {{ $slot }} + @endif + diff --git a/app/Core/Front/Components/View/Blade/navlist/CLAUDE.md b/app/Core/Front/Components/View/Blade/navlist/CLAUDE.md new file mode 100644 index 0000000..71a10b8 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navlist/CLAUDE.md @@ -0,0 +1,19 @@ +# Blade/navlist + +Navigation list sub-components for sidebar/panel navigation. + +## Files + +- **group.blade.php** -- Navigation group with optional heading label. Groups related nav items together with visual separation. +- **item.blade.php** -- Individual navigation item with label, href, icon, and active state. + +## Usage + +```blade + + + Dashboard + Settings + + +``` diff --git a/app/Core/Front/Components/View/Blade/navlist/group.blade.php b/app/Core/Front/Components/View/Blade/navlist/group.blade.php new file mode 100644 index 0000000..60040bc --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navlist/group.blade.php @@ -0,0 +1,9 @@ +@props([ + 'heading' => null, // Group heading text + 'expandable' => false, // Can be expanded/collapsed + 'expanded' => false, // Initial expanded state +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/navlist/item.blade.php b/app/Core/Front/Components/View/Blade/navlist/item.blade.php new file mode 100644 index 0000000..4395803 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navlist/item.blade.php @@ -0,0 +1,11 @@ +@props([ + 'href' => null, // Link URL + 'icon' => null, // Icon name + 'badge' => null, // Badge text/count + 'current' => false, // Active state + 'wire:navigate' => null, // Livewire SPA navigation +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/navmenu.blade.php b/app/Core/Front/Components/View/Blade/navmenu.blade.php new file mode 100644 index 0000000..d096eae --- /dev/null +++ b/app/Core/Front/Components/View/Blade/navmenu.blade.php @@ -0,0 +1,8 @@ +@props([ + 'position' => null, // top, right, bottom, left + 'align' => null, // start, center, end +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/pillbox.blade.php b/app/Core/Front/Components/View/Blade/pillbox.blade.php new file mode 100644 index 0000000..85ec133 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox.blade.php @@ -0,0 +1,3 @@ +{{-- Core Pillbox - Flux Pro component. Props: placeholder, label, description, size, searchable, disabled, invalid, multiple --}} +@php(\Core\Pro::requireFluxPro('core:pillbox')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/pillbox/CLAUDE.md b/app/Core/Front/Components/View/Blade/pillbox/CLAUDE.md new file mode 100644 index 0000000..43276a2 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox/CLAUDE.md @@ -0,0 +1,27 @@ +# Blade/pillbox + +Pillbox (tag/chip input) sub-components for multi-value selection. + +## Files + +- **create.blade.php** -- "Create new" action within the pillbox dropdown +- **empty.blade.php** -- Empty state when no options match the search +- **input.blade.php** -- Search/filter input within the pillbox +- **option.blade.php** -- Selectable option in the dropdown list +- **search.blade.php** -- Search container wrapping the input and results +- **trigger.blade.php** -- The pillbox trigger showing selected items as removable pills + +## Usage + +```blade + + + + + PHP + Go + No matches + Add new tag + + +``` diff --git a/app/Core/Front/Components/View/Blade/pillbox/create.blade.php b/app/Core/Front/Components/View/Blade/pillbox/create.blade.php new file mode 100644 index 0000000..2419e69 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox/create.blade.php @@ -0,0 +1,7 @@ +@props([ + 'minLength' => null, // min-length +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/pillbox/empty.blade.php b/app/Core/Front/Components/View/Blade/pillbox/empty.blade.php new file mode 100644 index 0000000..363ddb9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox/empty.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/pillbox/input.blade.php b/app/Core/Front/Components/View/Blade/pillbox/input.blade.php new file mode 100644 index 0000000..f32cca4 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox/input.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/pillbox/option.blade.php b/app/Core/Front/Components/View/Blade/pillbox/option.blade.php new file mode 100644 index 0000000..bef80df --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox/option.blade.php @@ -0,0 +1,2 @@ +{{-- Core Pillbox Option - Thin wrapper around flux:pillbox.option. Props: value, disabled --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/pillbox/search.blade.php b/app/Core/Front/Components/View/Blade/pillbox/search.blade.php new file mode 100644 index 0000000..c281f89 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox/search.blade.php @@ -0,0 +1,7 @@ +@props([ + 'placeholder' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/pillbox/trigger.blade.php b/app/Core/Front/Components/View/Blade/pillbox/trigger.blade.php new file mode 100644 index 0000000..484b0db --- /dev/null +++ b/app/Core/Front/Components/View/Blade/pillbox/trigger.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/popover.blade.php b/app/Core/Front/Components/View/Blade/popover.blade.php new file mode 100644 index 0000000..b6cd26f --- /dev/null +++ b/app/Core/Front/Components/View/Blade/popover.blade.php @@ -0,0 +1,10 @@ +@props([ + 'position' => null, // top, bottom, left, right + 'align' => null, // start, center, end + 'offset' => null, // Offset from trigger + 'trigger' => null, // click, hover +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/radio.blade.php b/app/Core/Front/Components/View/Blade/radio.blade.php new file mode 100644 index 0000000..2700627 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/radio.blade.php @@ -0,0 +1,2 @@ +{{-- Core Radio - Thin wrapper around flux:radio. Props: label, description, value, disabled, checked --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/radio/CLAUDE.md b/app/Core/Front/Components/View/Blade/radio/CLAUDE.md new file mode 100644 index 0000000..9bf8f1b --- /dev/null +++ b/app/Core/Front/Components/View/Blade/radio/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/radio + +Radio button sub-components. + +## Files + +- **group.blade.php** -- Radio button group container. Wraps multiple radio inputs with shared name and layout. diff --git a/app/Core/Front/Components/View/Blade/radio/group.blade.php b/app/Core/Front/Components/View/Blade/radio/group.blade.php new file mode 100644 index 0000000..a21d868 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/radio/group.blade.php @@ -0,0 +1,11 @@ +@props([ + 'label' => null, // Group label + 'description' => null, // Help text + 'variant' => null, // cards, segmented + 'wire:model' => null, + 'wire:model.live' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/select.blade.php b/app/Core/Front/Components/View/Blade/select.blade.php new file mode 100644 index 0000000..616d95c --- /dev/null +++ b/app/Core/Front/Components/View/Blade/select.blade.php @@ -0,0 +1,2 @@ +{{-- Core Select - Thin wrapper around flux:select. Props: label, description, placeholder, variant, size, disabled, invalid, multiple, searchable, clearable, filter --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/select/CLAUDE.md b/app/Core/Front/Components/View/Blade/select/CLAUDE.md new file mode 100644 index 0000000..04d4b32 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/select/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/select + +Select dropdown sub-components. + +## Files + +- **option.blade.php** -- Individual option within a select dropdown. Renders as a selectable item in the custom select component's option list. diff --git a/app/Core/Front/Components/View/Blade/select/option.blade.php b/app/Core/Front/Components/View/Blade/select/option.blade.php new file mode 100644 index 0000000..389fdd6 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/select/option.blade.php @@ -0,0 +1,8 @@ +@props([ + 'value' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/separator.blade.php b/app/Core/Front/Components/View/Blade/separator.blade.php new file mode 100644 index 0000000..d3035e4 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/separator.blade.php @@ -0,0 +1,6 @@ +@props([ + 'vertical' => false, // Vertical orientation + 'text' => null, // Text in the middle of separator +]) + + diff --git a/app/Core/Front/Components/View/Blade/slider.blade.php b/app/Core/Front/Components/View/Blade/slider.blade.php new file mode 100644 index 0000000..e1f20a9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/slider.blade.php @@ -0,0 +1,3 @@ +{{-- Core Slider - Flux Pro component. Props: range, min, max, step, big-step, min-steps-between --}} +@php(\Core\Pro::requireFluxPro('core:slider')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/slider/CLAUDE.md b/app/Core/Front/Components/View/Blade/slider/CLAUDE.md new file mode 100644 index 0000000..1f2e92a --- /dev/null +++ b/app/Core/Front/Components/View/Blade/slider/CLAUDE.md @@ -0,0 +1,7 @@ +# Blade/slider + +Slider (range input) sub-components. + +## Files + +- **tick.blade.php** -- Tick mark along a slider track. Provides visual markers at specific values on a range slider. diff --git a/app/Core/Front/Components/View/Blade/slider/tick.blade.php b/app/Core/Front/Components/View/Blade/slider/tick.blade.php new file mode 100644 index 0000000..59dcc42 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/slider/tick.blade.php @@ -0,0 +1,7 @@ +@props([ + 'value' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/subheading.blade.php b/app/Core/Front/Components/View/Blade/subheading.blade.php new file mode 100644 index 0000000..b79d5ac --- /dev/null +++ b/app/Core/Front/Components/View/Blade/subheading.blade.php @@ -0,0 +1,8 @@ +@props([ + 'level' => null, // 1, 2, 3, 4, 5, 6 (renders h1-h6) + 'size' => null, // xs, sm, base, lg, xl, 2xl +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/switch.blade.php b/app/Core/Front/Components/View/Blade/switch.blade.php new file mode 100644 index 0000000..1e210c3 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/switch.blade.php @@ -0,0 +1,10 @@ +@props([ + 'name' => null, // Switch name attribute + 'label' => null, // Label text + 'description' => null, // Help text + 'disabled' => false, + 'wire:model' => null, // Livewire binding + 'wire:model.live' => null, +]) + + diff --git a/app/Core/Front/Components/View/Blade/tab.blade.php b/app/Core/Front/Components/View/Blade/tab.blade.php new file mode 100644 index 0000000..b3c030f --- /dev/null +++ b/app/Core/Front/Components/View/Blade/tab.blade.php @@ -0,0 +1,11 @@ +{{-- Core Tab - Thin wrapper around flux:tab with Font Awesome icon support --}} +@props([ + 'icon' => null, +]) + +except('icon') }}> + @if($icon) + + @endif + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/tab/CLAUDE.md b/app/Core/Front/Components/View/Blade/tab/CLAUDE.md new file mode 100644 index 0000000..14f17db --- /dev/null +++ b/app/Core/Front/Components/View/Blade/tab/CLAUDE.md @@ -0,0 +1,19 @@ +# Blade/tab + +Tab sub-components for the `` system. + +## Files + +- **group.blade.php** -- Tab group container. Wraps tab triggers and manages selected state. +- **panel.blade.php** -- Tab content panel. Shows/hides based on which tab is selected. + +## Usage + +```blade + + General + Advanced + +General content +Advanced content +``` diff --git a/app/Core/Front/Components/View/Blade/tab/group.blade.php b/app/Core/Front/Components/View/Blade/tab/group.blade.php new file mode 100644 index 0000000..cd5d370 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/tab/group.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/tab/panel.blade.php b/app/Core/Front/Components/View/Blade/tab/panel.blade.php new file mode 100644 index 0000000..203b617 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/tab/panel.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/table.blade.php b/app/Core/Front/Components/View/Blade/table.blade.php new file mode 100644 index 0000000..79a10ef --- /dev/null +++ b/app/Core/Front/Components/View/Blade/table.blade.php @@ -0,0 +1,8 @@ +@props([ + 'paginate' => null, // Laravel paginator instance + 'containerClass' => null, // CSS classes for container +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/table/CLAUDE.md b/app/Core/Front/Components/View/Blade/table/CLAUDE.md new file mode 100644 index 0000000..2f475ac --- /dev/null +++ b/app/Core/Front/Components/View/Blade/table/CLAUDE.md @@ -0,0 +1,28 @@ +# Blade/table + +Table sub-components for data tables. + +## Files + +- **cell.blade.php** -- Individual table cell (``) +- **column.blade.php** -- Column header (``) with optional sorting +- **columns.blade.php** -- Column header row container (``) +- **row.blade.php** -- Table row (``) +- **rows.blade.php** -- Table body container (``) + +## Usage + +```blade + + + Name + Status + + + + Item 1 + Active + + + +``` diff --git a/app/Core/Front/Components/View/Blade/table/cell.blade.php b/app/Core/Front/Components/View/Blade/table/cell.blade.php new file mode 100644 index 0000000..ba0578c --- /dev/null +++ b/app/Core/Front/Components/View/Blade/table/cell.blade.php @@ -0,0 +1,9 @@ +@props([ + 'align' => null, // start, center, end + 'variant' => null, // default, strong + 'sticky' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/table/column.blade.php b/app/Core/Front/Components/View/Blade/table/column.blade.php new file mode 100644 index 0000000..1328dd6 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/table/column.blade.php @@ -0,0 +1,11 @@ +@props([ + 'align' => null, // start, center, end + 'sortable' => false, + 'sorted' => false, + 'direction' => null, // asc, desc + 'sticky' => false, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/table/columns.blade.php b/app/Core/Front/Components/View/Blade/table/columns.blade.php new file mode 100644 index 0000000..6dfd0de --- /dev/null +++ b/app/Core/Front/Components/View/Blade/table/columns.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/table/row.blade.php b/app/Core/Front/Components/View/Blade/table/row.blade.php new file mode 100644 index 0000000..4c26dd7 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/table/row.blade.php @@ -0,0 +1,7 @@ +@props([ + 'wire:key' => null, +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/table/rows.blade.php b/app/Core/Front/Components/View/Blade/table/rows.blade.php new file mode 100644 index 0000000..9d55e05 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/table/rows.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/tabs.blade.php b/app/Core/Front/Components/View/Blade/tabs.blade.php new file mode 100644 index 0000000..34bf230 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/tabs.blade.php @@ -0,0 +1,2 @@ +{{-- Core Tabs - Thin wrapper around flux:tabs. Props: variant (segmented|pills), wire:model --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/text.blade.php b/app/Core/Front/Components/View/Blade/text.blade.php new file mode 100644 index 0000000..37995d1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/text.blade.php @@ -0,0 +1,2 @@ +{{-- Core Text - Thin wrapper around flux:text. Props: size (xs|sm|base|lg|xl), dim --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/textarea.blade.php b/app/Core/Front/Components/View/Blade/textarea.blade.php new file mode 100644 index 0000000..0b1fed1 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/textarea.blade.php @@ -0,0 +1,2 @@ +{{-- Core Textarea - Thin wrapper around flux:textarea. Props: name, rows, placeholder, label, description, resize (none|vertical|horizontal|both), disabled, readonly, required, wire:model --}} +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/time-picker.blade.php b/app/Core/Front/Components/View/Blade/time-picker.blade.php new file mode 100644 index 0000000..7753ffa --- /dev/null +++ b/app/Core/Front/Components/View/Blade/time-picker.blade.php @@ -0,0 +1,3 @@ +{{-- Core Time Picker - Flux Pro component. Props: value, type, interval, min, max, label, placeholder, size, clearable, disabled, locale --}} +@php(\Core\Pro::requireFluxPro('core:time-picker')) +{{ $slot }} diff --git a/app/Core/Front/Components/View/Blade/tooltip.blade.php b/app/Core/Front/Components/View/Blade/tooltip.blade.php new file mode 100644 index 0000000..2a7fcd5 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/tooltip.blade.php @@ -0,0 +1,9 @@ +@props([ + 'content' => null, // Tooltip text + 'position' => null, // top, bottom, left, right + 'kbd' => null, // Keyboard shortcut hint +]) + + + {{ $slot }} + diff --git a/app/Core/Front/Components/View/Blade/web/CLAUDE.md b/app/Core/Front/Components/View/Blade/web/CLAUDE.md new file mode 100644 index 0000000..26715d9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/web/CLAUDE.md @@ -0,0 +1,11 @@ +# Blade/web + +Public-facing page templates for workspace websites. Registered under the `web::` namespace. + +## Files + +- **home.blade.php** -- Workspace homepage template +- **page.blade.php** -- Generic content page template +- **waitlist.blade.php** -- Pre-launch waitlist/signup page template + +These are rendered for workspace domains resolved by `FindDomainRecord` middleware. Used via `web::home`, `web::page`, `web::waitlist` view references. diff --git a/app/Core/Front/Components/View/Blade/web/home.blade.php b/app/Core/Front/Components/View/Blade/web/home.blade.php new file mode 100644 index 0000000..5f27e24 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/web/home.blade.php @@ -0,0 +1,78 @@ + + + +
    +
    +
    +

    + + {{ $content['site']['name'] ?? $workspace->name }} + +

    + @if(isset($content['site']['description'])) +

    + {{ $content['site']['description'] }} +

    + @endif +
    + + + @if(!empty($content['featured_posts'])) + + + + @else +
    +
    + +
    +

    No posts yet. Check back soon!

    +
    + @endif +
    +
    + +
    diff --git a/app/Core/Front/Components/View/Blade/web/page.blade.php b/app/Core/Front/Components/View/Blade/web/page.blade.php new file mode 100644 index 0000000..ed2e5a9 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/web/page.blade.php @@ -0,0 +1,44 @@ + + +
    +
    + + +
    +

    + {!! $page['title']['rendered'] ?? 'Untitled' !!} +

    +
    + + + @if(isset($page['_embedded']['wp:featuredmedia'][0])) +
    + {{ $page['title']['rendered'] ?? '' }} +
    + @endif + + +
    + {!! $page['content']['rendered'] ?? '' !!} +
    + +
    +
    + +
    diff --git a/app/Core/Front/Components/View/Blade/web/waitlist.blade.php b/app/Core/Front/Components/View/Blade/web/waitlist.blade.php new file mode 100644 index 0000000..5589753 --- /dev/null +++ b/app/Core/Front/Components/View/Blade/web/waitlist.blade.php @@ -0,0 +1,152 @@ +@php + $meta = [ + 'title' => ($workspace?->name ?? 'Host UK') . ' - Coming Soon', + 'description' => $workspace?->description ?? 'I\'m working on something amazing. Join the waitlist to be notified when it launches.', + 'url' => request()->url(), + ]; +@endphp + + + +
    +
    +
    + + +
    +
    +
    + + + + + Coming Soon +
    +
    +
    + + + @if($workspace?->icon) +
    + +
    + @else +
    + Host UK +
    + @endif + + +

    + + {{ $workspace?->name ?? 'Something Amazing' }} + +

    + + +

    + {{ $workspace?->description ?? 'I\'m working on something special. Join the waitlist to be the first to know when it launches.' }} +

    + + + @if($subscribed) +
    + + You're on the list! I'll notify you when it launches. +
    + @else + +
    +
    +
    + +
    + @csrf + + + + +
    +
    +
    +
    + +
    + +
    + +
    +
    + + @error('email') +

    {{ $message }}

    + @enderror +
    + +
    +
    +
    + @endif + + +
    +
    + @for($i = 1; $i <= 5; $i++) +
    + +
    + @endfor +
    +

    + Join hundreds of others waiting for launch +

    +
    + +
    +
    +
    + + +
    + +
    +
    +

    What to expect

    +
    +
    +
    + +
    +

    Fast Performance

    +

    Blazing fast load times with edge caching across 96+ locations worldwide.

    +
    +
    +
    + +
    +

    Secure & Private

    +

    GDPR compliant with EU-hosted infrastructure. Your data stays safe.

    +
    +
    +
    + +
    +

    Creator Friendly

    +

    Built for content creators who need reliable, adult-friendly hosting.

    +
    +
    +
    +
    + +
    diff --git a/app/Core/Front/Controller.php b/app/Core/Front/Controller.php new file mode 100644 index 0000000..aa2e58b --- /dev/null +++ b/app/Core/Front/Controller.php @@ -0,0 +1,17 @@ +h('') + ->c('
    Main content
    ') + ->f('
    Footer
    '); + +echo $page; +``` + +## The Five Regions + +| Letter | Region | HTML Element | Purpose | +|--------|---------|--------------|---------| +| **H** | Header | `
    ` | Top navigation, branding | +| **L** | Left | `