From 4e30af184126e85e4be1a1ebcd98d4fb8af77deb Mon Sep 17 00:00:00 2001 From: Stepan Date: Wed, 12 Nov 2025 23:04:55 +0100 Subject: [PATCH] UserTests and Answers --- app/Http/Controllers/QuestionController.php | 42 +- app/Http/Controllers/TestController.php | 2 +- app/Http/Controllers/UserTestController.php | 394 ++++++++++++ app/Http/Resources/QuestionResource.php | 2 - app/Http/Resources/UserTestAnswerResource.php | 54 ++ app/Http/Resources/UserTestResource.php | 62 ++ app/Models/Test.php | 5 + app/Models/UserTest.php | 92 +++ app/Models/UserTestAnswer.php | 63 ++ app/Policies/UserTestPolicy.php | 71 +++ ...25_11_11_154002_create_questions_table.php | 1 - ...5_11_12_173704_create_user_tests_table.php | 49 ++ ..._173734_create_user_test_answers_table.php | 33 + routes/api.php | 19 + storage/api-docs/api-docs.json | 562 ++++++++++++++++-- 15 files changed, 1371 insertions(+), 80 deletions(-) create mode 100644 app/Http/Controllers/UserTestController.php create mode 100644 app/Http/Resources/UserTestAnswerResource.php create mode 100644 app/Http/Resources/UserTestResource.php create mode 100644 app/Models/UserTest.php create mode 100644 app/Models/UserTestAnswer.php create mode 100644 app/Policies/UserTestPolicy.php create mode 100644 database/migrations/2025_11_12_173704_create_user_tests_table.php create mode 100644 database/migrations/2025_11_12_173734_create_user_test_answers_table.php diff --git a/app/Http/Controllers/QuestionController.php b/app/Http/Controllers/QuestionController.php index 4b05548..e0e9f82 100644 --- a/app/Http/Controllers/QuestionController.php +++ b/app/Http/Controllers/QuestionController.php @@ -80,10 +80,6 @@ class QuestionController extends Controller * } * ) * ) - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" * ) * ) */ @@ -117,14 +113,6 @@ class QuestionController extends Controller * response=200, * description="Question retrieved successfully", * @OA\JsonContent(ref="#/components/schemas/QuestionResource") - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=404, - * description="Question not found" * ) * ) */ @@ -174,14 +162,6 @@ class QuestionController extends Controller * response=200, * description="Question created successfully", * @OA\JsonContent(ref="#/components/schemas/QuestionResource") - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=422, - * description="Validation error" * ) * ) */ @@ -245,14 +225,6 @@ class QuestionController extends Controller * response=200, * description="Question updated successfully", * @OA\JsonContent(ref="#/components/schemas/QuestionResource") - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=422, - * description="Validation error" * ) * ) */ @@ -289,18 +261,6 @@ class QuestionController extends Controller * @OA\JsonContent( * @OA\Property(property="message", type="string", example="The hitcount was deleted") * ) - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Forbidden" - * ), - * @OA\Response( - * response=404, - * description="Question not found" * ) * ) */ @@ -310,6 +270,6 @@ class QuestionController extends Controller $question->delete(); Log::writeLog("Question '" . $question->title . "' is deleted by " . $request->user()->username); - return ['message' => 'The hitcount was deleted']; + return ['message' => 'The question was deleted']; } } diff --git a/app/Http/Controllers/TestController.php b/app/Http/Controllers/TestController.php index 2ba8b4c..831ee3b 100644 --- a/app/Http/Controllers/TestController.php +++ b/app/Http/Controllers/TestController.php @@ -95,7 +95,7 @@ class TestController extends Controller * required={"title","closed_at","questions"}, * @OA\Property(property="title", type="string", maxLength=255, example="Sample Test"), * @OA\Property(property="description", type="string", nullable=true, example="Optional description"), - * @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01T23:59:59Z"), + * @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01 23:59:59"), * @OA\Property(property="category_id", type="integer", nullable=true, example=3), * @OA\Property( * property="questions", diff --git a/app/Http/Controllers/UserTestController.php b/app/Http/Controllers/UserTestController.php new file mode 100644 index 0000000..7e660f7 --- /dev/null +++ b/app/Http/Controllers/UserTestController.php @@ -0,0 +1,394 @@ +user(); + $userTests = UserTest::where('user_id', $user->id) + ->orderBy('id', 'desc') + ->with(['user', 'category', 'answers.question']) + ->get(); + + return UserTestResource::collection($userTests); + } + + /** + * @OA\Post( + * path="/api/user-tests", + * summary="Create a new user test", + * description="Generate a new UserTest for the authenticated user with questions from a specific category and difficulty range.", + * tags={"UserTests"}, + * security={{"bearerAuth":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"category_id"}, + * @OA\Property(property="category_id", type="integer", example=3, description="Category from which to select questions"), + * @OA\Property(property="min_difficulty", type="integer", nullable=true, example=0, description="Minimum difficulty of questions"), + * @OA\Property(property="max_difficulty", type="integer", nullable=true, example=10, description="Maximum difficulty of questions") + * ) + * ), + * @OA\Response( + * response=422, + * description="Validation error or no questions found" + * ) + * ) + */ + public function store(Request $request) + { + $this->authorize('create', UserTest::class); + + $fields = $request->validate([ + 'category_id' => 'required|exists:categories,id', + 'min_difficulty' => 'nullable|integer', + 'max_difficulty' => 'nullable|integer', + ]); + + $minDifficulty = $fields['min_difficulty'] ?? 0; + $maxDifficulty = $fields['max_difficulty'] ?? 10; + + $questions = Question::where('category_id', $fields['category_id']) + ->whereBetween('difficulty', [$minDifficulty, $maxDifficulty]) + ->inRandomOrder() + ->take(10) + ->get(); + + if ($questions->isEmpty()) { + return response()->json(['message' => 'No questions found for this criteria'], 422); + } + + $userTest = new UserTest(); + $userTest->category_id = $fields['category_id']; + $userTest->user_id = $request->user()->id; + $userTest->closed_at = now()->addDay(); + $userTest->save(); + $userTest->generateEmptyAnswers($questions); + + return new UserTestResource($userTest); + + } + + /** + * @OA\Post( + * path="/api/user-tests/by-test", + * summary="Create a UserTest based on an existing Test", + * description="Generates a UserTest from an existing Test, including all its questions. Only available if the Test is available.", + * tags={"UserTests"}, + * security={{"bearerAuth":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"test_id"}, + * @OA\Property(property="test_id", type="integer", example=1, description="ID of the existing Test to create a UserTest from") + * ) + * ), + * @OA\Response( + * response=200, + * description="UserTest created successfully", + * @OA\JsonContent(ref="#/components/schemas/UserTestResource") + * ), + * @OA\Response( + * response=409, + * description="Test is not available at this time" + * ) + * ) + */ + public function storeByTest(Request $request) + { + $this->authorize('create', UserTest::class); + + $fields = $request->validate([ + 'test_id' => 'required|exists:tests,id' + ]); + + $test = Test::findOrFail($fields['test_id']); + + if(!$test->isAvailable()) + return response(['message' => 'This test isn\'t available now!'], 409); + + $test->load(['questions']); + + $userTest = new UserTest(); + $userTest->category_id = $test->category_id; + $userTest->test_id = $test->id; + $userTest->closed_at = $test->closed_at; + $userTest->user_id = $request->user()->id; + $userTest->save(); + $userTest->generateEmptyAnswers($test->questions->toArray()); + + return new UserTestResource($userTest); + } + + /** + * @OA\Post( + * path="/api/user-test-answers/{answer}/submit", + * summary="Submit an answer for a UserTestAnswer", + * description="Records the user's answer for a specific UserTestAnswer. The answer must match the question type (string for text, integer for single/multiple choice).", + * tags={"UserTestAnswers"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="answer", + * in="path", + * required=true, + * description="ID of the UserTestAnswer to submit", + * @OA\Schema(type="integer") + * ), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"answer"}, + * @OA\Property( + * property="answer", + * type="array", + * description="Array of answers. Should be string(s) if the question type is 'text' or integer(s) otherwise", + * @OA\Items( + * oneOf={ + * @OA\Schema(type="string"), + * @OA\Schema(type="integer") + * } + * ) + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Answer recorded successfully", + * @OA\JsonContent( + * @OA\Property(property="message", type="string", example="Answer is recorded") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=422, + * description="Validation error (wrong type or empty array)" + * ), + * @OA\Response( + * response=403, + * description="Forbidden (user cannot edit this answer)" + * ) + * ) + */ + public function answerByTest(Request $request, UserTestAnswer $answer) + { + $answer->load(['userTest', 'question']); + $answerType = $answer->question->type == 'text' ? 'string' : 'integer'; + + $this->authorize('canBeEdit', $answer->userTest); + + $fields = $request->validate([ + 'answer' => 'required|array|min:1', + 'answer.*' => 'required|' . $answerType + ]); + $answer->answer = $fields['answer']; + $answer->save(); + + return ['message' => 'Answer is recorded']; + } + + /** + * @OA\Get( + * path="/api/user-test-answers/{answer}", + * summary="Get a UserTestAnswer", + * description="Retrieve a specific UserTestAnswer by ID. Only the owner or authorized user can view it.", + * tags={"UserTestAnswers"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="answer", + * in="path", + * required=true, + * description="ID of the UserTestAnswer to retrieve", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="UserTestAnswer retrieved successfully", + * @OA\JsonContent(ref="#/components/schemas/UserTestAnswerResource") + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Forbidden: user cannot view this answer" + * ), + * @OA\Response( + * response=404, + * description="UserTestAnswer not found" + * ) + * ) + */ + public function showAnswer(UserTestAnswer $answer) + { + $answer->load(['userTest', 'question']); + + $this->authorize('view', $answer->userTest); + + return new UserTestAnswerResource($answer); + } + + /** + * @OA\Post( + * path="/api/user-tests/{userTest}/complete", + * summary="Complete a UserTest", + * description="Marks a UserTest as completed, calculates the score based on answers, and prevents further editing.", + * tags={"UserTests"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="userTest", + * in="path", + * required=true, + * description="ID of the UserTest to complete", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Test successfully completed", + * @OA\JsonContent( + * @OA\Property(property="message", type="string", example="Test is completed") + * ) + * ), + * @OA\Response( + * response=403, + * description="Forbidden: user cannot edit this test" + * ) + * ) + */ + public function completeTest(UserTest $userTest) + { + $this->authorize('canBeEdit', $userTest); + + $userTest->load(['category', 'answers.question', 'user']); + $userTest->completeTest(); + + return ['message' => 'Test is completed']; + } + + /** + * @OA\Get( + * path="/api/user-tests/{userTest}", + * summary="Get a single UserTest", + * description="Retrieve a UserTest with its answers, question details, category, and user info.", + * tags={"UserTests"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="userTest", + * in="path", + * required=true, + * description="ID of the UserTest to retrieve", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="UserTest details", + * @OA\JsonContent(ref="#/components/schemas/UserTestResource") + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Forbidden: user cannot view this test" + * ), + * @OA\Response( + * response=404, + * description="UserTest not found" + * ) + * ) + */ + public function show(UserTest $userTest) + { + $this->authorize('view', $userTest); + $userTest->load(['user', 'category', 'answers.question']); + + return new UserTestResource($userTest); + } + + /** + * @OA\Delete( + * path="/api/user-tests/{userTest}", + * summary="Delete a UserTest", + * description="Delete a UserTest by ID. Only the owner or admin can delete it.", + * tags={"UserTests"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="userTest", + * in="path", + * required=true, + * description="ID of the UserTest to delete", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="UserTest deleted successfully", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="message", type="string", example="The UserTest was deleted") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Forbidden: user cannot delete this test" + * ), + * @OA\Response( + * response=404, + * description="UserTest not found" + * ) + * ) + */ + public function destroy(Request $request, UserTest $userTest) + { + $this->authorize('delete', $userTest); + $userTest->load('user'); + + $username = $userTest->user ? $userTest->user->username : 'unknown'; + $userTestId = $userTest->id; + + $userTest->delete(); + + Log::writeLog("UserTest #$userTestId ($username)' is deleted by " . $request->user()->username); + + return ['message' => 'The UserTest was deleted']; + } +} diff --git a/app/Http/Resources/QuestionResource.php b/app/Http/Resources/QuestionResource.php index ceaa97b..b458c30 100644 --- a/app/Http/Resources/QuestionResource.php +++ b/app/Http/Resources/QuestionResource.php @@ -33,7 +33,6 @@ class QuestionResource extends JsonResource * type="array", * @OA\Items(type="integer", example=1) * ), - * @OA\Property(property="is_pending_question", type="integer", example=0), * @OA\Property(property="category_id", type="integer", nullable=true, example=3), * @OA\Property(property="category", ref="#/components/schemas/CategoryResource"), * @OA\Property(property="author_id", type="integer", nullable=true, example=2), @@ -52,7 +51,6 @@ class QuestionResource extends JsonResource 'difficulty' => $this->difficulty, 'variants' => $this->variants, 'correct_answers' => $this->correct_answers, - 'is_pending_question' => $this->is_pending_question, 'category_id' => $this->category_id, 'category' => new CategoryResource($this->whenLoaded('category')), diff --git a/app/Http/Resources/UserTestAnswerResource.php b/app/Http/Resources/UserTestAnswerResource.php new file mode 100644 index 0000000..2c54a15 --- /dev/null +++ b/app/Http/Resources/UserTestAnswerResource.php @@ -0,0 +1,54 @@ + $this->id, + + 'user_test_id' => $this->user_test_id, + + 'question_id' => $this->question_id, + 'question' => new QuestionResource($this->whenLoaded('question')), + + 'answer' => $this->answer, + + 'user_id' => $this->user_id, + 'user' => new UserResource($this->whenLoaded('user')), + + 'is_correct' => $this->isCorrect(), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/UserTestResource.php b/app/Http/Resources/UserTestResource.php new file mode 100644 index 0000000..7fdcbf2 --- /dev/null +++ b/app/Http/Resources/UserTestResource.php @@ -0,0 +1,62 @@ + $this->id, + + 'test_id' => $this->test_id, + 'test' => new TestResource($this->whenLoaded('test')), + + 'user_id' => $this->user_id, + 'user' => new UserResource($this->whenLoaded('user')), + + 'closed_at' => $this->closed_at, + 'is_completed' => $this->is_completed, + 'score' => $this->score, + + 'is_available' => $this->isAvailable(), + 'answers' => UserTestAnswerResource::collection($this->whenLoaded('answers')), + + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/Test.php b/app/Models/Test.php index d869565..768eeff 100644 --- a/app/Models/Test.php +++ b/app/Models/Test.php @@ -24,6 +24,11 @@ class Test extends Model 'updated_at', ]; + public function isAvailable() + { + return $this->closed_at === null || now()->lt($this->closed_at); + } + public function category() { return $this->belongsTo(Category::class); diff --git a/app/Models/UserTest.php b/app/Models/UserTest.php new file mode 100644 index 0000000..a6ab37f --- /dev/null +++ b/app/Models/UserTest.php @@ -0,0 +1,92 @@ + 'datetime', + 'is_completed' => 'boolean', + ]; + + /** + * Generating empty answers to fill later + * @param Collection|array $questions + * @return void + */ + public function generateEmptyAnswers(Collection|array $questions) + { + $answers = []; + foreach ($questions as $question) { + $questionId = is_array($question) ? $question['id'] : $question->id; + + $answers[] = [ + 'question_id' => $questionId, + 'user_test_id' => $this->id, + 'user_id' => $this->user_id, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + UserTestAnswer::insert($answers); + } + + public function completeTest() + { + $correctAnswers = 0; + $allAnswers = $this->answers->count(); + + foreach ($this->answers as $answer) { + if($answer->isCorrect()) + $correctAnswers++; + } + + $this->score = intval($correctAnswers * 100 / $allAnswers); + $this->is_completed = true; + $this->save(); + } + + /** + * Check cooldown for current test + * @return bool + */ + public function isAvailable() + { + return (!$this->is_completed) && + ($this->closed_at === null || now()->lt($this->closed_at)); + } + + public function category() + { + return $this->belongsTo(Category::class); + } + + public function test() + { + return $this->belongsTo(Test::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + public function answers() + { + return $this->hasMany(UserTestAnswer::class); + } +} \ No newline at end of file diff --git a/app/Models/UserTestAnswer.php b/app/Models/UserTestAnswer.php new file mode 100644 index 0000000..12a876b --- /dev/null +++ b/app/Models/UserTestAnswer.php @@ -0,0 +1,63 @@ + 'array', + ]; + + public function isCorrect() { + if(empty($this->answer)) + return false; + + if($this->question->type == 'text') { + $correctAnswer = trim(strtolower($this->question->correct_answers[0])); + $answer = trim(strtolower($this->answer[0])); + + return $correctAnswer == $answer; + } + if($this->question->type == 'single') { + $correctAnswer = intval($this->question->correct_answers[0]); + $answer = intval($this->answer[0]); + + return $correctAnswer == $answer; + } + + if($this->question->type == 'multiple') { + $correctAnswers = collect($this->question->correct_answers)->map(fn($a) => (int)$a)->sort()->values()->all(); + $answers = collect($this->answer)->map(fn($a) => (int)$a)->sort()->values()->all(); + + return $correctAnswers === $answers; + } + + return false; + } + + public function question() + { + return $this->belongsTo(Question::class); + } + + public function userTest() + { + return $this->belongsTo(UserTest::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Policies/UserTestPolicy.php b/app/Policies/UserTestPolicy.php new file mode 100644 index 0000000..e55d831 --- /dev/null +++ b/app/Policies/UserTestPolicy.php @@ -0,0 +1,71 @@ +id == $userTest->user_id || $user->type == 'creator' || $user->type == 'admin'; + } + + public function canBeEdit(User $user, UserTest $userTest): bool + { + return ($user->id == $userTest->user_id && $userTest->isAvailable()) || $user->type == 'creator' || $user->type == 'admin'; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->type != 'banned'; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, UserTest $userTest): bool + { + return $user->type == 'admin'; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, UserTest $userTest): bool + { + return $user->type == 'admin'; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, UserTest $userTest): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, UserTest $userTest): bool + { + return false; + } +} diff --git a/database/migrations/2025_11_11_154002_create_questions_table.php b/database/migrations/2025_11_11_154002_create_questions_table.php index 5f451c1..168dc18 100644 --- a/database/migrations/2025_11_11_154002_create_questions_table.php +++ b/database/migrations/2025_11_11_154002_create_questions_table.php @@ -20,7 +20,6 @@ return new class extends Migration $table->unsignedBigInteger('category_id')->nullable(); $table->unsignedBigInteger('author_id')->nullable(); $table->text('variants'); - $table->tinyInteger('is_pending_question')->unsigned()->default(0); $table->text('correct_answers')->default('[]'); $table->foreign('author_id')->references('id')->on('users')->onDelete('set null'); diff --git a/database/migrations/2025_11_12_173704_create_user_tests_table.php b/database/migrations/2025_11_12_173704_create_user_tests_table.php new file mode 100644 index 0000000..4d9ccc4 --- /dev/null +++ b/database/migrations/2025_11_12_173704_create_user_tests_table.php @@ -0,0 +1,49 @@ +id(); + $table->unsignedBigInteger('category_id')->nullable(); + $table->unsignedBigInteger('test_id')->nullable(); + $table->unsignedBigInteger('user_id'); + $table->dateTime('closed_at')->nullable(); + $table->boolean('is_completed')->default(false); + $table->integer('score')->default(0); + + $table->foreign('category_id') + ->references('id') + ->on('categories') + ->onDelete('set null'); + + $table->foreign('test_id') + ->references('id') + ->on('tests') + ->onDelete('set null'); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_tests'); + } +}; diff --git a/database/migrations/2025_11_12_173734_create_user_test_answers_table.php b/database/migrations/2025_11_12_173734_create_user_test_answers_table.php new file mode 100644 index 0000000..777a23b --- /dev/null +++ b/database/migrations/2025_11_12_173734_create_user_test_answers_table.php @@ -0,0 +1,33 @@ +id(); + + $table->foreignId('question_id')->constrained()->cascadeOnDelete(); + $table->text('answer')->nullable(); + $table->foreignId('user_test_id')->constrained('user_tests')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_test_answers'); + } +}; diff --git a/routes/api.php b/routes/api.php index e3385d3..a3349ac 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,8 +7,27 @@ use App\Http\Controllers\LogController; use App\Http\Controllers\QuestionController; use App\Http\Controllers\TestController; use App\Http\Controllers\UserController; +use App\Http\Controllers\UserTestController; use Illuminate\Support\Facades\Route; + +// UserTests +Route::middleware('auth:sanctum')->group(function () { + Route::get('user-tests', [UserTestController::class, 'index']); + Route::post('user-tests', [UserTestController::class, 'store']); + Route::post('user-tests/by-test', [UserTestController::class, 'storeByTest']); + + Route::get('user-tests/{userTest}', [UserTestController::class, 'show']); + Route::post('user-tests/{userTest}/complete', [UserTestController::class, 'completeTest']); + Route::delete('user-tests/{userTest}', [UserTestController::class, 'destroy']); +}); + +// UserTestAnswers +Route::middleware('auth:sanctum')->group(function () { + Route::get('user-test-answers/{answer}', [UserTestController::class, 'showAnswer']); + Route::post('user-test-answers/{answer}/submit', [UserTestController::class, 'answerByTest']); +}); + // Tests Route::get('tests', [TestController::class, 'index']); Route::get('tests/{test}', [TestController::class, 'show']); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index de62e44..d0757a2 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -1178,9 +1178,6 @@ } } } - }, - "401": { - "description": "Unauthorized" } } }, @@ -1275,12 +1272,6 @@ } } } - }, - "401": { - "description": "Unauthorized" - }, - "422": { - "description": "Validation error" } }, "security": [ @@ -1319,12 +1310,6 @@ } } } - }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Question not found" } } }, @@ -1430,12 +1415,6 @@ } } } - }, - "401": { - "description": "Unauthorized" - }, - "422": { - "description": "Validation error" } }, "security": [ @@ -1478,15 +1457,6 @@ } } } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Question not found" } }, "security": [ @@ -1626,7 +1596,7 @@ "closed_at": { "type": "string", "format": "date-time", - "example": "2025-12-01T23:59:59Z", + "example": "2025-12-01 23:59:59", "nullable": true }, "category_id": { @@ -2187,6 +2157,415 @@ } ] } + }, + "/api/user-tests": { + "get": { + "tags": [ + "UserTests" + ], + "summary": "Get list of user tests for the authenticated user", + "description": "Retrieve all UserTests belonging to the authenticated user, with related category, test, and answers.", + "operationId": "bf312e7c74799e8dbaa29025b8f52f46", + "responses": { + "200": { + "description": "List of user tests", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserTestResource" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "UserTests" + ], + "summary": "Create a new user test", + "description": "Generate a new UserTest for the authenticated user with questions from a specific category and difficulty range.", + "operationId": "dab4526b31f8a1bf1abf3048c3a4ecbe", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "category_id" + ], + "properties": { + "category_id": { + "description": "Category from which to select questions", + "type": "integer", + "example": 3 + }, + "min_difficulty": { + "description": "Minimum difficulty of questions", + "type": "integer", + "example": 0, + "nullable": true + }, + "max_difficulty": { + "description": "Maximum difficulty of questions", + "type": "integer", + "example": 10, + "nullable": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "422": { + "description": "Validation error or no questions found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/user-tests/by-test": { + "post": { + "tags": [ + "UserTests" + ], + "summary": "Create a UserTest based on an existing Test", + "description": "Generates a UserTest from an existing Test, including all its questions. Only available if the Test is available.", + "operationId": "81b613bf7df7621756ee71dc88027236", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "test_id" + ], + "properties": { + "test_id": { + "description": "ID of the existing Test to create a UserTest from", + "type": "integer", + "example": 1 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "UserTest created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserTestResource" + } + } + } + }, + "409": { + "description": "Test is not available at this time" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/user-test-answers/{answer}/submit": { + "post": { + "tags": [ + "UserTestAnswers" + ], + "summary": "Submit an answer for a UserTestAnswer", + "description": "Records the user's answer for a specific UserTestAnswer. The answer must match the question type (string for text, integer for single/multiple choice).", + "operationId": "4a7d5822bbbe5fe72fed7aca775ab792", + "parameters": [ + { + "name": "answer", + "in": "path", + "description": "ID of the UserTestAnswer to submit", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "answer" + ], + "properties": { + "answer": { + "description": "Array of answers. Should be string(s) if the question type is 'text' or integer(s) otherwise", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Answer recorded successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Answer is recorded" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Validation error (wrong type or empty array)" + }, + "403": { + "description": "Forbidden (user cannot edit this answer)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/user-test-answers/{answer}": { + "get": { + "tags": [ + "UserTestAnswers" + ], + "summary": "Get a UserTestAnswer", + "description": "Retrieve a specific UserTestAnswer by ID. Only the owner or authorized user can view it.", + "operationId": "ea81ce3e7c5383b8fabeef5338bb1db9", + "parameters": [ + { + "name": "answer", + "in": "path", + "description": "ID of the UserTestAnswer to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "UserTestAnswer retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserTestAnswerResource" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden: user cannot view this answer" + }, + "404": { + "description": "UserTestAnswer not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/user-tests/{userTest}/complete": { + "post": { + "tags": [ + "UserTests" + ], + "summary": "Complete a UserTest", + "description": "Marks a UserTest as completed, calculates the score based on answers, and prevents further editing.", + "operationId": "d9265537e9cfb510ba68eede4930ea09", + "parameters": [ + { + "name": "userTest", + "in": "path", + "description": "ID of the UserTest to complete", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Test successfully completed", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Test is completed" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Forbidden: user cannot edit this test" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/user-tests/{userTest}": { + "get": { + "tags": [ + "UserTests" + ], + "summary": "Get a single UserTest", + "description": "Retrieve a UserTest with its answers, question details, category, and user info.", + "operationId": "0de523e8221fc7a1b13fb6dbfd40e3f3", + "parameters": [ + { + "name": "userTest", + "in": "path", + "description": "ID of the UserTest to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "UserTest details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserTestResource" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden: user cannot view this test" + }, + "404": { + "description": "UserTest not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "UserTests" + ], + "summary": "Delete a UserTest", + "description": "Delete a UserTest by ID. Only the owner or admin can delete it.", + "operationId": "a5e4b2f9860f4428f72bd99b5eb9688d", + "parameters": [ + { + "name": "userTest", + "in": "path", + "description": "ID of the UserTest to delete", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "UserTest deleted successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "The UserTest was deleted" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden: user cannot delete this test" + }, + "404": { + "description": "UserTest not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -2326,10 +2705,6 @@ "example": 1 } }, - "is_pending_question": { - "type": "integer", - "example": 0 - }, "category_id": { "type": "integer", "example": 3, @@ -2449,6 +2824,115 @@ } }, "type": "object" + }, + "UserTestAnswerResource": { + "title": "UserTestAnswer", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "user_test_id": { + "type": "integer", + "example": 5 + }, + "question_id": { + "type": "integer", + "example": 12 + }, + "question": { + "$ref": "#/components/schemas/QuestionResource" + }, + "answer": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "42", + "43" + ] + }, + "user_id": { + "type": "integer", + "example": 10 + }, + "user": { + "$ref": "#/components/schemas/UserResource" + }, + "is_correct": { + "type": "boolean", + "example": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-11-11T20:39:22Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2025-11-11T20:39:22Z" + } + }, + "type": "object" + }, + "UserTestResource": { + "title": "UserTest", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "test_id": { + "type": "integer", + "example": 5 + }, + "test": { + "$ref": "#/components/schemas/TestResource" + }, + "user_id": { + "type": "integer", + "example": 10 + }, + "user": { + "$ref": "#/components/schemas/UserResource" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "example": "2025-12-01T23:59:59Z" + }, + "is_completed": { + "type": "boolean", + "example": false + }, + "score": { + "type": "integer", + "example": 85 + }, + "is_available": { + "type": "boolean", + "example": true + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserTestAnswerResource" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-11-11T20:39:22Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2025-11-11T20:39:22Z" + } + }, + "type": "object" } }, "securitySchemes": { @@ -2487,6 +2971,14 @@ { "name": "Users", "description": "Users" + }, + { + "name": "UserTests", + "description": "UserTests" + }, + { + "name": "UserTestAnswers", + "description": "UserTestAnswers" } ] } \ No newline at end of file