diff --git a/README.md b/README.md index 222401d..4d18fb1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ bin/run-ci This document outlines the available API endpoints for the Enarm API application. +Detailed reference (headers, status codes, examples per endpoint): + +- `docs/API_REFERENCE.md` + ### Health Check * **GET /up** diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index de638f3..5dd4f2a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,6 @@ class ApplicationController < ActionController::API + include SetCurrentRequestDetails + rescue_from ActionController::ParameterMissing do |exception| render json: { error: exception.message }, status: :bad_request end @@ -6,8 +8,10 @@ class ApplicationController < ActionController::API def authenticate_user! token = decoded_token user_id = token[:user_id] || token[:player_id] - @current_user = User.find_by(id: user_id) if user_id - render json: { error: "No autorizado" }, status: :unauthorized unless @current_user + Current.user = nil + Current.user = User.find_by(id: user_id) if user_id + @current_user = Current.user # Compatibilidad con controladores existentes + render json: { error: "No autorizado" }, status: :unauthorized unless Current.user end # Alias para compatibilidad con el frontend si es necesario @@ -20,7 +24,7 @@ def authenticate_admin! authenticate_user! return if performed? - unless @current_user.admin? + unless Current.user.admin? render json: { error: "Acceso restringido a administradores" }, status: :forbidden end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 4ea10a9..fe5ea06 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -1,5 +1,5 @@ class CategoriesController < ApplicationController - before_action :authenticate_admin!, except: :index + before_action :authenticate_admin!, except: [ :index, :show ] before_action :set_category, only: %i[ update show ] # GET /categories diff --git a/app/controllers/clinical_cases_controller.rb b/app/controllers/clinical_cases_controller.rb index 1dbf70b..5851355 100644 --- a/app/controllers/clinical_cases_controller.rb +++ b/app/controllers/clinical_cases_controller.rb @@ -10,6 +10,10 @@ def index if params[:category_id] @cases = @cases.where(category_id: params[:category_id]) end + if params[:q].present? + query = "%#{params[:q].to_s.strip.downcase}%" + @cases = @cases.where("LOWER(name) LIKE ?", query) + end render json: { current_page: @cases.current_page, per_page: @cases.per_page, total_entries: @cases.total_entries, clinical_cases: @cases } end @@ -57,6 +61,7 @@ def clinical_case_params :description, :category_id, :status, + :image, questions_attributes: [ :id, :_destroy, diff --git a/app/controllers/concerns/set_current_request_details.rb b/app/controllers/concerns/set_current_request_details.rb new file mode 100644 index 0000000..a338448 --- /dev/null +++ b/app/controllers/concerns/set_current_request_details.rb @@ -0,0 +1,15 @@ +module SetCurrentRequestDetails + extend ActiveSupport::Concern + + included do + before_action :set_current_request_details + end + + private + + def set_current_request_details + Current.request_id = request.request_id + Current.user_agent = request.user_agent + Current.ip_address = request.ip + end +end diff --git a/app/controllers/flashcards_controller.rb b/app/controllers/flashcards_controller.rb index 7158424..55f9e6d 100644 --- a/app/controllers/flashcards_controller.rb +++ b/app/controllers/flashcards_controller.rb @@ -9,6 +9,11 @@ def index render json: @flashcards end + # GET /flashcards/:id + def show + render json: @flashcard + end + # GET /flashcards/due def due @due_cards = @current_user.user_flashcards.due.includes(:flashcard) diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 94f0c66..1714cf9 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -5,8 +5,8 @@ class MessagesController < ApplicationController # List recent conversations def index # This is a bit complex for a simple query, but let's do a basic version - sent_ids = @current_user.sent_messages.select(:receiver_id).distinct.pluck(:receiver_id) - received_ids = @current_user.received_messages.select(:sender_id).distinct.pluck(:sender_id) + sent_ids = Current.user.sent_messages.select(:receiver_id).distinct.pluck(:receiver_id) + received_ids = Current.user.received_messages.select(:sender_id).distinct.pluck(:sender_id) user_ids = (sent_ids + received_ids).uniq @users = User.where(id: user_ids) @@ -16,15 +16,15 @@ def index # GET /messages/:user_id # History with a specific user def show - @messages = Message.between(@current_user.id, params[:id]).order(created_at: :asc) + @messages = Message.between(Current.user.id, params[:id]).order(created_at: :asc) # Mark as read - @messages.where(receiver_id: @current_user.id, read_at: nil).update_all(read_at: Time.current) + @messages.where(receiver_id: Current.user.id, read_at: nil).update_all(read_at: Time.current) render json: @messages end # POST /messages def create - @message = @current_user.sent_messages.build(message_params) + @message = Current.user.sent_messages.build(message_params) if @message.save render json: @message, status: :created else diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index cf2bfcf..47768cd 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -23,9 +23,10 @@ def index end else # Consider pagination for listing all questions - @questions = Question.all + @questions = Question.all.order(id: :desc) end - render json: @questions, include: [ :answers, :category, :clinical_case ] + @questions = @questions.paginate(page: params[:page]).includes(:answers, :category, :clinical_case) + render json: { current_page: @questions.current_page, per_page: @questions.per_page, total_entries: @questions.total_entries, questions: @questions }, include: [ :answers, :category, :clinical_case ] end # GET /questions/:id diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index bb7af5f..89809e8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -74,24 +74,12 @@ def contributions # POST /google_login def google_login - google_id = params[:google_id] - email = params[:email] - name = params[:name] - - user = User.find_by(google_id: google_id) || User.find_by(email: email) + social_login(provider: :google, provider_id_key: :google_id) + end - if user - user.update(google_id: google_id) if user.google_id.blank? - render json: user_json(user), status: :ok - else - user = User.new(email: email, name: name, google_id: google_id, role: :player) - user.password = SecureRandom.hex(10) - if user.save - render json: user_json(user), status: :created - else - render json: user.errors, status: :unprocessable_entity - end - end + # POST /facebook_login + def facebook_login + social_login(provider: :facebook, provider_id_key: :facebook_id) end private @@ -120,4 +108,32 @@ def user_json(user) token: token } end + + def social_login(provider:, provider_id_key:) + provider_id = params[provider_id_key] + email = params[:email] + name = params[:name] + + user = User.find_by(provider_id_key => provider_id) || User.find_by(email: email) + + if user + user.update(provider_id_key => provider_id) if user[provider_id_key].blank? && provider_id.present? + render json: user_json(user), status: :ok + else + user_attributes = { + email: email, + name: name, + role: :player, + provider_id_key => provider_id + } + user = User.new(user_attributes) + user.password = SecureRandom.hex(10) + + if user.save + render json: user_json(user), status: :created + else + render json: user.errors, status: :unprocessable_entity + end + end + end end diff --git a/app/models/clinical_case.rb b/app/models/clinical_case.rb index c383fcc..94e2d6b 100644 --- a/app/models/clinical_case.rb +++ b/app/models/clinical_case.rb @@ -1,4 +1,7 @@ class ClinicalCase < ApplicationRecord + MAX_IMAGE_SIZE = 5.megabytes + ALLOWED_IMAGE_TYPES = [ "image/png", "image/jpeg" ].freeze + belongs_to :category has_one_attached :image has_many :questions, dependent: :destroy, inverse_of: :clinical_case @@ -8,4 +11,19 @@ class ClinicalCase < ApplicationRecord enum :status, { pending: 0, published: 1, rejected: 2 } validates :name, presence: true + validate :image_must_be_valid + + private + + def image_must_be_valid + return unless image.attached? + + unless ALLOWED_IMAGE_TYPES.include?(image.content_type) + errors.add(:image, "debe ser PNG o JPG") + end + + if image.blob.byte_size > MAX_IMAGE_SIZE + errors.add(:image, "no debe superar 5 MB") + end + end end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..05cb1a5 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user, :request_id, :user_agent, :ip_address +end diff --git a/config/routes.rb b/config/routes.rb index 3f62510..c7b5b20 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,7 @@ collection do post "login" post "google_login" + post "facebook_login" get "me/stats", to: "users#stats" get "me/contributions", to: "users#contributions" end @@ -15,6 +16,7 @@ collection do post "login", to: "users#login" post "google_login", to: "users#google_login" + post "facebook_login", to: "users#facebook_login" end resources :achievements, only: [ :index ], controller: "users/achievements" end @@ -28,14 +30,9 @@ resources :achievements, only: [ :index, :create, :update, :destroy ] resources :user_exams, only: [ :index, :show, :create, :update ] - resources :flashcards, only: [ :index, :show ] do - collection do - get "due" - end - member do - post "review" - end - end + get "flashcards/due", to: "flashcards#due" + post "flashcards/:id/review", to: "flashcards#review" + resources :flashcards, only: [ :index, :show ] resources :specialists, only: [ :index, :show ] resources :messages, only: [ :index, :show, :create ] diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..64dc54a --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,801 @@ +# Enarm API Reference + +## Base URL + +- Local: `http://localhost:3000` + +## Common Headers + +- `Content-Type: application/json` for `POST`, `PATCH`, and `PUT` with JSON body. +- `Authorization: Bearer ` for protected endpoints. + +Example: + +```http +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... +Content-Type: application/json +``` + +## Authentication and Users + +### POST `/users/login` +- Purpose: Login with email/username + password. +- Required headers: `Content-Type: application/json` +- Status codes: `200`, `401` +- Example response (`200`): +```json +{ + "id": 12, + "name": "Ana", + "email": "ana@example.com", + "username": "ana", + "role": "player", + "preferences": {}, + "token": "jwt-token" +} +``` + +### POST `/users/google_login` +- Purpose: Login/signup with Google identity. +- Required headers: `Content-Type: application/json` +- Body: `google_id`, `email`, `name` +- Status codes: `200`, `201`, `422` +- Example response (`201`): +```json +{ + "id": 15, + "name": "Google User", + "email": "google.user@example.com", + "username": null, + "role": "player", + "preferences": {}, + "token": "jwt-token" +} +``` + +### POST `/users/facebook_login` +- Purpose: Login/signup with Facebook identity. +- Required headers: `Content-Type: application/json` +- Body: `facebook_id`, `email`, `name` +- Status codes: `200`, `201`, `422` +- Example response (`200`): +```json +{ + "id": 9, + "name": "FB User", + "email": "fb.user@example.com", + "username": "fbuser", + "role": "player", + "preferences": {}, + "token": "jwt-token" +} +``` + +### GET `/users` +- Purpose: List users (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `403` +- Example response (`200`): +```json +[ + { "id": 1, "email": "admin@example.com", "role": "admin" }, + { "id": 2, "email": "player@example.com", "role": "player" } +] +``` + +### POST `/users` +- Purpose: Register a new user (or return existing by email). +- Required headers: `Content-Type: application/json` +- Status codes: `200`, `201`, `422`, `400` +- Example response (`201`): +```json +{ + "id": 22, + "name": "Nuevo Usuario", + "email": "nuevo@example.com", + "username": "nuevo", + "role": "player", + "preferences": {}, + "token": "jwt-token" +} +``` + +### GET `/users/:id` +- Purpose: Get user profile (owner/admin). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `404` +- Example response (`200`): +```json +{ + "id": 22, + "name": "Nuevo Usuario", + "email": "nuevo@example.com", + "username": "nuevo", + "role": "player", + "preferences": {}, + "token": "jwt-token" +} +``` + +### PUT/PATCH `/users/:id` +- Purpose: Update user profile (owner/admin). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `404`, `422` +- Example response (`200`): +```json +{ + "id": 22, + "name": "Nuevo Nombre", + "email": "nuevo@example.com", + "username": "nuevo", + "role": "player", + "preferences": {}, + "token": "jwt-token" +} +``` + +### DELETE `/users/:id` +- Purpose: Delete user (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `204`, `401`, `403`, `404` +- Example response (`204`): no body. + +### GET `/users/me/stats` +- Purpose: Get current user stats. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +{ + "total_answers": 120, + "correct_answers": 89, + "incorrect_answers": 31, + "accuracy_percentage": 74.17, + "questions_answered": 90, + "last_activity": "2026-02-28T15:41:00Z", + "total_points": 350 +} +``` + +### GET `/users/me/contributions` +- Purpose: Get clinical cases created by current user. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +{ + "contributions": [ + { "id": 4, "name": "Caso clínico 1", "category_id": 2 } + ] +} +``` + +## Legacy Player Aliases + +- `POST /players/login` -> same as `/users/login` +- `POST /players/google_login` -> same as `/users/google_login` +- `POST /players/facebook_login` -> same as `/users/facebook_login` +- `GET /players/:player_id/achievements` -> same data contract as `/users/:user_id/achievements` + +## Categories + +### GET `/categories` +- Purpose: List categories. +- Required headers: none +- Status codes: `200` +- Example response (`200`): +```json +[ + { "id": 1, "name": "Cardiología", "description": "..." } +] +``` + +### POST `/categories` +- Purpose: Create category (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `201`, `401`, `403`, `422`, `400` +- Example response (`201`): +```json +{ "id": 8, "name": "Neurología", "description": "..." } +``` + +### GET `/categories/:id` +- Purpose: Get category detail. +- Required headers: none +- Status codes: `200`, `404` +- Example response (`200`): +```json +{ "id": 1, "name": "Cardiología", "description": "..." } +``` + +### PUT/PATCH `/categories/:id` +- Purpose: Update category (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `403`, `404`, `422` +- Example response (`200`): +```json +{ "id": 1, "name": "Cardiología", "description": "Actualizada" } +``` + +### DELETE `/categories/:id` +- Purpose: Route exists for deleting categories. +- Required headers: `Authorization: Bearer ` +- Status codes: currently inconsistent in code because `destroy` action is not implemented in `CategoriesController`. +- Example response: implementation pending. + +## Clinical Cases + +### GET `/clinical_cases` +- Purpose: List clinical cases, paginated, with optional filters by `category_id` and name search (`q`). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +{ + "current_page": 1, + "per_page": 30, + "total_entries": 120, + "clinical_cases": [{ "id": 10, "name": "Caso 10", "status": "approved" }] +} +``` + +Optional query params: + +- `page`: page number +- `category_id`: filter by specialty/category +- `q`: case-insensitive match against clinical case name + +### GET `/categories/:category_id/clinical_cases` +- Purpose: List clinical cases for a category. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): same shape as `/clinical_cases`. + +### GET `/clinical_cases/:id` +- Purpose: Get one clinical case with nested questions/answers. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `404` +- Example response (`200`): +```json +{ + "id": 10, + "name": "Caso 10", + "questions": [ + { + "id": 88, + "text": "Pregunta", + "answers": [{ "id": 301, "text": "Respuesta A", "is_correct": false }] + } + ] +} +``` + +### POST `/clinical_cases` +- Purpose: Create clinical case (authenticated user; non-admin is forced to `pending`). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `201`, `401`, `422`, `400` +- Example response (`201`): +```json +{ + "id": 101, + "name": "clinical_case_a1b2", + "status": "pending", + "category_id": 2 +} +``` + +Image validation rules (when uploading image): + +- Allowed MIME types: `image/png`, `image/jpeg` +- Max size: `5 MB` +- Validation errors are returned as `422` in `image` field. + +### PUT/PATCH `/clinical_cases/:id` +- Purpose: Update clinical case (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `403`, `404`, `422` +- Example response (`200`): +```json +{ "id": 101, "name": "Caso actualizado", "status": "approved" } +``` + +Image validation rules for update are the same as create: + +- Allowed MIME types: `image/png`, `image/jpeg` +- Max size: `5 MB` + +### DELETE `/clinical_cases/:id` +- Purpose: Delete clinical case (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `204`, `401`, `403`, `404` +- Example response (`204`): no body. + +## Questions + +### GET `/questions` +- Purpose: List questions, paginated, optionally filtered by `clinical_case_id` or `category_id` (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `403`, `404` +- Example response (`200`): +```json +{ + "current_page": 1, + "per_page": 30, + "total_entries": 200, + "questions": [ + { + "id": 55, + "text": "Pregunta", + "clinical_case_id": 98, + "answers": [{ "id": 1, "text": "A", "is_correct": false }] + } + ] +} +``` + +### GET `/questions/:id` +- Purpose: Get one question with answers (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `403`, `404` +- Example response (`200`): +```json +{ + "id": 55, + "text": "Pregunta", + "answers": [{ "id": 1, "text": "A", "is_correct": false }] +} +``` + +### POST `/questions` +- Purpose: Create question with optional nested answers (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `201`, `401`, `403`, `422`, `400` +- Example response (`201`): +```json +{ "id": 56, "text": "Nueva pregunta" } +``` + +### PUT/PATCH `/questions/:id` +- Purpose: Update question (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `403`, `404`, `422` +- Example response (`200`): +```json +{ "id": 56, "text": "Pregunta actualizada" } +``` + +### DELETE `/questions/:id` +- Purpose: Delete question (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `204`, `401`, `403`, `404` +- Example response (`204`): no body. + +## Exams + +### GET `/exams` +- Purpose: List exams with exam questions (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `403` +- Example response (`200`): +```json +[ + { + "id": 2, + "name": "Simulacro ENARM", + "exam_questions": [{ "id": 11, "question_id": 55, "position": 1 }] + } +] +``` + +### GET `/exams/:id` +- Purpose: Get one exam (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `403`, `404` +- Example response (`200`): +```json +{ + "id": 2, + "name": "Simulacro ENARM", + "exam_questions": [{ "id": 11, "question_id": 55, "position": 1 }] +} +``` + +### POST `/exams` +- Purpose: Create exam (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `201`, `401`, `403`, `422`, `400` +- Example response (`201`): +```json +{ "id": 3, "name": "Examen nuevo", "category_id": 1 } +``` + +### PUT/PATCH `/exams/:id` +- Purpose: Update exam (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `403`, `404`, `422` +- Example response (`200`): +```json +{ "id": 3, "name": "Examen actualizado" } +``` + +### DELETE `/exams/:id` +- Purpose: Delete exam (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `204`, `401`, `403`, `404` +- Example response (`204`): no body. + +## Practice Answers + +### POST `/user_answers` (alias: `/player_answers`) +- Purpose: Submit one or more practice answers and get feedback. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `201`, `401`, `422`, `400` +- Example response (`201`): +```json +{ + "message": "Respuestas guardadas correctamente!", + "results": [ + { + "question_id": 55, + "answer_id": 1, + "is_correct": false, + "explanation": "Explicación de la opción elegida", + "correct_answer": { + "id": 2, + "text": "Opción correcta", + "explanation": "Explicación correcta" + } + } + ], + "unlocked_achievements": [{ "id": 3, "name": "Racha de 10" }] +} +``` + +### GET `/user_answers` (alias: `/player_answers`) +- Purpose: Get answer history of authenticated user. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +[ + { "id": 1, "question_id": 55, "answer_id": 1, "is_correct": false } +] +``` + +## User Exams + +### GET `/user_exams` +- Purpose: List exam attempts of authenticated user. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +[ + { "id": 7, "exam_id": 2, "status": "completed", "score": 68.5 } +] +``` + +### GET `/user_exams/:id` +- Purpose: Get one exam attempt with exam content + submitted answers. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `404` +- Example response (`200`): +```json +{ + "id": 7, + "status": "in_progress", + "exam": { + "id": 2, + "exam_questions": [ + { + "id": 11, + "question": { + "id": 55, + "text": "Pregunta", + "answers": [{ "id": 1, "text": "A", "is_correct": false }] + } + } + ] + }, + "user_exam_answers": [] +} +``` + +### POST `/user_exams` +- Purpose: Start a new exam attempt. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Body: `exam_id` +- Status codes: `201`, `401`, `404`, `422` +- Example response (`201`): +```json +{ + "id": 8, + "exam_id": 2, + "status": "in_progress", + "started_at": "2026-02-28T16:00:00Z" +} +``` + +### PUT/PATCH `/user_exams/:id` +- Purpose: Submit answers and complete exam attempt. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `404`, `422`, `500` +- Example response (`200`): +```json +{ + "user_exam": { "id": 8, "status": "completed", "score": 72.0 }, + "score": 72.0, + "unlocked_achievements": [{ "id": 5, "name": "Primer examen" }] +} +``` + +## Achievements + +### GET `/achievements` +- Purpose: List achievement catalog. +- Required headers: none +- Status codes: `200` +- Example response (`200`): +```json +[ + { "id": 1, "name": "Primer paso", "description": "Responder 1 pregunta", "points": 10 } +] +``` + +### POST `/achievements` +- Purpose: Create achievement (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `201`, `401`, `403`, `422`, `400` +- Example response (`201`): +```json +{ "id": 12, "name": "Racha 50", "description": "50 correctas", "points": 100 } +``` + +### PUT/PATCH `/achievements/:id` +- Purpose: Update achievement (admin only). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `403`, `404`, `422` +- Example response (`200`): +```json +{ "id": 12, "name": "Racha 50", "description": "50 correctas seguidas", "points": 120 } +``` + +### DELETE `/achievements/:id` +- Purpose: Delete achievement (admin only). +- Required headers: `Authorization: Bearer ` +- Status codes: `204`, `401`, `403`, `404` +- Example response (`204`): no body. + +### GET `/users/:user_id/achievements` +- Purpose: List achievements unlocked by a user. +- Required headers: none +- Status codes: `200`, `404` +- Example response (`200`): +```json +[ + { + "id": 1, + "name": "Primer paso", + "description": "Responder 1 pregunta", + "points": 10, + "achieved_at": "2026-02-10T10:00:00Z", + "progress": 100 + } +] +``` + +## Flashcards + +### GET `/flashcards` +- Purpose: List flashcards (optional `category_id` filter). +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +[ + { "id": 1, "question": "¿Qué es choque séptico?", "category_id": 2 } +] +``` + +### GET `/flashcards/:id` +- Purpose: Get one flashcard by ID. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `404` +- Example response (`200`): +```json +{ + "id": 1, + "question": "¿Qué es choque séptico?", + "answer": "Disfunción orgánica por respuesta desregulada a infección", + "category_id": 2 +} +``` + +### GET `/flashcards/due` +- Purpose: List due flashcards for spaced repetition. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +[ + { + "id": 14, + "flashcard_id": 1, + "next_review_at": "2026-02-28T18:00:00Z", + "flashcard": { "id": 1, "question": "¿Qué es choque séptico?" } + } +] +``` + +### POST `/flashcards/:id/review` +- Purpose: Submit review quality score for a flashcard. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Body: `quality` (0-5) +- Status codes: `200`, `401`, `404`, `422` +- Example response (`200`): +```json +{ + "id": 14, + "flashcard_id": 1, + "interval": 3, + "ease_factor": 2.5, + "next_review_at": "2026-03-03T18:00:00Z" +} +``` + +## Specialists + +### GET `/specialists` +- Purpose: List verified specialists. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +[ + { + "id": 3, + "name": "Dra. Rivera", + "specialist_profile": { "specialty": "Ginecología", "is_verified": true } + } +] +``` + +### GET `/specialists/:id` +- Purpose: Get specialist profile. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401`, `404` +- Example response (`200`): +```json +{ + "id": 3, + "name": "Dra. Rivera", + "specialist_profile": { "specialty": "Ginecología", "is_verified": true } +} +``` + +## Messages + +### GET `/messages` +- Purpose: List users with conversations with current user. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +[ + { "id": 4, "name": "Dr. López", "email": "drlopez@example.com" } +] +``` + +### GET `/messages/:id` +- Purpose: Get conversation with user `:id` and mark received messages as read. +- Required headers: `Authorization: Bearer ` +- Status codes: `200`, `401` +- Example response (`200`): +```json +[ + { "id": 11, "sender_id": 2, "receiver_id": 4, "content": "Hola", "read_at": "2026-02-28T16:05:00Z" } +] +``` + +### POST `/messages` +- Purpose: Send message to another user. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `201`, `401`, `422`, `400` +- Example response (`201`): +```json +{ + "id": 20, + "sender_id": 2, + "receiver_id": 4, + "content": "Gracias por la asesoría" +} +``` + +## AI Endpoints (Admin) + +### POST `/ai/generate_question` +- Purpose: Generate a medical question from prompt. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `403` +- Example response (`200`): +```json +{ + "question": "Paciente con dolor torácico...", + "answers": [{ "text": "A", "is_correct": true }] +} +``` + +### POST `/ai/generate_clinical_case` +- Purpose: Generate clinical case from prompt. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json` +- Status codes: `200`, `401`, `403` +- Example response (`200`): +```json +{ + "clinical_case": { + "name": "Caso generado", + "questions": [{ "text": "Pregunta 1" }] + } +} +``` + +### POST `/ai/bulk_create_exam` +- Purpose: Parse PDF and create exam + questions + clinical cases. +- Required headers: `Authorization: Bearer ` +- Content type: `multipart/form-data` +- Required form fields: `file`, `category_id` +- Status codes: `201`, `400`, `401`, `403`, `422` +- Example response (`201`): +```json +{ + "id": 9, + "name": "Examen desde PDF", + "exam_questions": [ + { + "id": 90, + "question": { + "id": 500, + "text": "Pregunta importada", + "answers": [{ "id": 1001, "text": "A", "is_correct": false }] + } + } + ] +} +``` + +## Leaderboard and Health + +### GET `/leaderboard` +- Purpose: Top 10 users by achievement points. +- Required headers: none +- Status codes: `200` +- Example response (`200`): +```json +[ + { "id": 2, "name": "Ana", "username": "ana", "total_points": 420 } +] +``` + +### GET `/up` +- Purpose: Health check endpoint. +- Required headers: none +- Status codes: `200` +- Example response (`200`): +```json +{ "status": "ok" } +``` + +## Error Format (Common) + +- Unauthorized: +```json +{ "error": "No autorizado" } +``` +- Forbidden: +```json +{ "error": "Acceso restringido a administradores" } +``` +- Validation: +```json +{ "errors": ["Campo X no puede estar en blanco"] } +``` diff --git a/docs/POSTGRESQL_MIGRATION.md b/docs/POSTGRESQL_MIGRATION.md new file mode 100644 index 0000000..d7acecc --- /dev/null +++ b/docs/POSTGRESQL_MIGRATION.md @@ -0,0 +1,140 @@ +# Cómo volver a PostgreSQL + +Este proyecto está configurado para usar **MySQL** en development, test y production. Si en el futuro quieres volver a **PostgreSQL**, sigue estos pasos. + +## 1. Gemfile + +- **Quitar** (o mover solo a production) la gema `mysql2` del bloque principal. +- **Descomentar** la gema `pg` en el grupo `development, :test`. + +Ejemplo: + +```ruby +# Si solo production usara MySQL: +# group :production do +# gem "mysql2", "~> 0.5" +# end + +group :development, :test do + # ... + gem "pg" +end +``` + +Luego ejecuta: + +```bash +bundle install +``` + +## 2. config/database.yml + +Sustituir el bloque `default` y los entornos para usar el adapter de PostgreSQL. + +**default (PostgreSQL):** + +```yaml +default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + username: <%= ENV['DATABASE_USER'] || "postgres" %> + password: <%= ENV['DATABASE_PASSWORD'] || "test" %> + port: <%= ENV['DATABASE_PORT'] || "5432" %> + host: <%= ENV['DATABASE_HOST'] || "127.0.0.1" %> + +development: + <<: *default + database: enarmapi_development + +test: + <<: *default + database: enarmapi_test + +production: + <<: *default + database: enarmapi_production + username: <%= ENV['DATABASE_USER'] %> + password: <%= ENV['DATABASE_PASSWORD'] %> + host: <%= ENV['DATABASE_HOST'] || "127.0.0.1" %> +``` + +## 3. compose.yml (Docker para development) + +Cambiar el servicio de base de datos de MySQL a PostgreSQL y ajustar variables del `app`: + +- **Servicio `database`:** imagen `postgres:14`, puerto `5432`, variables `POSTGRES_*`. +- **Servicio `app`:** `DATABASE_PORT: "5432"`, `DATABASE_USER: postgres`, `DATABASE_PASSWORD: test`, `DATABASE_NAME: enarmapi_development`. +- **Volumen:** nombre tipo `db_pg_data` y montar en `/var/lib/postgresql/data`. +- Opcional: volver a añadir el servicio **pgAdmin** si lo usabas. +- Opcional: si tenías un script de inicialización (por ejemplo `init.sql` en la raíz), puedes montarlo de nuevo en el servicio `database` con `./init.sql:/docker-entrypoint-init.d/init.sql` (PostgreSQL ejecuta los `.sql` de ese directorio al crear el contenedor). + +Ejemplo mínimo: + +```yaml +services: + database: + image: postgres:14 + volumes: + - db_pg_data:/var/lib/postgresql/data + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: test + POSTGRES_USER: postgres + + app: + # ... + environment: + DATABASE_HOST: database + DATABASE_USER: postgres + DATABASE_PASSWORD: test + DATABASE_NAME: enarmapi_development + DATABASE_PORT: "5432" + RAILS_ENV: development + depends_on: + - database + +volumes: + db_pg_data: {} +``` + +## 4. Levantar y migrar + +Con Docker: + +```bash +docker compose down -v # opcional: borra volúmenes si quieres empezar de cero +docker compose up -d database +docker compose run --rm app bundle install +docker compose run --rm app bin/rails db:create +docker compose run --rm app bin/rails db:migrate +docker compose up app +``` + +Sin Docker (PostgreSQL instalado en tu máquina): + +```bash +bundle install +bin/rails db:create +bin/rails db:migrate +bin/rails server +``` + +## 5. Migraciones y diferencias SQL + +- Las migraciones de Rails suelen ser compatibles entre MySQL y PostgreSQL; revisa cualquier SQL crudo (`execute`, `raw SQL`) por diferencias de sintaxis o tipos. +- Si tienes migraciones con SQL específico de MySQL (por ejemplo `utf8mb4`, `LONGTEXT`), puede hacer falta adaptarlas o añadir migraciones condicionales por adapter. +- Después de cambiar de base de datos, es recomendable volver a crear y migrar en development/test (`db:drop db:create db:migrate`) o restaurar un dump si necesitas datos existentes. + +## 6. Resumen de cambios + +| Dónde | MySQL (actual) | PostgreSQL | +|-----------------|--------------------|-------------------| +| **Gemfile** | `mysql2` | `pg` | +| **database.yml** | adapter: mysql2, port 3306 | adapter: postgresql, port 5432 | +| **compose** | image: mysql:8.0, puerto 3306 | image: postgres:14, puerto 5432 | +| **Variables app** | DATABASE_PORT=3306, USER enarm | DATABASE_PORT=5432, USER postgres | + +Con estos pasos puedes volver a usar PostgreSQL en development (y, si lo configuras igual, en test y production) en cualquier momento. diff --git a/test/controllers/questions_controller_test.rb b/test/controllers/questions_controller_test.rb index b032401..7e4d4dd 100644 --- a/test/controllers/questions_controller_test.rb +++ b/test/controllers/questions_controller_test.rb @@ -92,8 +92,9 @@ class QuestionsControllerTest < ActionDispatch::IntegrationTest get questions_url, headers: @auth_headers, as: :json assert_response :success response_json = JSON.parse(response.body) - assert_not_empty response_json - response_ids = response_json.map { |q| q["id"] } + questions = response_json["questions"] + assert_not_empty questions + response_ids = questions.map { |q| q["id"] } assert_includes response_ids, @existing_question_in_cc.id assert_includes response_ids, @standalone_question.id end @@ -102,11 +103,12 @@ class QuestionsControllerTest < ActionDispatch::IntegrationTest get questions_url(clinical_case_id: @clinical_case_one.id), headers: @auth_headers, as: :json assert_response :success response_json = JSON.parse(response.body) - assert_not_empty response_json, "Response for clinical_case_id filter shouldn't be empty" - response_json.each do |question| + questions = response_json["questions"] + assert_not_empty questions, "Response for clinical_case_id filter shouldn't be empty" + questions.each do |question| assert_equal @clinical_case_one.id, question["clinical_case_id"] end - assert_equal @clinical_case_one.questions.count, response_json.size + assert_equal @clinical_case_one.questions.count, response_json["total_entries"] end test "should get index of questions filtered by category_id (includes CC and standalone)" do @@ -116,14 +118,15 @@ class QuestionsControllerTest < ActionDispatch::IntegrationTest get questions_url(category_id: @category_one.id), headers: @auth_headers, as: :json assert_response :success response_json = JSON.parse(response.body) + questions = response_json["questions"] - question_ids_in_response = response_json.map { |q| q["id"] } + question_ids_in_response = questions.map { |q| q["id"] } assert_includes question_ids_in_response, cc_question_in_cat_one.id assert_includes question_ids_in_response, sa_q_in_cat_one.id assert_not_includes question_ids_in_response, @standalone_question.id # This one is in category_two - assert_equal Question.by_category(@category_one.id).count, response_json.size + assert_equal Question.by_category(@category_one.id).count, response_json["total_entries"] end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 3a1c685..43e2b4b 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -113,6 +113,34 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal original_email, user_to_update.email # Ensure email was not changed end + test "should login existing user with facebook and link facebook_id" do + user = User.create!(username: "fbuser", email: "fbuser@example.com", password: "password123") + + post facebook_login_users_url, + params: { facebook_id: "fb_123", email: user.email, name: "FB User" }, + as: :json + + assert_response :ok + response_json = JSON.parse(response.body) + assert_equal user.email, response_json["email"] + assert response_json["token"].present? + assert_equal "fb_123", user.reload.facebook_id + end + + test "should create user with facebook login when user does not exist" do + assert_difference("User.count", 1) do + post facebook_login_users_url, + params: { facebook_id: "fb_new_1", email: "newfb@example.com", name: "Nuevo FB" }, + as: :json + end + + assert_response :created + response_json = JSON.parse(response.body) + assert_equal "newfb@example.com", response_json["email"] + assert response_json["token"].present? + assert_equal "fb_new_1", User.find_by(email: "newfb@example.com")&.facebook_id + end + test "GET me/contributions returns current user contributions when authenticated" do get me_contributions_users_url, headers: @auth_headers, as: :json assert_response :success diff --git a/test/models/clinical_case_test.rb b/test/models/clinical_case_test.rb index 3923546..d4fc054 100644 --- a/test/models/clinical_case_test.rb +++ b/test/models/clinical_case_test.rb @@ -120,4 +120,39 @@ class ClinicalCaseTest < ActiveSupport::TestCase # If Kaminari is used (`paginates_per`), this test would need adjustment. assert_equal 10, ClinicalCase.per_page end + + test "image should be invalid when content type is not png or jpg" do + clinical_case = ClinicalCase.new(name: "Case with invalid image type", category: @category) + clinical_case.image.attach( + io: StringIO.new("fake-image-content"), + filename: "case.gif", + content_type: "image/gif" + ) + + assert_not clinical_case.valid? + assert_includes clinical_case.errors[:image], "debe ser PNG o JPG" + end + + test "image should be invalid when larger than 5 MB" do + clinical_case = ClinicalCase.new(name: "Case with large image", category: @category) + clinical_case.image.attach( + io: StringIO.new("a" * (5.megabytes + 1)), + filename: "large.jpg", + content_type: "image/jpeg" + ) + + assert_not clinical_case.valid? + assert_includes clinical_case.errors[:image], "no debe superar 5 MB" + end + + test "image should be valid when jpg and up to 5 MB" do + clinical_case = ClinicalCase.new(name: "Case with valid image", category: @category) + clinical_case.image.attach( + io: StringIO.new("a" * 1024), + filename: "valid.jpg", + content_type: "image/jpeg" + ) + + assert clinical_case.valid?, clinical_case.errors.full_messages.join(", ") + end end