diff --git a/.env.example b/.env.example index 654a61c..c5e171c 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,8 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +MAIL_ACTIVATION_FRONTEND=https://localhost:3156/auth/email-activation/${ACTIVATION_CODE} +MAIL_RESET_PASS_FRONTEND=https://localhost:3156/auth/reset-password/${FORGOT_CODE} AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= @@ -63,3 +65,5 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +OPENAI_MODEL=gpt-4.1-mini +OPENAI_KEY= \ No newline at end of file diff --git a/README.md b/README.md index 3e8f1fe..bedc90c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ HoshiAI-be — the backend part of the HoshiAI project for the Web Programming c * [Laravel (Sanctum for api)](https://laravel.com/) * [L5-Swagger](https://github.com/DarkaOnLine/L5-Swagger) * [mobiledetect/mobiledetectlib](https://packagist.org/packages/mobiledetect/mobiledetectlib) +* [openai-php/client](https://github.com/openai-php/client) ## External API * [ip-api.com](http://ip-api.com/) @@ -37,6 +38,7 @@ MAIL_PASSWORD= MAIL_ENCRYPTION= MAIL_FROM_ADDRESS= ... +OPENAI_MODEL=gpt-4.1-mini OPENAI_KEY= ``` 4. Make migrations to your db diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php index 6e4e145..135931c 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/CategoryController.php @@ -26,7 +26,8 @@ class CategoryController extends Controller */ public function index() { - return CategoryResource::collection(Category::all()); + $categories = Category::withCount('user_tests', 'questions')->get(); + return CategoryResource::collection($categories); } /** @@ -91,7 +92,7 @@ class CategoryController extends Controller */ public function show(Category $category) { - return new CategoryResource($category); + return new CategoryResource($category->withCount('user_tests', 'questions')); } /** diff --git a/app/Http/Controllers/QuestionController.php b/app/Http/Controllers/QuestionController.php index e0e9f82..cb945f4 100644 --- a/app/Http/Controllers/QuestionController.php +++ b/app/Http/Controllers/QuestionController.php @@ -7,6 +7,7 @@ use App\Models\Log; use App\Models\Question; use App\Rules\ValidVariants; use Illuminate\Http\Request; +use OpenAI; class QuestionController extends Controller { @@ -23,8 +24,7 @@ class QuestionController extends Controller 'variants' => ['array', 'nullable'], 'variants.*.id' => ['required', 'integer'], 'variants.*.text' => ['required', 'string'], - 'correct_answers' => 'required|array', - 'is_pending_question' => 'integer' + 'correct_answers' => 'required|array' ]; } /** @@ -154,8 +154,7 @@ class QuestionController extends Controller * example=1 * ) * ), - * @OA\Property(property="category_id", type="integer", nullable=true, example=3), - * @OA\Property(property="is_pending_question", type="integer", example=0) + * @OA\Property(property="category_id", type="integer", nullable=true, example=3) * ) * ), * @OA\Response( @@ -217,8 +216,7 @@ class QuestionController extends Controller * example=1 * ) * ), - * @OA\Property(property="category_id", type="integer", nullable=true, example=2), - * @OA\Property(property="is_pending_question", type="integer", example=0) + * @OA\Property(property="category_id", type="integer", nullable=true, example=2) * ) * ), * @OA\Response( @@ -272,4 +270,163 @@ class QuestionController extends Controller return ['message' => 'The question was deleted']; } + + /** + * @OA\Post( + * path="/api/questions/openai-generate", + * summary="Generate a question using OpenAI (Creator or Admin)", + * description="Generates a new question via OpenAI API. Only authorized users (creator or admin) can call this endpoint.", + * tags={"Questions"}, + * security={{"bearerAuth":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"type","category_id","language","difficulty","promt"}, + * @OA\Property(property="type", type="string", description="Type of the question", enum={"single","multiple","text"}, example="single"), + * @OA\Property(property="category_id", type="integer", description="ID of the category", example=3), + * @OA\Property(property="language", type="string", description="Language for AI prompt", example="en"), + * @OA\Property(property="difficulty", type="integer", description="Difficulty level (0-10)", example=5), + * @OA\Property(property="promt", type="string", description="The prompt for AI to generate the question", example="Explain HTTP methods") + * ) + * ), + * @OA\Response( + * response=200, + * description="AI-generated question", + * @OA\JsonContent( + * @OA\Property(property="question", type="object", + * @OA\Property(property="title", type="string", example="Sample question"), + * @OA\Property(property="description", type="string", example="Optional description"), + * @OA\Property(property="type", type="string", enum={"single","multiple","text"}), + * @OA\Property(property="difficulty", type="integer", example=5), + * @OA\Property(property="variants", type="array", + * @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="text", type="string") + * ) + * ), + * @OA\Property(property="correct_answers", type="array", + * @OA\Items(oneOf={@OA\Schema(type="integer"), @OA\Schema(type="string")}) + * ), + * @OA\Property(property="category_id", type="integer", example=3) + * ) + * ) + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent( + * @OA\Property(property="message", type="string"), + * @OA\Property(property="errors", type="object") + * ) + * ), + * @OA\Response( + * response=500, + * description="AI returned invalid JSON", + * @OA\JsonContent( + * @OA\Property(property="error", type="string"), + * @OA\Property(property="raw", type="string") + * ) + * ) + * ) + */ + public function openai_generate(Request $request) + { + $this->authorize('create', Question::class); + + $fields = $request->validate([ + 'type' => 'required|string|in:single,multiple,text', + 'category_id' => 'required|exists:categories,id', + 'language' => 'required|string', + 'difficulty' => 'required|integer', + 'promt' => 'required|string' + ]); + $type = $fields['type']; + $lang = $fields['language']; + $difficulty = $fields['difficulty']; + $cat_id = $fields['category_id']; + $promt_json = json_encode($fields['promt']); + + $question_type_promt = ""; + $answer = ""; + $variations = <<< EOT +[ + { + "id": 1, + "text": "Option 1" + }, + { + "id": 2, + "text": "Option 2" + }, + { + "id": 3, + "text": "Option 3" + }, + { + "id": 4, + "text": "Option 4" + } +] +EOT; + switch ($type) { + case 'single': + $question_type_promt = "Create 4 answer options, with one of them being correct (the \"correct_answers\" field is an array containing the ID of the correct option)."; + $answer = json_encode([2]); ; + break; + case 'multiple': + $question_type_promt = "Create between 4 and 6 answer options, with several of them being correct (the \"correct_answers\" field is an array containing the IDs of the correct options)."; + $answer = json_encode([1,2]); ; + break; + case 'text': + $question_type_promt = "Generate a question where there can only be a single, precise answer that can be written without an extended explanation. The \"correct_answers\" field should be an array containing only ONE answer as a string. The \"variations\" must be empty array"; + $answer = json_encode(["Answer"]); + $variations = '[]'; + break; + } + + $openai_promt = <<< EOT +Generate 1 question using the following template (this is example): + +{ + "title": "Sample question", + "description": "Optional description", + "type": "$type", + "difficulty": $difficulty, + "variants": $variations, + "correct_answers": $answer, + "category_id": $cat_id +} + +You can change only variants, correct_answers, description and title. +Your response should be only JSON with no explanations, exactly following the template. + +$question_type_promt + +Question difficulty: $difficulty/10 +The question should be based on the following prompt with language $lang: +$promt_json + +EOT; + $client = OpenAI::client(env("OPENAI_KEY")); + + $response = $client->responses()->create([ + 'model' => env('OPENAI_MODEL', 'gpt-4.1-mini'), + 'input' => $openai_promt, + 'temperature' => 0, + ]); + $ai_answer = $response->outputText; + + $question_json = json_decode($ai_answer, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return response()->json([ + 'error' => 'AI returned invalid JSON', + 'raw' => $ai_answer, + ], 500); + } + + return response()->json([ + 'question' => $question_json, + ]); + } } diff --git a/app/Http/Controllers/UserTestController.php b/app/Http/Controllers/UserTestController.php index 7e660f7..9642bf5 100644 --- a/app/Http/Controllers/UserTestController.php +++ b/app/Http/Controllers/UserTestController.php @@ -15,7 +15,7 @@ class UserTestController extends Controller { /** * @OA\Get( - * path="/api/user-tests", + * path="/api/user-tests/me", * 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.", * tags={"UserTests"}, @@ -34,7 +34,7 @@ class UserTestController extends Controller * ) * ) */ - public function index(Request $request) + public function me(Request $request) { $user = $request->user(); $userTests = UserTest::where('user_id', $user->id) @@ -45,25 +45,97 @@ class UserTestController extends Controller return UserTestResource::collection($userTests); } + /** + * @OA\Get( + * path="/api/user-tests", + * summary="Get a list of user tests (only creator or admin)", + * description="Returns all user tests. Only the creator or an admin can access this endpoint.", + * tags={"UserTests"}, + * security={{"sanctum":{}}}, + * @OA\Parameter( + * name="test_id", + * in="query", + * description="Filter by Test ID", + * required=false, + * @OA\Schema(type="integer", example=5) + * ), + * @OA\Parameter( + * name="question_id", + * in="query", + * description="Filter by Question ID (via answers.question)", + * required=false, + * @OA\Schema(type="integer", example=12) + * ), + * @OA\Response( + * response=200, + * description="List of user tests retrieved successfully", + * @OA\JsonContent( + * type="array", + * @OA\Items(ref="#/components/schemas/UserTestResource") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthenticated — missing or invalid token" + * ), + * @OA\Response( + * response=403, + * description="Forbidden — only the creator or admin can access this resource" + * ) + * ) + */ + public function index(Request $request) + { + $this->authorize('viewAny', UserTest::class); + + $query = UserTest::query() + ->with(['user', 'category', 'answers.question']) + ->orderBy('id', 'desc'); + + // filter by test_id + if ($request->has('test_id')) { + $query->where('test_id', $request->input('test_id')); + } + + // filter by question_id + if ($request->has('question_id')) { + $questionId = $request->input('question_id'); + $query->whereHas('answers.question', function ($q) use ($questionId) { + $q->where('questions.id', $questionId); + }); + } + + $userTests = $query->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\Property(property="category_id", type="integer", example=3), + * @OA\Property(property="min_difficulty", type="integer", example=0), + * @OA\Property(property="max_difficulty", type="integer", example=10) + * ) + * ), + * @OA\Response( + * response=200, + * description="User test created successfully", + * @OA\JsonContent( + * @OA\Property(property="message", type="string", example="This test isn't available now!") * ) * ), * @OA\Response( * response=422, - * description="Validation error or no questions found" + * description="Validation error" * ) * ) */ diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php index e71515a..bf7499e 100644 --- a/app/Http/Resources/CategoryResource.php +++ b/app/Http/Resources/CategoryResource.php @@ -28,10 +28,16 @@ use Illuminate\Http\Resources\Json\JsonResource; * example="2025-11-10T18:00:00Z" * ), * @OA\Property( - * property="count", + * property="questions_count", * type="integer", * example=5, - * description="Dynamic count of related items" + * description="Dynamic count of questions by category" + * ), + * @OA\Property( + * property="user_tests_count", + * type="integer", + * example=5, + * description="Dynamic count of user tests" * ) * ) */ @@ -48,7 +54,8 @@ class CategoryResource extends JsonResource 'id' => $this->id, 'name' => $this->name, 'created_at' => $this->created_at, - 'count' => $this->count ?? 0 + 'questions_count' => $this->when(isset($this->questions_count), $this->questions_count, 0), + 'user_tests_count' => $this->when(isset($this->user_tests_count), $this->user_tests_count, 0), ]; } } diff --git a/app/Models/Category.php b/app/Models/Category.php index 90f2054..05ca9bf 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -13,4 +13,16 @@ class Category extends Model protected $fillable = [ 'name' ]; + public function tests() + { + return $this->hasMany(Test::class, 'category_id'); + } + public function user_tests() + { + return $this->hasMany(UserTest::class, 'category_id'); + } + public function questions() + { + return $this->hasMany(Question::class, 'category_id'); + } } diff --git a/app/Models/Test.php b/app/Models/Test.php index 768eeff..5f8202d 100644 --- a/app/Models/Test.php +++ b/app/Models/Test.php @@ -43,4 +43,6 @@ class Test extends Model return $this->belongsToMany(Question::class) ->withTimestamps(); } + + } \ No newline at end of file diff --git a/app/Policies/UserTestPolicy.php b/app/Policies/UserTestPolicy.php index e55d831..5ae918a 100644 --- a/app/Policies/UserTestPolicy.php +++ b/app/Policies/UserTestPolicy.php @@ -13,7 +13,7 @@ class UserTestPolicy */ public function viewAny(User $user): bool { - return true; + return $user->type == 'admin' || $user->type == 'creator'; } /** diff --git a/composer.json b/composer.json index 3b3bc8b..ddf5875 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", - "mobiledetect/mobiledetectlib": "^4.8" + "mobiledetect/mobiledetectlib": "^4.8", + "openai-php/client": "^0.18.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 8a62556..6ab9bbe 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a58d7ff4f8de3898bf7fa6350013d42a", + "content-hash": "dec861e2bbb0078cfccbd798a66b1404", "packages": [ { "name": "brick/math", @@ -2950,6 +2950,232 @@ ], "time": "2025-10-18T11:10:27+00:00" }, + { + "name": "openai-php/client", + "version": "v0.18.0", + "source": { + "type": "git", + "url": "https://github.com/openai-php/client.git", + "reference": "3362ab004fcfc9d77df3aff7671fbcbe70177cae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openai-php/client/zipball/3362ab004fcfc9d77df3aff7671fbcbe70177cae", + "reference": "3362ab004fcfc9d77df3aff7671fbcbe70177cae", + "shasum": "" + }, + "require": { + "php": "^8.2.0", + "php-http/discovery": "^1.20.0", + "php-http/multipart-stream-builder": "^1.4.2", + "psr/http-client": "^1.0.3", + "psr/http-client-implementation": "^1.0.1", + "psr/http-factory-implementation": "*", + "psr/http-message": "^1.1.0|^2.0.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.9.3", + "guzzlehttp/psr7": "^2.7.1", + "laravel/pint": "^1.24.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/collision": "^8.8.0", + "pestphp/pest": "^3.8.2|^4.0.0", + "pestphp/pest-plugin-arch": "^3.1.1|^4.0.0", + "pestphp/pest-plugin-type-coverage": "^3.5.1|^4.0.0", + "phpstan/phpstan": "^1.12.25", + "symfony/var-dumper": "^7.2.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/OpenAI.php" + ], + "psr-4": { + "OpenAI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri" + } + ], + "description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API", + "keywords": [ + "GPT-3", + "api", + "client", + "codex", + "dall-e", + "language", + "natural", + "openai", + "php", + "processing", + "sdk" + ], + "support": { + "issues": "https://github.com/openai-php/client/issues", + "source": "https://github.com/openai-php/client/tree/v0.18.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-10-31T18:58:57+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/multipart-stream-builder", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/multipart-stream-builder.git", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/10086e6de6f53489cca5ecc45b6f468604d3460e", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/discovery": "^1.15", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.5", + "php-http/message-factory": "^1.0.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Message\\MultipartStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "A builder class that help you create a multipart stream", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "multipart stream", + "stream" + ], + "support": { + "issues": "https://github.com/php-http/multipart-stream-builder/issues", + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.4.2" + }, + "time": "2024-09-04T13:22:54+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", diff --git a/routes/api.php b/routes/api.php index a3349ac..be1b252 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Route; // UserTests Route::middleware('auth:sanctum')->group(function () { Route::get('user-tests', [UserTestController::class, 'index']); + Route::get('user-tests/me', [UserTestController::class, 'me']); Route::post('user-tests', [UserTestController::class, 'store']); Route::post('user-tests/by-test', [UserTestController::class, 'storeByTest']); @@ -42,6 +43,7 @@ Route::get('questions', [QuestionController::class, 'index']); Route::get('questions/{question}', [QuestionController::class, 'show']); Route::middleware('auth:sanctum')->group(function () { + Route::post('questions/openai-generate', [QuestionController::class, 'openai_generate']); Route::post('questions', [QuestionController::class, 'store']); Route::put('questions/{question}', [QuestionController::class, 'update']); Route::delete('questions/{question}', [QuestionController::class, 'destroy']); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 3be46e8..0b559bc 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -1246,10 +1246,6 @@ "type": "integer", "example": 3, "nullable": true - }, - "is_pending_question": { - "type": "integer", - "example": 0 } }, "type": "object" @@ -1389,10 +1385,6 @@ "type": "integer", "example": 2, "nullable": true - }, - "is_pending_question": { - "type": "integer", - "example": 0 } }, "type": "object" @@ -1461,6 +1453,176 @@ ] } }, + "/api/questions/openai-generate": { + "post": { + "tags": [ + "Questions" + ], + "summary": "Generate a question using OpenAI (Creator or Admin)", + "description": "Generates a new question via OpenAI API. Only authorized users (creator or admin) can call this endpoint.", + "operationId": "89ba54de2578b1b1f2e75d559ef4ef6d", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "type", + "category_id", + "language", + "difficulty", + "promt" + ], + "properties": { + "type": { + "description": "Type of the question", + "type": "string", + "enum": [ + "single", + "multiple", + "text" + ], + "example": "single" + }, + "category_id": { + "description": "ID of the category", + "type": "integer", + "example": 3 + }, + "language": { + "description": "Language for AI prompt", + "type": "string", + "example": "en" + }, + "difficulty": { + "description": "Difficulty level (0-10)", + "type": "integer", + "example": 5 + }, + "promt": { + "description": "The prompt for AI to generate the question", + "type": "string", + "example": "Explain HTTP methods" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "AI-generated question", + "content": { + "application/json": { + "schema": { + "properties": { + "question": { + "properties": { + "title": { + "type": "string", + "example": "Sample question" + }, + "description": { + "type": "string", + "example": "Optional description" + }, + "type": { + "type": "string", + "enum": [ + "single", + "multiple", + "text" + ] + }, + "difficulty": { + "type": "integer", + "example": 5 + }, + "variants": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "text": { + "type": "string" + } + }, + "type": "object" + } + }, + "correct_answers": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + }, + "category_id": { + "type": "integer", + "example": 3 + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + }, + "errors": { + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "500": { + "description": "AI returned invalid JSON", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/api/tests": { "get": { "tags": [ @@ -2153,14 +2315,14 @@ ] } }, - "/api/user-tests": { + "/api/user-tests/me": { "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", + "operationId": "fef73e47f9c3188bea00ab44f7169105", "responses": { "200": { "description": "List of user tests", @@ -2186,13 +2348,70 @@ "bearerAuth": [] } ] + } + }, + "/api/user-tests": { + "get": { + "tags": [ + "UserTests" + ], + "summary": "Get a list of user tests (only creator or admin)", + "description": "Returns all user tests. Only the creator or an admin can access this endpoint.", + "operationId": "bf312e7c74799e8dbaa29025b8f52f46", + "parameters": [ + { + "name": "test_id", + "in": "query", + "description": "Filter by Test ID", + "required": false, + "schema": { + "type": "integer", + "example": 5 + } + }, + { + "name": "question_id", + "in": "query", + "description": "Filter by Question ID (via answers.question)", + "required": false, + "schema": { + "type": "integer", + "example": 12 + } + } + ], + "responses": { + "200": { + "description": "List of user tests retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserTestResource" + } + } + } + } + }, + "401": { + "description": "Unauthenticated — missing or invalid token" + }, + "403": { + "description": "Forbidden — only the creator or admin can access this resource" + } + }, + "security": [ + { + "sanctum": [] + } + ] }, "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, @@ -2204,21 +2423,16 @@ ], "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 + "example": 0 }, "max_difficulty": { - "description": "Maximum difficulty of questions", "type": "integer", - "example": 10, - "nullable": true + "example": 10 } }, "type": "object" @@ -2227,8 +2441,24 @@ } }, "responses": { + "200": { + "description": "User test created successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "This test isn't available now!" + } + }, + "type": "object" + } + } + } + }, "422": { - "description": "Validation error or no questions found" + "description": "Validation error" } }, "security": [ @@ -2582,8 +2812,13 @@ "format": "date-time", "example": "2025-11-10T18:00:00Z" }, - "count": { - "description": "Dynamic count of related items", + "questions_count": { + "description": "Dynamic count of questions by category", + "type": "integer", + "example": 5 + }, + "user_tests_count": { + "description": "Dynamic count of user tests", "type": "integer", "example": 5 }