diff --git a/app/Http/Controllers/SurveyQuestionController.php b/app/Http/Controllers/SurveyQuestionController.php new file mode 100644 index 0000000..f7e32bd --- /dev/null +++ b/app/Http/Controllers/SurveyQuestionController.php @@ -0,0 +1,249 @@ +where('is_active', true) + ->orderBy('order') + ->get(); + + return response()->json($questions); + } + + // USER: a single question with its options and the current user's own answers. + public function show($id) + { + $question = SurveyQuestion::with(['options', 'userAnswers'])->findOrFail($id); + + return response()->json($question); + } + + // ADMIN: list every question (incl. inactive) with options (+ vote tallies) and all answers. + public function adminIndex(Request $request) + { + $questions = SurveyQuestion::with([ + 'options' => fn ($q) => $q->withCount('answers'), + 'answers' => fn ($q) => $q->with(['user', 'option']), + ]) + ->orderBy('order') + ->get(); + + return response()->json($questions); + } + + // ADMIN: a single question with options (+ vote tallies) and all users' answers. + public function adminShow($id) + { + $question = SurveyQuestion::with([ + 'options' => fn ($q) => $q->withCount('answers'), + 'answers' => fn ($q) => $q->with(['user', 'option']), + ]) + ->findOrFail($id); + + return response()->json($question); + } + + // ADMIN: clean aggregated results — per option vote counts & percentages, no raw rows. + // Pass an $id for one question, omit it for all questions. + public function adminAnalytics($id = null) + { + $query = SurveyQuestion::with(['options' => fn ($q) => $q->withCount('answers')]); + + if (!is_null($id)) { + $query->where('id', $id); + } + + $questions = $query->orderBy('order')->get(); + + if (!is_null($id) && $questions->isEmpty()) { + abort(404); + } + + $analytics = $questions->map(function (SurveyQuestion $question) { + // Respondents = distinct users who answered (not number of selections). + $respondents = $question->answers()->distinct('user_id')->count('user_id'); + $totalSelections = (int) $question->options->sum('answers_count'); + + return [ + 'id' => $question->id, + 'question' => $question->question, + 'description' => $question->description, + 'type' => $question->type, + 'is_active' => $question->is_active, + 'total_respondents' => $respondents, + 'total_selections' => $totalSelections, + 'options' => $question->options->map(function ($option) use ($respondents) { + $votes = (int) $option->answers_count; + + return [ + 'id' => $option->id, + 'label' => $option->label, + 'value' => $option->value, + 'votes' => $votes, + // % of respondents who picked this option (can exceed 100% summed for multi-select). + 'percentage' => $respondents > 0 ? round($votes / $respondents * 100, 1) : 0, + ]; + })->values(), + ]; + }); + + return response()->json(is_null($id) ? $analytics->values() : $analytics->first()); + } + + // CREATE a question together with its options. + public function store(Request $request) + { + $data = $request->validate([ + 'question' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:single,multiple', + 'order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + 'options' => 'required|array|min:1', + 'options.*.label' => 'required|string|max:255', + 'options.*.value' => 'nullable|string|max:255', + 'options.*.order' => 'nullable|integer', + ]); + + $question = DB::transaction(function () use ($data) { + $question = SurveyQuestion::create([ + 'question' => $data['question'], + 'description' => $data['description'] ?? null, + 'type' => $data['type'], + 'order' => $data['order'] ?? 0, + 'is_active' => $data['is_active'] ?? true, + ]); + + $this->syncOptions($question, $data['options']); + + return $question; + }); + + return response()->json([ + 'message' => 'Question created successfully', + 'question' => $question->load('options'), + ], 201); + } + + // UPDATE a question; if options are provided they replace the existing set. + public function update(Request $request, $id) + { + $question = SurveyQuestion::findOrFail($id); + + $data = $request->validate([ + 'question' => 'sometimes|string|max:255', + 'description' => 'nullable|string', + 'type' => 'sometimes|in:single,multiple', + 'order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + 'options' => 'sometimes|array|min:1', + 'options.*.label' => 'required_with:options|string|max:255', + 'options.*.value' => 'nullable|string|max:255', + 'options.*.order' => 'nullable|integer', + ]); + + DB::transaction(function () use ($question, $data) { + $question->update(array_filter( + [ + 'question' => $data['question'] ?? null, + 'description' => array_key_exists('description', $data) ? $data['description'] : null, + 'type' => $data['type'] ?? null, + 'order' => $data['order'] ?? null, + 'is_active' => $data['is_active'] ?? null, + ], + fn ($value) => !is_null($value) + )); + + if (array_key_exists('options', $data)) { + // Replacing options invalidates existing answers for this question. + $question->answers()->delete(); + $question->options()->delete(); + $this->syncOptions($question, $data['options']); + } + }); + + return response()->json([ + 'message' => 'Question updated successfully', + 'question' => $question->load('options'), + ]); + } + + public function destroy($id) + { + $question = SurveyQuestion::findOrFail($id); + $question->delete(); // options + answers cascade + + return response()->json(['message' => 'Question deleted successfully']); + } + + // USER submits their answer(s) for a question. + public function answer(Request $request, $id) + { + $question = SurveyQuestion::findOrFail($id); + + $data = $request->validate([ + 'option_ids' => 'required|array|min:1', + 'option_ids.*' => 'integer', + ]); + + $optionIds = array_values(array_unique($data['option_ids'])); + + // Every selected option must belong to this question. + $validOptionIds = $question->options()->pluck('id')->all(); + if (array_diff($optionIds, $validOptionIds)) { + throw ValidationException::withMessages([ + 'option_ids' => ['One or more options do not belong to this question.'], + ]); + } + + // Enforce single vs multiple selection. + if ($question->type === 'single' && count($optionIds) > 1) { + throw ValidationException::withMessages([ + 'option_ids' => ['This question allows only a single option.'], + ]); + } + + $userId = auth()->id(); + + DB::transaction(function () use ($question, $optionIds, $userId) { + // Replace any previous answer for this user + question. + $question->answers()->where('user_id', $userId)->delete(); + + $rows = array_map(fn ($optionId) => [ + 'user_id' => $userId, + 'survey_question_id' => $question->id, + 'survey_option_id' => $optionId, + 'created_at' => now(), + 'updated_at' => now(), + ], $optionIds); + + $question->answers()->getRelated()->insert($rows); + }); + + return response()->json([ + 'message' => 'Answer submitted successfully', + 'question' => $question->load(['options', 'userAnswers']), + ]); + } + + private function syncOptions(SurveyQuestion $question, array $options): void + { + foreach (array_values($options) as $i => $option) { + $question->options()->create([ + 'label' => $option['label'], + 'value' => $option['value'] ?? null, + 'order' => $option['order'] ?? $i, + ]); + } + } +} diff --git a/app/Models/SurveyAnswer.php b/app/Models/SurveyAnswer.php new file mode 100644 index 0000000..bef7398 --- /dev/null +++ b/app/Models/SurveyAnswer.php @@ -0,0 +1,26 @@ +belongsTo(User::class); + } + + public function question(): BelongsTo + { + return $this->belongsTo(SurveyQuestion::class, 'survey_question_id'); + } + + public function option(): BelongsTo + { + return $this->belongsTo(SurveyOption::class, 'survey_option_id'); + } +} diff --git a/app/Models/SurveyOption.php b/app/Models/SurveyOption.php new file mode 100644 index 0000000..d8a0dcb --- /dev/null +++ b/app/Models/SurveyOption.php @@ -0,0 +1,26 @@ + 'integer', + ]; + + public function question(): BelongsTo + { + return $this->belongsTo(SurveyQuestion::class, 'survey_question_id'); + } + + public function answers(): HasMany + { + return $this->hasMany(SurveyAnswer::class); + } +} diff --git a/app/Models/SurveyQuestion.php b/app/Models/SurveyQuestion.php new file mode 100644 index 0000000..722c71b --- /dev/null +++ b/app/Models/SurveyQuestion.php @@ -0,0 +1,32 @@ + 'boolean', + 'order' => 'integer', + ]; + + public function options(): HasMany + { + return $this->hasMany(SurveyOption::class)->orderBy('order'); + } + + public function answers(): HasMany + { + return $this->hasMany(SurveyAnswer::class); + } + + // The current user's selected option ids for this question. + public function userAnswers(): HasMany + { + return $this->hasMany(SurveyAnswer::class)->where('user_id', auth()->id()); + } +} diff --git a/database/migrations/2026_06_01_100000_create_survey_questions_table.php b/database/migrations/2026_06_01_100000_create_survey_questions_table.php new file mode 100644 index 0000000..e70c0e1 --- /dev/null +++ b/database/migrations/2026_06_01_100000_create_survey_questions_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('question'); + $table->text('description')->nullable(); + // single = user picks exactly one option, multiple = user can pick many + $table->enum('type', ['single', 'multiple'])->default('single'); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('survey_questions'); + } +}; diff --git a/database/migrations/2026_06_01_100100_create_survey_options_table.php b/database/migrations/2026_06_01_100100_create_survey_options_table.php new file mode 100644 index 0000000..f5d2816 --- /dev/null +++ b/database/migrations/2026_06_01_100100_create_survey_options_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('survey_question_id')->constrained('survey_questions')->cascadeOnDelete(); + $table->string('label'); + $table->string('value')->nullable(); // optional machine value + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index(['survey_question_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('survey_options'); + } +}; diff --git a/database/migrations/2026_06_01_100200_create_survey_answers_table.php b/database/migrations/2026_06_01_100200_create_survey_answers_table.php new file mode 100644 index 0000000..05c1b1f --- /dev/null +++ b/database/migrations/2026_06_01_100200_create_survey_answers_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('survey_question_id')->constrained('survey_questions')->cascadeOnDelete(); + $table->foreignId('survey_option_id')->constrained('survey_options')->cascadeOnDelete(); + $table->timestamps(); + + // A user can select a given option only once. + $table->unique(['user_id', 'survey_option_id']); + $table->index(['user_id', 'survey_question_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('survey_answers'); + } +}; diff --git a/routes/api.php b/routes/api.php index 3936da7..7a2c1eb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,7 @@ use App\Http\Controllers\WorryController; use Illuminate\Support\Facades\Hash; use App\Http\Controllers\QuestionController; +use App\Http\Controllers\SurveyQuestionController; use App\Http\Controllers\SliderController; use App\Http\Controllers\ImageController; use App\Http\Controllers\MusicController; @@ -116,6 +117,20 @@ Route::delete('/questions/{question}', [QuestionController::class, 'destroy']); + /// survey questions feature (question + description + single/multi options, answered by users) + // Admin: see all users' answers (must be registered before the resource so it isn't caught by {survey_question}). + Route::middleware('abilities:admin')->group(function () { + // Aggregated analytics — register before the {id} routes so "analytics" isn't read as an id. + Route::get('/admin/survey-questions/analytics', [SurveyQuestionController::class, 'adminAnalytics']); + Route::get('/admin/survey-questions/{id}/analytics', [SurveyQuestionController::class, 'adminAnalytics']); + Route::get('/admin/survey-questions', [SurveyQuestionController::class, 'adminIndex']); + Route::get('/admin/survey-questions/{id}', [SurveyQuestionController::class, 'adminShow']); + }); + // User: answer + read questions with their own answers only. + Route::post('/survey-questions/{id}/answer', [SurveyQuestionController::class, 'answer']); + Route::apiResource('survey-questions', SurveyQuestionController::class); + + /// slider feature // Route::get('/slider', [SliderController::class, 'index']);