diff --git a/app/controllers/v2_ai_controller.rb b/app/controllers/v2_ai_controller.rb new file mode 100644 index 0000000..5e652a3 --- /dev/null +++ b/app/controllers/v2_ai_controller.rb @@ -0,0 +1,23 @@ +class V2AiController < ApplicationController + before_action :authenticate_admin! + + def generate_flashcards + topic = params[:topic] + count = params[:count] || 5 + difficulty = params[:difficulty] || "intermedio" + + suggestions = GenerativeAiService.generate_flashcards(topic, count, difficulty) + + flashcards = suggestions.map do |s| + Flashcard.create!( + front: s["front"], + back: s["back"], + status: "waiting_approval" + ) + end + + render json: flashcards + rescue => e + render json: { error: e.message }, status: :unprocessable_entity + end +end diff --git a/app/controllers/v2_coupons_controller.rb b/app/controllers/v2_coupons_controller.rb new file mode 100644 index 0000000..fc1f451 --- /dev/null +++ b/app/controllers/v2_coupons_controller.rb @@ -0,0 +1,27 @@ +class V2CouponsController < ApplicationController + before_action :authenticate_user! + + def me + active_coupons = Current.user.coupons.merge(UserCoupon.active) + used_coupons = Current.user.coupons.merge(UserCoupon.used) + + render json: { + active: active_coupons.map { |c| format_coupon(c, false) }, + used: used_coupons.map { |c| format_coupon(c, true) } + } + end + + private + + def format_coupon(coupon, used) + { + id: coupon.id, + code: coupon.code, + description: coupon.description, + expiration_date: coupon.expiration_date, + coupon_type: coupon.coupon_type, + used: used, + expired: coupon.expired? + } + end +end diff --git a/app/controllers/v2_flashcards_controller.rb b/app/controllers/v2_flashcards_controller.rb index fc920e3..2c58292 100644 --- a/app/controllers/v2_flashcards_controller.rb +++ b/app/controllers/v2_flashcards_controller.rb @@ -1,13 +1,24 @@ class V2FlashcardsController < ApplicationController before_action :authenticate_user! + def create + flashcard = Flashcard.new(flashcard_params) + flashcard.user = Current.user + flashcard.status = "published" # Assuming user created ones are published immediately + + if flashcard.save + # Automatically add to user's cards + Current.user.user_flashcards.create!(flashcard: flashcard) + render json: flashcard, status: :created + else + render json: { errors: flashcard.errors.full_messages }, status: :unprocessable_entity + end + end + 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| { @@ -30,4 +41,17 @@ def answer render json: { error: "Flashcard no encontrada para este usuario" }, status: :not_found end end + + private + + def flashcard_params + # Mapear specialtyId a category_id + p = params.permit(:front, :back, :specialtyId, :tags) + { + front: p[:front], + back: p[:back], + category_id: p[:specialtyId], + tags: p[:tags] + } + end end diff --git a/app/models/coupon.rb b/app/models/coupon.rb new file mode 100644 index 0000000..5e0e4ef --- /dev/null +++ b/app/models/coupon.rb @@ -0,0 +1,12 @@ +class Coupon < ApplicationRecord + has_many :user_coupons, dependent: :destroy + has_many :users, through: :user_coupons + + validates :code, presence: true, uniqueness: true + validates :coupon_type, presence: true + validates :expiration_date, presence: true + + def expired? + expiration_date < Time.current + end +end diff --git a/app/models/flashcard.rb b/app/models/flashcard.rb index 8cf6c90..53d6ae4 100644 --- a/app/models/flashcard.rb +++ b/app/models/flashcard.rb @@ -6,4 +6,12 @@ class Flashcard < ApplicationRecord has_many :users, through: :user_flashcards validates :front, :back, presence: true + + # status: pending, published, deleted, waiting_approval, not_approved + STATUSES = %w[pending published deleted waiting_approval not_approved].freeze + validates :status, inclusion: { in: STATUSES }, allow_nil: true + + def tag_list + tags&.split(",")&.map(&:strip) || [] + end end diff --git a/app/models/user.rb b/app/models/user.rb index 02bbb95..c5eb662 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,6 +21,8 @@ class User < ApplicationRecord has_many :user_flashcards, dependent: :destroy has_many :flashcards, through: :user_flashcards + has_many :user_coupons, dependent: :destroy + has_many :coupons, through: :user_coupons # Validaciones validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: VALID_EMAIL_REGEX } diff --git a/app/models/user_coupon.rb b/app/models/user_coupon.rb new file mode 100644 index 0000000..305312f --- /dev/null +++ b/app/models/user_coupon.rb @@ -0,0 +1,9 @@ +class UserCoupon < ApplicationRecord + belongs_to :user + belongs_to :coupon + + validates :user_id, uniqueness: { scope: :coupon_id, message: "ya ha usado este cupón" } + + scope :used, -> { where.not(used_at: nil) } + scope :active, -> { where(used_at: nil) } +end diff --git a/app/services/generative_ai_service.rb b/app/services/generative_ai_service.rb index da5febb..e8dc018 100644 --- a/app/services/generative_ai_service.rb +++ b/app/services/generative_ai_service.rb @@ -26,6 +26,19 @@ def self.generate_clinical_case(prompt) JSON.parse(response.content) end + def self.generate_flashcards(topic, count, difficulty) + prompt = "Genera #{count} flashcards de medicina sobre el tema '#{topic}' con dificultad #{difficulty}. " \ + "Cada flashcard debe tener un 'front' (pregunta o concepto) y un 'back' (respuesta corta o explicación). " \ + "Devuelve un array JSON de objetos con las llaves 'front' y 'back'." + + response = client.generate_content( + { contents: { role: "user", parts: { text: prompt } } } + ) + + json_content = response.content.gsub(/```json\n?/, "").gsub(/```\n?/, "").strip + JSON.parse(json_content) + end + def self.parse_pdf_to_exam(file) file_content = Base64.strict_encode64(file.read) diff --git a/config/routes.rb b/config/routes.rb index 602b109..c59c621 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,11 @@ Rails.application.routes.draw do # V2 Endpoints get "v2/leaderboard/national", to: "v2_leaderboard#national" + get "v2/coupons/me", to: "v2_coupons#me" 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" + post "v2/flashcards", to: "v2_flashcards#create" get "v2/knowledge-base", to: "v2_knowledge_base#index" get "v2/errors/summary", to: "v2_errors#summary" @@ -55,6 +57,7 @@ post "ai/generate_question", to: "ai#generate_question" post "ai/generate_clinical_case", to: "ai#generate_clinical_case" post "ai/bulk_create_exam", to: "ai#bulk_create_exam" + post "v2/ai/generate-flashcards", to: "v2_ai#generate_flashcards" get "leaderboard", to: "leaderboard#index" diff --git a/db/migrate/20260313222720_create_coupons.rb b/db/migrate/20260313222720_create_coupons.rb new file mode 100644 index 0000000..fb6d3af --- /dev/null +++ b/db/migrate/20260313222720_create_coupons.rb @@ -0,0 +1,12 @@ +class CreateCoupons < ActiveRecord::Migration[8.1] + def change + create_table :coupons do |t| + t.string :code + t.datetime :expiration_date + t.text :description + t.string :coupon_type + + t.timestamps + end + end +end diff --git a/db/migrate/20260313222722_create_user_coupons.rb b/db/migrate/20260313222722_create_user_coupons.rb new file mode 100644 index 0000000..21907b9 --- /dev/null +++ b/db/migrate/20260313222722_create_user_coupons.rb @@ -0,0 +1,11 @@ +class CreateUserCoupons < ActiveRecord::Migration[8.1] + def change + create_table :user_coupons do |t| + t.references :user, null: false, foreign_key: true + t.references :coupon, null: false, foreign_key: true + t.datetime :used_at + + t.timestamps + end + end +end diff --git a/db/migrate/20260313222726_add_status_and_tags_to_flashcards.rb b/db/migrate/20260313222726_add_status_and_tags_to_flashcards.rb new file mode 100644 index 0000000..81e4a25 --- /dev/null +++ b/db/migrate/20260313222726_add_status_and_tags_to_flashcards.rb @@ -0,0 +1,6 @@ +class AddStatusAndTagsToFlashcards < ActiveRecord::Migration[8.1] + def change + add_column :flashcards, :status, :string + add_column :flashcards, :tags, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index d4d0207..288bcbe 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_03_10_120345) do +ActiveRecord::Schema[8.1].define(version: 2026_03_13_222726) do create_table "achievements", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.json "criteria" @@ -89,6 +89,15 @@ t.index ["user_id"], name: "index_clinical_cases_on_user_id" end + create_table "coupons", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "code" + t.string "coupon_type" + t.datetime "created_at", null: false + t.text "description" + t.datetime "expiration_date" + t.datetime "updated_at", null: false + end + create_table "exam_questions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "exam_id", null: false @@ -118,6 +127,8 @@ t.bigint "category_id" t.datetime "created_at", null: false t.text "front" + t.string "status" + t.string "tags" t.datetime "updated_at", null: false t.bigint "user_id" t.index ["category_id"], name: "index_flashcards_on_category_id" @@ -188,6 +199,16 @@ t.index ["user_id"], name: "index_user_answers_on_user_id" end + create_table "user_coupons", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "coupon_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "used_at" + t.bigint "user_id", null: false + t.index ["coupon_id"], name: "index_user_coupons_on_coupon_id" + t.index ["user_id"], name: "index_user_coupons_on_user_id" + end + create_table "user_exam_answers", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "answer_id", null: false t.datetime "created_at", null: false @@ -266,6 +287,8 @@ add_foreign_key "user_answers", "answers" add_foreign_key "user_answers", "questions" add_foreign_key "user_answers", "users" + add_foreign_key "user_coupons", "coupons" + add_foreign_key "user_coupons", "users" add_foreign_key "user_exam_answers", "answers" add_foreign_key "user_exam_answers", "exam_questions" add_foreign_key "user_exam_answers", "user_exams" diff --git a/test/controllers/v2_ai_controller_test.rb b/test/controllers/v2_ai_controller_test.rb new file mode 100644 index 0000000..29b67da --- /dev/null +++ b/test/controllers/v2_ai_controller_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class V2AiControllerTest < ActionDispatch::IntegrationTest + fixtures :users + setup do + @admin = users(:admin) + @user = users(:player_one) + end + + test "should generate flashcards as admin" do + GenerativeAiService.stubs(:generate_flashcards).returns([ + { "front" => "AI Front 1", "back" => "AI Back 1" }, + { "front" => "AI Front 2", "back" => "AI Back 2" } + ]) + + assert_difference("Flashcard.count", 2) do + post "/v2/ai/generate-flashcards", + params: { topic: "Diabetes", count: 2, difficulty: "fácil" }, + headers: admin_auth_headers(@admin) + end + assert_response :success + + data = json_response + assert_equal 2, data.size + assert_equal "waiting_approval", data.first["status"] + end + + test "should fail to generate flashcards as regular user" do + post "/v2/ai/generate-flashcards", + params: { topic: "Diabetes" }, + headers: player_auth_headers(@user) + assert_response :forbidden + end +end diff --git a/test/controllers/v2_coupons_controller_test.rb b/test/controllers/v2_coupons_controller_test.rb new file mode 100644 index 0000000..1134ff5 --- /dev/null +++ b/test/controllers/v2_coupons_controller_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class V2CouponsControllerTest < ActionDispatch::IntegrationTest + fixtures :users + setup do + @user = users(:player_one) + @coupon1 = Coupon.create!(code: "DESC10", coupon_type: "percentage", expiration_date: 1.day.from_now, description: "10% off") + @coupon2 = Coupon.create!(code: "FREE", coupon_type: "fixed", expiration_date: 1.day.from_now, description: "Free") + @user.user_coupons.create!(coupon: @coupon1) + @user.user_coupons.create!(coupon: @coupon2, used_at: Time.current) + end + + test "should get me coupons" do + get "/v2/coupons/me", headers: player_auth_headers(@user) + assert_response :success + + data = json_response + assert_equal 1, data["active"].size + assert_equal 1, data["used"].size + assert_equal "DESC10", data["active"].first["code"] + assert_equal "FREE", data["used"].first["code"] + end + + test "should fail if not authenticated" do + get "/v2/coupons/me" + assert_response :unauthorized + end +end diff --git a/test/controllers/v2_flashcards_controller_test.rb b/test/controllers/v2_flashcards_controller_test.rb new file mode 100644 index 0000000..3ae9cf7 --- /dev/null +++ b/test/controllers/v2_flashcards_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class V2FlashcardsControllerTest < ActionDispatch::IntegrationTest + fixtures :users, :categories + setup do + @user = users(:player_one) + @category = categories(:one) + end + + test "should create flashcard" do + assert_difference("Flashcard.count", 1) do + assert_difference("UserFlashcard.count", 1) do + post "/v2/flashcards", + params: { front: "Front text", back: "Back text", specialtyId: @category.id, tags: "tag1, tag2" }, + headers: player_auth_headers(@user) + end + end + assert_response :created + + flashcard = Flashcard.last + assert_equal "Front text", flashcard.front + assert_equal "published", flashcard.status + assert_equal "tag1, tag2", flashcard.tags + assert_equal @category.id, flashcard.category_id + end + + test "should fail to create flashcard with invalid params" do + post "/v2/flashcards", + params: { front: "" }, + headers: player_auth_headers(@user) + assert_response :unprocessable_entity + end +end