Files
back-meditation/app/Http/Controllers/SurveyQuestionController.php
T
2026-08-20 17:02:25 +03:30

471 lines
18 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Media;
use App\Models\SurveyAnswer;
use App\Models\SurveyQuestion;
use App\Models\Tag;
use App\Models\User;
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.tags', '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.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: paginated list of users with their survey stats (age, gender, answers count).
public function adminUsers(Request $request)
{
$perPage = max(1, min((int) $request->input('per_page', 20), 100));
$answered = $request->input('answered');
$query = User::select('id', 'first_name', 'last_name', 'age', 'gender', 'email', 'mobile', 'created_at', 'identifier')
->withCount(['surveyAnswers as answers_count'])
->withCount(['surveyAnswers as questions_answered' => function ($q) {
$q->select(DB::raw('COUNT(DISTINCT survey_question_id)'));
}])
->orderByDesc('created_at');
if ($answered === '1') {
$query->has('surveyAnswers');
} elseif ($answered === '0') {
$query->doesntHave('surveyAnswers');
}
$users = $query->paginate($perPage);
return response()->json($users);
}
// ADMIN: single user profile with all their survey answers grouped by question.
// Accepts either local user ID or approagency identifier (UUID).
public function adminUserShow($identifier)
{
$query = User::query()
->select('id', 'first_name', 'last_name', 'age', 'gender', 'birthday', 'email', 'mobile', 'created_at')
->withCount(['surveyAnswers as answers_count'])
->withCount(['surveyAnswers as questions_answered' => function ($q) {
$q->select(DB::raw('COUNT(DISTINCT survey_question_id)'));
}]);
if (is_numeric($identifier)) {
$query->where('id', $identifier);
} else {
$query->where('identifier', $identifier);
}
$user = $query->first();
if (!$user) {
abort(404);
}
$answers = SurveyAnswer::where('user_id', $user->id)
->with(['question.options', 'option'])
->get()
->groupBy('survey_question_id')
->map(function ($group) {
$question = $group->first()->question;
return [
'question_id' => $question->id,
'question' => $question->question,
'type' => $question->type,
'answers' => $group->map(fn ($a) => [
'option_id' => $a->survey_option_id,
'label' => $a->option->label,
])->values(),
];
})
->values();
$user->survey_answers = $answers;
return response()->json($user);
}
// 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();
$limit = (int) $request->input('limit', 20);
$limit = max(1, min($limit, 100));
$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');
// Tags behind the user's chosen options.
$tagIds = $optionIds->isEmpty()
? collect()
: DB::table('survey_option_tag')
->whereIn('survey_option_id', $optionIds)
->pluck('tag_id')
->unique()
->values();
// Visible to this user: public or their own.
$visible = function ($q) use ($userId) {
$q->where('visibility', 'public')->orWhere('user_id', $userId);
};
$eager = ['image', 'detailImage', 'categories', 'subCategories', 'tags'];
$media = collect();
if ($tagIds->isNotEmpty()) {
// 1) Media directly sharing the chosen tags, ranked by overlap.
$tagMatched = Media::query()
->whereHas('tags', fn ($q) => $q->whereIn('tags.id', $tagIds))
->withCount(['tags as match_count' => fn ($q) => $q->whereIn('tags.id', $tagIds)])
->where($visible)
->with($eager)
->orderByDesc('match_count')
->orderByDesc('created_at')
->get();
// 2) Broaden to "similar" media: same categories / sub-categories as
// the tag-matched media (so a thinly-tagged catalog still surfaces
// the rest of the topic, not just the one over-tagged item).
$categoryIds = $tagMatched->pluck('categories')->flatten(1)->pluck('id')->unique()->values();
$subCategoryIds = $tagMatched->pluck('subCategories')->flatten(1)->pluck('id')->unique()->values();
$similar = collect();
if ($categoryIds->isNotEmpty() || $subCategoryIds->isNotEmpty()) {
$similar = Media::query()
->where($visible)
->whereNotIn('id', $tagMatched->pluck('id'))
->where(function ($q) use ($categoryIds, $subCategoryIds) {
if ($categoryIds->isNotEmpty()) {
$q->orWhereHas('categories', fn ($c) => $c->whereIn('categories.id', $categoryIds));
}
if ($subCategoryIds->isNotEmpty()) {
$q->orWhereHas('subCategories', fn ($s) => $s->whereIn('sub_categories.id', $subCategoryIds));
}
})
->with($eager)
->orderByDesc('created_at')
->get()
->each(fn ($m) => $m->match_count = 0);
}
$media = $tagMatched->concat($similar);
}
// Fallback (no answers / no tags / nothing matched): popular public media,
// so the suggestions row is never empty.
if ($media->isEmpty()) {
$media = Media::query()
->where($visible)
->withCount('plays as plays_count')
->with($eager)
->orderByDesc('plays_count')
->orderByDesc('created_at')
->get();
}
return response()->json($media->take($limit)->values());
}
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);
}
}
}
}