feat: add survey

This commit is contained in:
2026-06-01 09:08:12 +03:30
parent b3a7edf3a5
commit a14267f54f
8 changed files with 448 additions and 0 deletions
@@ -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,
]);
}
}
}