feat: add survey
This commit is contained in:
@@ -0,0 +1,249 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\SurveyQuestion;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class SurveyQuestionController extends Controller
|
||||||
|
{
|
||||||
|
// USER: list active questions with their options and the current user's own answers.
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$questions = SurveyQuestion::with(['options', 'userAnswers'])
|
||||||
|
->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SurveyAnswer extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['user_id', 'survey_question_id', 'survey_option_id'];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class SurveyOption extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['survey_question_id', 'label', 'value', 'order'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function question(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SurveyQuestion::class, 'survey_question_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function answers(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SurveyAnswer::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class SurveyQuestion extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['question', 'description', 'type', 'order', 'is_active'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => '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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('survey_questions', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('survey_options', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('survey_answers', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
use App\Http\Controllers\WorryController;
|
use App\Http\Controllers\WorryController;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use App\Http\Controllers\QuestionController;
|
use App\Http\Controllers\QuestionController;
|
||||||
|
use App\Http\Controllers\SurveyQuestionController;
|
||||||
use App\Http\Controllers\SliderController;
|
use App\Http\Controllers\SliderController;
|
||||||
use App\Http\Controllers\ImageController;
|
use App\Http\Controllers\ImageController;
|
||||||
use App\Http\Controllers\MusicController;
|
use App\Http\Controllers\MusicController;
|
||||||
@@ -116,6 +117,20 @@
|
|||||||
Route::delete('/questions/{question}', [QuestionController::class, 'destroy']);
|
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
|
/// slider feature
|
||||||
// Route::get('/slider', [SliderController::class, 'index']);
|
// Route::get('/slider', [SliderController::class, 'index']);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user