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.tags', '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')->with('tags'), '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')->with('tags'), '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', 'options.*.tags' => 'nullable|array', 'options.*.tags.*' => 'string', ]); $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.tags'), ], 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', 'options.*.tags' => 'nullable|array', 'options.*.tags.*' => 'string', ]); 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.tags'), ]); } 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 single question. // An empty option_ids array (or omitting it) means "no answer" / skip. public function answer(Request $request, $id) { $question = SurveyQuestion::findOrFail($id); $data = $request->validate([ 'option_ids' => 'nullable|array', 'option_ids.*' => 'integer', ]); $userId = auth()->id(); DB::transaction(function () use ($question, $data, $userId) { $this->syncAnswerFor($question, $data['option_ids'] ?? [], $userId); }); return response()->json([ 'message' => 'Answer submitted successfully', 'question' => $question->load(['options', 'userAnswers']), ]); } // USER submits answers for many questions at once. Each item may carry an // empty option_ids (skip), and the whole answers array may be empty too. public function bulkAnswer(Request $request) { $data = $request->validate([ 'answers' => 'present|array', 'answers.*.question_id' => 'required|integer|exists:survey_questions,id', 'answers.*.option_ids' => 'nullable|array', 'answers.*.option_ids.*' => 'integer', ]); $userId = auth()->id(); $items = $data['answers'] ?? []; $questions = SurveyQuestion::with('options') ->whereIn('id', collect($items)->pluck('question_id')->unique()->all()) ->get() ->keyBy('id'); DB::transaction(function () use ($items, $questions, $userId) { foreach ($items as $item) { $question = $questions->get($item['question_id']); if (!$question) { continue; } $this->syncAnswerFor($question, $item['option_ids'] ?? [], $userId); } }); return response()->json([ 'message' => 'Answers submitted successfully', 'questions' => SurveyQuestion::with(['options', 'userAnswers']) ->where('is_active', true) ->orderBy('order') ->get(), ]); } // Validate and replace a single user's answer set for one question. // Empty $optionIds clears the answer (the user chose not to answer it). private function syncAnswerFor(SurveyQuestion $question, array $optionIds, int $userId): void { $optionIds = array_values(array_unique(array_map('intval', $optionIds))); // 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 question #{$question->id}."], ]); } // Enforce single vs multiple selection. if ($question->type === 'single' && count($optionIds) > 1) { throw ValidationException::withMessages([ 'option_ids' => ["Question #{$question->id} allows only a single option."], ]); } // Replace any previous answer for this user + question. $question->answers()->where('user_id', $userId)->delete(); if ($optionIds) { $now = now(); $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); } } // USER: suggest media based on the tags attached to the options this user has chosen. public function suggestedMedia(Request $request) { $userId = auth()->id(); $answersQuery = SurveyAnswer::where('user_id', $userId); if ($request->filled('question_id')) { $answersQuery->where('survey_question_id', $request->question_id); } $optionIds = $answersQuery->pluck('survey_option_id'); if ($optionIds->isEmpty()) { return response()->json([]); } // Collect the tags behind the chosen options. $tagIds = DB::table('survey_option_tag') ->whereIn('survey_option_id', $optionIds) ->pluck('tag_id') ->unique() ->values(); if ($tagIds->isEmpty()) { return response()->json([]); } // Media sharing those tags, ranked by how many of them match. $media = Media::query() ->whereHas('tags', fn ($q) => $q->whereIn('tags.id', $tagIds)) ->withCount(['tags as match_count' => fn ($q) => $q->whereIn('tags.id', $tagIds)]) ->where(function ($q) use ($userId) { $q->where('visibility', 'public')->orWhere('user_id', $userId); }) ->with(['image', 'categories', 'subCategories', 'tags']) ->orderByDesc('match_count') ->orderByDesc('created_at') ->get(); return response()->json($media); } private function syncOptions(SurveyQuestion $question, array $options): void { foreach (array_values($options) as $i => $option) { $created = $question->options()->create([ 'label' => $option['label'], 'value' => $option['value'] ?? null, 'order' => $option['order'] ?? $i, ]); if (!empty($option['tags'])) { $tagIds = collect($option['tags']) ->map(fn ($name) => Tag::firstOrCreate(['name' => $name])->id) ->all(); $created->tags()->sync($tagIds); } } } }