From 9ee1d7f7145daf4934b8f034f050bf4c818f2f1b Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Sun, 18 Jan 2026 03:12:28 -0800 Subject: [PATCH] changed age to agegroup, added refresh route, token generation after otp verification --- README.md | 285 +++++++++++++ cmd/main.go | 3 + db/data/001_initial_seed_data.sql | 93 ++++- db/migrations/000001_yimaru.up.sql | 395 ++++++++++--------- db/query/user.sql | 101 +++-- gen/db/models.go | 2 +- gen/db/user.sql.go | 147 +++---- internal/domain/auth.go | 17 +- internal/domain/user.go | 36 +- internal/repository/auth.go | 2 +- internal/repository/user.go | 125 +++--- internal/services/arifpay/service.go | 2 + internal/services/authentication/impl.go | 142 +++++-- internal/services/authentication/service.go | 4 +- internal/services/user/common.go | 54 --- internal/services/user/direct.go | 47 ++- internal/services/user/service.go | 3 + internal/web_server/handlers/auth_handler.go | 16 +- internal/web_server/handlers/user.go | 90 +++-- 19 files changed, 1014 insertions(+), 550 deletions(-) diff --git a/README.md b/README.md index 5668932..47a9f34 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,288 @@ cd Yimaru-backend ├── makefile # Development and operations commands ├── .env # Environment configuration file └── README.md # Project documentation + + +1. Course Category (Top-Level Classification) + +Table: course_categories + +Purpose: +Logical grouping of courses (e.g., Learning English, Other Courses). + +Key Fields: + +id – Primary identifier + +name – Category name + +is_active – Soft enable/disable + +created_at – Audit timestamp + +Relationships: + +One Course Category → Many Courses + +Course Category +└── Courses[] + +2. Course + +Table: courses + +Purpose: +Represents a full course offering under a category. + +Key Fields: + +category_id – FK → course_categories.id + +title, description + +is_active + +Relationships: + +Belongs to one Course Category + +Has many Programs + +Course Category +└── Course + └── Programs[] + +3. Program + +Table: programs + +Purpose: +A structured learning track or syllabus within a course +(e.g., Beginner Track, Advanced Track). + +Key Fields: + +course_id – FK → courses.id + +title, description + +thumbnail + +display_order + +is_active + +Relationships: + +Belongs to one Course + +Has many Levels + +Course +└── Program + └── Levels[] + +4. Level + +Table: levels + +Purpose: +Represents a progression stage inside a program (Level 1, Level 2, etc.). + +Key Fields: + +program_id – FK → programs.id + +title, description + +level_index + +Aggregates: + +number_of_modules + +number_of_practices + +number_of_videos + +is_active + +Relationships: + +Belongs to one Program + +Has many Modules + +Can directly own Practices + +Program +└── Level + ├── Modules[] + └── Practices[] (owner_type = LEVEL) + +5. Module + +Table: modules + +Purpose: +A lesson or unit inside a level. + +Key Fields: + +level_id – FK → levels.id + +title + +content + +display_order + +is_active + +Relationships: + +Belongs to one Level + +Has many Videos + +Can directly own Practices + +Level +└── Module + ├── Module Videos[] + └── Practices[] (owner_type = MODULE) + +6. Module Video + +Table: module_videos + +Purpose: +Actual video learning content attached to a module. + +Key Fields: + +module_id – FK → modules.id + +title, description + +video_url + +duration, resolution + +Publishing controls: + +is_published + +publish_date + +visibility + +instructor_id + +thumbnail + +is_active + +Relationships: + +Belongs to one Module + +Module +└── Module Video + +7. Practice (Polymorphic Ownership) + +Table: practices + +Purpose: +Exercises or assessments that can belong to either a Level or a Module. + +Key Fields: + +owner_type – LEVEL | MODULE + +owner_id – ID of level or module + +title, description + +banner_image + +persona + +is_active + +Constraint: + +Enforced by CHECK (owner_type IN ('LEVEL', 'MODULE')) + +Ownership enforced at the application layer + +Relationships: + +One Practice → Many Practice Questions + +Level or Module +└── Practice + └── Practice Questions[] + +8. Practice Question (Lowest Level) + +Table: practice_questions + +Purpose: +Individual questions within a practice session. + +Key Fields: + +practice_id – FK → practices.id + +question + +Voice support: + +question_voice_prompt + +sample_answer_voice_prompt + +sample_answer + +tips + +type – MCQ | TRUE_FALSE | SHORT + +Relationships: + +Belongs to one Practice + +Practice +└── Practice Question + +Complete Hierarchical Flow (Compact View) +Course Category +└── Course + └── Program + └── Level + ├── Module + │ ├── Module Video + │ └── Practice (MODULE) + │ └── Practice Question + └── Practice (LEVEL) + └── Practice Question + +Architectural Observations + +Strict top-down hierarchy until Level + +Polymorphic design for practices allows reuse without table duplication + +Cascade deletes ensure referential integrity + +Aggregated counters in levels support fast analytics and UI summaries + +Schema is well-suited for: + +LMS platforms + +Progressive learning apps + +Video + assessment-based education systems \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index a4fbdb6..734cffa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,6 +4,7 @@ import ( // "context" // "context" + _ "Yimaru-Backend/docs" "Yimaru-Backend/internal/config" "Yimaru-Backend/internal/domain" customlogger "Yimaru-Backend/internal/logger" @@ -91,6 +92,7 @@ func main() { // ) userSvc := user.NewService( + repository.NewTokenStore(store), repository.NewUserStore(store), repository.NewOTPStore(store), messengerSvc, @@ -98,6 +100,7 @@ func main() { ) authSvc := authentication.NewService( + repository.NewOTPStore(store), repository.NewUserStore(store), *userSvc, repository.NewTokenStore(store), diff --git a/db/data/001_initial_seed_data.sql b/db/data/001_initial_seed_data.sql index 5e4725f..2729418 100644 --- a/db/data/001_initial_seed_data.sql +++ b/db/data/001_initial_seed_data.sql @@ -5,70 +5,133 @@ INSERT INTO users ( id, first_name, last_name, - -- user_name, + gender, + birth_day, email, phone_number, role, password, - status, + age, + education_level, + country, + region, + knowledge_level, + nick_name, + occupation, + learning_goal, + language_goal, + language_challange, + favourite_topic, + initial_assessment_completed, email_verified, phone_verified, + status, + last_login, profile_completed, + profile_picture_url, preferred_language, - created_at + created_at, + updated_at ) VALUES ( 10, 'Demo', 'Student', - -- 'demo_student', + 'Male', + '2000-01-01', 'student10@yimaru.com', NULL, 'USER', crypt('password@123', gen_salt('bf'))::bytea, - 'ACTIVE', + 22, + 'Bachelor', + 'Ethiopia', + 'Addis Ababa', + 'BEGINNER', + 'Demo', + 'Student', + 'Learn programming', + 'English', + 'Grammar', + 'Technology', + FALSE, TRUE, FALSE, + 'ACTIVE', + NULL, FALSE, + NULL, 'en', - CURRENT_TIMESTAMP + CURRENT_TIMESTAMP, + NULL ), ( 11, 'System', 'Admin', - -- 'sys_admin', + 'Female', + '1995-01-01', 'admin@yimaru.com', '0911001100', 'ADMIN', crypt('password@123', gen_salt('bf'))::bytea, + 28, + 'Master', + 'Ethiopia', + 'Addis Ababa', + 'ADVANCED', + 'SysAdmin', + 'Administrator', + 'Manage system', + 'English', + 'Writing', + 'Management', + TRUE, + TRUE, + TRUE, 'ACTIVE', + NULL, TRUE, - TRUE, - TRUE, + NULL, 'en', - CURRENT_TIMESTAMP + CURRENT_TIMESTAMP, + NULL ), ( 12, 'Support', 'Agent', - -- 'support_agent', + 'Female', + '1998-01-01', 'support@yimaru.com', '0911223344', 'SUPPORT', crypt('password@123', gen_salt('bf'))::bytea, + 25, + 'Diploma', + 'Ethiopia', + 'Addis Ababa', + 'INTERMEDIATE', + 'Support', + 'Agent', + 'Assist users', + 'English', + 'Conversation', + 'Customer Service', + TRUE, + TRUE, + TRUE, 'ACTIVE', + NULL, TRUE, - TRUE, - TRUE, + NULL, 'en', - CURRENT_TIMESTAMP + CURRENT_TIMESTAMP, + NULL ) ON CONFLICT (id) DO NOTHING; - -- ====================================================== -- Global Settings (LMS) -- ====================================================== diff --git a/db/migrations/000001_yimaru.up.sql b/db/migrations/000001_yimaru.up.sql index 7c07fa7..415f050 100644 --- a/db/migrations/000001_yimaru.up.sql +++ b/db/migrations/000001_yimaru.up.sql @@ -1,244 +1,267 @@ -CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, - first_name VARCHAR(255), - last_name VARCHAR(255), + CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + first_name VARCHAR(255), + last_name VARCHAR(255), - gender VARCHAR(255), - birth_day DATE, + gender VARCHAR(255), + birth_day DATE, - email VARCHAR(255), - phone_number VARCHAR(20), + email VARCHAR(255), + phone_number VARCHAR(20), - role VARCHAR(50) NOT NULL, -- SUPER_ADMIN, INSTRUCTOR, STUDENT, SUPPORT - password BYTEA NOT NULL, - age INT, - education_level VARCHAR(100), - country VARCHAR(100), - region VARCHAR(100), + role VARCHAR(50) NOT NULL, -- SUPER_ADMIN, INSTRUCTOR, STUDENT, SUPPORT + password BYTEA NOT NULL, + age INT, + education_level VARCHAR(100), + country VARCHAR(100), + region VARCHAR(100), - knowledge_level VARCHAR(50), -- BEGINNER, INTERMEDIATE, ADVANCED - nick_name VARCHAR(100), - occupation VARCHAR(150), - learning_goal TEXT, - language_goal TEXT, - language_challange TEXT, - favourite_topic TEXT, + knowledge_level VARCHAR(50), -- BEGINNER, INTERMEDIATE, ADVANCED + nick_name VARCHAR(100), + occupation VARCHAR(150), + learning_goal TEXT, + language_goal TEXT, + language_challange TEXT, + favourite_topic TEXT, - initial_assessment_completed BOOLEAN NOT NULL DEFAULT FALSE, - email_verified BOOLEAN NOT NULL DEFAULT FALSE, - phone_verified BOOLEAN NOT NULL DEFAULT FALSE, - status VARCHAR(50) NOT NULL, -- PENDING, ACTIVE, SUSPENDED, DEACTIVATED - last_login TIMESTAMPTZ, - profile_completed BOOLEAN, - profile_picture_url TEXT, - preferred_language VARCHAR(50), + initial_assessment_completed BOOLEAN NOT NULL DEFAULT FALSE, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + phone_verified BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(50) NOT NULL, -- PENDING, ACTIVE, SUSPENDED, DEACTIVATED + last_login TIMESTAMPTZ, + profile_completed BOOLEAN, + profile_picture_url TEXT, + preferred_language VARCHAR(50), - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ, - -- Enforce: at least one contact method must be provided - CONSTRAINT users_email_or_phone_required - CHECK (email IS NOT NULL OR phone_number IS NOT NULL) -); + -- Enforce: at least one contact method must be provided + CONSTRAINT users_email_or_phone_required + CHECK (email IS NOT NULL OR phone_number IS NOT NULL) + ); -CREATE TABLE IF NOT EXISTS assessment_questions ( - id BIGSERIAL PRIMARY KEY, + -- Remove the old column + ALTER TABLE users + DROP COLUMN age; - title TEXT NOT NULL, - description TEXT, + -- Add age_group with constrained values + ALTER TABLE users + ADD COLUMN age_group VARCHAR(20); - question_type VARCHAR(50) NOT NULL, - -- MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER + ALTER TABLE users + ADD CONSTRAINT users_age_group_check + CHECK ( + age_group IN ( + 'UNDER_13', + '13_17', + '18_24', + '25_34', + '35_44', + '45_54', + '55_PLUS' + ) + ); - difficulty_level VARCHAR(50), - -- EASY, MEDIUM, HARD + CREATE TABLE IF NOT EXISTS assessment_questions ( + id BIGSERIAL PRIMARY KEY, - points INT NOT NULL DEFAULT 1, + title TEXT NOT NULL, + description TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, + question_type VARCHAR(50) NOT NULL, + -- MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ -); + difficulty_level VARCHAR(50), + -- EASY, MEDIUM, HARD -CREATE TABLE IF NOT EXISTS assessment_question_options ( - id BIGSERIAL PRIMARY KEY, + points INT NOT NULL DEFAULT 1, - question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, - option_text TEXT NOT NULL, - option_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ + ); - is_correct BOOLEAN NOT NULL DEFAULT FALSE, + CREATE TABLE IF NOT EXISTS assessment_question_options ( + id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, - UNIQUE (question_id, option_order) -); + option_text TEXT NOT NULL, + option_order INT NOT NULL, -CREATE TABLE IF NOT EXISTS assessment_short_answers ( - id BIGSERIAL PRIMARY KEY, + is_correct BOOLEAN NOT NULL DEFAULT FALSE, - question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - correct_answer TEXT NOT NULL, + UNIQUE (question_id, option_order) + ); - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); + CREATE TABLE IF NOT EXISTS assessment_short_answers ( + id BIGSERIAL PRIMARY KEY, -ALTER TABLE assessment_questions -ADD CONSTRAINT chk_question_type -CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')); + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, -CREATE TABLE IF NOT EXISTS assessment_attempts ( - id BIGSERIAL PRIMARY KEY, + correct_answer TEXT NOT NULL, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); - total_questions INT NOT NULL, - total_points INT NOT NULL, + ALTER TABLE assessment_questions + ADD CONSTRAINT chk_question_type + CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')); - score INT, - percentage NUMERIC(5,2), + CREATE TABLE IF NOT EXISTS assessment_attempts ( + id BIGSERIAL PRIMARY KEY, - status VARCHAR(50) NOT NULL, - -- IN_PROGRESS, SUBMITTED, EVALUATED + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - submitted_at TIMESTAMPTZ, - evaluated_at TIMESTAMPTZ, + total_questions INT NOT NULL, + total_points INT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ -); + score INT, + percentage NUMERIC(5,2), -CREATE TABLE IF NOT EXISTS assessment_attempt_questions ( - id BIGSERIAL PRIMARY KEY, + status VARCHAR(50) NOT NULL, + -- IN_PROGRESS, SUBMITTED, EVALUATED - attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, - question_id BIGINT NOT NULL REFERENCES assessment_questions(id), + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMPTZ, + evaluated_at TIMESTAMPTZ, - question_type VARCHAR(50) NOT NULL, - points INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ + ); - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CREATE TABLE IF NOT EXISTS assessment_attempt_questions ( + id BIGSERIAL PRIMARY KEY, - UNIQUE (attempt_id, question_id) -); + attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id), -CREATE TABLE IF NOT EXISTS assessment_attempt_answers ( - id BIGSERIAL PRIMARY KEY, + question_type VARCHAR(50) NOT NULL, + points INT NOT NULL, - attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, - question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- For MCQ / TRUE_FALSE - selected_option_id BIGINT - REFERENCES assessment_question_options(id), + UNIQUE (attempt_id, question_id) + ); - -- For SHORT_ANSWER - submitted_text TEXT, + CREATE TABLE IF NOT EXISTS assessment_attempt_answers ( + id BIGSERIAL PRIMARY KEY, - is_correct BOOLEAN, - awarded_points INT NOT NULL DEFAULT 0, + attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- For MCQ / TRUE_FALSE + selected_option_id BIGINT + REFERENCES assessment_question_options(id), - UNIQUE (attempt_id, question_id), + -- For SHORT_ANSWER + submitted_text TEXT, - CHECK ( - (selected_option_id IS NOT NULL AND submitted_text IS NULL) - OR - (selected_option_id IS NULL AND submitted_text IS NOT NULL) - ) -); + is_correct BOOLEAN, + awarded_points INT NOT NULL DEFAULT 0, -ALTER TABLE assessment_attempts -ADD CONSTRAINT chk_attempt_status -CHECK (status IN ('IN_PROGRESS', 'SUBMITTED', 'EVALUATED')); + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, -ALTER TABLE assessment_attempt_questions -ADD CONSTRAINT chk_attempt_question_type -CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')); + UNIQUE (attempt_id, question_id), -CREATE TABLE refresh_tokens ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - revoked BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); + CHECK ( + (selected_option_id IS NOT NULL AND submitted_text IS NULL) + OR + (selected_option_id IS NULL AND submitted_text IS NOT NULL) + ) + ); -CREATE TABLE otps ( - id BIGSERIAL PRIMARY KEY, - user_id BIGSERIAL NOT NULL, - sent_to VARCHAR(255) NOT NULL, - medium VARCHAR(50) NOT NULL, -- email, sms - otp_for VARCHAR(50) NOT NULL, -- register, reset - otp VARCHAR(10) NOT NULL, - used BOOLEAN NOT NULL DEFAULT FALSE, - used_at TIMESTAMPTZ, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); + ALTER TABLE assessment_attempts + ADD CONSTRAINT chk_attempt_status + CHECK (status IN ('IN_PROGRESS', 'SUBMITTED', 'EVALUATED')); -CREATE TABLE IF NOT EXISTS notifications ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ALTER TABLE assessment_attempt_questions + ADD CONSTRAINT chk_attempt_question_type + CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')); - type TEXT NOT NULL CHECK ( - type IN ( - 'course_enrolled', - 'lesson_completed', - 'assessment_assigned', - 'assessment_submitted', - 'assessment_graded', - 'course_completed', - 'certificate_issued', - 'announcement', - 'otp_sent', - 'signup_welcome', - 'system_alert' - ) - ), + CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); - level TEXT NOT NULL CHECK ( - level IN ('info', 'warning', 'success', 'error') - ), + CREATE TABLE otps ( + id BIGSERIAL PRIMARY KEY, + user_id BIGSERIAL NOT NULL, + sent_to VARCHAR(255) NOT NULL, + medium VARCHAR(50) NOT NULL, -- email, sms + otp_for VARCHAR(50) NOT NULL, -- register, reset + otp VARCHAR(10) NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); - channel TEXT CHECK ( - channel IN ('email', 'sms', 'push', 'in_app') - ), + CREATE TABLE IF NOT EXISTS notifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - message TEXT NOT NULL, + type TEXT NOT NULL CHECK ( + type IN ( + 'course_enrolled', + 'lesson_completed', + 'assessment_assigned', + 'assessment_submitted', + 'assessment_graded', + 'course_completed', + 'certificate_issued', + 'announcement', + 'otp_sent', + 'signup_welcome', + 'system_alert' + ) + ), - payload JSONB, - is_read BOOLEAN NOT NULL DEFAULT FALSE, + level TEXT NOT NULL CHECK ( + level IN ('info', 'warning', 'success', 'error') + ), - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - read_at TIMESTAMPTZ -); + channel TEXT CHECK ( + channel IN ('email', 'sms', 'push', 'in_app') + ), -CREATE TABLE global_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); + title TEXT NOT NULL, + message TEXT NOT NULL, + + payload JSONB, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + read_at TIMESTAMPTZ + ); + + CREATE TABLE global_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS reported_issues ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + user_role VARCHAR(255) NOT NULL, + subject TEXT NOT NULL, + description TEXT NOT NULL, + issue_type TEXT NOT NULL, + -- e.g., "deposit", "withdrawal", "bet", "technical" + status TEXT NOT NULL DEFAULT 'pending', + -- pending, in_progress, resolved, rejected + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); -CREATE TABLE IF NOT EXISTS reported_issues ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id), - user_role VARCHAR(255) NOT NULL, - subject TEXT NOT NULL, - description TEXT NOT NULL, - issue_type TEXT NOT NULL, - -- e.g., "deposit", "withdrawal", "bet", "technical" - status TEXT NOT NULL DEFAULT 'pending', - -- pending, in_progress, resolved, rejected - metadata JSONB, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); diff --git a/db/query/user.sql b/db/query/user.sql index 3c56c2e..c78e332 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -5,6 +5,7 @@ FROM users WHERE id = $1 LIMIT 1; + -- name: IsProfileCompleted :one SELECT CASE WHEN profile_completed = true THEN true ELSE false END AS is_pending @@ -12,6 +13,7 @@ FROM users WHERE id = $1 LIMIT 1; + -- name: IsUserNameUnique :one SELECT CASE WHEN COUNT(*) = 0 THEN true ELSE false END AS is_unique @@ -19,6 +21,7 @@ FROM users WHERE id = $1; + -- name: CreateUser :one INSERT INTO users ( first_name, @@ -29,7 +32,7 @@ INSERT INTO users ( phone_number, role, password, - age, + age_group, education_level, country, region, @@ -51,33 +54,33 @@ INSERT INTO users ( updated_at ) VALUES ( - $1, -- first_name - $2, -- last_name - $3, -- gender - $4, -- birth_day - $5, -- email - $6, -- phone_number - $7, -- role - $8, -- password - $9, -- age - $10, -- education_level - $11, -- country - $12, -- region + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, -- age_group + $10, + $11, + $12, - $13, -- nick_name - $14, -- occupation - $15, -- learning_goal - $16, -- language_goal - $17, -- language_challange - $18, -- favourite_topic + $13, + $14, + $15, + $16, + $17, + $18, - $19, -- initial_assessment_completed - $20, -- email_verified - $21, -- phone_verified - $22, -- status - $23, -- profile_completed - $24, -- profile_picture_url - $25, -- preferred_language + $19, + $20, + $21, + $22, + $23, + $24, + $25, CURRENT_TIMESTAMP ) RETURNING @@ -89,7 +92,7 @@ RETURNING email, phone_number, role, - age, + age_group, education_level, country, region, @@ -111,11 +114,13 @@ RETURNING created_at, updated_at; + -- name: GetUserByID :one SELECT * FROM users WHERE id = $1; + -- name: GetAllUsers :many SELECT COUNT(*) OVER () AS total_count, @@ -127,10 +132,11 @@ SELECT email, phone_number, role, - age, + age_group, education_level, country, region, + knowledge_level, nick_name, occupation, learning_goal, @@ -138,30 +144,26 @@ SELECT language_challange, favourite_topic, initial_assessment_completed, - profile_picture_url, - preferred_language, email_verified, phone_verified, status, + last_login, profile_completed, + profile_picture_url, + preferred_language, created_at, updated_at FROM users -WHERE ($1 IS NULL OR role = $1) - AND ($2 IS NULL OR first_name ILIKE '%' || $2 || '%' - OR last_name ILIKE '%' || $2 || '%' - OR phone_number ILIKE '%' || $2 || '%' - OR email ILIKE '%' || $2 || '%') - AND ($3 IS NULL OR created_at >= $3) - AND ($4 IS NULL OR created_at <= $4) -LIMIT $5 -OFFSET $6; +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + -- name: GetTotalUsers :one SELECT COUNT(*) FROM users WHERE (role = $1 OR $1 IS NULL); + -- name: SearchUserByNameOrPhone :many SELECT id, @@ -172,7 +174,7 @@ SELECT email, phone_number, role, - age, + age_group, education_level, country, region, @@ -205,17 +207,14 @@ WHERE ( OR sqlc.narg('role') IS NULL ); + -- name: UpdateUser :exec UPDATE users SET first_name = COALESCE($1, first_name), last_name = COALESCE($2, last_name), - - -- email = COALESCE($3, email), - -- phone_number = COALESCE($4, phone_number), - knowledge_level = COALESCE($3, knowledge_level), - age = COALESCE($4, age), + age_group = COALESCE($4, age_group), education_level = COALESCE($5, education_level), country = COALESCE($6, country), region = COALESCE($7, region), @@ -226,21 +225,19 @@ SET language_challange = COALESCE($12, language_challange), favourite_topic = COALESCE($13, favourite_topic), initial_assessment_completed = COALESCE($14, initial_assessment_completed), - -- email_verified = COALESCE($15, email_verified), - -- phone_verified = COALESCE($16, phone_verified), - -- status = COALESCE($19, status), profile_completed = COALESCE($15, profile_completed), profile_picture_url = COALESCE($16, profile_picture_url), preferred_language = COALESCE($17, preferred_language), gender = COALESCE($18, gender), - birth_day = COALESCE($19, gender), - updated_at = CURRENT_TIMESTAMP + birth_day = COALESCE($19, birth_day), + updated_at = CURRENT_TIMESTAMP WHERE id = $20; -- name: DeleteUser :exec DELETE FROM users WHERE id = $1; + -- name: CheckPhoneEmailExist :one SELECT EXISTS ( @@ -250,6 +247,7 @@ SELECT SELECT 1 FROM users u2 WHERE u2.email = $2 ) AS email_exists; + -- -- name: GetUserByUserName :one -- SELECT -- id, @@ -295,7 +293,7 @@ SELECT phone_number, role, password, - age, + age_group, education_level, country, region, @@ -341,3 +339,4 @@ SET knowledge_level = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2; + diff --git a/gen/db/models.go b/gen/db/models.go index 9694221..78cbddc 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -221,7 +221,6 @@ type User struct { PhoneNumber pgtype.Text `json:"phone_number"` Role string `json:"role"` Password []byte `json:"password"` - Age pgtype.Int4 `json:"age"` EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` @@ -242,4 +241,5 @@ type User struct { PreferredLanguage pgtype.Text `json:"preferred_language"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + AgeGroup pgtype.Text `json:"age_group"` } diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index afc5087..8ff099e 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -48,7 +48,7 @@ INSERT INTO users ( phone_number, role, password, - age, + age_group, education_level, country, region, @@ -70,33 +70,33 @@ INSERT INTO users ( updated_at ) VALUES ( - $1, -- first_name - $2, -- last_name - $3, -- gender - $4, -- birth_day - $5, -- email - $6, -- phone_number - $7, -- role - $8, -- password - $9, -- age - $10, -- education_level - $11, -- country - $12, -- region + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, -- age_group + $10, + $11, + $12, - $13, -- nick_name - $14, -- occupation - $15, -- learning_goal - $16, -- language_goal - $17, -- language_challange - $18, -- favourite_topic + $13, + $14, + $15, + $16, + $17, + $18, - $19, -- initial_assessment_completed - $20, -- email_verified - $21, -- phone_verified - $22, -- status - $23, -- profile_completed - $24, -- profile_picture_url - $25, -- preferred_language + $19, + $20, + $21, + $22, + $23, + $24, + $25, CURRENT_TIMESTAMP ) RETURNING @@ -108,7 +108,7 @@ RETURNING email, phone_number, role, - age, + age_group, education_level, country, region, @@ -140,7 +140,7 @@ type CreateUserParams struct { PhoneNumber pgtype.Text `json:"phone_number"` Role string `json:"role"` Password []byte `json:"password"` - Age pgtype.Int4 `json:"age"` + AgeGroup pgtype.Text `json:"age_group"` EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` @@ -168,7 +168,7 @@ type CreateUserRow struct { Email pgtype.Text `json:"email"` PhoneNumber pgtype.Text `json:"phone_number"` Role string `json:"role"` - Age pgtype.Int4 `json:"age"` + AgeGroup pgtype.Text `json:"age_group"` EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` @@ -199,7 +199,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU arg.PhoneNumber, arg.Role, arg.Password, - arg.Age, + arg.AgeGroup, arg.EducationLevel, arg.Country, arg.Region, @@ -227,7 +227,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU &i.Email, &i.PhoneNumber, &i.Role, - &i.Age, + &i.AgeGroup, &i.EducationLevel, &i.Country, &i.Region, @@ -271,10 +271,11 @@ SELECT email, phone_number, role, - age, + age_group, education_level, country, region, + knowledge_level, nick_name, occupation, learning_goal, @@ -282,33 +283,23 @@ SELECT language_challange, favourite_topic, initial_assessment_completed, - profile_picture_url, - preferred_language, email_verified, phone_verified, status, + last_login, profile_completed, + profile_picture_url, + preferred_language, created_at, updated_at FROM users -WHERE ($1 IS NULL OR role = $1) - AND ($2 IS NULL OR first_name ILIKE '%' || $2 || '%' - OR last_name ILIKE '%' || $2 || '%' - OR phone_number ILIKE '%' || $2 || '%' - OR email ILIKE '%' || $2 || '%') - AND ($3 IS NULL OR created_at >= $3) - AND ($4 IS NULL OR created_at <= $4) -LIMIT $5 -OFFSET $6 +LIMIT $2::INT +OFFSET $1::INT ` type GetAllUsersParams struct { - Column1 interface{} `json:"column_1"` - Column2 interface{} `json:"column_2"` - Column3 interface{} `json:"column_3"` - Column4 interface{} `json:"column_4"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` } type GetAllUsersRow struct { @@ -321,10 +312,11 @@ type GetAllUsersRow struct { Email pgtype.Text `json:"email"` PhoneNumber pgtype.Text `json:"phone_number"` Role string `json:"role"` - Age pgtype.Int4 `json:"age"` + AgeGroup pgtype.Text `json:"age_group"` EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` + KnowledgeLevel pgtype.Text `json:"knowledge_level"` NickName pgtype.Text `json:"nick_name"` Occupation pgtype.Text `json:"occupation"` LearningGoal pgtype.Text `json:"learning_goal"` @@ -332,25 +324,19 @@ type GetAllUsersRow struct { LanguageChallange pgtype.Text `json:"language_challange"` FavouriteTopic pgtype.Text `json:"favourite_topic"` InitialAssessmentCompleted bool `json:"initial_assessment_completed"` - ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` - PreferredLanguage pgtype.Text `json:"preferred_language"` EmailVerified bool `json:"email_verified"` PhoneVerified bool `json:"phone_verified"` Status string `json:"status"` + LastLogin pgtype.Timestamptz `json:"last_login"` ProfileCompleted pgtype.Bool `json:"profile_completed"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + PreferredLanguage pgtype.Text `json:"preferred_language"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]GetAllUsersRow, error) { - rows, err := q.db.Query(ctx, GetAllUsers, - arg.Column1, - arg.Column2, - arg.Column3, - arg.Column4, - arg.Limit, - arg.Offset, - ) + rows, err := q.db.Query(ctx, GetAllUsers, arg.Offset, arg.Limit) if err != nil { return nil, err } @@ -368,10 +354,11 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get &i.Email, &i.PhoneNumber, &i.Role, - &i.Age, + &i.AgeGroup, &i.EducationLevel, &i.Country, &i.Region, + &i.KnowledgeLevel, &i.NickName, &i.Occupation, &i.LearningGoal, @@ -379,12 +366,13 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get &i.LanguageChallange, &i.FavouriteTopic, &i.InitialAssessmentCompleted, - &i.ProfilePictureUrl, - &i.PreferredLanguage, &i.EmailVerified, &i.PhoneVerified, &i.Status, + &i.LastLogin, &i.ProfileCompleted, + &i.ProfilePictureUrl, + &i.PreferredLanguage, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -425,7 +413,7 @@ SELECT phone_number, role, password, - age, + age_group, education_level, country, region, @@ -467,7 +455,7 @@ type GetUserByEmailPhoneRow struct { PhoneNumber pgtype.Text `json:"phone_number"` Role string `json:"role"` Password []byte `json:"password"` - Age pgtype.Int4 `json:"age"` + AgeGroup pgtype.Text `json:"age_group"` EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` @@ -534,7 +522,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho &i.PhoneNumber, &i.Role, &i.Password, - &i.Age, + &i.AgeGroup, &i.EducationLevel, &i.Country, &i.Region, @@ -558,7 +546,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho } const GetUserByID = `-- name: GetUserByID :one -SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, age, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_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, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group FROM users WHERE id = $1 ` @@ -576,7 +564,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.PhoneNumber, &i.Role, &i.Password, - &i.Age, &i.EducationLevel, &i.Country, &i.Region, @@ -597,6 +584,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.PreferredLanguage, &i.CreatedAt, &i.UpdatedAt, + &i.AgeGroup, ) return i, err } @@ -655,7 +643,7 @@ SELECT email, phone_number, role, - age, + age_group, education_level, country, region, @@ -703,7 +691,7 @@ type SearchUserByNameOrPhoneRow struct { Email pgtype.Text `json:"email"` PhoneNumber pgtype.Text `json:"phone_number"` Role string `json:"role"` - Age pgtype.Int4 `json:"age"` + AgeGroup pgtype.Text `json:"age_group"` EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` @@ -742,7 +730,7 @@ func (q *Queries) SearchUserByNameOrPhone(ctx context.Context, arg SearchUserByN &i.Email, &i.PhoneNumber, &i.Role, - &i.Age, + &i.AgeGroup, &i.EducationLevel, &i.Country, &i.Region, @@ -795,12 +783,8 @@ UPDATE users SET first_name = COALESCE($1, first_name), last_name = COALESCE($2, last_name), - - -- email = COALESCE($3, email), - -- phone_number = COALESCE($4, phone_number), - knowledge_level = COALESCE($3, knowledge_level), - age = COALESCE($4, age), + age_group = COALESCE($4, age_group), education_level = COALESCE($5, education_level), country = COALESCE($6, country), region = COALESCE($7, region), @@ -811,15 +795,12 @@ SET language_challange = COALESCE($12, language_challange), favourite_topic = COALESCE($13, favourite_topic), initial_assessment_completed = COALESCE($14, initial_assessment_completed), - -- email_verified = COALESCE($15, email_verified), - -- phone_verified = COALESCE($16, phone_verified), - -- status = COALESCE($19, status), profile_completed = COALESCE($15, profile_completed), profile_picture_url = COALESCE($16, profile_picture_url), preferred_language = COALESCE($17, preferred_language), gender = COALESCE($18, gender), - birth_day = COALESCE($19, gender), - updated_at = CURRENT_TIMESTAMP + birth_day = COALESCE($19, birth_day), + updated_at = CURRENT_TIMESTAMP WHERE id = $20 ` @@ -827,7 +808,7 @@ type UpdateUserParams struct { FirstName pgtype.Text `json:"first_name"` LastName pgtype.Text `json:"last_name"` KnowledgeLevel pgtype.Text `json:"knowledge_level"` - Age pgtype.Int4 `json:"age"` + AgeGroup pgtype.Text `json:"age_group"` EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` @@ -851,7 +832,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { arg.FirstName, arg.LastName, arg.KnowledgeLevel, - arg.Age, + arg.AgeGroup, arg.EducationLevel, arg.Country, arg.Region, diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 374fe91..e0c668b 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -1,6 +1,21 @@ package domain -import "time" +import ( + "time" +) + +type LoginSuccess struct { + UserId int64 + Role Role + RfToken string +} + +type LoginRequest struct { + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Password string `json:"password"` + OTPCode string `json:"otp_code"` +} type RefreshToken struct { ID int64 diff --git a/internal/domain/user.go b/internal/domain/user.go index 22cae6f..4a49212 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -5,6 +5,18 @@ import ( "time" ) +type AgeGroup string + +const ( + AgeUnder13 AgeGroup = "UNDER_13" + Age13To17 AgeGroup = "13_17" + Age18To24 AgeGroup = "18_24" + Age25To34 AgeGroup = "25_34" + Age35To44 AgeGroup = "35_44" + Age45To54 AgeGroup = "45_54" + Age55Plus AgeGroup = "55_PLUS" +) + var ( ErrUserNotVerified = errors.New("user not verified") ErrUserNotFound = errors.New("user not found") @@ -42,7 +54,7 @@ type User struct { Password []byte Role Role - Age int + AgeGroup string EducationLevel string Country string Region string @@ -81,7 +93,7 @@ type UserProfileResponse struct { PhoneNumber string `json:"phone_number,omitempty"` Role Role `json:"role"` - Age int `json:"age,omitempty"` + AgeGroup string `json:"age_group,omitempty"` EducationLevel string `json:"education_level,omitempty"` Country string `json:"country,omitempty"` Region string `json:"region,omitempty"` @@ -138,7 +150,7 @@ type CreateUserReq struct { Status UserStatus - Age int + AgeGroup string EducationLevel string Country string Region string @@ -166,27 +178,20 @@ type UpdateUserStatusReq struct { } type UpdateUserReq struct { - // Identity (enforced from auth context, not request body) UserID int64 `json:"-"` - // Basic profile FirstName string `json:"first_name"` LastName string `json:"last_name"` - Gender string `json:"gender"` - BirthDay time.Time `json:"birth_day"` + Gender string `json:"gender"` + BirthDay *string `json:"birth_day"` // YYYY-MM-DD - // Contact (optional – at least one must exist at DB level) - // Email string `json:"email"` - // PhoneNumber string `json:"phone_number"` + AgeGroup *AgeGroup `json:"age_group"` - // Personal details - Age int64 `json:"age"` EducationLevel string `json:"education_level"` Country string `json:"country"` Region string `json:"region"` - // Learning / profile KnowledgeLevel string `json:"knowledge_level"` NickName string `json:"nick_name"` Occupation string `json:"occupation"` @@ -196,11 +201,8 @@ type UpdateUserReq struct { FavouriteTopic string `json:"favourite_topic"` InitialAssessmentCompleted bool `json:"initial_assessment_completed"` - // EmailVerified bool `json:"email_verified"` - // PhoneVerified bool `json:"phone_verified"` - ProfileCompleted bool `json:"profile_completed"` + ProfileCompleted bool `json:"profile_completed"` - // Media & preferences ProfilePictureURL string `json:"profile_picture_url"` PreferredLanguage string `json:"preferred_language"` } diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 093d815..1c874bf 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -126,7 +126,7 @@ func (s *Store) GetUserByEmailOrPhone( Password: u.Password, Role: domain.Role(u.Role), - Age: int(u.Age.Int32), + AgeGroup: u.AgeGroup.String, EducationLevel: u.EducationLevel.String, Country: u.Country.String, Region: u.Region.String, diff --git a/internal/repository/user.go b/internal/repository/user.go index 2dab2f7..bce49ff 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -84,7 +84,7 @@ func (s *Store) CreateUserWithoutOtp( Role: string(user.Role), Password: user.Password, - Age: pgtype.Int4{Int32: int32(user.Age), Valid: user.Age > 0}, + AgeGroup: pgtype.Text{String: user.AgeGroup, Valid: user.AgeGroup != ""}, EducationLevel: pgtype.Text{String: user.EducationLevel, Valid: user.EducationLevel != ""}, Country: pgtype.Text{String: user.Country, Valid: user.Country != ""}, Region: pgtype.Text{String: user.Region, Valid: user.Region != ""}, @@ -178,7 +178,7 @@ func (s *Store) CreateUser( Role: string(user.Role), Password: user.Password, - Age: pgtype.Int4{Int32: int32(user.Age), Valid: user.Age > 0}, + AgeGroup: pgtype.Text{String: user.AgeGroup, Valid: user.AgeGroup != ""}, EducationLevel: pgtype.Text{String: user.EducationLevel, Valid: user.EducationLevel != ""}, Country: pgtype.Text{String: user.Country, Valid: user.Country != ""}, Region: pgtype.Text{String: user.Region, Valid: user.Region != ""}, @@ -254,7 +254,7 @@ func (s *Store) GetUserByID( PhoneNumber: u.PhoneNumber.String, Role: domain.Role(u.Role), - Age: int(u.Age.Int32), + AgeGroup: u.AgeGroup.String, EducationLevel: u.EducationLevel.String, Country: u.Country.String, Region: u.Region.String, @@ -289,41 +289,56 @@ func (s *Store) GetAllUsers( limit, offset int32, ) ([]domain.User, int64, error) { - var roleParam sql.NullString - if role != nil && *role != "" { - roleParam = sql.NullString{String: *role, Valid: true} - } else { - roleParam = sql.NullString{Valid: false} // This will make $1 IS NULL work - } + // var roleParam sql.NullString + // if role != nil && *role != "" { + // roleParam = sql.NullString{String: *role, Valid: true} + // } else { + // roleParam = sql.NullString{Valid: false} // This will make $1 IS NULL work + // } - var queryParam sql.NullString - if query != nil && *query != "" { - queryParam = sql.NullString{String: *query, Valid: true} - } else { - queryParam = sql.NullString{Valid: false} - } + // var queryParam sql.NullString + // if query != nil && *query != "" { + // queryParam = sql.NullString{String: *query, Valid: true} + // } else { + // queryParam = sql.NullString{Valid: false} + // } - var createdAfterParam sql.NullTime - if createdAfter != nil { - createdAfterParam = sql.NullTime{Time: *createdAfter, Valid: true} - } else { - createdAfterParam = sql.NullTime{Valid: false} - } + // var createdAfterParam sql.NullTime + // if createdAfter != nil { + // createdAfterParam = sql.NullTime{Time: *createdAfter, Valid: true} + // } else { + // createdAfterParam = sql.NullTime{Valid: false} + // } - var createdBeforeParam sql.NullTime - if createdBefore != nil { - createdBeforeParam = sql.NullTime{Time: *createdBefore, Valid: true} - } else { - createdBeforeParam = sql.NullTime{Valid: false} - } + // var createdBeforeParam sql.NullTime + // if createdBefore != nil { + // createdBeforeParam = sql.NullTime{Time: *createdBefore, Valid: true} + // } else { + // createdBeforeParam = sql.NullTime{Valid: false} + // } params := dbgen.GetAllUsersParams{ - Column1: roleParam.String, - Column2: pgtype.Text{String: queryParam.String, Valid: queryParam.Valid}, - Column3: pgtype.Timestamptz{Time: createdAfterParam.Time, Valid: createdAfterParam.Valid}, - Column4: pgtype.Timestamptz{Time: createdBeforeParam.Time, Valid: createdBeforeParam.Valid}, - Limit: int32(limit), - Offset: int32(offset), + // Role: pgtype.Text{ + // String: roleParam.String, + // Valid: roleParam.String != "", + // }, + // Query: queryParam.String, + // CreatedAfter: pgtype.Timestamptz{ + // Time: createdAfterParam.Time, + // Valid: createdAfterParam.Valid, + // }, + // CreatedBefore: pgtype.Timestamptz{ + // Time: createdBeforeParam.Time, + // Valid: createdBeforeParam.Valid, + // }, + Limit: pgtype.Int4{ + Int32: limit, + Valid: true, + }, + Offset: pgtype.Int4{ + Int32: offset, + Valid: true, + }, } rows, err := s.queries.GetAllUsers(ctx, params) @@ -356,7 +371,7 @@ func (s *Store) GetAllUsers( PhoneNumber: u.PhoneNumber.String, Role: domain.Role(u.Role), - Age: int(u.Age.Int32), + AgeGroup: u.AgeGroup.String, EducationLevel: u.EducationLevel.String, Country: u.Country.String, Region: u.Region.String, @@ -443,7 +458,7 @@ func (s *Store) SearchUserByNameOrPhone( PhoneNumber: u.PhoneNumber.String, Role: domain.Role(u.Role), - Age: int(u.Age.Int32), + AgeGroup: u.AgeGroup.String, EducationLevel: u.EducationLevel.String, Country: u.Country.String, Region: u.Region.String, @@ -477,19 +492,27 @@ func (s *Store) UpdateUser( req domain.UpdateUserReq, ) error { + var birthDate pgtype.Date + if req.BirthDay != nil && *req.BirthDay != "" { + t, err := time.Parse("2006-01-02", *req.BirthDay) + if err != nil { + return err + } + birthDate = pgtype.Date{Time: t, Valid: true} + } + + var ageGroup pgtype.Text + if req.AgeGroup != nil { + ageGroup = pgtype.Text{String: string(*req.AgeGroup), Valid: true} + } + return s.queries.UpdateUser(ctx, dbgen.UpdateUserParams{ FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""}, LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, - Gender: pgtype.Text{ - String: req.Gender, - Valid: req.Gender != "", - }, - BirthDay: pgtype.Date{ - Time: req.BirthDay, - Valid: true, - }, - Age: pgtype.Int4{Int32: int32(req.Age), Valid: req.Age > 0}, + KnowledgeLevel: pgtype.Text{String: req.KnowledgeLevel, Valid: req.KnowledgeLevel != ""}, + AgeGroup: ageGroup, + EducationLevel: pgtype.Text{String: req.EducationLevel, Valid: req.EducationLevel != ""}, Country: pgtype.Text{String: req.Country, Valid: req.Country != ""}, Region: pgtype.Text{String: req.Region, Valid: req.Region != ""}, @@ -501,17 +524,17 @@ func (s *Store) UpdateUser( LanguageChallange: pgtype.Text{String: req.LanguageChallange, Valid: req.LanguageChallange != ""}, FavouriteTopic: pgtype.Text{String: req.FavouriteTopic, Valid: req.FavouriteTopic != ""}, - ProfileCompleted: pgtype.Bool{ - Bool: req.ProfileCompleted, - Valid: true, - }, + InitialAssessmentCompleted: req.InitialAssessmentCompleted, + ProfileCompleted: pgtype.Bool{Bool: req.ProfileCompleted, Valid: true}, ProfilePictureUrl: pgtype.Text{String: req.ProfilePictureURL, Valid: req.ProfilePictureURL != ""}, PreferredLanguage: pgtype.Text{String: req.PreferredLanguage, Valid: req.PreferredLanguage != ""}, + Gender: pgtype.Text{String: req.Gender, Valid: req.Gender != ""}, + BirthDay: birthDate, + ID: req.UserID, }) - } // DeleteUser removes a user @@ -637,7 +660,7 @@ func (s *Store) GetUserByEmailPhone( Password: u.Password, Role: domain.Role(u.Role), - Age: int(u.Age.Int32), + AgeGroup: u.AgeGroup.String, EducationLevel: u.EducationLevel.String, Country: u.Country.String, Region: u.Region.String, @@ -690,7 +713,7 @@ func mapCreateUserResult( Role: domain.Role(userRes.Role), Password: password, - Age: int(userRes.Age.Int32), + AgeGroup: userRes.AgeGroup.String, EducationLevel: userRes.EducationLevel.String, Country: userRes.Country.String, Region: userRes.Region.String, diff --git a/internal/services/arifpay/service.go b/internal/services/arifpay/service.go index 394f6f0..fe6c545 100644 --- a/internal/services/arifpay/service.go +++ b/internal/services/arifpay/service.go @@ -30,6 +30,7 @@ func NewArifpayService(cfg *config.Config, transactionSvc transaction.Service, h } } + func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool, userId int64) (map[string]any, error) { // Generate unique nonce nonce := uuid.NewString() @@ -42,6 +43,7 @@ func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientR NotifyURL = s.cfg.ARIFPAY.B2CNotifyUrl } + // Construct full checkout request checkoutReq := domain.CheckoutSessionRequest{ CancelURL: s.cfg.ARIFPAY.CancelUrl, diff --git a/internal/services/authentication/impl.go b/internal/services/authentication/impl.go index 0318690..fa89c26 100644 --- a/internal/services/authentication/impl.go +++ b/internal/services/authentication/impl.go @@ -20,67 +20,136 @@ var ( ErrUserSuspended = errors.New("user has been suspended") ) -type LoginSuccess struct { - UserId int64 - Role domain.Role - RfToken string -} - -type LoginRequest struct { - Email string `json:"email"` - PhoneNumber string `json:"phone_number"` - Password string `json:"password"` - OTPCode string `json:"otp_code"` -} - func (s *Service) Login( ctx context.Context, - req LoginRequest, -) (LoginSuccess, error) { + req domain.LoginRequest, +) (domain.LoginSuccess, error) { - // Try to find user by username first user, err := s.userStore.GetUserByEmailPhone(ctx, req.Email, req.PhoneNumber) if err != nil { - // If not found by username, try email or phone lookup using the same identifier - return LoginSuccess{}, err + return domain.LoginSuccess{}, err } if user.Status == domain.UserStatusPending { - return LoginSuccess{}, domain.ErrUserNotVerified + return domain.LoginSuccess{}, domain.ErrUserNotVerified } - // Status check instead of Suspended if user.Status == domain.UserStatusSuspended { - return LoginSuccess{}, ErrUserSuspended + return domain.LoginSuccess{}, ErrUserSuspended } + // Email + password login if req.Email != "" { if err := matchPassword(req.Password, user.Password); err != nil { - return LoginSuccess{}, err + return domain.LoginSuccess{}, err } - } else if req.PhoneNumber != "" { - if err := s.UserSvc.VerifyOtp(ctx, req.Email, req.PhoneNumber, req.OTPCode); err != nil { - return LoginSuccess{}, err + + oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID) + if err != nil && !errors.Is(err, ErrRefreshTokenNotFound) { + return domain.LoginSuccess{}, err + } + + if err == nil && !oldRefreshToken.Revoked { + if err := s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token); err != nil { + return domain.LoginSuccess{}, err + } + } + + refreshToken, err := generateRefreshToken() + if err != nil { + return domain.LoginSuccess{}, err + } + + if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ + Token: refreshToken, + UserID: user.ID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), + }); err != nil { + return domain.LoginSuccess{}, err + } + + return domain.LoginSuccess{ + UserId: user.ID, + Role: user.Role, + RfToken: refreshToken, + }, nil + } + + // Phone + OTP login + if req.PhoneNumber != "" { + return s.VerifyOtp(ctx, req.Email, req.PhoneNumber, req.OTPCode) + } + + // ❗ Mandatory fallback return + return domain.LoginSuccess{}, ErrInvalidPassword +} + +func (s *Service) VerifyOtp( + ctx context.Context, + email, phone, otpCode string, +) (domain.LoginSuccess, error) { + + user, err := s.userStore.GetUserByEmailPhone(ctx, email, phone) + if err != nil { + return domain.LoginSuccess{}, err + } + + // 1. Retrieve OTP + storedOtp, err := s.otpStore.GetOtp(ctx, user.ID) + if err != nil { + return domain.LoginSuccess{}, err + } + + // 2. Already used + if storedOtp.Used { + return domain.LoginSuccess{}, domain.ErrOtpAlreadyUsed + } + + // 3. Expired + if time.Now().After(storedOtp.ExpiresAt) { + return domain.LoginSuccess{}, domain.ErrOtpExpired + } + + // 4. Invalid + if storedOtp.Otp != otpCode { + return domain.LoginSuccess{}, domain.ErrInvalidOtp + } + + // 5. Mark OTP as used + storedOtp.Used = true + storedOtp.UsedAt = timePtr(time.Now()) + + if err := s.otpStore.MarkOtpAsUsed(ctx, storedOtp); err != nil { + return domain.LoginSuccess{}, err + } + + // 6. Activate user if still pending + if user.Status == domain.UserStatusPending { + if err := s.userStore.UpdateUserStatus(ctx, domain.UpdateUserStatusReq{ + UserID: user.ID, + Status: string(domain.UserStatusActive), + }); err != nil { + return domain.LoginSuccess{}, err } } - // Handle existing refresh token + // 7. Handle existing refresh token oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID) if err != nil && !errors.Is(err, ErrRefreshTokenNotFound) { - return LoginSuccess{}, err + return domain.LoginSuccess{}, err } - // Revoke if exists and not revoked if err == nil && !oldRefreshToken.Revoked { if err := s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token); err != nil { - return LoginSuccess{}, err + return domain.LoginSuccess{}, err } } - // Generate new refresh token + // 8. Generate new refresh token refreshToken, err := generateRefreshToken() if err != nil { - return LoginSuccess{}, err + return domain.LoginSuccess{}, err } if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ @@ -89,17 +158,22 @@ func (s *Service) Login( CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), }); err != nil { - return LoginSuccess{}, err + return domain.LoginSuccess{}, err } - // Return login success payload - return LoginSuccess{ + // 9. Return success payload + return domain.LoginSuccess{ UserId: user.ID, Role: user.Role, RfToken: refreshToken, }, nil } +// helper function to get a pointer to time.Time +func timePtr(t time.Time) time.Time { + return t +} + func (s *Service) RefreshToken(ctx context.Context, refToken string) (domain.RefreshToken, error) { token, err := s.tokenStore.GetRefreshToken(ctx, refToken) diff --git a/internal/services/authentication/service.go b/internal/services/authentication/service.go index 30d0e96..eb76bf7 100644 --- a/internal/services/authentication/service.go +++ b/internal/services/authentication/service.go @@ -19,14 +19,16 @@ type Tokens struct { RefreshToken string } type Service struct { + otpStore ports.OtpStore userStore ports.UserStore UserSvc user.Service tokenStore ports.TokenStore RefreshExpiry int } -func NewService(userStore ports.UserStore, userSvc user.Service, tokenStore ports.TokenStore, RefreshExpiry int) *Service { +func NewService(otpStore ports.OtpStore, userStore ports.UserStore, userSvc user.Service, tokenStore ports.TokenStore, RefreshExpiry int) *Service { return &Service{ + otpStore: otpStore, userStore: userStore, UserSvc: userSvc, tokenStore: tokenStore, diff --git a/internal/services/user/common.go b/internal/services/user/common.go index 6cfb130..1408ab8 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -10,56 +10,6 @@ import ( "golang.org/x/crypto/bcrypt" ) -func (s *Service) VerifyOtp(ctx context.Context, email, phone, otpCode string) error { - - user, err := s.userStore.GetUserByEmailPhone(ctx, email, phone) - if err != nil { - return err - } - // 1. Retrieve the OTP from the store - storedOtp, err := s.otpStore.GetOtp(ctx, user.ID) - if err != nil { - return err // could be ErrOtpNotFound or other DB errors - } - - // 2. Check if OTP was already used - if storedOtp.Used { - return domain.ErrOtpAlreadyUsed - } - - // 3. Check if OTP has expired - if time.Now().After(storedOtp.ExpiresAt) { - return domain.ErrOtpExpired - } - - // 4. Check if the provided OTP matches - if storedOtp.Otp != otpCode { - return domain.ErrInvalidOtp - } - - // 5. Mark OTP as used - storedOtp.Used = true - storedOtp.UsedAt = timePtr(time.Now()) - - if err := s.otpStore.MarkOtpAsUsed(ctx, storedOtp); err != nil { - return err - } - - // user, err := s.userStore.GetUserByUserName(ctx, userName) - // if err != nil { - // return err - // } - - newUser := domain.UpdateUserStatusReq{ - UserID: user.ID, - Status: string(domain.UserStatusActive), - } - - s.userStore.UpdateUserStatus(ctx, newUser) - - return nil -} - func (s *Service) ResendOtp( ctx context.Context, email, phone string, @@ -150,10 +100,6 @@ func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpF return s.otpStore.CreateOtp(ctx, otp) } -// helper function to get a pointer to time.Time -func timePtr(t time.Time) time.Time { - return t -} func hashPassword(plaintextPassword string) ([]byte, error) { hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 13af1be..7e38761 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -3,6 +3,7 @@ package user import ( "Yimaru-Backend/internal/domain" "context" + "time" ) func (s *Service) UpdateUserKnowledgeLevel(ctx context.Context, userID int64, knowledgeLevel string) error { @@ -23,8 +24,8 @@ func (s *Service) CreateUser( // Create the user return s.userStore.CreateUserWithoutOtp(ctx, domain.User{ - FirstName: req.FirstName, - LastName: req.LastName, + FirstName: req.FirstName, + LastName: req.LastName, // UserName: req.UserName, Email: req.Email, PhoneNumber: req.PhoneNumber, @@ -33,7 +34,7 @@ func (s *Service) CreateUser( EmailVerified: true, // assuming auto-verified on creation PhoneVerified: true, Status: domain.UserStatusActive, - Age: req.Age, + AgeGroup: req.AgeGroup, EducationLevel: req.EducationLevel, Country: req.Country, Region: req.Region, @@ -46,10 +47,44 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error { return s.userStore.DeleteUser(ctx, id) } -func (s *Service) GetAllUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) { - // Get all Users - return s.userStore.GetAllUsers(ctx, &filter.Role, &filter.Query, &filter.CreatedBefore.Value, &filter.CreatedAfter.Value, int32(filter.PageSize), int32(filter.Page)) +func (s *Service) GetAllUsers( + ctx context.Context, + filter domain.UserFilter, +) ([]domain.User, int64, error) { + + var before *time.Time + if filter.CreatedBefore.Valid { + before = &filter.CreatedBefore.Value + } + + var after *time.Time + if filter.CreatedAfter.Valid { + after = &filter.CreatedAfter.Value + } + + var role *string + if filter.Role != "" { + role = &filter.Role + } + + var query *string + if filter.Query != "" { + query = &filter.Query + } + + offset := int32(filter.Page * filter.PageSize) + + return s.userStore.GetAllUsers( + ctx, + role, + query, + before, + after, + int32(filter.PageSize), + offset, + ) } + func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) { return s.userStore.GetUserByID(ctx, id) diff --git a/internal/services/user/service.go b/internal/services/user/service.go index 27f77cd..aacef04 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -12,6 +12,7 @@ const ( ) type Service struct { + tokenStore ports.TokenStore userStore ports.UserStore otpStore ports.OtpStore messengerSvc *messenger.Service @@ -19,12 +20,14 @@ type Service struct { } func NewService( + tokenStore ports.TokenStore, userStore ports.UserStore, otpStore ports.OtpStore, messengerSvc *messenger.Service, cfg *config.Config, ) *Service { return &Service{ + tokenStore: tokenStore, userStore: userStore, otpStore: otpStore, messengerSvc: messengerSvc, diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index b4b0919..5035a10 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -37,14 +37,14 @@ type loginUserRes struct { // @Tags auth // @Accept json // @Produce json -// @Param login body authentication.LoginRequest true "Login user" +// @Param login body domain.LoginRequest true "Login user" // @Success 200 {object} loginUserRes // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /api/v1/{tenant_slug}/user-login [post] func (h *Handler) LoginUser(c *fiber.Ctx) error { - var req authentication.LoginRequest + var req domain.LoginRequest if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse LoginUser request", zap.Int("status_code", fiber.StatusBadRequest), @@ -180,14 +180,14 @@ type LoginAdminRes struct { // @Tags auth // @Accept json // @Produce json -// @Param login body authentication.LoginRequest true "Login admin" +// @Param login body domain.LoginRequest true "Login admin" // @Success 200 {object} LoginAdminRes // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /api/v1/{tenant_slug}/admin-login [post] func (h *Handler) LoginAdmin(c *fiber.Ctx) error { - var req authentication.LoginRequest + var req domain.LoginRequest if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request", zap.Int("status_code", fiber.StatusBadRequest), @@ -205,7 +205,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, errMsg) } - successRes, err := h.authSvc.Login(c.Context(), authentication.LoginRequest(req)) + successRes, err := h.authSvc.Login(c.Context(), domain.LoginRequest(req)) if err != nil { switch { case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): @@ -279,14 +279,14 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error { // @Tags auth // @Accept json // @Produce json -// @Param login body authentication.LoginRequest true "Login super-admin" +// @Param login body domain.LoginRequest true "Login super-admin" // @Success 200 {object} LoginAdminRes // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /api/v1/super-login [post] func (h *Handler) LoginSuper(c *fiber.Ctx) error { - var req authentication.LoginRequest + var req domain.LoginRequest if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request", zap.Int("status_code", fiber.StatusBadRequest), @@ -304,7 +304,7 @@ func (h *Handler) LoginSuper(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, errMsg) } - successRes, err := h.authSvc.Login(c.Context(), authentication.LoginRequest(req)) + successRes, err := h.authSvc.Login(c.Context(), domain.LoginRequest(req)) if err != nil { switch { case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index e66745e..4017b18 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -390,7 +390,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error { // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse -// @Router /api/v1/{tenant_slug}/users [get] +// @Router /api/v1/users [get] func (h *Handler) GetAllUsers(c *fiber.Ctx) error { searchQuery := c.Query("query") searchString := domain.ValidString{ @@ -452,38 +452,38 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { } // Map to profile response to avoid leaking sensitive fields - result := make([]domain.UserProfileResponse, len(users)) - for i, u := range users { - result[i] = domain.UserProfileResponse{ - ID: u.ID, - FirstName: u.FirstName, - LastName: u.LastName, - // UserName: u.UserName, - Email: u.Email, - PhoneNumber: u.PhoneNumber, - Role: u.Role, - Age: u.Age, - EducationLevel: u.EducationLevel, - Country: u.Country, - Region: u.Region, - NickName: u.NickName, - Occupation: u.Occupation, - LearningGoal: u.LearningGoal, - LanguageGoal: u.LanguageGoal, - LanguageChallange: u.LanguageChallange, - FavouriteTopic: u.FavouriteTopic, - EmailVerified: u.EmailVerified, - PhoneVerified: u.PhoneVerified, - LastLogin: u.LastLogin, - ProfileCompleted: u.ProfileCompleted, - ProfilePictureURL: u.ProfilePictureURL, - PreferredLanguage: u.PreferredLanguage, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - } - } + // result := make([]domain.UserProfileResponse, len(users)) + // for i, u := range users { + // result[i] = domain{ + // ID: u.ID, + // FirstName: u.FirstName, + // LastName: u.LastName, + // Gender: u.Gender, + // Email: u.Email, + // PhoneNumber: u.PhoneNumber, + // Role: u.Role, + // Age: u.Age, + // EducationLevel: u.EducationLevel, + // Country: u.Country, + // Region: u.Region, + // NickName: u.NickName, + // Occupation: u.Occupation, + // LearningGoal: u.LearningGoal, + // LanguageGoal: u.LanguageGoal, + // LanguageChallange: u.LanguageChallange, + // FavouriteTopic: u.FavouriteTopic, + // EmailVerified: u.EmailVerified, + // PhoneVerified: u.PhoneVerified, + // LastLogin: u.LastLogin, + // ProfileCompleted: u.ProfileCompleted, + // ProfilePictureURL: u.ProfilePictureURL, + // PreferredLanguage: u.PreferredLanguage, + // CreatedAt: u.CreatedAt, + // UpdatedAt: u.UpdatedAt, + // } + // } - return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": result, "total": total}, nil) + return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": users, "total": total}, nil) } // VerifyOtp godoc @@ -523,7 +523,7 @@ func (h *Handler) VerifyOtp(c *fiber.Ctx) error { } // Call service to verify OTP - err := h.userSvc.VerifyOtp(c.Context(), req.Email, req.PhoneNumber, req.Otp) + loginSuccess, err := h.authSvc.VerifyOtp(c.Context(), req.Email, req.PhoneNumber, req.Otp) if err != nil { var errMsg string switch { @@ -555,7 +555,7 @@ func (h *Handler) VerifyOtp(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "OTP verified successfully", - Data: nil, + Data: loginSuccess, }) } @@ -1214,7 +1214,7 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error { Email: user.Email, PhoneNumber: user.PhoneNumber, Role: user.Role, - Age: user.Age, + AgeGroup: user.AgeGroup, EducationLevel: user.EducationLevel, Country: user.Country, Region: user.Region, @@ -1303,7 +1303,7 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error { Email: user.Email, PhoneNumber: user.PhoneNumber, Role: user.Role, - Age: user.Age, + AgeGroup: user.AgeGroup, EducationLevel: user.EducationLevel, Country: user.Country, Region: user.Region, @@ -1425,7 +1425,7 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error { Email: user.Email, PhoneNumber: user.PhoneNumber, Role: user.Role, - Age: user.Age, + AgeGroup: user.AgeGroup, EducationLevel: user.EducationLevel, Country: user.Country, Region: user.Region, @@ -1500,14 +1500,22 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error { // } res := domain.UserProfileResponse{ - ID: user.ID, - FirstName: user.FirstName, - LastName: user.LastName, + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + LearningGoal: user.LearningGoal, + LanguageGoal: user.LanguageGoal, + LanguageChallange: user.LanguageChallange, + Gender: user.Gender, + InitialAssessmentCompleted: user.InitialAssessmentCompleted, + NickName: user.NickName, + Occupation: user.Occupation, + FavouriteTopic: user.FavouriteTopic, // UserName: user.UserName, Email: user.Email, PhoneNumber: user.PhoneNumber, Role: user.Role, - Age: user.Age, + AgeGroup: user.AgeGroup, EducationLevel: user.EducationLevel, Country: user.Country, Region: user.Region,