diff --git a/app/controllers/v2_errors_controller.rb b/app/controllers/v2_errors_controller.rb new file mode 100644 index 0000000..fc9061d --- /dev/null +++ b/app/controllers/v2_errors_controller.rb @@ -0,0 +1,33 @@ +class V2ErrorsController < ApplicationController + before_action :authenticate_user! + + def summary + # Preguntas fallidas del usuario + failed_answers = Current.user.user_answers.incorrect.includes(question: [ :answers, { clinical_case: :category } ]).recent + + # Agrupar por especialidad (categoría) para el resumen + specialties_count = failed_answers.each_with_object(Hash.new(0)) do |ua, hash| + category = ua.question.effective_category + hash[category] += 1 if category + end + + most_failed_specialties = specialties_count.map do |category, count| + { id: "esp#{category.id}", name: category.name, count: count } + end.sort_by { |s| -s[:count] }.take(5) + + recent_failed_questions = failed_answers.take(10).map do |ua| + { + id: "q#{ua.question.id}", + question: ua.question.text, + correctAnswer: ua.question.answers.find(&:is_correct?)&.text, + userAnswer: ua.answer.text, + explanation: ua.answer.description # O la del correct answer si estuviera ahí + } + end + + render json: { + mostFailedSpecialties: most_failed_specialties, + recentFailedQuestions: recent_failed_questions + } + end +end diff --git a/app/controllers/v2_flashcards_controller.rb b/app/controllers/v2_flashcards_controller.rb new file mode 100644 index 0000000..fc920e3 --- /dev/null +++ b/app/controllers/v2_flashcards_controller.rb @@ -0,0 +1,33 @@ +class V2FlashcardsController < ApplicationController + before_action :authenticate_user! + + def review + # Obtener flashcards pendientes de repaso para el usuario actual + user_flashcards = Current.user.user_flashcards.due.limit(20) + + # Si no hay pendientes, podríamos sugerir algunas nuevas o simplemente enviar vacío + # El requisito dice que el backend decide según SRS + + render json: { + flashcards: user_flashcards.map { |uf| + { + id: uf.flashcard.id, + front: uf.flashcard.front, + back: uf.flashcard.back, + category: uf.flashcard.category&.name || "General" + } + } + } + end + + def answer + user_flashcard = Current.user.user_flashcards.find_by(flashcard_id: params[:id]) + + if user_flashcard + user_flashcard.review(params[:quality].to_i) + render json: { message: "Respuesta registrada", next_review: user_flashcard.next_review } + else + render json: { error: "Flashcard no encontrada para este usuario" }, status: :not_found + end + end +end diff --git a/app/controllers/v2_images_controller.rb b/app/controllers/v2_images_controller.rb new file mode 100644 index 0000000..d97e294 --- /dev/null +++ b/app/controllers/v2_images_controller.rb @@ -0,0 +1,35 @@ +class V2ImagesController < ApplicationController + before_action :authenticate_user! + + def bank + # El banco de imágenes se basa en casos clínicos que tienen imágenes adjuntas + cases = ClinicalCase.with_attached_image.published + + if params[:category].present? + cases = cases.joins(:category).where(categories: { name: params[:category] }) + end + + if params[:search].present? + search = "%#{params[:search]}%" + cases = cases.where("clinical_cases.name LIKE :search OR clinical_cases.tags LIKE :search", search: search) + end + + cases = cases.paginate(page: params[:page] || 1, per_page: 20) + + render json: { + images: cases.map { |c| + { + id: "img#{c.id}", + url: (rails_blob_url(c.image, only_path: false) rescue nil), + title: c.name, + category: c.category.name, + tags: c.tag_list + } + }, + pagination: { + current: cases.current_page, + total: cases.total_pages + } + } + end +end diff --git a/app/controllers/v2_knowledge_base_controller.rb b/app/controllers/v2_knowledge_base_controller.rb new file mode 100644 index 0000000..79a6e47 --- /dev/null +++ b/app/controllers/v2_knowledge_base_controller.rb @@ -0,0 +1,26 @@ +class V2KnowledgeBaseController < ApplicationController + before_action :authenticate_user! + + def index + topics = Topic.includes(:articles) + + if params[:topic].present? + topics = topics.where("title LIKE ?", "%#{params[:topic]}%") + end + + if params[:search].present? + search = "%#{params[:search]}%" + topics = topics.joins(:articles).where("articles.title LIKE :search OR articles.content LIKE :search", search: search).distinct + end + + render json: { + topics: topics.map { |t| + { + id: "t#{t.id}", + title: t.title, + articles: t.articles.map { |a| { id: "a#{a.id}", title: a.title } } + } + } + } + end +end diff --git a/app/controllers/v2_leaderboard_controller.rb b/app/controllers/v2_leaderboard_controller.rb new file mode 100644 index 0000000..f869474 --- /dev/null +++ b/app/controllers/v2_leaderboard_controller.rb @@ -0,0 +1,31 @@ +class V2LeaderboardController < ApplicationController + before_action :authenticate_user! + + def national + # En un entorno real, esto podría estar cacheado o usar una tabla de rankings + # Para esta implementación, calculamos on-the-fly ordenando por total_points + + users_with_points = User.all.sort_by(&:total_points).reverse + + top_players = users_with_points.take(100).each_with_index.map do |user, index| + { + rank: index + 1, + nickname: user.username || user.name || "DoctorX", + points: user.total_points, + avatar: "https://via.placeholder.com/150" + } + end + + current_user_rank = users_with_points.index(Current.user) + 1 rescue nil + + render json: { + currentUser: { + rank: current_user_rank, + points: Current.user.total_points, + avatar: "https://via.placeholder.com/150", + nickname: Current.user.username || Current.user.name || "DoctorX" + }, + topPlayers: top_players + } + end +end diff --git a/app/models/article.rb b/app/models/article.rb new file mode 100644 index 0000000..f3608bd --- /dev/null +++ b/app/models/article.rb @@ -0,0 +1,4 @@ +class Article < ApplicationRecord + belongs_to :topic + validates :title, presence: true +end diff --git a/app/models/clinical_case.rb b/app/models/clinical_case.rb index 94e2d6b..4d69839 100644 --- a/app/models/clinical_case.rb +++ b/app/models/clinical_case.rb @@ -13,6 +13,10 @@ class ClinicalCase < ApplicationRecord validates :name, presence: true validate :image_must_be_valid + def tag_list + tags&.split(",")&.map(&:strip) || [] + end + private def image_must_be_valid diff --git a/app/models/topic.rb b/app/models/topic.rb new file mode 100644 index 0000000..dbdebb6 --- /dev/null +++ b/app/models/topic.rb @@ -0,0 +1,4 @@ +class Topic < ApplicationRecord + has_many :articles, dependent: :destroy + validates :title, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 0ccb62d..02bbb95 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -47,7 +47,9 @@ def calculate_accuracy end def total_points - achievements.sum(:points) || 0 + achievement_points = achievements.sum(:points) || 0 + correct_answer_points = user_answers.correct.count + achievement_points + correct_answer_points end def answered?(question) diff --git a/config/routes.rb b/config/routes.rb index c7b5b20..602b109 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,12 @@ Rails.application.routes.draw do + # V2 Endpoints + get "v2/leaderboard/national", to: "v2_leaderboard#national" + get "v2/images/bank", to: "v2_images#bank" + get "v2/flashcards/review", to: "v2_flashcards#review" + post "v2/flashcards/review/:id/answer", to: "v2_flashcards#answer" + get "v2/knowledge-base", to: "v2_knowledge_base#index" + get "v2/errors/summary", to: "v2_errors#summary" + # Nuevas rutas unificadas resources :users do collection do diff --git a/db/migrate/20260310120343_add_tags_to_clinical_cases.rb b/db/migrate/20260310120343_add_tags_to_clinical_cases.rb new file mode 100644 index 0000000..a531a8f --- /dev/null +++ b/db/migrate/20260310120343_add_tags_to_clinical_cases.rb @@ -0,0 +1,5 @@ +class AddTagsToClinicalCases < ActiveRecord::Migration[8.1] + def change + add_column :clinical_cases, :tags, :string + end +end diff --git a/db/migrate/20260310120345_create_topics_and_articles.rb b/db/migrate/20260310120345_create_topics_and_articles.rb new file mode 100644 index 0000000..7296088 --- /dev/null +++ b/db/migrate/20260310120345_create_topics_and_articles.rb @@ -0,0 +1,15 @@ +class CreateTopicsAndArticles < ActiveRecord::Migration[8.1] + def change + create_table :topics do |t| + t.string :title + t.timestamps + end + + create_table :articles do |t| + t.string :title + t.text :content + t.references :topic, null: false, foreign_key: true + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index de6aca5..d4d0207 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_22_040006) do +ActiveRecord::Schema[8.1].define(version: 2026_03_10_120345) do create_table "achievements", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.json "criteria" @@ -59,6 +59,15 @@ t.index ["question_id"], name: "index_answers_on_question_id" end + create_table "articles", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.text "content" + t.datetime "created_at", null: false + t.string "title" + t.bigint "topic_id", null: false + t.datetime "updated_at", null: false + t.index ["topic_id"], name: "index_articles_on_topic_id" + end + create_table "categories", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "description" @@ -72,6 +81,7 @@ t.text "description" t.string "name" t.integer "status", default: 0 + t.string "tags" t.datetime "updated_at", null: false t.bigint "user_id" t.index ["category_id"], name: "index_clinical_cases_on_category_id" @@ -146,6 +156,12 @@ t.index ["user_id"], name: "index_specialist_profiles_on_user_id" end + create_table "topics", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "title" + t.datetime "updated_at", null: false + end + create_table "user_achievements", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "achieved_at" t.bigint "achievement_id", null: false @@ -234,6 +250,7 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "answers", "questions" + add_foreign_key "articles", "topics" add_foreign_key "clinical_cases", "categories" add_foreign_key "clinical_cases", "users" add_foreign_key "exam_questions", "exams" diff --git a/test/controllers/v2_endpoints_test.rb b/test/controllers/v2_endpoints_test.rb new file mode 100644 index 0000000..c1809c7 --- /dev/null +++ b/test/controllers/v2_endpoints_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class V2EndpointsTest < ActionDispatch::IntegrationTest + setup do + @user = User.create!(email: "test@example.com", password: "password123", username: "testuser") + @category = Category.create!(name: "Test Category") + @token = JsonWebToken.encode(user_id: @user.id) + @headers = { "Authorization" => "Bearer #{@token}" } + end + + test "should get national leaderboard" do + get "/v2/leaderboard/national", headers: @headers + assert_response :success + json = JSON.parse(response.body) + assert_includes json.keys, "currentUser" + assert_includes json.keys, "topPlayers" + end + + test "should get image bank" do + get "/v2/images/bank", headers: @headers + assert_response :success + json = JSON.parse(response.body) + assert_includes json.keys, "images" + assert_includes json.keys, "pagination" + end + + test "should get flashcards for review" do + get "/v2/flashcards/review", headers: @headers + assert_response :success + json = JSON.parse(response.body) + assert_includes json.keys, "flashcards" + end + + test "should get knowledge base" do + get "/v2/knowledge-base", headers: @headers + assert_response :success + json = JSON.parse(response.body) + assert_includes json.keys, "topics" + end + + test "should get errors summary" do + get "/v2/errors/summary", headers: @headers + assert_response :success + json = JSON.parse(response.body) + assert_includes json.keys, "mostFailedSpecialties" + assert_includes json.keys, "recentFailedQuestions" + end +end