From 29a94899da12b1c090d92ebcbd01fa5782343746 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Mon, 16 Mar 2026 14:34:07 +0100 Subject: [PATCH] Fixes the configuration file --- app/Configs/Content.php | 74 ++++++++++ app/Http/Controllers/ScreeningController.php | 6 +- .../Screening/UpdateScreeningRequest.php | 4 +- app/Models/Config.php | 42 ++++++ app/Nova/ConfigResource.php | 139 ++++++++++++++++++ app/Policies/ConfigPolicy.php | 71 +++++++++ app/Providers/AppServiceProvider.php | 2 +- app/Providers/NovaServiceProvider.php | 5 + app/Services/Config.php | 126 ++++++++++++++++ ...6_02_03_082959_create_screenings_table.php | 2 +- ...2026_02_03_083003_create_configs_table.php | 30 ++++ resources/js/Pages/Screening/Result.vue | 2 +- resources/js/Pages/Screening/Show.vue | 19 ++- resources/js/Pages/Session/Result.vue | 2 +- resources/js/Pages/Session/Show.vue | 50 ++++++- routes/web.php | 3 +- 16 files changed, 559 insertions(+), 18 deletions(-) create mode 100644 app/Configs/Content.php create mode 100644 app/Models/Config.php create mode 100644 app/Nova/ConfigResource.php create mode 100644 app/Policies/ConfigPolicy.php create mode 100644 app/Services/Config.php create mode 100644 database/migrations/2026_02_03_083003_create_configs_table.php diff --git a/app/Configs/Content.php b/app/Configs/Content.php new file mode 100644 index 0000000..048e1ab --- /dev/null +++ b/app/Configs/Content.php @@ -0,0 +1,74 @@ + [ + 'type' => 'markdown', + 'default' => '', + ], + ]; + } + + /** + * Returns the default value for the given field key, or null if the key does not exist. + */ + public function getDefault(string $key): mixed + { + $fields = $this->getFieldKeys(); + + if (! Arr::has($fields, $key)) { + return null; + } + + return Arr::get($fields, "{$key}.default"); + } + + /** + * Returns Nova field instances for this config group, each wired with + * resolveUsing (read from json_value with default fallback) and + * fillUsing (write back into json_value) callbacks. + */ + public function getNovaFields(): array + { + return [ + Markdown::make('Disclaimer', 'disclaimer') + ->resolveUsing(function (mixed $value, mixed $resource): string { + $jsonValue = Arr::get((array) $resource->json_value, 'disclaimer'); + + if ($jsonValue === null || $jsonValue === '') { + return (string) $this->getDefault('disclaimer'); + } + + return (string) $jsonValue; + }) + ->fillUsing(function (mixed $request, mixed $model, string $attribute, string $requestAttribute): void { + $current = Arr::wrap((array) $model->json_value); + Arr::set($current, $attribute, $request->{$requestAttribute}); + $model->json_value = $current; + }), + ]; + } +} diff --git a/app/Http/Controllers/ScreeningController.php b/app/Http/Controllers/ScreeningController.php index deaa9da..604dde3 100644 --- a/app/Http/Controllers/ScreeningController.php +++ b/app/Http/Controllers/ScreeningController.php @@ -103,15 +103,17 @@ private function calculateAndUpdateScore(Screening $screening, array $answers): } /** - * Calculate the total score from the answers. + * Calculate the total score from the answers — yes = 1, unknown = 0.5, no = 0. */ - private function calculateScore(array $answers): int + private function calculateScore(array $answers): float { $score = 0; foreach ($answers as $value) { if ($value === 'yes') { $score++; + } elseif ($value === 'unknown') { + $score += 0.5; } } diff --git a/app/Http/Requests/Screening/UpdateScreeningRequest.php b/app/Http/Requests/Screening/UpdateScreeningRequest.php index 1caad1a..ad5f8c8 100644 --- a/app/Http/Requests/Screening/UpdateScreeningRequest.php +++ b/app/Http/Requests/Screening/UpdateScreeningRequest.php @@ -25,7 +25,7 @@ public function rules(): array { return [ 'answers' => ['required', 'array', 'size:10'], - 'answers.*' => ['required', 'string', 'in:yes,no'], + 'answers.*' => ['required', 'string', 'in:yes,unknown,no'], ]; } @@ -40,7 +40,7 @@ public function messages(): array 'answers.size' => 'All 10 screening questions must be answered.', 'answers.*.required' => 'Each screening question must have an answer.', 'answers.*.string' => 'Each answer must be a valid text value.', - 'answers.*.in' => 'Each answer must be either "yes" or "no".', + 'answers.*.in' => 'Each answer must be "yes", "I don\'t know", or "no".', ]; } } diff --git a/app/Models/Config.php b/app/Models/Config.php new file mode 100644 index 0000000..77a358c --- /dev/null +++ b/app/Models/Config.php @@ -0,0 +1,42 @@ + 'array', + ]; + } + + /** + * Scope to filter config records by their string key identifier. + */ + public function scopeForKey(Builder $query, string $key): Builder + { + return $query->where('key', $key); + } +} diff --git a/app/Nova/ConfigResource.php b/app/Nova/ConfigResource.php new file mode 100644 index 0000000..32e44fd --- /dev/null +++ b/app/Nova/ConfigResource.php @@ -0,0 +1,139 @@ + + */ + public static string $model = \App\Models\Config::class; + + /** + * The columns that should be searched. + * + * @var array + */ + public static $search = ['key']; + + /** + * The logical group associated with the resource. + * + * @var string + */ + public static $group = 'Other'; + + /** + * Get the displayable label of the resource. + */ + public static function label(): string + { + return 'Settings'; + } + + /** + * Get the displayable singular label of the resource. + */ + public static function singularLabel(): string + { + return 'Setting'; + } + + /** + * Resolves the display title from the config class title or falls back to the raw key. + */ + public function title(): string + { + return $this->getConfigClass()?->title ?? $this->resource->key; + } + + /** + * Get the fields displayed by the resource. + * + * @return array + */ + public function fields(NovaRequest $request): array + { + return array_merge( + [ + Text::make('Name', 'key') + ->resolveUsing(fn ($value) => $this->getConfigClass()?->title ?? $value) + ->exceptOnForms(), + + Text::make('Description', 'key') + ->resolveUsing(fn () => $this->getConfigClass()?->description ?? '') + ->exceptOnForms(), + ], + array_map( + fn ($field) => $field->hideFromIndex(), + $this->getConfigClass()?->getNovaFields() ?? [] + ), + [ + DateTime::make('Created At')->exceptOnForms()->hideFromIndex()->sortable()->filterable(), + DateTime::make('Updated At')->exceptOnForms()->hideFromIndex()->sortable()->filterable(), + ], + ); + } + + /** + * Get the cards available for the request. + * + * @return array + */ + public function cards(NovaRequest $request): array + { + return []; + } + + /** + * Get the filters available for the resource. + * + * @return array + */ + public function filters(NovaRequest $request): array + { + return []; + } + + /** + * Get the lenses available for the resource. + * + * @return array + */ + public function lenses(NovaRequest $request): array + { + return []; + } + + /** + * Get the actions available for the resource. + * + * @return array + */ + public function actions(NovaRequest $request): array + { + return []; + } + + /** + * Resolves the config class instance for this resource's key via the Config service. + */ + private function getConfigClass(): ?object + { + $key = $this->resource?->key; + + if ($key === null) { + return null; + } + + return app(\App\Services\Config::class)->getConfigClassForGroup($key); + } +} diff --git a/app/Policies/ConfigPolicy.php b/app/Policies/ConfigPolicy.php new file mode 100644 index 0000000..23e7c5b --- /dev/null +++ b/app/Policies/ConfigPolicy.php @@ -0,0 +1,71 @@ +app->singleton(\App\Services\Config::class); } /** diff --git a/app/Providers/NovaServiceProvider.php b/app/Providers/NovaServiceProvider.php index a26c953..d374bf9 100644 --- a/app/Providers/NovaServiceProvider.php +++ b/app/Providers/NovaServiceProvider.php @@ -4,6 +4,7 @@ use App\Models\User; use App\Nova\CategoryResource; +use App\Nova\ConfigResource; use App\Nova\Dashboards\Main; use App\Nova\LogResource; use App\Nova\QuestionGroupResource; @@ -46,6 +47,10 @@ public function boot(): void MenuSection::make('Users', [ MenuItem::resource(\App\Nova\User::class), ])->icon('users')->collapsible(), + + MenuSection::make('Settings', [ + MenuItem::resource(ConfigResource::class), + ])->icon('cog')->collapsible(), ]; }); } diff --git a/app/Services/Config.php b/app/Services/Config.php new file mode 100644 index 0000000..2976a84 --- /dev/null +++ b/app/Services/Config.php @@ -0,0 +1,126 @@ +discoverConfigClasses(); + $this->hydrateProperties(); + } + + /** + * Scans app/Configs/ for PHP files, instantiates each class, + * and indexes them by their key property. + */ + private function discoverConfigClasses(): void + { + $files = glob(app_path('Configs/*.php')) ?: []; + + foreach ($files as $file) { + $className = 'App\\Configs\\'.pathinfo($file, PATHINFO_FILENAME); + $instance = new $className; + $this->configClasses[$instance->key] = $instance; + } + } + + /** + * Iterates over all discovered config classes, ensures a DB row exists, + * resolves typed field values, and assigns them to public properties. + */ + private function hydrateProperties(): void + { + try { + $dbRecords = ConfigModel::all()->keyBy('key'); + + foreach ($this->configClasses as $key => $instance) { + $record = $this->ensureDbRecord($key, $dbRecords); + $jsonValue = Arr::wrap((array) ($record->json_value ?? [])); + $this->assignFieldProperties($key, $instance, $jsonValue); + } + } catch (QueryException) { + // Silently skip if configs table does not yet exist during migrations. + } + } + + /** + * Ensures a database record exists for the given config key, + * creating one with an empty json_value if it does not. + */ + private function ensureDbRecord(string $key, Collection $dbRecords): ConfigModel + { + if ($dbRecords->has($key)) { + return $dbRecords->get($key); + } + + return ConfigModel::firstOrCreate( + ['key' => $key], + ['json_value' => ''], + ); + } + + /** + * Resolves each field value from json_value (with default fallback), + * applies type casting, and assigns to the matching typed public property. + */ + private function assignFieldProperties(string $key, object $instance, array $jsonValue): void + { + foreach ($instance->getFieldKeys() as $fieldKey => $definition) { + $type = Arr::get($definition, 'type', 'string'); + $rawValue = Arr::get($jsonValue, $fieldKey); + + if ($rawValue === null || $rawValue === '') { + $rawValue = $instance->getDefault($fieldKey); + } + + $castedValue = $this->castValue($rawValue, $type); + $propertyName = Str::camel($key).Str::studly($fieldKey); + + if (property_exists($this, $propertyName)) { + $this->{$propertyName} = $castedValue; + } + } + } + + /** + * Casts a raw value to the PHP type defined by the config field type string. + */ + private function castValue(mixed $value, string $type): mixed + { + return match ($type) { + 'string', 'markdown' => (string) $value, + 'int', 'integer' => (int) $value, + 'bool', 'boolean' => (bool) $value, + 'float' => (float) $value, + default => $value, + }; + } + + /** + * Returns the Config Field class instance for a given group key, + * or null when no class has been discovered for that key. + */ + public function getConfigClassForGroup(string $group): ?object + { + return Arr::get($this->configClasses, $group); + } +} diff --git a/database/migrations/2026_02_03_082959_create_screenings_table.php b/database/migrations/2026_02_03_082959_create_screenings_table.php index 806e654..d7012e1 100644 --- a/database/migrations/2026_02_03_082959_create_screenings_table.php +++ b/database/migrations/2026_02_03_082959_create_screenings_table.php @@ -16,7 +16,7 @@ public function up(): void Schema::create('screenings', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->integer('score')->nullable(); + $table->decimal('score', 4, 1)->nullable(); $table->boolean('passed')->nullable(); $table->timestamps(); }); diff --git a/database/migrations/2026_02_03_083003_create_configs_table.php b/database/migrations/2026_02_03_083003_create_configs_table.php new file mode 100644 index 0000000..aaf326f --- /dev/null +++ b/database/migrations/2026_02_03_083003_create_configs_table.php @@ -0,0 +1,30 @@ +string('key')->primary(); + $table->json('json_value')->nullable(); + $table->timestamps(); + }); + } + + /** + * Drops the configs table on rollback. + */ + public function down(): void + { + Schema::dropIfExists('configs'); + } +}; diff --git a/resources/js/Pages/Screening/Result.vue b/resources/js/Pages/Screening/Result.vue index a0a8fbf..a969454 100644 --- a/resources/js/Pages/Screening/Result.vue +++ b/resources/js/Pages/Screening/Result.vue @@ -71,7 +71,7 @@ const handleStartCategory = (categoryId) => {
{{ category.name }} diff --git a/resources/js/Pages/Screening/Show.vue b/resources/js/Pages/Screening/Show.vue index 87f5f6b..eb9344b 100644 --- a/resources/js/Pages/Screening/Show.vue +++ b/resources/js/Pages/Screening/Show.vue @@ -1,5 +1,5 @@