changed age to agegroup, added refresh route, token generation after otp verification

This commit is contained in:
Yared Yemane 2026-01-18 03:12:28 -08:00
parent 513927f48f
commit 9ee1d7f714
19 changed files with 1014 additions and 550 deletions

285
README.md
View File

@ -69,3 +69,288 @@ cd Yimaru-backend
├── makefile # Development and operations commands ├── makefile # Development and operations commands
├── .env # Environment configuration file ├── .env # Environment configuration file
└── README.md # Project documentation └── 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

View File

@ -4,6 +4,7 @@ import (
// "context" // "context"
// "context" // "context"
_ "Yimaru-Backend/docs"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
customlogger "Yimaru-Backend/internal/logger" customlogger "Yimaru-Backend/internal/logger"
@ -91,6 +92,7 @@ func main() {
// ) // )
userSvc := user.NewService( userSvc := user.NewService(
repository.NewTokenStore(store),
repository.NewUserStore(store), repository.NewUserStore(store),
repository.NewOTPStore(store), repository.NewOTPStore(store),
messengerSvc, messengerSvc,
@ -98,6 +100,7 @@ func main() {
) )
authSvc := authentication.NewService( authSvc := authentication.NewService(
repository.NewOTPStore(store),
repository.NewUserStore(store), repository.NewUserStore(store),
*userSvc, *userSvc,
repository.NewTokenStore(store), repository.NewTokenStore(store),

View File

@ -5,70 +5,133 @@ INSERT INTO users (
id, id,
first_name, first_name,
last_name, last_name,
-- user_name, gender,
birth_day,
email, email,
phone_number, phone_number,
role, role,
password, 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, email_verified,
phone_verified, phone_verified,
status,
last_login,
profile_completed, profile_completed,
profile_picture_url,
preferred_language, preferred_language,
created_at created_at,
updated_at
) )
VALUES VALUES
( (
10, 10,
'Demo', 'Demo',
'Student', 'Student',
-- 'demo_student', 'Male',
'2000-01-01',
'student10@yimaru.com', 'student10@yimaru.com',
NULL, NULL,
'USER', 'USER',
crypt('password@123', gen_salt('bf'))::bytea, crypt('password@123', gen_salt('bf'))::bytea,
'ACTIVE', 22,
'Bachelor',
'Ethiopia',
'Addis Ababa',
'BEGINNER',
'Demo',
'Student',
'Learn programming',
'English',
'Grammar',
'Technology',
FALSE,
TRUE, TRUE,
FALSE, FALSE,
'ACTIVE',
NULL,
FALSE, FALSE,
NULL,
'en', 'en',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP,
NULL
), ),
( (
11, 11,
'System', 'System',
'Admin', 'Admin',
-- 'sys_admin', 'Female',
'1995-01-01',
'admin@yimaru.com', 'admin@yimaru.com',
'0911001100', '0911001100',
'ADMIN', 'ADMIN',
crypt('password@123', gen_salt('bf'))::bytea, crypt('password@123', gen_salt('bf'))::bytea,
28,
'Master',
'Ethiopia',
'Addis Ababa',
'ADVANCED',
'SysAdmin',
'Administrator',
'Manage system',
'English',
'Writing',
'Management',
TRUE,
TRUE,
TRUE,
'ACTIVE', 'ACTIVE',
NULL,
TRUE, TRUE,
TRUE, NULL,
TRUE,
'en', 'en',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP,
NULL
), ),
( (
12, 12,
'Support', 'Support',
'Agent', 'Agent',
-- 'support_agent', 'Female',
'1998-01-01',
'support@yimaru.com', 'support@yimaru.com',
'0911223344', '0911223344',
'SUPPORT', 'SUPPORT',
crypt('password@123', gen_salt('bf'))::bytea, 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', 'ACTIVE',
NULL,
TRUE, TRUE,
TRUE, NULL,
TRUE,
'en', 'en',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP,
NULL
) )
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ====================================================== -- ======================================================
-- Global Settings (LMS) -- Global Settings (LMS)
-- ====================================================== -- ======================================================

View File

@ -1,244 +1,267 @@
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
first_name VARCHAR(255), first_name VARCHAR(255),
last_name VARCHAR(255), last_name VARCHAR(255),
gender VARCHAR(255), gender VARCHAR(255),
birth_day DATE, birth_day DATE,
email VARCHAR(255), email VARCHAR(255),
phone_number VARCHAR(20), phone_number VARCHAR(20),
role VARCHAR(50) NOT NULL, -- SUPER_ADMIN, INSTRUCTOR, STUDENT, SUPPORT role VARCHAR(50) NOT NULL, -- SUPER_ADMIN, INSTRUCTOR, STUDENT, SUPPORT
password BYTEA NOT NULL, password BYTEA NOT NULL,
age INT, age INT,
education_level VARCHAR(100), education_level VARCHAR(100),
country VARCHAR(100), country VARCHAR(100),
region VARCHAR(100), region VARCHAR(100),
knowledge_level VARCHAR(50), -- BEGINNER, INTERMEDIATE, ADVANCED knowledge_level VARCHAR(50), -- BEGINNER, INTERMEDIATE, ADVANCED
nick_name VARCHAR(100), nick_name VARCHAR(100),
occupation VARCHAR(150), occupation VARCHAR(150),
learning_goal TEXT, learning_goal TEXT,
language_goal TEXT, language_goal TEXT,
language_challange TEXT, language_challange TEXT,
favourite_topic TEXT, favourite_topic TEXT,
initial_assessment_completed BOOLEAN NOT NULL DEFAULT FALSE, initial_assessment_completed BOOLEAN NOT NULL DEFAULT FALSE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE, email_verified BOOLEAN NOT NULL DEFAULT FALSE,
phone_verified BOOLEAN NOT NULL DEFAULT FALSE, phone_verified BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(50) NOT NULL, -- PENDING, ACTIVE, SUSPENDED, DEACTIVATED status VARCHAR(50) NOT NULL, -- PENDING, ACTIVE, SUSPENDED, DEACTIVATED
last_login TIMESTAMPTZ, last_login TIMESTAMPTZ,
profile_completed BOOLEAN, profile_completed BOOLEAN,
profile_picture_url TEXT, profile_picture_url TEXT,
preferred_language VARCHAR(50), preferred_language VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
-- Enforce: at least one contact method must be provided -- Enforce: at least one contact method must be provided
CONSTRAINT users_email_or_phone_required CONSTRAINT users_email_or_phone_required
CHECK (email IS NOT NULL OR phone_number IS NOT NULL) CHECK (email IS NOT NULL OR phone_number IS NOT NULL)
); );
CREATE TABLE IF NOT EXISTS assessment_questions ( -- Remove the old column
id BIGSERIAL PRIMARY KEY, ALTER TABLE users
DROP COLUMN age;
title TEXT NOT NULL, -- Add age_group with constrained values
description TEXT, ALTER TABLE users
ADD COLUMN age_group VARCHAR(20);
question_type VARCHAR(50) NOT NULL, ALTER TABLE users
-- MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER 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), CREATE TABLE IF NOT EXISTS assessment_questions (
-- EASY, MEDIUM, HARD 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, difficulty_level VARCHAR(50),
updated_at TIMESTAMPTZ -- EASY, MEDIUM, HARD
);
CREATE TABLE IF NOT EXISTS assessment_question_options ( points INT NOT NULL DEFAULT 1,
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
option_text TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
option_order INT NOT NULL, 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 ( is_correct BOOLEAN NOT NULL DEFAULT FALSE,
id BIGSERIAL PRIMARY KEY,
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 question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE,
ADD CONSTRAINT chk_question_type
CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER'));
CREATE TABLE IF NOT EXISTS assessment_attempts ( correct_answer TEXT NOT NULL,
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
total_questions INT NOT NULL, ALTER TABLE assessment_questions
total_points INT NOT NULL, ADD CONSTRAINT chk_question_type
CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER'));
score INT, CREATE TABLE IF NOT EXISTS assessment_attempts (
percentage NUMERIC(5,2), id BIGSERIAL PRIMARY KEY,
status VARCHAR(50) NOT NULL, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- IN_PROGRESS, SUBMITTED, EVALUATED
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, total_questions INT NOT NULL,
submitted_at TIMESTAMPTZ, total_points INT NOT NULL,
evaluated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, score INT,
updated_at TIMESTAMPTZ percentage NUMERIC(5,2),
);
CREATE TABLE IF NOT EXISTS assessment_attempt_questions ( status VARCHAR(50) NOT NULL,
id BIGSERIAL PRIMARY KEY, -- IN_PROGRESS, SUBMITTED, EVALUATED
attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
question_id BIGINT NOT NULL REFERENCES assessment_questions(id), submitted_at TIMESTAMPTZ,
evaluated_at TIMESTAMPTZ,
question_type VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
points INT NOT NULL, 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 ( question_type VARCHAR(50) NOT NULL,
id BIGSERIAL PRIMARY KEY, points INT NOT NULL,
attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE,
-- For MCQ / TRUE_FALSE UNIQUE (attempt_id, question_id)
selected_option_id BIGINT );
REFERENCES assessment_question_options(id),
-- For SHORT_ANSWER CREATE TABLE IF NOT EXISTS assessment_attempt_answers (
submitted_text TEXT, id BIGSERIAL PRIMARY KEY,
is_correct BOOLEAN, attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE,
awarded_points INT NOT NULL DEFAULT 0, 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 ( is_correct BOOLEAN,
(selected_option_id IS NOT NULL AND submitted_text IS NULL) awarded_points INT NOT NULL DEFAULT 0,
OR
(selected_option_id IS NULL AND submitted_text IS NOT NULL)
)
);
ALTER TABLE assessment_attempts created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD CONSTRAINT chk_attempt_status
CHECK (status IN ('IN_PROGRESS', 'SUBMITTED', 'EVALUATED'));
ALTER TABLE assessment_attempt_questions UNIQUE (attempt_id, question_id),
ADD CONSTRAINT chk_attempt_question_type
CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER'));
CREATE TABLE refresh_tokens ( CHECK (
id BIGSERIAL PRIMARY KEY, (selected_option_id IS NOT NULL AND submitted_text IS NULL)
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, OR
token TEXT NOT NULL UNIQUE, (selected_option_id IS NULL AND submitted_text IS NOT NULL)
expires_at TIMESTAMPTZ NOT NULL, )
revoked BOOLEAN NOT NULL DEFAULT FALSE, );
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE otps ( ALTER TABLE assessment_attempts
id BIGSERIAL PRIMARY KEY, ADD CONSTRAINT chk_attempt_status
user_id BIGSERIAL NOT NULL, CHECK (status IN ('IN_PROGRESS', 'SUBMITTED', 'EVALUATED'));
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
);
CREATE TABLE IF NOT EXISTS notifications ( ALTER TABLE assessment_attempt_questions
id BIGSERIAL PRIMARY KEY, ADD CONSTRAINT chk_attempt_question_type
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, CHECK (question_type IN ('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER'));
type TEXT NOT NULL CHECK ( CREATE TABLE refresh_tokens (
type IN ( id BIGSERIAL PRIMARY KEY,
'course_enrolled', user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
'lesson_completed', token TEXT NOT NULL UNIQUE,
'assessment_assigned', expires_at TIMESTAMPTZ NOT NULL,
'assessment_submitted', revoked BOOLEAN NOT NULL DEFAULT FALSE,
'assessment_graded', created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
'course_completed', );
'certificate_issued',
'announcement',
'otp_sent',
'signup_welcome',
'system_alert'
)
),
level TEXT NOT NULL CHECK ( CREATE TABLE otps (
level IN ('info', 'warning', 'success', 'error') 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 ( CREATE TABLE IF NOT EXISTS notifications (
channel IN ('email', 'sms', 'push', 'in_app') id BIGSERIAL PRIMARY KEY,
), user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL, type TEXT NOT NULL CHECK (
message TEXT NOT NULL, 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, level TEXT NOT NULL CHECK (
is_read BOOLEAN NOT NULL DEFAULT FALSE, level IN ('info', 'warning', 'success', 'error')
),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, channel TEXT CHECK (
read_at TIMESTAMPTZ channel IN ('email', 'sms', 'push', 'in_app')
); ),
CREATE TABLE global_settings ( title TEXT NOT NULL,
key TEXT PRIMARY KEY, message TEXT NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, payload JSONB,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 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
);

View File

@ -5,6 +5,7 @@ FROM users
WHERE id = $1 WHERE id = $1
LIMIT 1; LIMIT 1;
-- name: IsProfileCompleted :one -- name: IsProfileCompleted :one
SELECT SELECT
CASE WHEN profile_completed = true THEN true ELSE false END AS is_pending CASE WHEN profile_completed = true THEN true ELSE false END AS is_pending
@ -12,6 +13,7 @@ FROM users
WHERE id = $1 WHERE id = $1
LIMIT 1; LIMIT 1;
-- name: IsUserNameUnique :one -- name: IsUserNameUnique :one
SELECT SELECT
CASE WHEN COUNT(*) = 0 THEN true ELSE false END AS is_unique CASE WHEN COUNT(*) = 0 THEN true ELSE false END AS is_unique
@ -19,6 +21,7 @@ FROM users
WHERE id = $1; WHERE id = $1;
-- name: CreateUser :one -- name: CreateUser :one
INSERT INTO users ( INSERT INTO users (
first_name, first_name,
@ -29,7 +32,7 @@ INSERT INTO users (
phone_number, phone_number,
role, role,
password, password,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -51,33 +54,33 @@ INSERT INTO users (
updated_at updated_at
) )
VALUES ( VALUES (
$1, -- first_name $1,
$2, -- last_name $2,
$3, -- gender $3,
$4, -- birth_day $4,
$5, -- email $5,
$6, -- phone_number $6,
$7, -- role $7,
$8, -- password $8,
$9, -- age $9, -- age_group
$10, -- education_level $10,
$11, -- country $11,
$12, -- region $12,
$13, -- nick_name $13,
$14, -- occupation $14,
$15, -- learning_goal $15,
$16, -- language_goal $16,
$17, -- language_challange $17,
$18, -- favourite_topic $18,
$19, -- initial_assessment_completed $19,
$20, -- email_verified $20,
$21, -- phone_verified $21,
$22, -- status $22,
$23, -- profile_completed $23,
$24, -- profile_picture_url $24,
$25, -- preferred_language $25,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
RETURNING RETURNING
@ -89,7 +92,7 @@ RETURNING
email, email,
phone_number, phone_number,
role, role,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -111,11 +114,13 @@ RETURNING
created_at, created_at,
updated_at; updated_at;
-- name: GetUserByID :one -- name: GetUserByID :one
SELECT * SELECT *
FROM users FROM users
WHERE id = $1; WHERE id = $1;
-- name: GetAllUsers :many -- name: GetAllUsers :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -127,10 +132,11 @@ SELECT
email, email,
phone_number, phone_number,
role, role,
age, age_group,
education_level, education_level,
country, country,
region, region,
knowledge_level,
nick_name, nick_name,
occupation, occupation,
learning_goal, learning_goal,
@ -138,30 +144,26 @@ SELECT
language_challange, language_challange,
favourite_topic, favourite_topic,
initial_assessment_completed, initial_assessment_completed,
profile_picture_url,
preferred_language,
email_verified, email_verified,
phone_verified, phone_verified,
status, status,
last_login,
profile_completed, profile_completed,
profile_picture_url,
preferred_language,
created_at, created_at,
updated_at updated_at
FROM users FROM users
WHERE ($1 IS NULL OR role = $1) LIMIT sqlc.narg('limit')::INT
AND ($2 IS NULL OR first_name ILIKE '%' || $2 || '%' OFFSET sqlc.narg('offset')::INT;
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;
-- name: GetTotalUsers :one -- name: GetTotalUsers :one
SELECT COUNT(*) SELECT COUNT(*)
FROM users FROM users
WHERE (role = $1 OR $1 IS NULL); WHERE (role = $1 OR $1 IS NULL);
-- name: SearchUserByNameOrPhone :many -- name: SearchUserByNameOrPhone :many
SELECT SELECT
id, id,
@ -172,7 +174,7 @@ SELECT
email, email,
phone_number, phone_number,
role, role,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -205,17 +207,14 @@ WHERE (
OR sqlc.narg('role') IS NULL OR sqlc.narg('role') IS NULL
); );
-- name: UpdateUser :exec -- name: UpdateUser :exec
UPDATE users UPDATE users
SET SET
first_name = COALESCE($1, first_name), first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name), last_name = COALESCE($2, last_name),
-- email = COALESCE($3, email),
-- phone_number = COALESCE($4, phone_number),
knowledge_level = COALESCE($3, knowledge_level), knowledge_level = COALESCE($3, knowledge_level),
age = COALESCE($4, age), age_group = COALESCE($4, age_group),
education_level = COALESCE($5, education_level), education_level = COALESCE($5, education_level),
country = COALESCE($6, country), country = COALESCE($6, country),
region = COALESCE($7, region), region = COALESCE($7, region),
@ -226,21 +225,19 @@ SET
language_challange = COALESCE($12, language_challange), language_challange = COALESCE($12, language_challange),
favourite_topic = COALESCE($13, favourite_topic), favourite_topic = COALESCE($13, favourite_topic),
initial_assessment_completed = COALESCE($14, initial_assessment_completed), 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_completed = COALESCE($15, profile_completed),
profile_picture_url = COALESCE($16, profile_picture_url), profile_picture_url = COALESCE($16, profile_picture_url),
preferred_language = COALESCE($17, preferred_language), preferred_language = COALESCE($17, preferred_language),
gender = COALESCE($18, gender), gender = COALESCE($18, gender),
birth_day = COALESCE($19, gender), birth_day = COALESCE($19, birth_day),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $20; WHERE id = $20;
-- name: DeleteUser :exec -- name: DeleteUser :exec
DELETE FROM users DELETE FROM users
WHERE id = $1; WHERE id = $1;
-- name: CheckPhoneEmailExist :one -- name: CheckPhoneEmailExist :one
SELECT SELECT
EXISTS ( EXISTS (
@ -250,6 +247,7 @@ SELECT
SELECT 1 FROM users u2 WHERE u2.email = $2 SELECT 1 FROM users u2 WHERE u2.email = $2
) AS email_exists; ) AS email_exists;
-- -- name: GetUserByUserName :one -- -- name: GetUserByUserName :one
-- SELECT -- SELECT
-- id, -- id,
@ -295,7 +293,7 @@ SELECT
phone_number, phone_number,
role, role,
password, password,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -341,3 +339,4 @@ SET
knowledge_level = $1, knowledge_level = $1,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $2; WHERE id = $2;

View File

@ -221,7 +221,6 @@ type User struct {
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
Role string `json:"role"` Role string `json:"role"`
Password []byte `json:"password"` Password []byte `json:"password"`
Age pgtype.Int4 `json:"age"`
EducationLevel pgtype.Text `json:"education_level"` EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"` Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"` Region pgtype.Text `json:"region"`
@ -242,4 +241,5 @@ type User struct {
PreferredLanguage pgtype.Text `json:"preferred_language"` PreferredLanguage pgtype.Text `json:"preferred_language"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
AgeGroup pgtype.Text `json:"age_group"`
} }

View File

@ -48,7 +48,7 @@ INSERT INTO users (
phone_number, phone_number,
role, role,
password, password,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -70,33 +70,33 @@ INSERT INTO users (
updated_at updated_at
) )
VALUES ( VALUES (
$1, -- first_name $1,
$2, -- last_name $2,
$3, -- gender $3,
$4, -- birth_day $4,
$5, -- email $5,
$6, -- phone_number $6,
$7, -- role $7,
$8, -- password $8,
$9, -- age $9, -- age_group
$10, -- education_level $10,
$11, -- country $11,
$12, -- region $12,
$13, -- nick_name $13,
$14, -- occupation $14,
$15, -- learning_goal $15,
$16, -- language_goal $16,
$17, -- language_challange $17,
$18, -- favourite_topic $18,
$19, -- initial_assessment_completed $19,
$20, -- email_verified $20,
$21, -- phone_verified $21,
$22, -- status $22,
$23, -- profile_completed $23,
$24, -- profile_picture_url $24,
$25, -- preferred_language $25,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
RETURNING RETURNING
@ -108,7 +108,7 @@ RETURNING
email, email,
phone_number, phone_number,
role, role,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -140,7 +140,7 @@ type CreateUserParams struct {
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
Role string `json:"role"` Role string `json:"role"`
Password []byte `json:"password"` Password []byte `json:"password"`
Age pgtype.Int4 `json:"age"` AgeGroup pgtype.Text `json:"age_group"`
EducationLevel pgtype.Text `json:"education_level"` EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"` Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"` Region pgtype.Text `json:"region"`
@ -168,7 +168,7 @@ type CreateUserRow struct {
Email pgtype.Text `json:"email"` Email pgtype.Text `json:"email"`
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
Role string `json:"role"` Role string `json:"role"`
Age pgtype.Int4 `json:"age"` AgeGroup pgtype.Text `json:"age_group"`
EducationLevel pgtype.Text `json:"education_level"` EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"` Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"` Region pgtype.Text `json:"region"`
@ -199,7 +199,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU
arg.PhoneNumber, arg.PhoneNumber,
arg.Role, arg.Role,
arg.Password, arg.Password,
arg.Age, arg.AgeGroup,
arg.EducationLevel, arg.EducationLevel,
arg.Country, arg.Country,
arg.Region, arg.Region,
@ -227,7 +227,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU
&i.Email, &i.Email,
&i.PhoneNumber, &i.PhoneNumber,
&i.Role, &i.Role,
&i.Age, &i.AgeGroup,
&i.EducationLevel, &i.EducationLevel,
&i.Country, &i.Country,
&i.Region, &i.Region,
@ -271,10 +271,11 @@ SELECT
email, email,
phone_number, phone_number,
role, role,
age, age_group,
education_level, education_level,
country, country,
region, region,
knowledge_level,
nick_name, nick_name,
occupation, occupation,
learning_goal, learning_goal,
@ -282,33 +283,23 @@ SELECT
language_challange, language_challange,
favourite_topic, favourite_topic,
initial_assessment_completed, initial_assessment_completed,
profile_picture_url,
preferred_language,
email_verified, email_verified,
phone_verified, phone_verified,
status, status,
last_login,
profile_completed, profile_completed,
profile_picture_url,
preferred_language,
created_at, created_at,
updated_at updated_at
FROM users FROM users
WHERE ($1 IS NULL OR role = $1) LIMIT $2::INT
AND ($2 IS NULL OR first_name ILIKE '%' || $2 || '%' OFFSET $1::INT
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
` `
type GetAllUsersParams struct { type GetAllUsersParams struct {
Column1 interface{} `json:"column_1"` Offset pgtype.Int4 `json:"offset"`
Column2 interface{} `json:"column_2"` Limit pgtype.Int4 `json:"limit"`
Column3 interface{} `json:"column_3"`
Column4 interface{} `json:"column_4"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
} }
type GetAllUsersRow struct { type GetAllUsersRow struct {
@ -321,10 +312,11 @@ type GetAllUsersRow struct {
Email pgtype.Text `json:"email"` Email pgtype.Text `json:"email"`
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
Role string `json:"role"` Role string `json:"role"`
Age pgtype.Int4 `json:"age"` AgeGroup pgtype.Text `json:"age_group"`
EducationLevel pgtype.Text `json:"education_level"` EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"` Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"` Region pgtype.Text `json:"region"`
KnowledgeLevel pgtype.Text `json:"knowledge_level"`
NickName pgtype.Text `json:"nick_name"` NickName pgtype.Text `json:"nick_name"`
Occupation pgtype.Text `json:"occupation"` Occupation pgtype.Text `json:"occupation"`
LearningGoal pgtype.Text `json:"learning_goal"` LearningGoal pgtype.Text `json:"learning_goal"`
@ -332,25 +324,19 @@ type GetAllUsersRow struct {
LanguageChallange pgtype.Text `json:"language_challange"` LanguageChallange pgtype.Text `json:"language_challange"`
FavouriteTopic pgtype.Text `json:"favourite_topic"` FavouriteTopic pgtype.Text `json:"favourite_topic"`
InitialAssessmentCompleted bool `json:"initial_assessment_completed"` InitialAssessmentCompleted bool `json:"initial_assessment_completed"`
ProfilePictureUrl pgtype.Text `json:"profile_picture_url"`
PreferredLanguage pgtype.Text `json:"preferred_language"`
EmailVerified bool `json:"email_verified"` EmailVerified bool `json:"email_verified"`
PhoneVerified bool `json:"phone_verified"` PhoneVerified bool `json:"phone_verified"`
Status string `json:"status"` Status string `json:"status"`
LastLogin pgtype.Timestamptz `json:"last_login"`
ProfileCompleted pgtype.Bool `json:"profile_completed"` 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"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]GetAllUsersRow, error) { func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]GetAllUsersRow, error) {
rows, err := q.db.Query(ctx, GetAllUsers, rows, err := q.db.Query(ctx, GetAllUsers, arg.Offset, arg.Limit)
arg.Column1,
arg.Column2,
arg.Column3,
arg.Column4,
arg.Limit,
arg.Offset,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -368,10 +354,11 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get
&i.Email, &i.Email,
&i.PhoneNumber, &i.PhoneNumber,
&i.Role, &i.Role,
&i.Age, &i.AgeGroup,
&i.EducationLevel, &i.EducationLevel,
&i.Country, &i.Country,
&i.Region, &i.Region,
&i.KnowledgeLevel,
&i.NickName, &i.NickName,
&i.Occupation, &i.Occupation,
&i.LearningGoal, &i.LearningGoal,
@ -379,12 +366,13 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get
&i.LanguageChallange, &i.LanguageChallange,
&i.FavouriteTopic, &i.FavouriteTopic,
&i.InitialAssessmentCompleted, &i.InitialAssessmentCompleted,
&i.ProfilePictureUrl,
&i.PreferredLanguage,
&i.EmailVerified, &i.EmailVerified,
&i.PhoneVerified, &i.PhoneVerified,
&i.Status, &i.Status,
&i.LastLogin,
&i.ProfileCompleted, &i.ProfileCompleted,
&i.ProfilePictureUrl,
&i.PreferredLanguage,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -425,7 +413,7 @@ SELECT
phone_number, phone_number,
role, role,
password, password,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -467,7 +455,7 @@ type GetUserByEmailPhoneRow struct {
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
Role string `json:"role"` Role string `json:"role"`
Password []byte `json:"password"` Password []byte `json:"password"`
Age pgtype.Int4 `json:"age"` AgeGroup pgtype.Text `json:"age_group"`
EducationLevel pgtype.Text `json:"education_level"` EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"` Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"` Region pgtype.Text `json:"region"`
@ -534,7 +522,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho
&i.PhoneNumber, &i.PhoneNumber,
&i.Role, &i.Role,
&i.Password, &i.Password,
&i.Age, &i.AgeGroup,
&i.EducationLevel, &i.EducationLevel,
&i.Country, &i.Country,
&i.Region, &i.Region,
@ -558,7 +546,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho
} }
const GetUserByID = `-- name: GetUserByID :one 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 FROM users
WHERE id = $1 WHERE id = $1
` `
@ -576,7 +564,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.PhoneNumber, &i.PhoneNumber,
&i.Role, &i.Role,
&i.Password, &i.Password,
&i.Age,
&i.EducationLevel, &i.EducationLevel,
&i.Country, &i.Country,
&i.Region, &i.Region,
@ -597,6 +584,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.PreferredLanguage, &i.PreferredLanguage,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.AgeGroup,
) )
return i, err return i, err
} }
@ -655,7 +643,7 @@ SELECT
email, email,
phone_number, phone_number,
role, role,
age, age_group,
education_level, education_level,
country, country,
region, region,
@ -703,7 +691,7 @@ type SearchUserByNameOrPhoneRow struct {
Email pgtype.Text `json:"email"` Email pgtype.Text `json:"email"`
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
Role string `json:"role"` Role string `json:"role"`
Age pgtype.Int4 `json:"age"` AgeGroup pgtype.Text `json:"age_group"`
EducationLevel pgtype.Text `json:"education_level"` EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"` Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"` Region pgtype.Text `json:"region"`
@ -742,7 +730,7 @@ func (q *Queries) SearchUserByNameOrPhone(ctx context.Context, arg SearchUserByN
&i.Email, &i.Email,
&i.PhoneNumber, &i.PhoneNumber,
&i.Role, &i.Role,
&i.Age, &i.AgeGroup,
&i.EducationLevel, &i.EducationLevel,
&i.Country, &i.Country,
&i.Region, &i.Region,
@ -795,12 +783,8 @@ UPDATE users
SET SET
first_name = COALESCE($1, first_name), first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name), last_name = COALESCE($2, last_name),
-- email = COALESCE($3, email),
-- phone_number = COALESCE($4, phone_number),
knowledge_level = COALESCE($3, knowledge_level), knowledge_level = COALESCE($3, knowledge_level),
age = COALESCE($4, age), age_group = COALESCE($4, age_group),
education_level = COALESCE($5, education_level), education_level = COALESCE($5, education_level),
country = COALESCE($6, country), country = COALESCE($6, country),
region = COALESCE($7, region), region = COALESCE($7, region),
@ -811,15 +795,12 @@ SET
language_challange = COALESCE($12, language_challange), language_challange = COALESCE($12, language_challange),
favourite_topic = COALESCE($13, favourite_topic), favourite_topic = COALESCE($13, favourite_topic),
initial_assessment_completed = COALESCE($14, initial_assessment_completed), 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_completed = COALESCE($15, profile_completed),
profile_picture_url = COALESCE($16, profile_picture_url), profile_picture_url = COALESCE($16, profile_picture_url),
preferred_language = COALESCE($17, preferred_language), preferred_language = COALESCE($17, preferred_language),
gender = COALESCE($18, gender), gender = COALESCE($18, gender),
birth_day = COALESCE($19, gender), birth_day = COALESCE($19, birth_day),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $20 WHERE id = $20
` `
@ -827,7 +808,7 @@ type UpdateUserParams struct {
FirstName pgtype.Text `json:"first_name"` FirstName pgtype.Text `json:"first_name"`
LastName pgtype.Text `json:"last_name"` LastName pgtype.Text `json:"last_name"`
KnowledgeLevel pgtype.Text `json:"knowledge_level"` KnowledgeLevel pgtype.Text `json:"knowledge_level"`
Age pgtype.Int4 `json:"age"` AgeGroup pgtype.Text `json:"age_group"`
EducationLevel pgtype.Text `json:"education_level"` EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"` Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"` Region pgtype.Text `json:"region"`
@ -851,7 +832,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
arg.FirstName, arg.FirstName,
arg.LastName, arg.LastName,
arg.KnowledgeLevel, arg.KnowledgeLevel,
arg.Age, arg.AgeGroup,
arg.EducationLevel, arg.EducationLevel,
arg.Country, arg.Country,
arg.Region, arg.Region,

View File

@ -1,6 +1,21 @@
package domain 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 { type RefreshToken struct {
ID int64 ID int64

View File

@ -5,6 +5,18 @@ import (
"time" "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 ( var (
ErrUserNotVerified = errors.New("user not verified") ErrUserNotVerified = errors.New("user not verified")
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
@ -42,7 +54,7 @@ type User struct {
Password []byte Password []byte
Role Role Role Role
Age int AgeGroup string
EducationLevel string EducationLevel string
Country string Country string
Region string Region string
@ -81,7 +93,7 @@ type UserProfileResponse struct {
PhoneNumber string `json:"phone_number,omitempty"` PhoneNumber string `json:"phone_number,omitempty"`
Role Role `json:"role"` Role Role `json:"role"`
Age int `json:"age,omitempty"` AgeGroup string `json:"age_group,omitempty"`
EducationLevel string `json:"education_level,omitempty"` EducationLevel string `json:"education_level,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"` Region string `json:"region,omitempty"`
@ -138,7 +150,7 @@ type CreateUserReq struct {
Status UserStatus Status UserStatus
Age int AgeGroup string
EducationLevel string EducationLevel string
Country string Country string
Region string Region string
@ -166,27 +178,20 @@ type UpdateUserStatusReq struct {
} }
type UpdateUserReq struct { type UpdateUserReq struct {
// Identity (enforced from auth context, not request body)
UserID int64 `json:"-"` UserID int64 `json:"-"`
// Basic profile
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"last_name"` LastName string `json:"last_name"`
Gender string `json:"gender"` Gender string `json:"gender"`
BirthDay time.Time `json:"birth_day"` BirthDay *string `json:"birth_day"` // YYYY-MM-DD
// Contact (optional at least one must exist at DB level) AgeGroup *AgeGroup `json:"age_group"`
// Email string `json:"email"`
// PhoneNumber string `json:"phone_number"`
// Personal details
Age int64 `json:"age"`
EducationLevel string `json:"education_level"` EducationLevel string `json:"education_level"`
Country string `json:"country"` Country string `json:"country"`
Region string `json:"region"` Region string `json:"region"`
// Learning / profile
KnowledgeLevel string `json:"knowledge_level"` KnowledgeLevel string `json:"knowledge_level"`
NickName string `json:"nick_name"` NickName string `json:"nick_name"`
Occupation string `json:"occupation"` Occupation string `json:"occupation"`
@ -196,11 +201,8 @@ type UpdateUserReq struct {
FavouriteTopic string `json:"favourite_topic"` FavouriteTopic string `json:"favourite_topic"`
InitialAssessmentCompleted bool `json:"initial_assessment_completed"` InitialAssessmentCompleted bool `json:"initial_assessment_completed"`
// EmailVerified bool `json:"email_verified"` ProfileCompleted bool `json:"profile_completed"`
// PhoneVerified bool `json:"phone_verified"`
ProfileCompleted bool `json:"profile_completed"`
// Media & preferences
ProfilePictureURL string `json:"profile_picture_url"` ProfilePictureURL string `json:"profile_picture_url"`
PreferredLanguage string `json:"preferred_language"` PreferredLanguage string `json:"preferred_language"`
} }

View File

@ -126,7 +126,7 @@ func (s *Store) GetUserByEmailOrPhone(
Password: u.Password, Password: u.Password,
Role: domain.Role(u.Role), Role: domain.Role(u.Role),
Age: int(u.Age.Int32), AgeGroup: u.AgeGroup.String,
EducationLevel: u.EducationLevel.String, EducationLevel: u.EducationLevel.String,
Country: u.Country.String, Country: u.Country.String,
Region: u.Region.String, Region: u.Region.String,

View File

@ -84,7 +84,7 @@ func (s *Store) CreateUserWithoutOtp(
Role: string(user.Role), Role: string(user.Role),
Password: user.Password, 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 != ""}, EducationLevel: pgtype.Text{String: user.EducationLevel, Valid: user.EducationLevel != ""},
Country: pgtype.Text{String: user.Country, Valid: user.Country != ""}, Country: pgtype.Text{String: user.Country, Valid: user.Country != ""},
Region: pgtype.Text{String: user.Region, Valid: user.Region != ""}, Region: pgtype.Text{String: user.Region, Valid: user.Region != ""},
@ -178,7 +178,7 @@ func (s *Store) CreateUser(
Role: string(user.Role), Role: string(user.Role),
Password: user.Password, 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 != ""}, EducationLevel: pgtype.Text{String: user.EducationLevel, Valid: user.EducationLevel != ""},
Country: pgtype.Text{String: user.Country, Valid: user.Country != ""}, Country: pgtype.Text{String: user.Country, Valid: user.Country != ""},
Region: pgtype.Text{String: user.Region, Valid: user.Region != ""}, Region: pgtype.Text{String: user.Region, Valid: user.Region != ""},
@ -254,7 +254,7 @@ func (s *Store) GetUserByID(
PhoneNumber: u.PhoneNumber.String, PhoneNumber: u.PhoneNumber.String,
Role: domain.Role(u.Role), Role: domain.Role(u.Role),
Age: int(u.Age.Int32), AgeGroup: u.AgeGroup.String,
EducationLevel: u.EducationLevel.String, EducationLevel: u.EducationLevel.String,
Country: u.Country.String, Country: u.Country.String,
Region: u.Region.String, Region: u.Region.String,
@ -289,41 +289,56 @@ func (s *Store) GetAllUsers(
limit, offset int32, limit, offset int32,
) ([]domain.User, int64, error) { ) ([]domain.User, int64, error) {
var roleParam sql.NullString // var roleParam sql.NullString
if role != nil && *role != "" { // if role != nil && *role != "" {
roleParam = sql.NullString{String: *role, Valid: true} // roleParam = sql.NullString{String: *role, Valid: true}
} else { // } else {
roleParam = sql.NullString{Valid: false} // This will make $1 IS NULL work // roleParam = sql.NullString{Valid: false} // This will make $1 IS NULL work
} // }
var queryParam sql.NullString // var queryParam sql.NullString
if query != nil && *query != "" { // if query != nil && *query != "" {
queryParam = sql.NullString{String: *query, Valid: true} // queryParam = sql.NullString{String: *query, Valid: true}
} else { // } else {
queryParam = sql.NullString{Valid: false} // queryParam = sql.NullString{Valid: false}
} // }
var createdAfterParam sql.NullTime // var createdAfterParam sql.NullTime
if createdAfter != nil { // if createdAfter != nil {
createdAfterParam = sql.NullTime{Time: *createdAfter, Valid: true} // createdAfterParam = sql.NullTime{Time: *createdAfter, Valid: true}
} else { // } else {
createdAfterParam = sql.NullTime{Valid: false} // createdAfterParam = sql.NullTime{Valid: false}
} // }
var createdBeforeParam sql.NullTime // var createdBeforeParam sql.NullTime
if createdBefore != nil { // if createdBefore != nil {
createdBeforeParam = sql.NullTime{Time: *createdBefore, Valid: true} // createdBeforeParam = sql.NullTime{Time: *createdBefore, Valid: true}
} else { // } else {
createdBeforeParam = sql.NullTime{Valid: false} // createdBeforeParam = sql.NullTime{Valid: false}
} // }
params := dbgen.GetAllUsersParams{ params := dbgen.GetAllUsersParams{
Column1: roleParam.String, // Role: pgtype.Text{
Column2: pgtype.Text{String: queryParam.String, Valid: queryParam.Valid}, // String: roleParam.String,
Column3: pgtype.Timestamptz{Time: createdAfterParam.Time, Valid: createdAfterParam.Valid}, // Valid: roleParam.String != "",
Column4: pgtype.Timestamptz{Time: createdBeforeParam.Time, Valid: createdBeforeParam.Valid}, // },
Limit: int32(limit), // Query: queryParam.String,
Offset: int32(offset), // 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) rows, err := s.queries.GetAllUsers(ctx, params)
@ -356,7 +371,7 @@ func (s *Store) GetAllUsers(
PhoneNumber: u.PhoneNumber.String, PhoneNumber: u.PhoneNumber.String,
Role: domain.Role(u.Role), Role: domain.Role(u.Role),
Age: int(u.Age.Int32), AgeGroup: u.AgeGroup.String,
EducationLevel: u.EducationLevel.String, EducationLevel: u.EducationLevel.String,
Country: u.Country.String, Country: u.Country.String,
Region: u.Region.String, Region: u.Region.String,
@ -443,7 +458,7 @@ func (s *Store) SearchUserByNameOrPhone(
PhoneNumber: u.PhoneNumber.String, PhoneNumber: u.PhoneNumber.String,
Role: domain.Role(u.Role), Role: domain.Role(u.Role),
Age: int(u.Age.Int32), AgeGroup: u.AgeGroup.String,
EducationLevel: u.EducationLevel.String, EducationLevel: u.EducationLevel.String,
Country: u.Country.String, Country: u.Country.String,
Region: u.Region.String, Region: u.Region.String,
@ -477,19 +492,27 @@ func (s *Store) UpdateUser(
req domain.UpdateUserReq, req domain.UpdateUserReq,
) error { ) 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{ return s.queries.UpdateUser(ctx, dbgen.UpdateUserParams{
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""}, FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, 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 != ""}, EducationLevel: pgtype.Text{String: req.EducationLevel, Valid: req.EducationLevel != ""},
Country: pgtype.Text{String: req.Country, Valid: req.Country != ""}, Country: pgtype.Text{String: req.Country, Valid: req.Country != ""},
Region: pgtype.Text{String: req.Region, Valid: req.Region != ""}, 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 != ""}, LanguageChallange: pgtype.Text{String: req.LanguageChallange, Valid: req.LanguageChallange != ""},
FavouriteTopic: pgtype.Text{String: req.FavouriteTopic, Valid: req.FavouriteTopic != ""}, FavouriteTopic: pgtype.Text{String: req.FavouriteTopic, Valid: req.FavouriteTopic != ""},
ProfileCompleted: pgtype.Bool{ InitialAssessmentCompleted: req.InitialAssessmentCompleted,
Bool: req.ProfileCompleted, ProfileCompleted: pgtype.Bool{Bool: req.ProfileCompleted, Valid: true},
Valid: true,
},
ProfilePictureUrl: pgtype.Text{String: req.ProfilePictureURL, Valid: req.ProfilePictureURL != ""}, ProfilePictureUrl: pgtype.Text{String: req.ProfilePictureURL, Valid: req.ProfilePictureURL != ""},
PreferredLanguage: pgtype.Text{String: req.PreferredLanguage, Valid: req.PreferredLanguage != ""}, PreferredLanguage: pgtype.Text{String: req.PreferredLanguage, Valid: req.PreferredLanguage != ""},
Gender: pgtype.Text{String: req.Gender, Valid: req.Gender != ""},
BirthDay: birthDate,
ID: req.UserID, ID: req.UserID,
}) })
} }
// DeleteUser removes a user // DeleteUser removes a user
@ -637,7 +660,7 @@ func (s *Store) GetUserByEmailPhone(
Password: u.Password, Password: u.Password,
Role: domain.Role(u.Role), Role: domain.Role(u.Role),
Age: int(u.Age.Int32), AgeGroup: u.AgeGroup.String,
EducationLevel: u.EducationLevel.String, EducationLevel: u.EducationLevel.String,
Country: u.Country.String, Country: u.Country.String,
Region: u.Region.String, Region: u.Region.String,
@ -690,7 +713,7 @@ func mapCreateUserResult(
Role: domain.Role(userRes.Role), Role: domain.Role(userRes.Role),
Password: password, Password: password,
Age: int(userRes.Age.Int32), AgeGroup: userRes.AgeGroup.String,
EducationLevel: userRes.EducationLevel.String, EducationLevel: userRes.EducationLevel.String,
Country: userRes.Country.String, Country: userRes.Country.String,
Region: userRes.Region.String, Region: userRes.Region.String,

View File

@ -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) { func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool, userId int64) (map[string]any, error) {
// Generate unique nonce // Generate unique nonce
nonce := uuid.NewString() nonce := uuid.NewString()
@ -42,6 +43,7 @@ func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientR
NotifyURL = s.cfg.ARIFPAY.B2CNotifyUrl NotifyURL = s.cfg.ARIFPAY.B2CNotifyUrl
} }
// Construct full checkout request // Construct full checkout request
checkoutReq := domain.CheckoutSessionRequest{ checkoutReq := domain.CheckoutSessionRequest{
CancelURL: s.cfg.ARIFPAY.CancelUrl, CancelURL: s.cfg.ARIFPAY.CancelUrl,

View File

@ -20,67 +20,136 @@ var (
ErrUserSuspended = errors.New("user has been suspended") 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( func (s *Service) Login(
ctx context.Context, ctx context.Context,
req LoginRequest, req domain.LoginRequest,
) (LoginSuccess, error) { ) (domain.LoginSuccess, error) {
// Try to find user by username first
user, err := s.userStore.GetUserByEmailPhone(ctx, req.Email, req.PhoneNumber) user, err := s.userStore.GetUserByEmailPhone(ctx, req.Email, req.PhoneNumber)
if err != nil { if err != nil {
// If not found by username, try email or phone lookup using the same identifier return domain.LoginSuccess{}, err
return LoginSuccess{}, err
} }
if user.Status == domain.UserStatusPending { if user.Status == domain.UserStatusPending {
return LoginSuccess{}, domain.ErrUserNotVerified return domain.LoginSuccess{}, domain.ErrUserNotVerified
} }
// Status check instead of Suspended
if user.Status == domain.UserStatusSuspended { if user.Status == domain.UserStatusSuspended {
return LoginSuccess{}, ErrUserSuspended return domain.LoginSuccess{}, ErrUserSuspended
} }
// Email + password login
if req.Email != "" { if req.Email != "" {
if err := matchPassword(req.Password, user.Password); err != nil { 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 { oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID)
return LoginSuccess{}, err 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) oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID)
if err != nil && !errors.Is(err, ErrRefreshTokenNotFound) { 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 == nil && !oldRefreshToken.Revoked {
if err := s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token); err != nil { 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() refreshToken, err := generateRefreshToken()
if err != nil { if err != nil {
return LoginSuccess{}, err return domain.LoginSuccess{}, err
} }
if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{
@ -89,17 +158,22 @@ func (s *Service) Login(
CreatedAt: time.Now(), CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second),
}); err != nil { }); err != nil {
return LoginSuccess{}, err return domain.LoginSuccess{}, err
} }
// Return login success payload // 9. Return success payload
return LoginSuccess{ return domain.LoginSuccess{
UserId: user.ID, UserId: user.ID,
Role: user.Role, Role: user.Role,
RfToken: refreshToken, RfToken: refreshToken,
}, nil }, 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) { func (s *Service) RefreshToken(ctx context.Context, refToken string) (domain.RefreshToken, error) {
token, err := s.tokenStore.GetRefreshToken(ctx, refToken) token, err := s.tokenStore.GetRefreshToken(ctx, refToken)

View File

@ -19,14 +19,16 @@ type Tokens struct {
RefreshToken string RefreshToken string
} }
type Service struct { type Service struct {
otpStore ports.OtpStore
userStore ports.UserStore userStore ports.UserStore
UserSvc user.Service UserSvc user.Service
tokenStore ports.TokenStore tokenStore ports.TokenStore
RefreshExpiry int 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{ return &Service{
otpStore: otpStore,
userStore: userStore, userStore: userStore,
UserSvc: userSvc, UserSvc: userSvc,
tokenStore: tokenStore, tokenStore: tokenStore,

View File

@ -10,56 +10,6 @@ import (
"golang.org/x/crypto/bcrypt" "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( func (s *Service) ResendOtp(
ctx context.Context, ctx context.Context,
email, phone string, 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) 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) { func hashPassword(plaintextPassword string) ([]byte, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12)

View File

@ -3,6 +3,7 @@ package user
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"context" "context"
"time"
) )
func (s *Service) UpdateUserKnowledgeLevel(ctx context.Context, userID int64, knowledgeLevel string) error { func (s *Service) UpdateUserKnowledgeLevel(ctx context.Context, userID int64, knowledgeLevel string) error {
@ -23,8 +24,8 @@ func (s *Service) CreateUser(
// Create the user // Create the user
return s.userStore.CreateUserWithoutOtp(ctx, domain.User{ return s.userStore.CreateUserWithoutOtp(ctx, domain.User{
FirstName: req.FirstName, FirstName: req.FirstName,
LastName: req.LastName, LastName: req.LastName,
// UserName: req.UserName, // UserName: req.UserName,
Email: req.Email, Email: req.Email,
PhoneNumber: req.PhoneNumber, PhoneNumber: req.PhoneNumber,
@ -33,7 +34,7 @@ func (s *Service) CreateUser(
EmailVerified: true, // assuming auto-verified on creation EmailVerified: true, // assuming auto-verified on creation
PhoneVerified: true, PhoneVerified: true,
Status: domain.UserStatusActive, Status: domain.UserStatusActive,
Age: req.Age, AgeGroup: req.AgeGroup,
EducationLevel: req.EducationLevel, EducationLevel: req.EducationLevel,
Country: req.Country, Country: req.Country,
Region: req.Region, Region: req.Region,
@ -46,10 +47,44 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error {
return s.userStore.DeleteUser(ctx, id) return s.userStore.DeleteUser(ctx, id)
} }
func (s *Service) GetAllUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) { func (s *Service) GetAllUsers(
// Get all Users ctx context.Context,
return s.userStore.GetAllUsers(ctx, &filter.Role, &filter.Query, &filter.CreatedBefore.Value, &filter.CreatedAfter.Value, int32(filter.PageSize), int32(filter.Page)) 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) { func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) {
return s.userStore.GetUserByID(ctx, id) return s.userStore.GetUserByID(ctx, id)

View File

@ -12,6 +12,7 @@ const (
) )
type Service struct { type Service struct {
tokenStore ports.TokenStore
userStore ports.UserStore userStore ports.UserStore
otpStore ports.OtpStore otpStore ports.OtpStore
messengerSvc *messenger.Service messengerSvc *messenger.Service
@ -19,12 +20,14 @@ type Service struct {
} }
func NewService( func NewService(
tokenStore ports.TokenStore,
userStore ports.UserStore, userStore ports.UserStore,
otpStore ports.OtpStore, otpStore ports.OtpStore,
messengerSvc *messenger.Service, messengerSvc *messenger.Service,
cfg *config.Config, cfg *config.Config,
) *Service { ) *Service {
return &Service{ return &Service{
tokenStore: tokenStore,
userStore: userStore, userStore: userStore,
otpStore: otpStore, otpStore: otpStore,
messengerSvc: messengerSvc, messengerSvc: messengerSvc,

View File

@ -37,14 +37,14 @@ type loginUserRes struct {
// @Tags auth // @Tags auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param login body authentication.LoginRequest true "Login user" // @Param login body domain.LoginRequest true "Login user"
// @Success 200 {object} loginUserRes // @Success 200 {object} loginUserRes
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 401 {object} response.APIResponse // @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /api/v1/{tenant_slug}/user-login [post] // @Router /api/v1/{tenant_slug}/user-login [post]
func (h *Handler) LoginUser(c *fiber.Ctx) error { func (h *Handler) LoginUser(c *fiber.Ctx) error {
var req authentication.LoginRequest var req domain.LoginRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("Failed to parse LoginUser request", h.mongoLoggerSvc.Info("Failed to parse LoginUser request",
zap.Int("status_code", fiber.StatusBadRequest), zap.Int("status_code", fiber.StatusBadRequest),
@ -180,14 +180,14 @@ type LoginAdminRes struct {
// @Tags auth // @Tags auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param login body authentication.LoginRequest true "Login admin" // @Param login body domain.LoginRequest true "Login admin"
// @Success 200 {object} LoginAdminRes // @Success 200 {object} LoginAdminRes
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 401 {object} response.APIResponse // @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /api/v1/{tenant_slug}/admin-login [post] // @Router /api/v1/{tenant_slug}/admin-login [post]
func (h *Handler) LoginAdmin(c *fiber.Ctx) error { func (h *Handler) LoginAdmin(c *fiber.Ctx) error {
var req authentication.LoginRequest var req domain.LoginRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request", h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request",
zap.Int("status_code", fiber.StatusBadRequest), zap.Int("status_code", fiber.StatusBadRequest),
@ -205,7 +205,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, errMsg) 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 { if err != nil {
switch { switch {
case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): 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 // @Tags auth
// @Accept json // @Accept json
// @Produce 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 // @Success 200 {object} LoginAdminRes
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 401 {object} response.APIResponse // @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /api/v1/super-login [post] // @Router /api/v1/super-login [post]
func (h *Handler) LoginSuper(c *fiber.Ctx) error { func (h *Handler) LoginSuper(c *fiber.Ctx) error {
var req authentication.LoginRequest var req domain.LoginRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request", h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request",
zap.Int("status_code", fiber.StatusBadRequest), zap.Int("status_code", fiber.StatusBadRequest),
@ -304,7 +304,7 @@ func (h *Handler) LoginSuper(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, errMsg) 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 { if err != nil {
switch { switch {
case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound):

View File

@ -390,7 +390,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// @Success 200 {object} response.APIResponse // @Success 200 {object} response.APIResponse
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 500 {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 { func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
searchQuery := c.Query("query") searchQuery := c.Query("query")
searchString := domain.ValidString{ searchString := domain.ValidString{
@ -452,38 +452,38 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
} }
// Map to profile response to avoid leaking sensitive fields // Map to profile response to avoid leaking sensitive fields
result := make([]domain.UserProfileResponse, len(users)) // result := make([]domain.UserProfileResponse, len(users))
for i, u := range users { // for i, u := range users {
result[i] = domain.UserProfileResponse{ // result[i] = domain{
ID: u.ID, // ID: u.ID,
FirstName: u.FirstName, // FirstName: u.FirstName,
LastName: u.LastName, // LastName: u.LastName,
// UserName: u.UserName, // Gender: u.Gender,
Email: u.Email, // Email: u.Email,
PhoneNumber: u.PhoneNumber, // PhoneNumber: u.PhoneNumber,
Role: u.Role, // Role: u.Role,
Age: u.Age, // Age: u.Age,
EducationLevel: u.EducationLevel, // EducationLevel: u.EducationLevel,
Country: u.Country, // Country: u.Country,
Region: u.Region, // Region: u.Region,
NickName: u.NickName, // NickName: u.NickName,
Occupation: u.Occupation, // Occupation: u.Occupation,
LearningGoal: u.LearningGoal, // LearningGoal: u.LearningGoal,
LanguageGoal: u.LanguageGoal, // LanguageGoal: u.LanguageGoal,
LanguageChallange: u.LanguageChallange, // LanguageChallange: u.LanguageChallange,
FavouriteTopic: u.FavouriteTopic, // FavouriteTopic: u.FavouriteTopic,
EmailVerified: u.EmailVerified, // EmailVerified: u.EmailVerified,
PhoneVerified: u.PhoneVerified, // PhoneVerified: u.PhoneVerified,
LastLogin: u.LastLogin, // LastLogin: u.LastLogin,
ProfileCompleted: u.ProfileCompleted, // ProfileCompleted: u.ProfileCompleted,
ProfilePictureURL: u.ProfilePictureURL, // ProfilePictureURL: u.ProfilePictureURL,
PreferredLanguage: u.PreferredLanguage, // PreferredLanguage: u.PreferredLanguage,
CreatedAt: u.CreatedAt, // CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt, // 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 // VerifyOtp godoc
@ -523,7 +523,7 @@ func (h *Handler) VerifyOtp(c *fiber.Ctx) error {
} }
// Call service to verify OTP // 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 { if err != nil {
var errMsg string var errMsg string
switch { switch {
@ -555,7 +555,7 @@ func (h *Handler) VerifyOtp(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "OTP verified successfully", Message: "OTP verified successfully",
Data: nil, Data: loginSuccess,
}) })
} }
@ -1214,7 +1214,7 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
Email: user.Email, Email: user.Email,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
Age: user.Age, AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
@ -1303,7 +1303,7 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
Email: user.Email, Email: user.Email,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
Age: user.Age, AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
@ -1425,7 +1425,7 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
Email: user.Email, Email: user.Email,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
Age: user.Age, AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
@ -1500,14 +1500,22 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
// } // }
res := domain.UserProfileResponse{ res := domain.UserProfileResponse{
ID: user.ID, ID: user.ID,
FirstName: user.FirstName, FirstName: user.FirstName,
LastName: user.LastName, 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, // UserName: user.UserName,
Email: user.Email, Email: user.Email,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
Age: user.Age, AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,