471 lines
18 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|
|
}
|