initial assessment implementation

This commit is contained in:
Yared Yemane 2026-01-08 04:42:39 -08:00
parent 7309a2bc83
commit 19ac718526
17 changed files with 1718 additions and 866 deletions

View File

@ -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 oclock.', '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 dont 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, 'Ive 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, Id start earlier.', 2, FALSE),
(15, 'Youd better meet the deadline this time.', 3, TRUE),
(15, 'Why dont 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);

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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

View File

@ -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
}

View File

@ -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"`

View File

@ -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,

View File

@ -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
// }

View File

@ -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)
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -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...

View File

@ -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,

View File

@ -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,
})
}

View File

@ -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)