From 19ac71852628a24d18d664a9649311737d5a280b Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 8 Jan 2026 04:42:39 -0800 Subject: [PATCH] initial assessment implementation --- db/data/001_initial_seed_data.sql | 390 +++++----- db/data/003_fix_autoincrement_desync.sql | 20 +- db/migrations/000001_yimaru.up.sql | 112 ++- db/query/initial_assessment.sql | 310 ++++++-- docker-compose.yml | 2 +- gen/db/initial_assessment.sql.go | 714 +++++++++++++++--- gen/db/models.go | 66 +- gen/db/user.sql.go | 6 +- internal/domain/initial_assessment.go | 83 +- internal/ports/initial_assessment.go | 23 +- internal/ports/user.go | 17 +- internal/repository/initial_assessment.go | 225 ++---- internal/services/assessment/helpers.go | 13 + .../services/assessment/initial_assessment.go | 387 ++++++---- internal/web_server/handlers/auth_handler.go | 26 +- .../web_server/handlers/initial_assessment.go | 146 ++-- internal/web_server/routes.go | 44 +- 17 files changed, 1718 insertions(+), 866 deletions(-) create mode 100644 internal/services/assessment/helpers.go diff --git a/db/data/001_initial_seed_data.sql b/db/data/001_initial_seed_data.sql index 8d23e8f..e23ef9e 100644 --- a/db/data/001_initial_seed_data.sql +++ b/db/data/001_initial_seed_data.sql @@ -1,5 +1,74 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; + +INSERT INTO users ( + id, + first_name, + last_name, + user_name, + email, + phone_number, + role, + password, + status, + email_verified, + phone_verified, + profile_completed, + preferred_language, + created_at +) +VALUES +( + 10, + 'Demo', + 'Student', + 'demo_student', + 'student10@yimaru.com', + NULL, + 'USER', + crypt('password@123', gen_salt('bf'))::bytea, + 'ACTIVE', + TRUE, + FALSE, + FALSE, + 'en', + CURRENT_TIMESTAMP +), +( + 11, + 'System', + 'Admin', + 'sys_admin', + 'admin@yimaru.com', + '0911001100', + 'ADMIN', + crypt('password@123', gen_salt('bf'))::bytea, + 'ACTIVE', + TRUE, + TRUE, + TRUE, + 'en', + CURRENT_TIMESTAMP +), +( + 12, + 'Support', + 'Agent', + 'support_agent', + 'support@yimaru.com', + '0911223344', + 'SUPPORT', + crypt('password@123', gen_salt('bf'))::bytea, + 'ACTIVE', + TRUE, + TRUE, + TRUE, + 'en', + CURRENT_TIMESTAMP +) +ON CONFLICT (id) DO NOTHING; + + -- ====================================================== -- Global Settings (LMS) -- ====================================================== @@ -14,195 +83,144 @@ VALUES ON CONFLICT (key) DO NOTHING; -- ====================================================== -INSERT INTO users ( - id, - first_name, - last_name, - user_name, - email, - phone_number, - role, - password, - age, - education_level, - country, - region, - knowledge_level, - nick_name, - occupation, - learning_goal, - language_goal, - language_challange, - favoutite_topic, - initial_assessment_completed, - email_verified, - phone_verified, - status, - last_login, - profile_completed, - profile_picture_url, - preferred_language, - created_at, - updated_at -) -VALUES -( - 1, - 'Sarah', - 'Connor', - 'SarahC', - 'yaredyemane1@gmail.com', - NULL, - 'SUPER_ADMIN', - crypt('password@123', gen_salt('bf'))::bytea, - 35, - 'Masters', - 'USA', - 'California', - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - FALSE, - TRUE, - FALSE, - 'ACTIVE', - NULL, - FALSE, - NULL, - 'en', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -), -( - 2, - 'Test', - 'Instructor', - 'InstructorT', - 'instructor@yimaru.com', - '0988554466', - 'INSTRUCTOR', - crypt('password@123', gen_salt('bf'))::bytea, - 30, - 'Bachelors', - 'USA', - 'New York', - NULL, - NULL, - 'Instructor', - NULL, - NULL, - NULL, - NULL, - FALSE, - TRUE, - TRUE, - 'ACTIVE', - NULL, - FALSE, - NULL, - 'en', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -), -( - 3, - 'Demo', - 'Student', - 'DemoS', - 'student@yimaru.com', - NULL, - 'STUDENT', - crypt('password@123', gen_salt('bf'))::bytea, - 22, - 'High School', - 'USA', - 'Texas', - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - FALSE, - TRUE, - FALSE, - 'ACTIVE', - NULL, - FALSE, - NULL, - 'en', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -) -ON CONFLICT (id) DO UPDATE -SET first_name = EXCLUDED.first_name, - last_name = EXCLUDED.last_name, - user_name = EXCLUDED.user_name, - email = EXCLUDED.email, - phone_number = EXCLUDED.phone_number, - role = EXCLUDED.role, - password = EXCLUDED.password, - age = EXCLUDED.age, - education_level = EXCLUDED.education_level, - country = EXCLUDED.country, - region = EXCLUDED.region, - knowledge_level = EXCLUDED.knowledge_level, - nick_name = EXCLUDED.nick_name, - occupation = EXCLUDED.occupation, - learning_goal = EXCLUDED.learning_goal, - language_goal = EXCLUDED.language_goal, - language_challange = EXCLUDED.language_challange, - favoutite_topic = EXCLUDED.favoutite_topic, - initial_assessment_completed = EXCLUDED.initial_assessment_completed, - email_verified = EXCLUDED.email_verified, - phone_verified = EXCLUDED.phone_verified, - status = EXCLUDED.status, - last_login = EXCLUDED.last_login, - profile_completed = EXCLUDED.profile_completed, - profile_picture_url = EXCLUDED.profile_picture_url, - preferred_language = EXCLUDED.preferred_language, - updated_at = CURRENT_TIMESTAMP; +-- ====================================================== +-- Assessment Questions – Level A2 (EASY) +-- ====================================================== --- ====================================================== --- Courses --- ====================================================== --- ====================================================== --- Course Categories --- ====================================================== -INSERT INTO course_categories ( - id, - name, - is_active, - created_at -) +INSERT INTO assessment_questions (id, title, question_type, difficulty_level, points, is_active) VALUES - (1, 'Learning English', TRUE, CURRENT_TIMESTAMP), - (2, 'Other Courses', TRUE, CURRENT_TIMESTAMP) +(1, 'What would you say to greet someone before lunchtime?', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), +(2, 'Which question is correct to ask about your routine?', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), +(3, 'She ___ like pizza.', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), +(4, 'I usually go to school and start class ____ eight o’clock.', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), +(5, 'Someone says, “Here is the book you asked for.” What is the best response?', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE) ON CONFLICT (id) DO NOTHING; +INSERT INTO assessment_question_options (question_id, option_text, option_order, is_correct) +VALUES +-- Q1 +(1, 'Good morning.', 1, TRUE), +(1, 'How do you do?', 2, FALSE), +(1, 'Good afternoon.', 3, FALSE), +(1, 'Goodbye.', 4, FALSE), + +-- Q2 +(2, 'What time you wake up?', 1, FALSE), +(2, 'What time do you wake up?', 2, TRUE), +(2, 'What time are you wake up?', 3, FALSE), +(2, 'What time waking you?', 4, FALSE), + +-- Q3 +(3, 'do not', 1, FALSE), +(3, 'not', 2, FALSE), +(3, 'is not', 3, FALSE), +(3, 'does not', 4, TRUE), + +-- Q4 +(4, 'about', 1, FALSE), +(4, 'on', 2, FALSE), +(4, 'at', 3, TRUE), +(4, 'in', 4, FALSE), + +-- Q5 +(5, 'Never mind.', 1, FALSE), +(5, 'Really?', 2, FALSE), +(5, 'What a pity!', 3, FALSE), +(5, 'Thank you.', 4, TRUE); + -- ====================================================== --- Notifications (Sample) +-- Assessment Questions – Level B1 (MEDIUM) -- ====================================================== -INSERT INTO notifications ( - user_id, - type, - level, - channel, - title, - message, - created_at -) -VALUES ( - 3, - 'course_enrolled', - 'info', - 'in_app', - 'Welcome to your course', - 'You have successfully enrolled in Introduction to Go Programming.', - CURRENT_TIMESTAMP -); + +INSERT INTO assessment_questions (id, title, question_type, difficulty_level, points, is_active) +VALUES +(6, 'How do you introduce your friend to another person?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), +(7, 'How would you ask for the price of an item in a shop?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), +(8, 'Which sentence correctly gives simple directions?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), +(9, 'The watch shows 10:50, but the real time is 10:45. What can you say?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), +(10, 'Which instruction is correct when giving directions?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO assessment_question_options (question_id, option_text, option_order, is_correct) +VALUES +-- Q6 +(6, 'Hello, my name is Samson.', 1, FALSE), +(6, 'Good morning. Nice to meet you.', 2, FALSE), +(6, 'Let me introduce myself to my friend.', 3, FALSE), +(6, 'This is my friend, Samson.', 4, TRUE), + +-- Q7 +(7, 'How many are these?', 1, FALSE), +(7, 'What is this?', 2, FALSE), +(7, 'How much is this?', 3, TRUE), +(7, 'Where is the nearest shop?', 4, FALSE), + +-- Q8 +(8, 'Thank you very much for asking.', 1, FALSE), +(8, 'Turn left and walk two blocks.', 2, TRUE), +(8, 'Why don’t you eat out.', 3, FALSE), +(8, 'Take the bus to the park.', 4, FALSE), + +-- Q9 +(9, 'My watch is slow.', 1, TRUE), +(9, 'My watch is late.', 2, FALSE), +(9, 'My watch is fast.', 3, FALSE), +(9, 'My watch is early.', 4, FALSE), + +-- Q10 +(10, 'Turn left.', 1, TRUE), +(10, 'Turn on left.', 2, FALSE), +(10, 'Turn left side.', 3, FALSE), +(10, 'Turn to straight.', 4, FALSE); + +-- ====================================================== +-- Assessment Questions – Level B2 (HARD) +-- ====================================================== + +INSERT INTO assessment_questions (id, title, question_type, difficulty_level, points, is_active) +VALUES +(11, 'What is the most polite way to ask to speak to someone on the phone?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), +(12, 'How do you correctly state the age of a person who is 30 years old?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), +(13, 'When asking for help with a new Yimaru App feature, which option is most appropriate?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), +(14, 'Which word has the unvoiced “th” sound?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), +(15, 'Which sentence sounds like a warning, not friendly advice?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), +(16, 'What does this sentence mean? “I will definitely be there on time.”', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO assessment_question_options (question_id, option_text, option_order, is_correct) +VALUES +-- Q11 +(11, 'May I speak to Mr. Tesfaye, please?', 1, TRUE), +(11, 'Can I talk to Mr. Tesfaye?', 2, FALSE), +(11, 'Is Mr. Tesfaye there?', 3, FALSE), +(11, 'I want to talk to Mr. Tesfaye.', 4, FALSE), + +-- Q12 +(12, 'He is thirty years.', 1, FALSE), +(12, 'He has thirty years.', 2, FALSE), +(12, 'He has thirty years old.', 3, FALSE), +(12, 'He is thirty.', 4, TRUE), + +-- Q13 +(13, 'Are you familiar with how this feature works?', 1, FALSE), +(13, 'Could you walk me through how this feature works?', 2, TRUE), +(13, 'I believe I understand how this feature works.', 3, FALSE), +(13, 'I’ve tried similar features before.', 4, FALSE), + +-- Q14 +(14, 'That', 1, FALSE), +(14, 'They', 2, FALSE), +(14, 'These', 3, FALSE), +(14, 'Three', 4, TRUE), + +-- Q15 +(15, 'You might want to plan your time better.', 1, FALSE), +(15, 'If I were you, I’d start earlier.', 2, FALSE), +(15, 'You’d better meet the deadline this time.', 3, TRUE), +(15, 'Why don’t you try using a planner?', 4, FALSE), + +-- Q16 +(16, 'The speaker is unsure about arriving.', 1, FALSE), +(16, 'The speaker is promising to arrive on time.', 2, TRUE), +(16, 'The speaker might arrive late.', 3, FALSE), +(16, 'The speaker has already arrived.', 4, FALSE); diff --git a/db/data/003_fix_autoincrement_desync.sql b/db/data/003_fix_autoincrement_desync.sql index 1de6eac..c615073 100644 --- a/db/data/003_fix_autoincrement_desync.sql +++ b/db/data/003_fix_autoincrement_desync.sql @@ -51,14 +51,14 @@ SELECT setval( ) FROM notifications; -SELECT setval( - pg_get_serial_sequence('referral_codes', 'id'), - COALESCE(MAX(id), 1) -) -FROM referral_codes; +-- SELECT setval( +-- pg_get_serial_sequence('referral_codes', 'id'), +-- COALESCE(MAX(id), 1) +-- ) +-- FROM referral_codes; -SELECT setval( - pg_get_serial_sequence('user_referrals', 'id'), - COALESCE(MAX(id), 1) -) -FROM user_referrals; +-- SELECT setval( +-- pg_get_serial_sequence('user_referrals', 'id'), +-- COALESCE(MAX(id), 1) +-- ) +-- FROM user_referrals; diff --git a/db/migrations/000001_yimaru.up.sql b/db/migrations/000001_yimaru.up.sql index 7b9e8fe..61f485d 100644 --- a/db/migrations/000001_yimaru.up.sql +++ b/db/migrations/000001_yimaru.up.sql @@ -36,42 +36,126 @@ CREATE TABLE IF NOT EXISTS users ( CHECK (email IS NOT NULL OR phone_number IS NOT NULL) ); -CREATE TABLE assessment_questions ( +CREATE TABLE IF NOT EXISTS assessment_questions ( id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, description TEXT, - question_type VARCHAR(50) NOT NULL, -- MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER - difficulty_level VARCHAR(50) NOT NULL, -- BEGINNER, INTERMEDIATE, ADVANCED + + question_type VARCHAR(50) NOT NULL, + -- MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER + + difficulty_level VARCHAR(50), + -- EASY, MEDIUM, HARD + + points INT NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ ); -CREATE TABLE assessment_question_options ( +CREATE TABLE IF NOT EXISTS assessment_question_options ( id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + option_text TEXT NOT NULL, - is_correct BOOLEAN NOT NULL DEFAULT FALSE + option_order INT NOT NULL, + + is_correct BOOLEAN NOT NULL DEFAULT FALSE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE (question_id, option_order) ); -CREATE TABLE assessment_attempts ( +CREATE TABLE IF NOT EXISTS assessment_short_answers ( id BIGSERIAL PRIMARY KEY, + + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + + correct_answer TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE assessment_questions +ADD CONSTRAINT chk_question_type +CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')); + +CREATE TABLE IF NOT EXISTS assessment_attempts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + total_questions INT NOT NULL, - correct_answers INT NOT NULL, - score_percentage NUMERIC(5,2) NOT NULL, - knowledge_level VARCHAR(50) NOT NULL, -- BEGINNER, INTERMEDIATE, ADVANCED - completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + total_points INT NOT NULL, + + score INT, + percentage NUMERIC(5,2), + + status VARCHAR(50) NOT NULL, + -- IN_PROGRESS, SUBMITTED, EVALUATED + + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMPTZ, + evaluated_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ ); -CREATE TABLE assessment_answers ( +CREATE TABLE IF NOT EXISTS assessment_attempt_questions ( id BIGSERIAL PRIMARY KEY, + + attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, question_id BIGINT NOT NULL REFERENCES assessment_questions(id), - selected_option_id BIGINT REFERENCES assessment_question_options(id), - short_answer TEXT, - is_correct BOOLEAN NOT NULL + + question_type VARCHAR(50) NOT NULL, + points INT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE (attempt_id, question_id) ); +CREATE TABLE IF NOT EXISTS assessment_attempt_answers ( + id BIGSERIAL PRIMARY KEY, + + attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + + -- For MCQ / TRUE_FALSE + selected_option_id BIGINT + REFERENCES assessment_question_options(id), + + -- For SHORT_ANSWER + submitted_text TEXT, + + is_correct BOOLEAN, + awarded_points INT NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE (attempt_id, question_id), + + CHECK ( + (selected_option_id IS NOT NULL AND submitted_text IS NULL) + OR + (selected_option_id IS NULL AND submitted_text IS NOT NULL) + ) +); + +ALTER TABLE assessment_attempts +ADD CONSTRAINT chk_attempt_status +CHECK (status IN ('IN_PROGRESS', 'SUBMITTED', 'EVALUATED')); + +ALTER TABLE assessment_attempt_questions +ADD CONSTRAINT chk_attempt_question_type +CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')); + CREATE TABLE refresh_tokens ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/db/query/initial_assessment.sql b/db/query/initial_assessment.sql index ea6b719..2c06d4f 100644 --- a/db/query/initial_assessment.sql +++ b/db/query/initial_assessment.sql @@ -1,93 +1,251 @@ --- name: CreateAssessmentAttempt :one -INSERT INTO assessment_attempts ( - user_id, - total_questions, - correct_answers, - score_percentage, - knowledge_level -) -VALUES ( - $1, -- user_id - $2, -- total_questions - $3, -- correct_answers - $4, -- score_percentage - $5 -- knowledge_level -) -RETURNING - id, - user_id, - total_questions, - correct_answers, - score_percentage, - knowledge_level, - completed_at; - --- -- name: CreateAssessmentAnswer :exec --- INSERT INTO assessment_answers ( --- attempt_id, --- question_id, --- selected_option_id, --- short_answer, --- is_correct --- ) --- VALUES ( --- $1, -- attempt_id --- $2, -- question_id --- $3, -- selected_option_id --- $4, -- short_answer --- $5 -- is_correct --- ); - --- name: GetAssessmentOptionByID :one -SELECT - id, - question_id, - option_text, - is_correct -FROM assessment_question_options -WHERE id = $1 -LIMIT 1; - --- name: GetCorrectOptionForQuestion :one -SELECT - id -FROM assessment_question_options -WHERE question_id = $1 - AND is_correct = TRUE -LIMIT 1; - --- name: GetLatestAssessmentAttempt :one -SELECT * -FROM assessment_attempts -WHERE user_id = $1 -ORDER BY completed_at DESC -LIMIT 1; - -- name: CreateAssessmentQuestion :one INSERT INTO assessment_questions ( title, description, question_type, - difficulty_level + difficulty_level, + points, + is_active +) +VALUES ( + $1, -- title + $2, -- description + $3, -- question_type + $4, -- difficulty_level + $5, -- points + $6 -- is_active ) -VALUES ($1, $2, $3, $4) RETURNING *; --- name: CreateAssessmentQuestionOption :exec -INSERT INTO assessment_question_options ( - question_id, - option_text, - is_correct -) -VALUES ($1, $2, $3); +-- name: GetAssessmentQuestionByID :one +SELECT * +FROM assessment_questions +WHERE id = $1; -- name: GetActiveAssessmentQuestions :many SELECT * FROM assessment_questions -WHERE is_active = TRUE -ORDER BY difficulty_level, id; +WHERE is_active = true +ORDER BY created_at DESC; + +-- name: GetAssessmentQuestionsPaginated :many +SELECT + COUNT(*) OVER () AS total_count, + id, + title, + description, + question_type, + difficulty_level, + points, + is_active, + created_at, + updated_at +FROM assessment_questions +WHERE ($1 IS NULL OR question_type = $1) + AND ($2 IS NULL OR difficulty_level = $2) + AND ($3 IS NULL OR is_active = $3) +LIMIT $4 +OFFSET $5; + +-- name: UpdateAssessmentQuestion :exec +UPDATE assessment_questions +SET + title = COALESCE($1, title), + description = COALESCE($2, description), + question_type = COALESCE($3, question_type), + difficulty_level = COALESCE($4, difficulty_level), + points = COALESCE($5, points), + is_active = COALESCE($6, is_active), + updated_at = CURRENT_TIMESTAMP +WHERE id = $7; + +-- name: DeleteAssessmentQuestion :exec +DELETE FROM assessment_questions +WHERE id = $1; + +-- name: CreateQuestionOption :one +INSERT INTO assessment_question_options ( + question_id, + option_text, + option_order, + is_correct +) +VALUES ( + $1, -- question_id + $2, -- option_text + $3, -- option_order + $4 -- is_correct +) +RETURNING *; -- name: GetQuestionOptions :many SELECT * FROM assessment_question_options +WHERE question_id = $1 +ORDER BY option_order; + +-- name: DeleteQuestionOptionsByQuestionID :exec +DELETE FROM assessment_question_options WHERE question_id = $1; + +-- name: CreateShortAnswer :one +INSERT INTO assessment_short_answers ( + question_id, + correct_answer +) +VALUES ( + $1, -- question_id + $2 -- correct_answer +) +RETURNING *; + +-- name: GetShortAnswersByQuestionID :many +SELECT * +FROM assessment_short_answers +WHERE question_id = $1; + +-------------------------------------------------------------------------------------- + +-- name: CreateAssessmentAttempt :one +INSERT INTO assessment_attempts ( + user_id, + total_questions, + total_points, + status +) +VALUES ( + $1, -- user_id + $2, -- total_questions + $3, -- total_points + 'IN_PROGRESS' +) +RETURNING *; + +-- name: GetAssessmentAttemptByID :one +SELECT * +FROM assessment_attempts +WHERE id = $1; + +-- name: GetUserAssessmentAttempts :many +SELECT + id, + user_id, + total_questions, + total_points, + score, + percentage, + status, + started_at, + submitted_at, + evaluated_at +FROM assessment_attempts +WHERE user_id = $1 +ORDER BY started_at DESC; + +-- name: SubmitAssessmentAttempt :exec +UPDATE assessment_attempts +SET + status = 'SUBMITTED', + submitted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: AddAttemptQuestion :exec +INSERT INTO assessment_attempt_questions ( + attempt_id, + question_id, + question_type, + points +) +VALUES ( + $1, -- attempt_id + $2, -- question_id + $3, -- question_type + $4 -- points +); + +-- name: GetAttemptQuestions :many +SELECT + aq.question_id, + aq.question_type, + aq.points, + q.title, + q.description +FROM assessment_attempt_questions aq +JOIN assessment_questions q ON q.id = aq.question_id +WHERE aq.attempt_id = $1; + +-- name: UpsertAttemptAnswer :exec +INSERT INTO assessment_attempt_answers ( + attempt_id, + question_id, + selected_option_id, + submitted_text +) +VALUES ( + $1, -- attempt_id + $2, -- question_id + $3, -- selected_option_id + $4 -- submitted_text +) +ON CONFLICT (attempt_id, question_id) +DO UPDATE SET + selected_option_id = EXCLUDED.selected_option_id, + submitted_text = EXCLUDED.submitted_text; + +-- name: GetAttemptAnswers :many +SELECT * +FROM assessment_attempt_answers +WHERE attempt_id = $1; + +-- name: EvaluateMCQAnswer :exec +UPDATE assessment_attempt_answers a +SET + is_correct = o.is_correct, + awarded_points = CASE WHEN o.is_correct THEN q.points ELSE 0 END +FROM assessment_question_options o +JOIN assessment_questions q ON q.id = a.question_id +WHERE a.selected_option_id = o.id + AND a.attempt_id = $1; + +-- name: EvaluateShortAnswer :exec +UPDATE assessment_attempt_answers a +SET + is_correct = EXISTS ( + SELECT 1 + FROM assessment_short_answers s + WHERE s.question_id = a.question_id + AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) + ), + awarded_points = CASE + WHEN EXISTS ( + SELECT 1 + FROM assessment_short_answers s + WHERE s.question_id = a.question_id + AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) + ) + THEN q.points + ELSE 0 + END +FROM assessment_questions q +WHERE a.question_id = q.id + AND a.attempt_id = $1; + +-- name: FinalizeAssessmentAttempt :exec +UPDATE assessment_attempts +SET + score = sub.total_score, + percentage = ROUND((sub.total_score::NUMERIC / total_points) * 100, 2), + status = 'EVALUATED', + evaluated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +FROM ( + SELECT attempt_id, SUM(awarded_points) AS total_score + FROM assessment_attempt_answers + WHERE attempt_id = $1 + GROUP BY attempt_id +) sub +WHERE assessment_attempts.id = sub.attempt_id; + + + diff --git a/docker-compose.yml b/docker-compose.yml index 05bafaf..2331e39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: container_name: yimaru-backend-postgres-1 image: postgres:16-alpine ports: - - "5422:5432" + - "5432:5422" environment: - POSTGRES_PASSWORD=secret - POSTGRES_USER=root diff --git a/gen/db/initial_assessment.sql.go b/gen/db/initial_assessment.sql.go index 6499b76..2f52cc1 100644 --- a/gen/db/initial_assessment.sql.go +++ b/gen/db/initial_assessment.sql.go @@ -11,56 +11,78 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const AddAttemptQuestion = `-- name: AddAttemptQuestion :exec +INSERT INTO assessment_attempt_questions ( + attempt_id, + question_id, + question_type, + points +) +VALUES ( + $1, -- attempt_id + $2, -- question_id + $3, -- question_type + $4 -- points +) +` + +type AddAttemptQuestionParams struct { + AttemptID int64 `json:"attempt_id"` + QuestionID int64 `json:"question_id"` + QuestionType string `json:"question_type"` + Points int32 `json:"points"` +} + +func (q *Queries) AddAttemptQuestion(ctx context.Context, arg AddAttemptQuestionParams) error { + _, err := q.db.Exec(ctx, AddAttemptQuestion, + arg.AttemptID, + arg.QuestionID, + arg.QuestionType, + arg.Points, + ) + return err +} + const CreateAssessmentAttempt = `-- name: CreateAssessmentAttempt :one + INSERT INTO assessment_attempts ( user_id, total_questions, - correct_answers, - score_percentage, - knowledge_level + total_points, + status ) VALUES ( $1, -- user_id $2, -- total_questions - $3, -- correct_answers - $4, -- score_percentage - $5 -- knowledge_level + $3, -- total_points + 'IN_PROGRESS' ) -RETURNING - id, - user_id, - total_questions, - correct_answers, - score_percentage, - knowledge_level, - completed_at +RETURNING id, user_id, total_questions, total_points, score, percentage, status, started_at, submitted_at, evaluated_at, created_at, updated_at ` type CreateAssessmentAttemptParams struct { - UserID int64 `json:"user_id"` - TotalQuestions int32 `json:"total_questions"` - CorrectAnswers int32 `json:"correct_answers"` - ScorePercentage pgtype.Numeric `json:"score_percentage"` - KnowledgeLevel string `json:"knowledge_level"` + UserID int64 `json:"user_id"` + TotalQuestions int32 `json:"total_questions"` + TotalPoints int32 `json:"total_points"` } +// ------------------------------------------------------------------------------------ func (q *Queries) CreateAssessmentAttempt(ctx context.Context, arg CreateAssessmentAttemptParams) (AssessmentAttempt, error) { - row := q.db.QueryRow(ctx, CreateAssessmentAttempt, - arg.UserID, - arg.TotalQuestions, - arg.CorrectAnswers, - arg.ScorePercentage, - arg.KnowledgeLevel, - ) + row := q.db.QueryRow(ctx, CreateAssessmentAttempt, arg.UserID, arg.TotalQuestions, arg.TotalPoints) var i AssessmentAttempt err := row.Scan( &i.ID, &i.UserID, &i.TotalQuestions, - &i.CorrectAnswers, - &i.ScorePercentage, - &i.KnowledgeLevel, - &i.CompletedAt, + &i.TotalPoints, + &i.Score, + &i.Percentage, + &i.Status, + &i.StartedAt, + &i.SubmittedAt, + &i.EvaluatedAt, + &i.CreatedAt, + &i.UpdatedAt, ) return i, err } @@ -70,17 +92,28 @@ INSERT INTO assessment_questions ( title, description, question_type, - difficulty_level + difficulty_level, + points, + is_active ) -VALUES ($1, $2, $3, $4) -RETURNING id, title, description, question_type, difficulty_level, is_active, created_at, updated_at +VALUES ( + $1, -- title + $2, -- description + $3, -- question_type + $4, -- difficulty_level + $5, -- points + $6 -- is_active +) +RETURNING id, title, description, question_type, difficulty_level, points, is_active, created_at, updated_at ` type CreateAssessmentQuestionParams struct { Title string `json:"title"` Description pgtype.Text `json:"description"` QuestionType string `json:"question_type"` - DifficultyLevel string `json:"difficulty_level"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + IsActive bool `json:"is_active"` } func (q *Queries) CreateAssessmentQuestion(ctx context.Context, arg CreateAssessmentQuestionParams) (AssessmentQuestion, error) { @@ -89,6 +122,8 @@ func (q *Queries) CreateAssessmentQuestion(ctx context.Context, arg CreateAssess arg.Description, arg.QuestionType, arg.DifficultyLevel, + arg.Points, + arg.IsActive, ) var i AssessmentQuestion err := row.Scan( @@ -97,6 +132,7 @@ func (q *Queries) CreateAssessmentQuestion(ctx context.Context, arg CreateAssess &i.Description, &i.QuestionType, &i.DifficultyLevel, + &i.Points, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -104,31 +140,169 @@ func (q *Queries) CreateAssessmentQuestion(ctx context.Context, arg CreateAssess return i, err } -const CreateAssessmentQuestionOption = `-- name: CreateAssessmentQuestionOption :exec +const CreateQuestionOption = `-- name: CreateQuestionOption :one INSERT INTO assessment_question_options ( question_id, option_text, + option_order, is_correct ) -VALUES ($1, $2, $3) +VALUES ( + $1, -- question_id + $2, -- option_text + $3, -- option_order + $4 -- is_correct +) +RETURNING id, question_id, option_text, option_order, is_correct, created_at ` -type CreateAssessmentQuestionOptionParams struct { - QuestionID int64 `json:"question_id"` - OptionText string `json:"option_text"` - IsCorrect bool `json:"is_correct"` +type CreateQuestionOptionParams struct { + QuestionID int64 `json:"question_id"` + OptionText string `json:"option_text"` + OptionOrder int32 `json:"option_order"` + IsCorrect bool `json:"is_correct"` } -func (q *Queries) CreateAssessmentQuestionOption(ctx context.Context, arg CreateAssessmentQuestionOptionParams) error { - _, err := q.db.Exec(ctx, CreateAssessmentQuestionOption, arg.QuestionID, arg.OptionText, arg.IsCorrect) +func (q *Queries) CreateQuestionOption(ctx context.Context, arg CreateQuestionOptionParams) (AssessmentQuestionOption, error) { + row := q.db.QueryRow(ctx, CreateQuestionOption, + arg.QuestionID, + arg.OptionText, + arg.OptionOrder, + arg.IsCorrect, + ) + var i AssessmentQuestionOption + err := row.Scan( + &i.ID, + &i.QuestionID, + &i.OptionText, + &i.OptionOrder, + &i.IsCorrect, + &i.CreatedAt, + ) + return i, err +} + +const CreateShortAnswer = `-- name: CreateShortAnswer :one +INSERT INTO assessment_short_answers ( + question_id, + correct_answer +) +VALUES ( + $1, -- question_id + $2 -- correct_answer +) +RETURNING id, question_id, correct_answer, created_at +` + +type CreateShortAnswerParams struct { + QuestionID int64 `json:"question_id"` + CorrectAnswer string `json:"correct_answer"` +} + +func (q *Queries) CreateShortAnswer(ctx context.Context, arg CreateShortAnswerParams) (AssessmentShortAnswer, error) { + row := q.db.QueryRow(ctx, CreateShortAnswer, arg.QuestionID, arg.CorrectAnswer) + var i AssessmentShortAnswer + err := row.Scan( + &i.ID, + &i.QuestionID, + &i.CorrectAnswer, + &i.CreatedAt, + ) + return i, err +} + +const DeleteAssessmentQuestion = `-- name: DeleteAssessmentQuestion :exec +DELETE FROM assessment_questions +WHERE id = $1 +` + +func (q *Queries) DeleteAssessmentQuestion(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteAssessmentQuestion, id) + return err +} + +const DeleteQuestionOptionsByQuestionID = `-- name: DeleteQuestionOptionsByQuestionID :exec +DELETE FROM assessment_question_options +WHERE question_id = $1 +` + +func (q *Queries) DeleteQuestionOptionsByQuestionID(ctx context.Context, questionID int64) error { + _, err := q.db.Exec(ctx, DeleteQuestionOptionsByQuestionID, questionID) + return err +} + +const EvaluateMCQAnswer = `-- name: EvaluateMCQAnswer :exec +UPDATE assessment_attempt_answers a +SET + is_correct = o.is_correct, + awarded_points = CASE WHEN o.is_correct THEN q.points ELSE 0 END +FROM assessment_question_options o +JOIN assessment_questions q ON q.id = a.question_id +WHERE a.selected_option_id = o.id + AND a.attempt_id = $1 +` + +func (q *Queries) EvaluateMCQAnswer(ctx context.Context, attemptID int64) error { + _, err := q.db.Exec(ctx, EvaluateMCQAnswer, attemptID) + return err +} + +const EvaluateShortAnswer = `-- name: EvaluateShortAnswer :exec +UPDATE assessment_attempt_answers a +SET + is_correct = EXISTS ( + SELECT 1 + FROM assessment_short_answers s + WHERE s.question_id = a.question_id + AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) + ), + awarded_points = CASE + WHEN EXISTS ( + SELECT 1 + FROM assessment_short_answers s + WHERE s.question_id = a.question_id + AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) + ) + THEN q.points + ELSE 0 + END +FROM assessment_questions q +WHERE a.question_id = q.id + AND a.attempt_id = $1 +` + +func (q *Queries) EvaluateShortAnswer(ctx context.Context, attemptID int64) error { + _, err := q.db.Exec(ctx, EvaluateShortAnswer, attemptID) + return err +} + +const FinalizeAssessmentAttempt = `-- name: FinalizeAssessmentAttempt :exec +UPDATE assessment_attempts +SET + score = sub.total_score, + percentage = ROUND((sub.total_score::NUMERIC / total_points) * 100, 2), + status = 'EVALUATED', + evaluated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +FROM ( + SELECT attempt_id, SUM(awarded_points) AS total_score + FROM assessment_attempt_answers + WHERE attempt_id = $1 + GROUP BY attempt_id +) sub +WHERE assessment_attempts.id = sub.attempt_id +` + +func (q *Queries) FinalizeAssessmentAttempt(ctx context.Context, attemptID int64) error { + _, err := q.db.Exec(ctx, FinalizeAssessmentAttempt, attemptID) return err } const GetActiveAssessmentQuestions = `-- name: GetActiveAssessmentQuestions :many -SELECT id, title, description, question_type, difficulty_level, is_active, created_at, updated_at +SELECT id, title, description, question_type, difficulty_level, points, is_active, created_at, updated_at FROM assessment_questions -WHERE is_active = TRUE -ORDER BY difficulty_level, id +WHERE is_active = true +ORDER BY created_at DESC ` func (q *Queries) GetActiveAssessmentQuestions(ctx context.Context) ([]AssessmentQuestion, error) { @@ -146,6 +320,7 @@ func (q *Queries) GetActiveAssessmentQuestions(ctx context.Context) ([]Assessmen &i.Description, &i.QuestionType, &i.DifficultyLevel, + &i.Points, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -160,92 +335,219 @@ func (q *Queries) GetActiveAssessmentQuestions(ctx context.Context) ([]Assessmen return items, nil } -const GetAssessmentOptionByID = `-- name: GetAssessmentOptionByID :one - -SELECT - id, - question_id, - option_text, - is_correct -FROM assessment_question_options -WHERE id = $1 -LIMIT 1 -` - -// -- name: CreateAssessmentAnswer :exec -// INSERT INTO assessment_answers ( -// -// attempt_id, -// question_id, -// selected_option_id, -// short_answer, -// is_correct -// -// ) -// VALUES ( -// -// $1, -- attempt_id -// $2, -- question_id -// $3, -- selected_option_id -// $4, -- short_answer -// $5 -- is_correct -// -// ); -func (q *Queries) GetAssessmentOptionByID(ctx context.Context, id int64) (AssessmentQuestionOption, error) { - row := q.db.QueryRow(ctx, GetAssessmentOptionByID, id) - var i AssessmentQuestionOption - err := row.Scan( - &i.ID, - &i.QuestionID, - &i.OptionText, - &i.IsCorrect, - ) - return i, err -} - -const GetCorrectOptionForQuestion = `-- name: GetCorrectOptionForQuestion :one -SELECT - id -FROM assessment_question_options -WHERE question_id = $1 - AND is_correct = TRUE -LIMIT 1 -` - -func (q *Queries) GetCorrectOptionForQuestion(ctx context.Context, questionID int64) (int64, error) { - row := q.db.QueryRow(ctx, GetCorrectOptionForQuestion, questionID) - var id int64 - err := row.Scan(&id) - return id, err -} - -const GetLatestAssessmentAttempt = `-- name: GetLatestAssessmentAttempt :one -SELECT id, user_id, total_questions, correct_answers, score_percentage, knowledge_level, completed_at +const GetAssessmentAttemptByID = `-- name: GetAssessmentAttemptByID :one +SELECT id, user_id, total_questions, total_points, score, percentage, status, started_at, submitted_at, evaluated_at, created_at, updated_at FROM assessment_attempts -WHERE user_id = $1 -ORDER BY completed_at DESC -LIMIT 1 +WHERE id = $1 ` -func (q *Queries) GetLatestAssessmentAttempt(ctx context.Context, userID int64) (AssessmentAttempt, error) { - row := q.db.QueryRow(ctx, GetLatestAssessmentAttempt, userID) +func (q *Queries) GetAssessmentAttemptByID(ctx context.Context, id int64) (AssessmentAttempt, error) { + row := q.db.QueryRow(ctx, GetAssessmentAttemptByID, id) var i AssessmentAttempt err := row.Scan( &i.ID, &i.UserID, &i.TotalQuestions, - &i.CorrectAnswers, - &i.ScorePercentage, - &i.KnowledgeLevel, - &i.CompletedAt, + &i.TotalPoints, + &i.Score, + &i.Percentage, + &i.Status, + &i.StartedAt, + &i.SubmittedAt, + &i.EvaluatedAt, + &i.CreatedAt, + &i.UpdatedAt, ) return i, err } +const GetAssessmentQuestionByID = `-- name: GetAssessmentQuestionByID :one +SELECT id, title, description, question_type, difficulty_level, points, is_active, created_at, updated_at +FROM assessment_questions +WHERE id = $1 +` + +func (q *Queries) GetAssessmentQuestionByID(ctx context.Context, id int64) (AssessmentQuestion, error) { + row := q.db.QueryRow(ctx, GetAssessmentQuestionByID, id) + var i AssessmentQuestion + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetAssessmentQuestionsPaginated = `-- name: GetAssessmentQuestionsPaginated :many +SELECT + COUNT(*) OVER () AS total_count, + id, + title, + description, + question_type, + difficulty_level, + points, + is_active, + created_at, + updated_at +FROM assessment_questions +WHERE ($1 IS NULL OR question_type = $1) + AND ($2 IS NULL OR difficulty_level = $2) + AND ($3 IS NULL OR is_active = $3) +LIMIT $4 +OFFSET $5 +` + +type GetAssessmentQuestionsPaginatedParams struct { + Column1 interface{} `json:"column_1"` + Column2 interface{} `json:"column_2"` + Column3 interface{} `json:"column_3"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type GetAssessmentQuestionsPaginatedRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetAssessmentQuestionsPaginated(ctx context.Context, arg GetAssessmentQuestionsPaginatedParams) ([]GetAssessmentQuestionsPaginatedRow, error) { + rows, err := q.db.Query(ctx, GetAssessmentQuestionsPaginated, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAssessmentQuestionsPaginatedRow + for rows.Next() { + var i GetAssessmentQuestionsPaginatedRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.Title, + &i.Description, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAttemptAnswers = `-- name: GetAttemptAnswers :many +SELECT id, attempt_id, question_id, selected_option_id, submitted_text, is_correct, awarded_points, created_at +FROM assessment_attempt_answers +WHERE attempt_id = $1 +` + +func (q *Queries) GetAttemptAnswers(ctx context.Context, attemptID int64) ([]AssessmentAttemptAnswer, error) { + rows, err := q.db.Query(ctx, GetAttemptAnswers, attemptID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AssessmentAttemptAnswer + for rows.Next() { + var i AssessmentAttemptAnswer + if err := rows.Scan( + &i.ID, + &i.AttemptID, + &i.QuestionID, + &i.SelectedOptionID, + &i.SubmittedText, + &i.IsCorrect, + &i.AwardedPoints, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAttemptQuestions = `-- name: GetAttemptQuestions :many +SELECT + aq.question_id, + aq.question_type, + aq.points, + q.title, + q.description +FROM assessment_attempt_questions aq +JOIN assessment_questions q ON q.id = aq.question_id +WHERE aq.attempt_id = $1 +` + +type GetAttemptQuestionsRow struct { + QuestionID int64 `json:"question_id"` + QuestionType string `json:"question_type"` + Points int32 `json:"points"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` +} + +func (q *Queries) GetAttemptQuestions(ctx context.Context, attemptID int64) ([]GetAttemptQuestionsRow, error) { + rows, err := q.db.Query(ctx, GetAttemptQuestions, attemptID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAttemptQuestionsRow + for rows.Next() { + var i GetAttemptQuestionsRow + if err := rows.Scan( + &i.QuestionID, + &i.QuestionType, + &i.Points, + &i.Title, + &i.Description, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetQuestionOptions = `-- name: GetQuestionOptions :many -SELECT id, question_id, option_text, is_correct +SELECT id, question_id, option_text, option_order, is_correct, created_at FROM assessment_question_options WHERE question_id = $1 +ORDER BY option_order ` func (q *Queries) GetQuestionOptions(ctx context.Context, questionID int64) ([]AssessmentQuestionOption, error) { @@ -261,7 +563,9 @@ func (q *Queries) GetQuestionOptions(ctx context.Context, questionID int64) ([]A &i.ID, &i.QuestionID, &i.OptionText, + &i.OptionOrder, &i.IsCorrect, + &i.CreatedAt, ); err != nil { return nil, err } @@ -272,3 +576,181 @@ func (q *Queries) GetQuestionOptions(ctx context.Context, questionID int64) ([]A } return items, nil } + +const GetShortAnswersByQuestionID = `-- name: GetShortAnswersByQuestionID :many +SELECT id, question_id, correct_answer, created_at +FROM assessment_short_answers +WHERE question_id = $1 +` + +func (q *Queries) GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]AssessmentShortAnswer, error) { + rows, err := q.db.Query(ctx, GetShortAnswersByQuestionID, questionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AssessmentShortAnswer + for rows.Next() { + var i AssessmentShortAnswer + if err := rows.Scan( + &i.ID, + &i.QuestionID, + &i.CorrectAnswer, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserAssessmentAttempts = `-- name: GetUserAssessmentAttempts :many +SELECT + id, + user_id, + total_questions, + total_points, + score, + percentage, + status, + started_at, + submitted_at, + evaluated_at +FROM assessment_attempts +WHERE user_id = $1 +ORDER BY started_at DESC +` + +type GetUserAssessmentAttemptsRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + TotalQuestions int32 `json:"total_questions"` + TotalPoints int32 `json:"total_points"` + Score pgtype.Int4 `json:"score"` + Percentage pgtype.Numeric `json:"percentage"` + Status string `json:"status"` + StartedAt pgtype.Timestamptz `json:"started_at"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + EvaluatedAt pgtype.Timestamptz `json:"evaluated_at"` +} + +func (q *Queries) GetUserAssessmentAttempts(ctx context.Context, userID int64) ([]GetUserAssessmentAttemptsRow, error) { + rows, err := q.db.Query(ctx, GetUserAssessmentAttempts, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserAssessmentAttemptsRow + for rows.Next() { + var i GetUserAssessmentAttemptsRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.TotalQuestions, + &i.TotalPoints, + &i.Score, + &i.Percentage, + &i.Status, + &i.StartedAt, + &i.SubmittedAt, + &i.EvaluatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const SubmitAssessmentAttempt = `-- name: SubmitAssessmentAttempt :exec +UPDATE assessment_attempts +SET + status = 'SUBMITTED', + submitted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) SubmitAssessmentAttempt(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, SubmitAssessmentAttempt, id) + return err +} + +const UpdateAssessmentQuestion = `-- name: UpdateAssessmentQuestion :exec +UPDATE assessment_questions +SET + title = COALESCE($1, title), + description = COALESCE($2, description), + question_type = COALESCE($3, question_type), + difficulty_level = COALESCE($4, difficulty_level), + points = COALESCE($5, points), + is_active = COALESCE($6, is_active), + updated_at = CURRENT_TIMESTAMP +WHERE id = $7 +` + +type UpdateAssessmentQuestionParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + IsActive bool `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAssessmentQuestion(ctx context.Context, arg UpdateAssessmentQuestionParams) error { + _, err := q.db.Exec(ctx, UpdateAssessmentQuestion, + arg.Title, + arg.Description, + arg.QuestionType, + arg.DifficultyLevel, + arg.Points, + arg.IsActive, + arg.ID, + ) + return err +} + +const UpsertAttemptAnswer = `-- name: UpsertAttemptAnswer :exec +INSERT INTO assessment_attempt_answers ( + attempt_id, + question_id, + selected_option_id, + submitted_text +) +VALUES ( + $1, -- attempt_id + $2, -- question_id + $3, -- selected_option_id + $4 -- submitted_text +) +ON CONFLICT (attempt_id, question_id) +DO UPDATE SET + selected_option_id = EXCLUDED.selected_option_id, + submitted_text = EXCLUDED.submitted_text +` + +type UpsertAttemptAnswerParams struct { + AttemptID int64 `json:"attempt_id"` + QuestionID int64 `json:"question_id"` + SelectedOptionID pgtype.Int8 `json:"selected_option_id"` + SubmittedText pgtype.Text `json:"submitted_text"` +} + +func (q *Queries) UpsertAttemptAnswer(ctx context.Context, arg UpsertAttemptAnswerParams) error { + _, err := q.db.Exec(ctx, UpsertAttemptAnswer, + arg.AttemptID, + arg.QuestionID, + arg.SelectedOptionID, + arg.SubmittedText, + ) + return err +} diff --git a/gen/db/models.go b/gen/db/models.go index 6663a2d..45e6e25 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -8,22 +8,39 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -type AssessmentAnswer struct { - ID int64 `json:"id"` - QuestionID int64 `json:"question_id"` - SelectedOptionID pgtype.Int8 `json:"selected_option_id"` - ShortAnswer pgtype.Text `json:"short_answer"` - IsCorrect bool `json:"is_correct"` +type AssessmentAttempt struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + TotalQuestions int32 `json:"total_questions"` + TotalPoints int32 `json:"total_points"` + Score pgtype.Int4 `json:"score"` + Percentage pgtype.Numeric `json:"percentage"` + Status string `json:"status"` + StartedAt pgtype.Timestamptz `json:"started_at"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + EvaluatedAt pgtype.Timestamptz `json:"evaluated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type AssessmentAttempt struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - TotalQuestions int32 `json:"total_questions"` - CorrectAnswers int32 `json:"correct_answers"` - ScorePercentage pgtype.Numeric `json:"score_percentage"` - KnowledgeLevel string `json:"knowledge_level"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` +type AssessmentAttemptAnswer struct { + ID int64 `json:"id"` + AttemptID int64 `json:"attempt_id"` + QuestionID int64 `json:"question_id"` + SelectedOptionID pgtype.Int8 `json:"selected_option_id"` + SubmittedText pgtype.Text `json:"submitted_text"` + IsCorrect pgtype.Bool `json:"is_correct"` + AwardedPoints int32 `json:"awarded_points"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AssessmentAttemptQuestion struct { + ID int64 `json:"id"` + AttemptID int64 `json:"attempt_id"` + QuestionID int64 `json:"question_id"` + QuestionType string `json:"question_type"` + Points int32 `json:"points"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type AssessmentQuestion struct { @@ -31,17 +48,27 @@ type AssessmentQuestion struct { Title string `json:"title"` Description pgtype.Text `json:"description"` QuestionType string `json:"question_type"` - DifficultyLevel string `json:"difficulty_level"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type AssessmentQuestionOption struct { - ID int64 `json:"id"` - QuestionID int64 `json:"question_id"` - OptionText string `json:"option_text"` - IsCorrect bool `json:"is_correct"` + ID int64 `json:"id"` + QuestionID int64 `json:"question_id"` + OptionText string `json:"option_text"` + OptionOrder int32 `json:"option_order"` + IsCorrect bool `json:"is_correct"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AssessmentShortAnswer struct { + ID int64 `json:"id"` + QuestionID int64 `json:"question_id"` + CorrectAnswer string `json:"correct_answer"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Course struct { @@ -197,7 +224,6 @@ type User struct { EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` - Medium string `json:"medium"` KnowledgeLevel pgtype.Text `json:"knowledge_level"` NickName pgtype.Text `json:"nick_name"` Occupation pgtype.Text `json:"occupation"` diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 5a54342..e6aa89a 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -423,7 +423,6 @@ SELECT language_challange, favoutite_topic, - medium, email_verified, phone_verified, status, @@ -463,7 +462,6 @@ type GetUserByEmailPhoneRow struct { LanguageGoal pgtype.Text `json:"language_goal"` LanguageChallange pgtype.Text `json:"language_challange"` FavoutiteTopic pgtype.Text `json:"favoutite_topic"` - Medium string `json:"medium"` EmailVerified bool `json:"email_verified"` PhoneVerified bool `json:"phone_verified"` Status string `json:"status"` @@ -497,7 +495,6 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho &i.LanguageGoal, &i.LanguageChallange, &i.FavoutiteTopic, - &i.Medium, &i.EmailVerified, &i.PhoneVerified, &i.Status, @@ -512,7 +509,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho } const GetUserByID = `-- name: GetUserByID :one -SELECT id, first_name, last_name, user_name, email, phone_number, role, password, age, education_level, country, region, medium, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favoutite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at +SELECT id, first_name, last_name, user_name, email, phone_number, role, password, age, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favoutite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at FROM users WHERE id = $1 ` @@ -533,7 +530,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.EducationLevel, &i.Country, &i.Region, - &i.Medium, &i.KnowledgeLevel, &i.NickName, &i.Occupation, diff --git a/internal/domain/initial_assessment.go b/internal/domain/initial_assessment.go index 9f85bef..20f93b1 100644 --- a/internal/domain/initial_assessment.go +++ b/internal/domain/initial_assessment.go @@ -1,47 +1,70 @@ package domain -import "time" +import dbgen "Yimaru-Backend/gen/db" type QuestionType string const ( - QuestionTypeMultipleChoice QuestionType = "multiple_choice" - QuestionTypeTrueFalse QuestionType = "true_false" - QuestionTypeShortAnswer QuestionType = "short_answer" + MultipleChoice QuestionType = "MULTIPLE_CHOICE" + TrueFalse QuestionType = "TRUE_FALSE" + ShortAnswer QuestionType = "SHORT_ANSWER" ) -type SubmitAssessmentReq struct { - Answers []UserAnswer `json:"answers" validate:"required,min=1"` +type QuestionWithDetails struct { + Question dbgen.AssessmentQuestion + Options []QuestionOption } -type AssessmentQuestion struct { - ID int64 +type QuestionOption struct { + QuestionID int64 `json:"question_id"` + OptionText string `json:"option_text"` +} + +type CreateAssessmentQuestionInput struct { Title string - Description string - QuestionType string + Description *string + QuestionType QuestionType DifficultyLevel string - Options []AssessmentOption + Points int32 + IsActive bool + + // Multiple Choice only + Options []CreateQuestionOptionInput + + // Short Answer only + CorrectAnswer *string } -type AssessmentOption struct { - ID int64 - OptionText string - IsCorrect bool +type CreateQuestionOptionInput struct { + Text string + Order int32 + IsCorrect bool } -type UserAnswer struct { - QuestionID int64 - SelectedOptionID int64 - ShortAnswer string - IsCorrect bool -} +// type AssessmentQuestion struct { +// ID int64 +// QuestionText string +// Type QuestionType +// Options []string +// CorrectAnswer string +// } -type AssessmentAttempt struct { - ID int64 - UserID int64 - TotalQuestions int - CorrectAnswers int - ScorePercentage float64 - KnowledgeLevel string - CompletedAt time.Time -} +// type AssessmentOption struct { +// ID int64 +// OptionText string +// IsCorrect bool +// } + +// type AttemptAnswer struct { +// QuestionID int64 +// Answer string +// IsCorrect *bool +// } + +// type AssessmentAttempt struct { +// ID int64 +// UserID int64 +// Answers []AttemptAnswer +// Score int +// Completed bool +// } diff --git a/internal/ports/initial_assessment.go b/internal/ports/initial_assessment.go index f3fef3b..f68848e 100644 --- a/internal/ports/initial_assessment.go +++ b/internal/ports/initial_assessment.go @@ -1,16 +1,23 @@ package ports import ( - "Yimaru-Backend/internal/domain" "context" + + dbgen "Yimaru-Backend/gen/db" ) type InitialAssessmentStore interface { - CreateAssessmentQuestion( - ctx context.Context, - q domain.AssessmentQuestion, - ) (domain.AssessmentQuestion, error) - GetActiveAssessmentQuestions(ctx context.Context) ([]domain.AssessmentQuestion, error) - // SaveAssessmentAttempt(ctx context.Context, userID int64, answers []domain.UserAnswer) (domain.AssessmentAttempt, error) - GetOptionByID(ctx context.Context, optionID int64) (domain.AssessmentOption, error) + CreateAssessmentQuestion(ctx context.Context, arg dbgen.CreateAssessmentQuestionParams) (dbgen.AssessmentQuestion, error) + GetAssessmentQuestionByID(ctx context.Context, id int64) (dbgen.AssessmentQuestion, error) + GetActiveAssessmentQuestions(ctx context.Context) ([]dbgen.AssessmentQuestion, error) + GetAssessmentQuestionsPaginated(ctx context.Context, arg dbgen.GetAssessmentQuestionsPaginatedParams) ([]dbgen.GetAssessmentQuestionsPaginatedRow, error) + UpdateAssessmentQuestion(ctx context.Context, arg dbgen.UpdateAssessmentQuestionParams) error + DeleteAssessmentQuestion(ctx context.Context, id int64) error + + CreateQuestionOption(ctx context.Context, arg dbgen.CreateQuestionOptionParams) (dbgen.AssessmentQuestionOption, error) + GetQuestionOptions(ctx context.Context, questionID int64) ([]dbgen.AssessmentQuestionOption, error) + DeleteQuestionOptionsByQuestionID(ctx context.Context, questionID int64) error + + CreateShortAnswer(ctx context.Context, arg dbgen.CreateShortAnswerParams) (dbgen.AssessmentShortAnswer, error) + GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]dbgen.AssessmentShortAnswer, error) } diff --git a/internal/ports/user.go b/internal/ports/user.go index 55fe0fb..5c7fc3f 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -4,20 +4,19 @@ import ( "context" "time" - dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" ) type UserStore interface { UpdateUserStatus(ctx context.Context, user domain.UpdateUserReq) error - GetCorrectOptionForQuestion( - ctx context.Context, - questionID int64, - ) (int64, error) - GetLatestAssessmentAttempt( - ctx context.Context, - userID int64, - ) (*dbgen.AssessmentAttempt, error) + // GetCorrectOptionForQuestion( + // ctx context.Context, + // questionID int64, + // ) (int64, error) + // GetLatestAssessmentAttempt( + // ctx context.Context, + // userID int64, + // ) (*dbgen.AssessmentAttempt, error) UpdateUserKnowledgeLevel(ctx context.Context, userID int64, knowledgeLevel string) error IsUserNameUnique(ctx context.Context, userName string) (bool, error) IsUserPending(ctx context.Context, UserName string) (bool, error) diff --git a/internal/repository/initial_assessment.go b/internal/repository/initial_assessment.go index 1736bfc..b87ee35 100644 --- a/internal/repository/initial_assessment.go +++ b/internal/repository/initial_assessment.go @@ -2,173 +2,84 @@ package repository import ( dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/ports" "context" - - "github.com/jackc/pgx/v5/pgtype" ) func NewInitialAssessmentStore(s *Store) ports.InitialAssessmentStore { return s } -func (r *Store) GetCorrectOptionForQuestion( - ctx context.Context, - questionID int64, -) (int64, error) { - - optId, err := r.queries.GetCorrectOptionForQuestion(ctx, questionID) - if err != nil { - return 0, err - } - - return optId, nil -} - -func (r *Store) GetLatestAssessmentAttempt( - ctx context.Context, - userID int64, -) (*dbgen.AssessmentAttempt, error) { - - attempt, err := r.queries.GetLatestAssessmentAttempt(ctx, userID) - if err != nil { - return nil, err - } - - return &attempt, nil -} - func (s *Store) CreateAssessmentQuestion( ctx context.Context, - q domain.AssessmentQuestion, -) (domain.AssessmentQuestion, error) { - - row, err := s.queries.CreateAssessmentQuestion(ctx, dbgen.CreateAssessmentQuestionParams{ - Title: q.Title, - Description: pgtype.Text{String: q.Description, Valid: q.Description != ""}, - QuestionType: q.QuestionType, - DifficultyLevel: q.DifficultyLevel, - }) - if err != nil { - return domain.AssessmentQuestion{}, err - } - - for _, opt := range q.Options { - if err := s.queries.CreateAssessmentQuestionOption(ctx, - dbgen.CreateAssessmentQuestionOptionParams{ - QuestionID: row.ID, - OptionText: opt.OptionText, - IsCorrect: opt.IsCorrect, - }, - ); err != nil { - return domain.AssessmentQuestion{}, err - } - } - - q.ID = row.ID - return q, nil + arg dbgen.CreateAssessmentQuestionParams, +) (dbgen.AssessmentQuestion, error) { + return s.queries.CreateAssessmentQuestion(ctx, arg) } -func (s *Store) GetActiveAssessmentQuestions(ctx context.Context) ([]domain.AssessmentQuestion, error) { - questionsRows, err := s.queries.GetActiveAssessmentQuestions(ctx) - if err != nil { - return nil, err - } - - questions := make([]domain.AssessmentQuestion, 0, len(questionsRows)) - for _, q := range questionsRows { - optionsRows, err := s.queries.GetQuestionOptions(ctx, q.ID) - if err != nil { - return nil, err - } - - options := make([]domain.AssessmentOption, 0, len(optionsRows)) - for _, o := range optionsRows { - options = append(options, domain.AssessmentOption{ - ID: o.ID, - OptionText: o.OptionText, - IsCorrect: o.IsCorrect, - }) - } - - questions = append(questions, domain.AssessmentQuestion{ - ID: q.ID, - Title: q.Title, - Description: q.Description.String, - QuestionType: q.QuestionType, - DifficultyLevel: q.DifficultyLevel, - Options: options, - }) - } - - return questions, nil +func (s *Store) GetAssessmentQuestionByID( + ctx context.Context, + id int64, +) (dbgen.AssessmentQuestion, error) { + return s.queries.GetAssessmentQuestionByID(ctx, id) } -// SaveAssessmentAttempt saves the attempt summary and answers -// func (s *Store) SaveAssessmentAttempt(ctx context.Context, userID int64, answers []domain.UserAnswer) (domain.AssessmentAttempt, error) { -// total := len(answers) -// correct := 0 - -// for _, ans := range answers { -// if ans.IsCorrect { -// correct++ -// } -// } - -// score := float64(correct) / float64(total) * 100 -// knowledgeLevel := "BEGINNER" -// switch { -// case score >= 80: -// knowledgeLevel = "ADVANCED" -// case score >= 50: -// knowledgeLevel = "INTERMEDIATE" -// } - -// // Save attempt -// attemptRow, err := s.queries.CreateAssessmentAttempt(ctx, dbgen.CreateAssessmentAttemptParams{ -// UserID: userID, -// TotalQuestions: int32(total), -// CorrectAnswers: int32(correct), -// ScorePercentage: pgtype.Numeric{Int: big.NewInt(int64(score * 100)), Valid: true}, -// KnowledgeLevel: knowledgeLevel, -// }) -// if err != nil { -// return domain.AssessmentAttempt{}, err -// } - -// // Save answers -// for _, ans := range answers { -// err := s.queries.CreateAssessmentAnswer(ctx, dbgen.CreateAssessmentAnswerParams{ -// AttemptID: attemptRow.ID, -// QuestionID: ans.QuestionID, -// SelectedOptionID: pgtype.Int8{Int64: ans.SelectedOptionID, Valid: true}, -// ShortAnswer: pgtype.Text{String: ans.ShortAnswer, Valid: true}, -// IsCorrect: ans.IsCorrect, -// }) -// if err != nil { -// return domain.AssessmentAttempt{}, err -// } -// } - -// return domain.AssessmentAttempt{ -// ID: attemptRow.ID, -// UserID: userID, -// TotalQuestions: total, -// CorrectAnswers: correct, -// ScorePercentage: score, -// KnowledgeLevel: knowledgeLevel, -// CompletedAt: attemptRow.CompletedAt.Time, -// }, nil -// } - -// GetOptionByID fetches a single option to validate correctness -func (s *Store) GetOptionByID(ctx context.Context, optionID int64) (domain.AssessmentOption, error) { - o, err := s.queries.GetAssessmentOptionByID(ctx, optionID) - if err != nil { - return domain.AssessmentOption{}, err - } - return domain.AssessmentOption{ - ID: o.ID, - OptionText: o.OptionText, - IsCorrect: o.IsCorrect, - }, nil +func (s *Store) GetActiveAssessmentQuestions( + ctx context.Context, +) ([]dbgen.AssessmentQuestion, error) { + return s.queries.GetActiveAssessmentQuestions(ctx) +} + +func (s *Store) GetAssessmentQuestionsPaginated( + ctx context.Context, + arg dbgen.GetAssessmentQuestionsPaginatedParams, +) ([]dbgen.GetAssessmentQuestionsPaginatedRow, error) { + return s.queries.GetAssessmentQuestionsPaginated(ctx, arg) +} + +func (s *Store) UpdateAssessmentQuestion( + ctx context.Context, + arg dbgen.UpdateAssessmentQuestionParams, +) error { + return s.queries.UpdateAssessmentQuestion(ctx, arg) +} + +func (s *Store) DeleteAssessmentQuestion( + ctx context.Context, + id int64, +) error { + return s.queries.DeleteAssessmentQuestion(ctx, id) +} + +func (s *Store) CreateQuestionOption( + ctx context.Context, + arg dbgen.CreateQuestionOptionParams, +) (dbgen.AssessmentQuestionOption, error) { + return s.queries.CreateQuestionOption(ctx, arg) +} + +func (s *Store) GetQuestionOptions( + ctx context.Context, + questionID int64, +) ([]dbgen.AssessmentQuestionOption, error) { + return s.queries.GetQuestionOptions(ctx, questionID) +} + +func (s *Store) DeleteQuestionOptionsByQuestionID( + ctx context.Context, + questionID int64, +) error { + return s.queries.DeleteQuestionOptionsByQuestionID(ctx, questionID) +} + +func (s *Store) CreateShortAnswer( + ctx context.Context, + arg dbgen.CreateShortAnswerParams, +) (dbgen.AssessmentShortAnswer, error) { + return s.queries.CreateShortAnswer(ctx, arg) +} + +func (s *Store) GetShortAnswersByQuestionID( + ctx context.Context, + questionID int64, +) ([]dbgen.AssessmentShortAnswer, error) { + return s.queries.GetShortAnswersByQuestionID(ctx, questionID) } diff --git a/internal/services/assessment/helpers.go b/internal/services/assessment/helpers.go new file mode 100644 index 0000000..ba5405b --- /dev/null +++ b/internal/services/assessment/helpers.go @@ -0,0 +1,13 @@ +package assessment + +import "database/sql" + +func toNullString(v *string) sql.NullString { + if v == nil { + return sql.NullString{} + } + return sql.NullString{ + String: *v, + Valid: true, + } +} diff --git a/internal/services/assessment/initial_assessment.go b/internal/services/assessment/initial_assessment.go index e6e272e..6f925f4 100644 --- a/internal/services/assessment/initial_assessment.go +++ b/internal/services/assessment/initial_assessment.go @@ -1,180 +1,303 @@ package assessment import ( + dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" "context" "errors" + + "github.com/jackc/pgx/v5/pgtype" ) -func (s *Service) GetActiveAssessmentQuestions( +func (s *Service) CreateQuestion( ctx context.Context, -) ([]domain.AssessmentQuestion, error) { + input domain.CreateAssessmentQuestionInput, +) error { + repo := s.initialAssessmentStore + + // 1. Create Question + question, err := repo.CreateAssessmentQuestion( + ctx, + dbgen.CreateAssessmentQuestionParams{ + Title: input.Title, + Description: func() pgtype.Text { + ns := toNullString(input.Description) + return pgtype.Text{String: ns.String, Valid: ns.Valid} + }(), + QuestionType: string(input.QuestionType), + DifficultyLevel: pgtype.Text{String: input.DifficultyLevel}, + Points: input.Points, + IsActive: input.IsActive, + }, + ) + if err != nil { + return err + } + + // 2. Branch by Question Type + switch input.QuestionType { + + case domain.MultipleChoice: + if len(input.Options) == 0 { + return errors.New("multiple choice question requires options") + } + + for _, opt := range input.Options { + _, err := repo.CreateQuestionOption( + ctx, + dbgen.CreateQuestionOptionParams{ + QuestionID: question.ID, + OptionText: opt.Text, + OptionOrder: opt.Order, + IsCorrect: opt.IsCorrect, + }, + ) + if err != nil { + return err + } + } + + case domain.TrueFalse: + // TRUE + if _, err := repo.CreateQuestionOption( + ctx, + dbgen.CreateQuestionOptionParams{ + QuestionID: question.ID, + OptionText: "True", + OptionOrder: 1, + IsCorrect: true, + }, + ); err != nil { + return err + } + + // FALSE + if _, err := repo.CreateQuestionOption( + ctx, + dbgen.CreateQuestionOptionParams{ + QuestionID: question.ID, + OptionText: "False", + OptionOrder: 2, + IsCorrect: false, + }, + ); err != nil { + return err + } + + case domain.ShortAnswer: + if input.CorrectAnswer == nil || *input.CorrectAnswer == "" { + return errors.New("short answer question requires correct_answer") + } + + _, err := repo.CreateShortAnswer( + ctx, + dbgen.CreateShortAnswerParams{ + QuestionID: question.ID, + CorrectAnswer: *input.CorrectAnswer, + }, + ) + if err != nil { + return err + } + + default: + return errors.New("unsupported question type") + } + + return nil +} + +func (s *Service) ListQuestions(ctx context.Context) ([]domain.QuestionWithDetails, error) { + // repo := s.initialAssessmentStore questions, err := s.initialAssessmentStore.GetActiveAssessmentQuestions(ctx) if err != nil { return nil, err } - // IMPORTANT: - // Do NOT expose correct answers to the client - for i := range questions { - for j := range questions[i].Options { - questions[i].Options[j].IsCorrect = false + out := make([]domain.QuestionWithDetails, 0, len(questions)) + for _, q := range questions { + item := domain.QuestionWithDetails{Question: q} + + switch domain.QuestionType(q.QuestionType) { + case domain.MultipleChoice, domain.TrueFalse: + opts, err := s.initialAssessmentStore.GetQuestionOptions(ctx, q.ID) + if err != nil { + return nil, err + } + for _, opt := range opts { + tempOpt := domain.QuestionOption{ + QuestionID: opt.ID, + OptionText: opt.OptionText, + } + item.Options = append(item.Options, tempOpt) + } + + // case domain.ShortAnswer: + // sa, err := s.initialAssessmentStore.GetShortAnswerByQuestionID(ctx, q.ID) + // if err != nil { + // return nil, err + // } + // item.ShortAnswer = &sa + // } + + out = append(out, item) } } - return questions, nil + return out, nil } -func (s *Service) CreateAssessmentQuestion( - ctx context.Context, - q domain.AssessmentQuestion, -) (domain.AssessmentQuestion, error) { +func (s *Service) GetQuestionByID(ctx context.Context, id int64) (domain.QuestionWithDetails, error) { + repo := s.initialAssessmentStore - // Basic validation - if q.Title == "" { - return domain.AssessmentQuestion{}, errors.New("question title is required") + q, err := repo.GetAssessmentQuestionByID(ctx, id) + if err != nil { + return domain.QuestionWithDetails{}, err } - if q.QuestionType == "" { - return domain.AssessmentQuestion{}, errors.New("question type is required") - } - - if q.DifficultyLevel == "" { - return domain.AssessmentQuestion{}, errors.New("difficulty level is required") - } - - // Multiple choice / true-false must have options - if q.QuestionType != string(domain.QuestionTypeShortAnswer) { - if len(q.Options) < 2 { - return domain.AssessmentQuestion{}, errors.New("at least two options are required") + item := domain.QuestionWithDetails{Question: q} + switch domain.QuestionType(q.QuestionType) { + case domain.MultipleChoice, domain.TrueFalse: + opts, err := repo.GetQuestionOptions(ctx, q.ID) + if err != nil { + return domain.QuestionWithDetails{}, err } - - hasCorrect := false - for _, opt := range q.Options { - if opt.OptionText == "" { - return domain.AssessmentQuestion{}, errors.New("option text cannot be empty") - } - if opt.IsCorrect { - hasCorrect = true + for _, opt := range opts { + tempOpt := domain.QuestionOption{ + QuestionID: opt.ID, + OptionText: opt.OptionText, } + item.Options = append(item.Options, tempOpt) } - - if !hasCorrect { - return domain.AssessmentQuestion{}, errors.New("at least one correct option is required") - } + // case domain.ShortAnswer: + // sa, err := repo.GetShortAnswerByQuestionID(ctx, q.ID) + // if err != nil { + // return QuestionWithDetails{}, err + // } + // item.ShortAnswer = &sa } - // Persist via repository - return s.initialAssessmentStore.CreateAssessmentQuestion(ctx, q) + return item, nil } -// func (s *Service) SubmitAssessment( -// ctx context.Context, -// userID int64, -// responses []domain.UserAnswer, -// ) (domain.AssessmentAttempt, error) { +// func (s *Service) UpdateQuestion(ctx context.Context, id int64, input domain.UpdateAssessmentQuestionInput) error { +// repo := s.initialAssessmentStore -// if userID <= 0 { -// return domain.AssessmentAttempt{}, errors.New("invalid user id") +// // fetch existing +// existing, err := repo.GetAssessmentQuestionByID(ctx, id) +// if err != nil { +// return err // } -// if len(responses) == 0 { -// return domain.AssessmentAttempt{}, errors.New("no responses submitted") -// } - -// // Step 1: Validate and evaluate answers -// for i, ans := range responses { -// if ans.QuestionID == 0 { -// return domain.AssessmentAttempt{}, errors.New("invalid question id") -// } - -// isCorrect, err := s.validateAnswer(ctx, ans) -// if err != nil { -// return domain.AssessmentAttempt{}, err -// } - -// responses[i].IsCorrect = isCorrect -// } - -// // Step 2: Persist assessment attempt + answers -// attempt, err := s.initialAssessmentStore.SaveAssessmentAttempt( +// // update base question +// _, err = repo.UpdateAssessmentQuestion( // ctx, -// userID, -// responses, +// dbgen.UpdateAssessmentQuestionParams{ +// ID: id, +// Title: input.Title, +// Description: func() pgtype.Text { +// ns := toNullString(input.Description) +// return pgtype.Text{String: ns.String, Valid: ns.Valid} +// }(), +// QuestionType: string(input.QuestionType), +// DifficultyLevel: pgtype.Text{String: input.DifficultyLevel}, +// Points: input.Points, +// IsActive: input.IsActive, +// }, // ) // if err != nil { -// return domain.AssessmentAttempt{}, err +// return err // } -// // Step 3: Update user's knowledge level -// if err := s.userStore.UpdateUserKnowledgeLevel( -// ctx, -// userID, -// attempt.KnowledgeLevel, -// ); err != nil { -// return domain.AssessmentAttempt{}, err +// // remove previous dependents (safe to remove regardless of new type) +// // try delete options and short answer; ignore not-found errors if repo returns them +// if err := repo.DeleteQuestionOptionsByQuestionID(ctx, id); err != nil { +// return err +// } +// if err := repo.DeleteShortAnswerByQuestionID(ctx, id); err != nil { +// return err // } -// // Step 4: Send in-app notification -// notification := &domain.Notification{ - -// RecipientID: userID, -// Level: domain.NotificationLevelInfo, -// Reciever: domain.NotificationRecieverSideCustomer, -// IsRead: false, -// DeliveryStatus: domain.DeliveryStatusSent, -// DeliveryChannel: domain.DeliveryChannelInApp, -// Payload: domain.NotificationPayload{ -// Headline: "Knowledge Assessment Completed", -// Message: "Your knowledge assessment is complete. Your knowledge level is " + attempt.KnowledgeLevel + ".", -// Tags: []string{"assessment", "knowledge-level"}, -// }, -// Timestamp: time.Now(), -// Type: domain.NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE, +// // create dependents for new type +// switch input.QuestionType { +// case domain.MultipleChoice: +// if len(input.Options) == 0 { +// return errors.New("multiple choice question requires options") +// } +// for _, opt := range input.Options { +// if _, err := repo.CreateQuestionOption( +// ctx, +// dbgen.CreateQuestionOptionParams{ +// QuestionID: id, +// OptionText: opt.Text, +// OptionOrder: opt.Order, +// IsCorrect: opt.IsCorrect, +// }, +// ); err != nil { +// return err +// } +// } +// case domain.TrueFalse: +// if _, err := repo.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ +// QuestionID: id, +// OptionText: "True", +// OptionOrder: 1, +// IsCorrect: true, +// }); err != nil { +// return err +// } +// if _, err := repo.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ +// QuestionID: id, +// OptionText: "False", +// OptionOrder: 2, +// IsCorrect: false, +// }); err != nil { +// return err +// } +// case domain.ShortAnswer: +// if input.CorrectAnswer == nil || *input.CorrectAnswer == "" { +// return errors.New("short answer question requires correct_answer") +// } +// if _, err := repo.CreateShortAnswer(ctx, dbgen.CreateShortAnswerParams{ +// QuestionID: id, +// CorrectAnswer: *input.CorrectAnswer, +// }); err != nil { +// return err +// } +// default: +// return errors.New("unsupported question type") // } -// if err := s.notificationSvc.SendNotification(ctx, notification); err != nil { -// return domain.AssessmentAttempt{}, err -// } - -// return attempt, nil +// _ = existing +// return nil // } -func (s *Service) validateAnswer( - ctx context.Context, - answer domain.UserAnswer, -) (bool, error) { +// func (s *Service) DeleteQuestion(ctx context.Context, id int64) error { +// repo := s.initialAssessmentStore - // Multiple choice / True-False - if answer.SelectedOptionID != 0 { - option, err := s.initialAssessmentStore.GetOptionByID( - ctx, - answer.SelectedOptionID, - ) - if err != nil { - return false, err - } - return option.IsCorrect, nil - } +// q, err := repo.GetAssessmentQuestionByID(ctx, id) +// if err != nil { +// return err +// } - // Short answer (future-proofing) - if answer.ShortAnswer != "" { - // Placeholder: subjective/manual evaluation - // For now, mark incorrect - return false, nil - } +// // delete dependents by existing type +// switch domain.QuestionType(q.QuestionType) { +// case domain.MultipleChoice, domain.TrueFalse: +// if err := repo.DeleteQuestionOptionsByQuestionID(ctx, id); err != nil { +// return err +// } +// case domain.ShortAnswer: +// if err := repo.DeleteShortAnswerByQuestionID(ctx, id); err != nil { +// return err +// } +// } - return false, errors.New("invalid answer submission") -} +// if err := repo.DeleteAssessmentQuestion(ctx, id); err != nil { +// return err +// } -func CalculateKnowledgeLevel(score float64) string { - switch { - case score >= 80: - return "ADVANCED" - case score >= 50: - return "INTERMEDIATE" - default: - return "BEGINNER" - } -} +// return nil +// } + +// ...existing code... diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index e09956c..b4b0919 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -108,19 +108,19 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error { } } - if successRes.Role != domain.RoleStudent { - h.mongoLoggerSvc.Info("Login attempt: user login of other role", - zap.Int("status_code", fiber.StatusForbidden), - zap.String("role", string(successRes.Role)), - zap.String("email", req.Email), - zap.String("phone_number", req.PhoneNumber), - zap.Time("timestamp", time.Now()), - ) - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: "Failed to login", - Error: "Only users are allowed to login", - }) - } + // if successRes.Role != domain.RoleStudent { + // h.mongoLoggerSvc.Info("Login attempt: user login of other role", + // zap.Int("status_code", fiber.StatusForbidden), + // zap.String("role", string(successRes.Role)), + // zap.String("email", req.Email), + // zap.String("phone_number", req.PhoneNumber), + // zap.Time("timestamp", time.Now()), + // ) + // return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + // Message: "Failed to login", + // Error: "Only users are allowed to login", + // }) + // } accessToken, err := jwtutil.CreateJwt( successRes.UserId, diff --git a/internal/web_server/handlers/initial_assessment.go b/internal/web_server/handlers/initial_assessment.go index 3a5d303..b10da03 100644 --- a/internal/web_server/handlers/initial_assessment.go +++ b/internal/web_server/handlers/initial_assessment.go @@ -2,23 +2,24 @@ package handlers import ( "Yimaru-Backend/internal/domain" + "strconv" "github.com/gofiber/fiber/v2" ) // CreateAssessmentQuestion godoc // @Summary Create assessment question -// @Description Creates a new question for the initial knowledge assessment -// @Tags assessment +// @Description Creates a new assessment question with options or short answer depending on question type +// @Tags assessment-question // @Accept json // @Produce json -// @Param question body domain.AssessmentQuestion true "Assessment question payload" -// @Success 201 {object} domain.Response{data=domain.AssessmentQuestion} +// @Param body body domain.CreateAssessmentQuestionInput true "Create question payload" +// @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/assessment/questions [post] func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { - var req domain.AssessmentQuestion + var req domain.CreateAssessmentQuestionInput if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", @@ -26,31 +27,38 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { }) } - question, err := h.assessmentSvc.CreateAssessmentQuestion(c.Context(), req) - if err != nil { + // Basic validation + if req.Title == "" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation error", + Error: "title is required", + }) + } + + if err := h.assessmentSvc.CreateQuestion(c.Context(), req); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create assessment question", Error: err.Error(), }) } return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Assessment question created successfully", - Data: question, + Message: "Assessment question created successfully", + StatusCode: fiber.StatusCreated, + Success: true, }) } -// GetActiveAssessmentQuestions godoc -// @Summary Get active initial assessment questions -// @Description Returns all active questions used for initial knowledge assessment -// @Tags assessment -// @Accept json +// ListAssessmentQuestions godoc +// @Summary List assessment questions +// @Description Returns all active assessment questions with their options or answers +// @Tags assessment-question // @Produce json -// @Success 200 {object} domain.Response{data=[]domain.AssessmentQuestion} +// @Success 200 {array} domain.QuestionWithDetails // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/assessment/questions [get] -func (h *Handler) GetActiveAssessmentQuestions(c *fiber.Ctx) error { - questions, err := h.assessmentSvc.GetActiveAssessmentQuestions(c.Context()) +func (h *Handler) ListAssessmentQuestions(c *fiber.Ctx) error { + questions, err := h.assessmentSvc.ListQuestions(c.Context()) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to fetch assessment questions", @@ -59,81 +67,47 @@ func (h *Handler) GetActiveAssessmentQuestions(c *fiber.Ctx) error { } return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Assessment questions fetched successfully", - Data: questions, + Message: "Questions fetched successfully", + Data: questions, + Success: true, + StatusCode: 200, }) } -// SubmitAssessment godoc -// @Summary Submit initial knowledge assessment -// @Description Evaluates user responses, calculates knowledge level, updates user profile, and sends notification -// @Tags assessment -// @Accept json +// GetAssessmentQuestionByID godoc +// @Summary Get assessment question by ID +// @Description Returns a single assessment question with its options or answer +// @Tags assessment-question // @Produce json -// @Param user_id path int true "User ID" -// @Param payload body domain.SubmitAssessmentReq true "Assessment responses" -// @Success 200 {object} domain.Response +// @Param id path int true "Question ID" +// @Success 200 {object} domain.QuestionWithDetails // @Failure 400 {object} domain.ErrorResponse // @Failure 404 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/{tenant_slug}/assessment/submit [post] -// func (h *Handler) SubmitAssessment(c *fiber.Ctx) error { +// @Router /api/v1/assessment/questions/{id} [get] +func (h *Handler) GetAssessmentQuestionByID(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question ID", + Error: "question ID must be a positive integer", + }) + } -// // User ID (from auth context or path, depending on your setup) -// userIDStr, ok := c.Locals("user_id").(string) -// if !ok || userIDStr == "" { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Message: "Invalid user context", -// Error: "User ID not found in request context", -// }) -// } + question, err := h.assessmentSvc.GetQuestionByID(c.Context(), id) + if err != nil { + // Adjust if you introduce a sentinel error (e.g. ErrQuestionNotFound) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to fetch assessment question", + Error: err.Error(), + }) + } -// userID, err := strconv.ParseInt(userIDStr, 10, 64) -// if err != nil || userID <= 0 { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Message: "Invalid user ID", -// Error: "User ID must be a positive integer", -// }) -// } - -// // Parse request body -// var req domain.SubmitAssessmentReq -// if err := c.BodyParser(&req); err != nil { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Message: "Invalid request body", -// Error: err.Error(), -// }) -// } - -// if len(req.Answers) == 0 { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Message: "No answers submitted", -// Error: "Assessment answers cannot be empty", -// }) -// } - -// // Submit assessment -// attempt, err := h.assessmentSvc.SubmitAssessment( -// c.Context(), -// userID, -// req.Answers, -// ) -// if err != nil { -// if errors.Is(err, authentication.ErrUserNotFound) { -// return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ -// Message: "User not found", -// Error: err.Error(), -// }) -// } - -// return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ -// Message: "Failed to submit assessment", -// Error: err.Error(), -// }) -// } - -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Assessment submitted successfully", -// Data: attempt, -// }) -// } + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Question fetched successfully", + Data: question, + Success: true, + StatusCode: 200, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 67cd64f..fd2736d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -81,10 +81,48 @@ func (a *App) initAppRoutes() { }) }) - //assessment Routes + // Assessment questions groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion) - groupV1.Get("/assessment/questions", h.GetActiveAssessmentQuestions) - // groupV1.Post("/assessment/submit", a.authMiddleware, h.SubmitAssessment) + groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) + groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID) + // groupV1.Put("/assessment/questions/:id", h.UpdateAssessmentQuestion) + // groupV1.Delete("/assessment/questions/:id", h.DeleteAssessmentQuestion) + + // Start a new assessment attempt + // groupV1.Post( + // "/assessment/attempts", + // h.StartAssessmentAttempt, + // ) + + // // Submit or update an answer + // groupV1.Post( + // "/assessment/attempts/:attempt_id/answers", + // h.SubmitAssessmentAnswer, + // ) + + // // Final submission (locks answers) + // groupV1.Post( + // "/assessment/attempts/:attempt_id/submit", + // h.SubmitAssessmentAttempt, + // ) + + // // Get attempt details + // groupV1.Get( + // "/assessment/attempts/:attempt_id", + // h.GetAssessmentAttemptByID, + // ) + + // Get final result + answers + // groupV1.Get( + // "/assessment/attempts/:attempt_id/result", + // h.GetAssessmentResult, + // ) + + // // Evaluate attempt (admin / system) + // groupV1.Post( + // "/assessment/attempts/:attempt_id/evaluate", + // h.EvaluateAssessmentAttempt, + // ) // Course Management Routes groupV1.Post("/course-categories", h.CreateCourseCategory)