diff --git a/cmd/main.go b/cmd/main.go index 7ceace8..a4fbdb6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -90,17 +90,19 @@ func main() { // repository.NewBranchStatStore(store), // ) - authSvc := authentication.NewService( - repository.NewUserStore(store), - repository.NewTokenStore(store), - cfg.RefreshExpiry, - ) userSvc := user.NewService( repository.NewUserStore(store), repository.NewOTPStore(store), messengerSvc, cfg, ) + + authSvc := authentication.NewService( + repository.NewUserStore(store), + *userSvc, + repository.NewTokenStore(store), + cfg.RefreshExpiry, + ) // leagueSvc := league.New(repository.NewLeagueStore(store)) // eventSvc := event.New( // cfg.Bet365Token, diff --git a/db/migrations/000001_yimaru.up.sql b/db/migrations/000001_yimaru.up.sql index d0ad527..7b9e8fe 100644 --- a/db/migrations/000001_yimaru.up.sql +++ b/db/migrations/000001_yimaru.up.sql @@ -66,7 +66,6 @@ CREATE TABLE assessment_attempts ( CREATE TABLE assessment_answers ( id BIGSERIAL PRIMARY KEY, - attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, question_id BIGINT NOT NULL REFERENCES assessment_questions(id), selected_option_id BIGINT REFERENCES assessment_question_options(id), short_answer TEXT, diff --git a/db/query/initial_assessment.sql b/db/query/initial_assessment.sql index 8f9dcdf..ea6b719 100644 --- a/db/query/initial_assessment.sql +++ b/db/query/initial_assessment.sql @@ -22,21 +22,21 @@ RETURNING knowledge_level, completed_at; --- name: CreateAssessmentAnswer :exec -INSERT INTO assessment_answers ( - attempt_id, - question_id, - selected_option_id, - short_answer, - is_correct -) -VALUES ( - $1, -- attempt_id - $2, -- question_id - $3, -- selected_option_id - $4, -- short_answer - $5 -- is_correct -); +-- -- name: CreateAssessmentAnswer :exec +-- INSERT INTO assessment_answers ( +-- attempt_id, +-- question_id, +-- selected_option_id, +-- short_answer, +-- is_correct +-- ) +-- VALUES ( +-- $1, -- attempt_id +-- $2, -- question_id +-- $3, -- selected_option_id +-- $4, -- short_answer +-- $5 -- is_correct +-- ); -- name: GetAssessmentOptionByID :one SELECT diff --git a/db/query/user.sql b/db/query/user.sql index bf4ab29..f825488 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -120,14 +120,12 @@ SELECT education_level, country, region, - nick_name, occupation, learning_goal, language_goal, language_challange, favoutite_topic, - initial_assessment_completed, profile_picture_url, preferred_language, @@ -135,30 +133,18 @@ SELECT phone_verified, status, profile_completed, - preferred_language, created_at, updated_at FROM users -WHERE ( - role = $1 OR $1 IS NULL - ) - AND ( - first_name ILIKE '%' || sqlc.narg('query') || '%' - OR last_name ILIKE '%' || sqlc.narg('query') || '%' - OR phone_number ILIKE '%' || sqlc.narg('query') || '%' - OR email ILIKE '%' || sqlc.narg('query') || '%' - OR sqlc.narg('query') IS NULL - ) - AND ( - created_at >= sqlc.narg('created_after') - OR sqlc.narg('created_after') IS NULL - ) - AND ( - created_at <= sqlc.narg('created_before') - OR sqlc.narg('created_before') IS NULL - ) -LIMIT sqlc.narg('limit') -OFFSET sqlc.narg('offset'); +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; -- name: GetTotalUsers :one SELECT COUNT(*) diff --git a/docs/docs.go b/docs/docs.go index 6de58f8..e207ffb 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1504,7 +1504,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.loginAdminReq" + "$ref": "#/definitions/authentication.LoginRequest" } } ], @@ -2024,7 +2024,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.loginAdminReq" + "$ref": "#/definitions/authentication.LoginRequest" } } ], @@ -2056,65 +2056,6 @@ const docTemplate = `{ } } }, - "/api/v1/{tenant_slug}/assessment/submit": { - "post": { - "description": "Evaluates user responses, calculates knowledge level, updates user profile, and sends notification", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "assessment" - ], - "summary": "Submit initial knowledge assessment", - "parameters": [ - { - "type": "integer", - "description": "User ID", - "name": "user_id", - "in": "path", - "required": true - }, - { - "description": "Assessment responses", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.SubmitAssessmentReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/api/v1/{tenant_slug}/otp/resend": { "post": { "description": "Resend OTP if the previous one is expired", @@ -2161,6 +2102,65 @@ const docTemplate = `{ } } }, + "/api/v1/{tenant_slug}/user": { + "put": { + "description": "Updates user profile information (partial updates supported)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "Update user payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/{tenant_slug}/user-login": { "post": { "description": "Login user", @@ -2181,7 +2181,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.loginUserReq" + "$ref": "#/definitions/authentication.LoginRequest" } } ], @@ -2631,9 +2631,99 @@ const docTemplate = `{ } } } + }, + "/api/v1/{tenant_slug}/users": { + "get": { + "description": "Get users with optional filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get all users", + "parameters": [ + { + "type": "string", + "description": "Role filter", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "Search query", + "name": "query", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "Created before (RFC3339)", + "name": "created_before", + "in": "query" + }, + { + "type": "string", + "description": "Created after (RFC3339)", + "name": "created_after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } } }, "definitions": { + "authentication.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "otp_code": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone_number": { + "type": "string" + } + } + }, "domain.AssessmentOption": { "type": "object", "properties": { @@ -3078,11 +3168,11 @@ const docTemplate = `{ }, "domain.ResendOtpReq": { "type": "object", - "required": [ - "user_name" - ], "properties": { - "user_name": { + "email": { + "type": "string" + }, + "phone_number": { "type": "string" } } @@ -3120,21 +3210,6 @@ const docTemplate = `{ "RoleSupport" ] }, - "domain.SubmitAssessmentReq": { - "type": "object", - "required": [ - "answers" - ], - "properties": { - "answers": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/domain.UserAnswer" - } - } - } - }, "domain.UpdateKnowledgeLevelReq": { "type": "object", "properties": { @@ -3147,22 +3222,71 @@ const docTemplate = `{ } } }, - "domain.UserAnswer": { + "domain.UpdateUserReq": { "type": "object", "properties": { - "isCorrect": { - "type": "boolean" + "age": { + "$ref": "#/definitions/domain.ValidInt" }, - "questionID": { + "country": { + "$ref": "#/definitions/domain.ValidString" + }, + "educationLevel": { + "$ref": "#/definitions/domain.ValidString" + }, + "favoutiteTopic": { + "$ref": "#/definitions/domain.ValidString" + }, + "firstName": { + "$ref": "#/definitions/domain.ValidString" + }, + "knowledgeLevel": { + "description": "Profile fields", + "allOf": [ + { + "$ref": "#/definitions/domain.ValidString" + } + ] + }, + "languageChallange": { + "$ref": "#/definitions/domain.ValidString" + }, + "languageGoal": { + "$ref": "#/definitions/domain.ValidString" + }, + "lastName": { + "$ref": "#/definitions/domain.ValidString" + }, + "learningGoal": { + "$ref": "#/definitions/domain.ValidString" + }, + "nickName": { + "$ref": "#/definitions/domain.ValidString" + }, + "occupation": { + "$ref": "#/definitions/domain.ValidString" + }, + "preferredLanguage": { + "$ref": "#/definitions/domain.ValidString" + }, + "profileCompleted": { + "$ref": "#/definitions/domain.ValidBool" + }, + "profilePictureURL": { + "$ref": "#/definitions/domain.ValidString" + }, + "region": { + "$ref": "#/definitions/domain.ValidString" + }, + "status": { + "$ref": "#/definitions/domain.ValidString" + }, + "userID": { "type": "integer", "format": "int64" }, - "selectedOptionID": { - "type": "integer", - "format": "int64" - }, - "shortAnswer": { - "type": "string" + "userName": { + "$ref": "#/definitions/domain.ValidString" } } }, @@ -3268,17 +3392,52 @@ const docTemplate = `{ "UserStatusDeactivated" ] }, + "domain.ValidBool": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "value": { + "type": "boolean" + } + } + }, + "domain.ValidInt": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "value": { + "type": "integer" + } + } + }, + "domain.ValidString": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "value": { + "type": "string" + } + } + }, "domain.VerifyOtpReq": { "type": "object", "required": [ - "otp", - "user_name" + "otp" ], "properties": { + "email": { + "type": "string" + }, "otp": { "type": "string" }, - "user_name": { + "phone_number": { "type": "string" } } @@ -3511,40 +3670,6 @@ const docTemplate = `{ } } }, - "handlers.loginAdminReq": { - "type": "object", - "required": [ - "password", - "user_name" - ], - "properties": { - "password": { - "type": "string", - "example": "password123" - }, - "user_name": { - "type": "string", - "example": "adminuser" - } - } - }, - "handlers.loginUserReq": { - "type": "object", - "required": [ - "password", - "user_name" - ], - "properties": { - "password": { - "type": "string", - "example": "password123" - }, - "user_name": { - "type": "string", - "example": "johndoe" - } - } - }, "handlers.loginUserRes": { "type": "object", "properties": { @@ -3556,6 +3681,9 @@ const docTemplate = `{ }, "role": { "type": "string" + }, + "user_id": { + "type": "integer" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 564c3d4..8b402ec 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1496,7 +1496,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.loginAdminReq" + "$ref": "#/definitions/authentication.LoginRequest" } } ], @@ -2016,7 +2016,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.loginAdminReq" + "$ref": "#/definitions/authentication.LoginRequest" } } ], @@ -2048,65 +2048,6 @@ } } }, - "/api/v1/{tenant_slug}/assessment/submit": { - "post": { - "description": "Evaluates user responses, calculates knowledge level, updates user profile, and sends notification", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "assessment" - ], - "summary": "Submit initial knowledge assessment", - "parameters": [ - { - "type": "integer", - "description": "User ID", - "name": "user_id", - "in": "path", - "required": true - }, - { - "description": "Assessment responses", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.SubmitAssessmentReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/api/v1/{tenant_slug}/otp/resend": { "post": { "description": "Resend OTP if the previous one is expired", @@ -2153,6 +2094,65 @@ } } }, + "/api/v1/{tenant_slug}/user": { + "put": { + "description": "Updates user profile information (partial updates supported)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "Update user payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/{tenant_slug}/user-login": { "post": { "description": "Login user", @@ -2173,7 +2173,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.loginUserReq" + "$ref": "#/definitions/authentication.LoginRequest" } } ], @@ -2623,9 +2623,99 @@ } } } + }, + "/api/v1/{tenant_slug}/users": { + "get": { + "description": "Get users with optional filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get all users", + "parameters": [ + { + "type": "string", + "description": "Role filter", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "Search query", + "name": "query", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "Created before (RFC3339)", + "name": "created_before", + "in": "query" + }, + { + "type": "string", + "description": "Created after (RFC3339)", + "name": "created_after", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } } }, "definitions": { + "authentication.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "otp_code": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone_number": { + "type": "string" + } + } + }, "domain.AssessmentOption": { "type": "object", "properties": { @@ -3070,11 +3160,11 @@ }, "domain.ResendOtpReq": { "type": "object", - "required": [ - "user_name" - ], "properties": { - "user_name": { + "email": { + "type": "string" + }, + "phone_number": { "type": "string" } } @@ -3112,21 +3202,6 @@ "RoleSupport" ] }, - "domain.SubmitAssessmentReq": { - "type": "object", - "required": [ - "answers" - ], - "properties": { - "answers": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/domain.UserAnswer" - } - } - } - }, "domain.UpdateKnowledgeLevelReq": { "type": "object", "properties": { @@ -3139,22 +3214,71 @@ } } }, - "domain.UserAnswer": { + "domain.UpdateUserReq": { "type": "object", "properties": { - "isCorrect": { - "type": "boolean" + "age": { + "$ref": "#/definitions/domain.ValidInt" }, - "questionID": { + "country": { + "$ref": "#/definitions/domain.ValidString" + }, + "educationLevel": { + "$ref": "#/definitions/domain.ValidString" + }, + "favoutiteTopic": { + "$ref": "#/definitions/domain.ValidString" + }, + "firstName": { + "$ref": "#/definitions/domain.ValidString" + }, + "knowledgeLevel": { + "description": "Profile fields", + "allOf": [ + { + "$ref": "#/definitions/domain.ValidString" + } + ] + }, + "languageChallange": { + "$ref": "#/definitions/domain.ValidString" + }, + "languageGoal": { + "$ref": "#/definitions/domain.ValidString" + }, + "lastName": { + "$ref": "#/definitions/domain.ValidString" + }, + "learningGoal": { + "$ref": "#/definitions/domain.ValidString" + }, + "nickName": { + "$ref": "#/definitions/domain.ValidString" + }, + "occupation": { + "$ref": "#/definitions/domain.ValidString" + }, + "preferredLanguage": { + "$ref": "#/definitions/domain.ValidString" + }, + "profileCompleted": { + "$ref": "#/definitions/domain.ValidBool" + }, + "profilePictureURL": { + "$ref": "#/definitions/domain.ValidString" + }, + "region": { + "$ref": "#/definitions/domain.ValidString" + }, + "status": { + "$ref": "#/definitions/domain.ValidString" + }, + "userID": { "type": "integer", "format": "int64" }, - "selectedOptionID": { - "type": "integer", - "format": "int64" - }, - "shortAnswer": { - "type": "string" + "userName": { + "$ref": "#/definitions/domain.ValidString" } } }, @@ -3260,17 +3384,52 @@ "UserStatusDeactivated" ] }, + "domain.ValidBool": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "value": { + "type": "boolean" + } + } + }, + "domain.ValidInt": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "value": { + "type": "integer" + } + } + }, + "domain.ValidString": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "value": { + "type": "string" + } + } + }, "domain.VerifyOtpReq": { "type": "object", "required": [ - "otp", - "user_name" + "otp" ], "properties": { + "email": { + "type": "string" + }, "otp": { "type": "string" }, - "user_name": { + "phone_number": { "type": "string" } } @@ -3503,40 +3662,6 @@ } } }, - "handlers.loginAdminReq": { - "type": "object", - "required": [ - "password", - "user_name" - ], - "properties": { - "password": { - "type": "string", - "example": "password123" - }, - "user_name": { - "type": "string", - "example": "adminuser" - } - } - }, - "handlers.loginUserReq": { - "type": "object", - "required": [ - "password", - "user_name" - ], - "properties": { - "password": { - "type": "string", - "example": "password123" - }, - "user_name": { - "type": "string", - "example": "johndoe" - } - } - }, "handlers.loginUserRes": { "type": "object", "properties": { @@ -3548,6 +3673,9 @@ }, "role": { "type": "string" + }, + "user_id": { + "type": "integer" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 652cd3d..ad5a3dd 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,15 @@ definitions: + authentication.LoginRequest: + properties: + email: + type: string + otp_code: + type: string + password: + type: string + phone_number: + type: string + type: object domain.AssessmentOption: properties: id: @@ -300,10 +311,10 @@ definitions: type: object domain.ResendOtpReq: properties: - user_name: + email: + type: string + phone_number: type: string - required: - - user_name type: object domain.Response: properties: @@ -330,16 +341,6 @@ definitions: - RoleStudent - RoleInstructor - RoleSupport - domain.SubmitAssessmentReq: - properties: - answers: - items: - $ref: '#/definitions/domain.UserAnswer' - minItems: 1 - type: array - required: - - answers - type: object domain.UpdateKnowledgeLevelReq: properties: knowledge_level: @@ -348,18 +349,49 @@ definitions: user_id: type: integer type: object - domain.UserAnswer: + domain.UpdateUserReq: properties: - isCorrect: - type: boolean - questionID: + age: + $ref: '#/definitions/domain.ValidInt' + country: + $ref: '#/definitions/domain.ValidString' + educationLevel: + $ref: '#/definitions/domain.ValidString' + favoutiteTopic: + $ref: '#/definitions/domain.ValidString' + firstName: + $ref: '#/definitions/domain.ValidString' + knowledgeLevel: + allOf: + - $ref: '#/definitions/domain.ValidString' + description: Profile fields + languageChallange: + $ref: '#/definitions/domain.ValidString' + languageGoal: + $ref: '#/definitions/domain.ValidString' + lastName: + $ref: '#/definitions/domain.ValidString' + learningGoal: + $ref: '#/definitions/domain.ValidString' + nickName: + $ref: '#/definitions/domain.ValidString' + occupation: + $ref: '#/definitions/domain.ValidString' + preferredLanguage: + $ref: '#/definitions/domain.ValidString' + profileCompleted: + $ref: '#/definitions/domain.ValidBool' + profilePictureURL: + $ref: '#/definitions/domain.ValidString' + region: + $ref: '#/definitions/domain.ValidString' + status: + $ref: '#/definitions/domain.ValidString' + userID: format: int64 type: integer - selectedOptionID: - format: int64 - type: integer - shortAnswer: - type: string + userName: + $ref: '#/definitions/domain.ValidString' type: object domain.UserProfileResponse: properties: @@ -431,15 +463,37 @@ definitions: - UserStatusActive - UserStatusSuspended - UserStatusDeactivated + domain.ValidBool: + properties: + valid: + type: boolean + value: + type: boolean + type: object + domain.ValidInt: + properties: + valid: + type: boolean + value: + type: integer + type: object + domain.ValidString: + properties: + valid: + type: boolean + value: + type: string + type: object domain.VerifyOtpReq: properties: + email: + type: string otp: type: string - user_name: + phone_number: type: string required: - otp - - user_name type: object handlers.AdminProfileRes: properties: @@ -596,30 +650,6 @@ definitions: - message - recipient type: object - handlers.loginAdminReq: - properties: - password: - example: password123 - type: string - user_name: - example: adminuser - type: string - required: - - password - - user_name - type: object - handlers.loginUserReq: - properties: - password: - example: password123 - type: string - user_name: - example: johndoe - type: string - required: - - password - - user_name - type: object handlers.loginUserRes: properties: access_token: @@ -628,6 +658,8 @@ definitions: type: string role: type: string + user_id: + type: integer type: object handlers.logoutReq: properties: @@ -708,7 +740,7 @@ paths: name: login required: true schema: - $ref: '#/definitions/handlers.loginAdminReq' + $ref: '#/definitions/authentication.LoginRequest' produces: - application/json responses: @@ -731,46 +763,6 @@ paths: summary: Login user tags: - auth - /api/v1/{tenant_slug}/assessment/submit: - post: - consumes: - - application/json - description: Evaluates user responses, calculates knowledge level, updates user - profile, and sends notification - parameters: - - description: User ID - in: path - name: user_id - required: true - type: integer - - description: Assessment responses - in: body - name: payload - required: true - schema: - $ref: '#/definitions/domain.SubmitAssessmentReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Submit initial knowledge assessment - tags: - - assessment /api/v1/{tenant_slug}/otp/resend: post: consumes: @@ -801,6 +793,45 @@ paths: summary: Resend OTP tags: - otp + /api/v1/{tenant_slug}/user: + put: + consumes: + - application/json + description: Updates user profile information (partial updates supported) + parameters: + - description: User ID + in: path + name: user_id + required: true + type: integer + - description: Update user payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.UpdateUserReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update user profile + tags: + - user /api/v1/{tenant_slug}/user-login: post: consumes: @@ -812,7 +843,7 @@ paths: name: login required: true schema: - $ref: '#/definitions/handlers.loginUserReq' + $ref: '#/definitions/authentication.LoginRequest' produces: - application/json responses: @@ -1108,6 +1139,54 @@ paths: summary: Get user profile tags: - user + /api/v1/{tenant_slug}/users: + get: + consumes: + - application/json + description: Get users with optional filters + parameters: + - description: Role filter + in: query + name: role + type: string + - description: Search query + in: query + name: query + type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: page_size + type: integer + - description: Created before (RFC3339) + in: query + name: created_before + type: string + - description: Created after (RFC3339) + in: query + name: created_after + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get all users + tags: + - user /api/v1/admin: get: consumes: @@ -2023,7 +2102,7 @@ paths: name: login required: true schema: - $ref: '#/definitions/handlers.loginAdminReq' + $ref: '#/definitions/authentication.LoginRequest' produces: - application/json responses: diff --git a/gen/db/initial_assessment.sql.go b/gen/db/initial_assessment.sql.go index 87f326b..6499b76 100644 --- a/gen/db/initial_assessment.sql.go +++ b/gen/db/initial_assessment.sql.go @@ -11,42 +11,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const CreateAssessmentAnswer = `-- name: CreateAssessmentAnswer :exec -INSERT INTO assessment_answers ( - attempt_id, - question_id, - selected_option_id, - short_answer, - is_correct -) -VALUES ( - $1, -- attempt_id - $2, -- question_id - $3, -- selected_option_id - $4, -- short_answer - $5 -- is_correct -) -` - -type CreateAssessmentAnswerParams struct { - AttemptID int64 `json:"attempt_id"` - QuestionID int64 `json:"question_id"` - SelectedOptionID pgtype.Int8 `json:"selected_option_id"` - ShortAnswer pgtype.Text `json:"short_answer"` - IsCorrect bool `json:"is_correct"` -} - -func (q *Queries) CreateAssessmentAnswer(ctx context.Context, arg CreateAssessmentAnswerParams) error { - _, err := q.db.Exec(ctx, CreateAssessmentAnswer, - arg.AttemptID, - arg.QuestionID, - arg.SelectedOptionID, - arg.ShortAnswer, - arg.IsCorrect, - ) - return err -} - const CreateAssessmentAttempt = `-- name: CreateAssessmentAttempt :one INSERT INTO assessment_attempts ( user_id, @@ -197,6 +161,7 @@ func (q *Queries) GetActiveAssessmentQuestions(ctx context.Context) ([]Assessmen } const GetAssessmentOptionByID = `-- name: GetAssessmentOptionByID :one + SELECT id, question_id, @@ -207,6 +172,25 @@ WHERE id = $1 LIMIT 1 ` +// -- name: CreateAssessmentAnswer :exec +// INSERT INTO assessment_answers ( +// +// attempt_id, +// question_id, +// selected_option_id, +// short_answer, +// is_correct +// +// ) +// VALUES ( +// +// $1, -- attempt_id +// $2, -- question_id +// $3, -- selected_option_id +// $4, -- short_answer +// $5 -- is_correct +// +// ); func (q *Queries) GetAssessmentOptionByID(ctx context.Context, id int64) (AssessmentQuestionOption, error) { row := q.db.QueryRow(ctx, GetAssessmentOptionByID, id) var i AssessmentQuestionOption diff --git a/gen/db/models.go b/gen/db/models.go index b4a68c8..6663a2d 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -10,7 +10,6 @@ import ( type AssessmentAnswer struct { ID int64 `json:"id"` - AttemptID int64 `json:"attempt_id"` QuestionID int64 `json:"question_id"` SelectedOptionID pgtype.Int8 `json:"selected_option_id"` ShortAnswer pgtype.Text `json:"short_answer"` @@ -198,6 +197,7 @@ type User struct { EducationLevel pgtype.Text `json:"education_level"` Country pgtype.Text `json:"country"` Region pgtype.Text `json:"region"` + Medium string `json:"medium"` KnowledgeLevel pgtype.Text `json:"knowledge_level"` NickName pgtype.Text `json:"nick_name"` Occupation pgtype.Text `json:"occupation"` diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 81f36d1..5a54342 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -267,14 +267,12 @@ SELECT education_level, country, region, - nick_name, occupation, learning_goal, language_goal, language_challange, favoutite_topic, - initial_assessment_completed, profile_picture_url, preferred_language, @@ -282,39 +280,27 @@ SELECT phone_verified, status, profile_completed, - preferred_language, created_at, updated_at FROM users -WHERE ( - role = $1 OR $1 IS NULL - ) - AND ( - first_name ILIKE '%' || $2 || '%' - OR last_name ILIKE '%' || $2 || '%' - OR phone_number ILIKE '%' || $2 || '%' - OR email ILIKE '%' || $2 || '%' - OR $2 IS NULL - ) - AND ( - created_at >= $3 - OR $3 IS NULL - ) - AND ( - created_at <= $4 - OR $4 IS NULL - ) -LIMIT $6 -OFFSET $5 +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 ` type GetAllUsersParams struct { - Role string `json:"role"` - Query pgtype.Text `json:"query"` - CreatedAfter pgtype.Timestamptz `json:"created_after"` - CreatedBefore pgtype.Timestamptz `json:"created_before"` - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` + 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"` } type GetAllUsersRow struct { @@ -343,19 +329,18 @@ type GetAllUsersRow struct { PhoneVerified bool `json:"phone_verified"` Status string `json:"status"` ProfileCompleted bool `json:"profile_completed"` - PreferredLanguage_2 pgtype.Text `json:"preferred_language_2"` 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.Role, - arg.Query, - arg.CreatedAfter, - arg.CreatedBefore, - arg.Offset, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Column4, arg.Limit, + arg.Offset, ) if err != nil { return nil, err @@ -390,7 +375,6 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get &i.PhoneVerified, &i.Status, &i.ProfileCompleted, - &i.PreferredLanguage_2, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -439,6 +423,7 @@ SELECT language_challange, favoutite_topic, + medium, email_verified, phone_verified, status, @@ -478,6 +463,7 @@ type GetUserByEmailPhoneRow struct { LanguageGoal pgtype.Text `json:"language_goal"` LanguageChallange pgtype.Text `json:"language_challange"` FavoutiteTopic pgtype.Text `json:"favoutite_topic"` + Medium string `json:"medium"` EmailVerified bool `json:"email_verified"` PhoneVerified bool `json:"phone_verified"` Status string `json:"status"` @@ -511,6 +497,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho &i.LanguageGoal, &i.LanguageChallange, &i.FavoutiteTopic, + &i.Medium, &i.EmailVerified, &i.PhoneVerified, &i.Status, @@ -525,7 +512,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho } const GetUserByID = `-- name: GetUserByID :one -SELECT id, first_name, last_name, user_name, email, phone_number, role, password, age, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favoutite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at +SELECT id, first_name, last_name, user_name, email, phone_number, role, password, age, education_level, country, region, medium, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favoutite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at FROM users WHERE id = $1 ` @@ -546,6 +533,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.EducationLevel, &i.Country, &i.Region, + &i.Medium, &i.KnowledgeLevel, &i.NickName, &i.Occupation, diff --git a/internal/config/config.go b/internal/config/config.go index cc323de..43ba271 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ var ( // } type AFROSMSConfig struct { + AfroSMSSenderName string `mapstructure:"afrom_sms_sender_name"` AfroSMSIdentifierID string `mapstructure:"afro_sms_identifier_id"` AfroSMSAPIKey string `mapstructure:"afro_sms_api_key"` AfroSMSBaseURL string `mapstructure:"afro_sms_base_url"` @@ -259,6 +260,7 @@ func (c *Config) loadEnv() error { c.AFROSMSConfig.AfroSMSAPIKey = os.Getenv("AFRO_SMS_API_KEY") c.AFROSMSConfig.AfroSMSIdentifierID = os.Getenv("AFRO_SMS_IDENTIFIER_ID") c.AFROSMSConfig.AfroSMSBaseURL = os.Getenv("AFRO_SMS_BASE_URL") + c.AFROSMSConfig.AfroSMSSenderName = os.Getenv("AFRO_SMS_SENDER_NAME") //Telebirr c.TELEBIRR.TelebirrBaseURL = os.Getenv("TELEBIRR_BASE_URL") diff --git a/internal/domain/otp.go b/internal/domain/otp.go index 66cdf9c..543662b 100644 --- a/internal/domain/otp.go +++ b/internal/domain/otp.go @@ -40,10 +40,12 @@ type Otp struct { } type VerifyOtpReq struct { - UserName string `json:"user_name" validate:"required"` - Otp string `json:"otp" validate:"required"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Otp string `json:"otp" validate:"required"` } type ResendOtpReq struct { - UserName string `json:"user_name" validate:"required"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` } diff --git a/internal/ports/initial_assessment.go b/internal/ports/initial_assessment.go index d9c0bdb..f3fef3b 100644 --- a/internal/ports/initial_assessment.go +++ b/internal/ports/initial_assessment.go @@ -11,6 +11,6 @@ type InitialAssessmentStore interface { q domain.AssessmentQuestion, ) (domain.AssessmentQuestion, error) GetActiveAssessmentQuestions(ctx context.Context) ([]domain.AssessmentQuestion, error) - SaveAssessmentAttempt(ctx context.Context, userID int64, answers []domain.UserAnswer) (domain.AssessmentAttempt, error) + // SaveAssessmentAttempt(ctx context.Context, userID int64, answers []domain.UserAnswer) (domain.AssessmentAttempt, error) GetOptionByID(ctx context.Context, optionID int64) (domain.AssessmentOption, error) } diff --git a/internal/repository/initial_assessment.go b/internal/repository/initial_assessment.go index 33c34ff..1736bfc 100644 --- a/internal/repository/initial_assessment.go +++ b/internal/repository/initial_assessment.go @@ -5,7 +5,6 @@ import ( "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/ports" "context" - "math/big" "github.com/jackc/pgx/v5/pgtype" ) @@ -105,61 +104,61 @@ func (s *Store) GetActiveAssessmentQuestions(ctx context.Context) ([]domain.Asse } // SaveAssessmentAttempt saves the attempt summary and answers -func (s *Store) SaveAssessmentAttempt(ctx context.Context, userID int64, answers []domain.UserAnswer) (domain.AssessmentAttempt, error) { - total := len(answers) - correct := 0 +// func (s *Store) SaveAssessmentAttempt(ctx context.Context, userID int64, answers []domain.UserAnswer) (domain.AssessmentAttempt, error) { +// total := len(answers) +// correct := 0 - for _, ans := range answers { - if ans.IsCorrect { - correct++ - } - } +// for _, ans := range answers { +// if ans.IsCorrect { +// correct++ +// } +// } - score := float64(correct) / float64(total) * 100 - knowledgeLevel := "BEGINNER" - switch { - case score >= 80: - knowledgeLevel = "ADVANCED" - case score >= 50: - knowledgeLevel = "INTERMEDIATE" - } +// score := float64(correct) / float64(total) * 100 +// knowledgeLevel := "BEGINNER" +// switch { +// case score >= 80: +// knowledgeLevel = "ADVANCED" +// case score >= 50: +// knowledgeLevel = "INTERMEDIATE" +// } - // Save attempt - attemptRow, err := s.queries.CreateAssessmentAttempt(ctx, dbgen.CreateAssessmentAttemptParams{ - UserID: userID, - TotalQuestions: int32(total), - CorrectAnswers: int32(correct), - ScorePercentage: pgtype.Numeric{Int: big.NewInt(int64(score * 100)), Valid: true}, - KnowledgeLevel: knowledgeLevel, - }) - if err != nil { - return domain.AssessmentAttempt{}, err - } +// // Save attempt +// attemptRow, err := s.queries.CreateAssessmentAttempt(ctx, dbgen.CreateAssessmentAttemptParams{ +// UserID: userID, +// TotalQuestions: int32(total), +// CorrectAnswers: int32(correct), +// ScorePercentage: pgtype.Numeric{Int: big.NewInt(int64(score * 100)), Valid: true}, +// KnowledgeLevel: knowledgeLevel, +// }) +// if err != nil { +// return domain.AssessmentAttempt{}, err +// } - // Save answers - for _, ans := range answers { - err := s.queries.CreateAssessmentAnswer(ctx, dbgen.CreateAssessmentAnswerParams{ - AttemptID: attemptRow.ID, - QuestionID: ans.QuestionID, - SelectedOptionID: pgtype.Int8{Int64: ans.SelectedOptionID, Valid: true}, - ShortAnswer: pgtype.Text{String: ans.ShortAnswer, Valid: true}, - IsCorrect: ans.IsCorrect, - }) - if err != nil { - return domain.AssessmentAttempt{}, err - } - } +// // Save answers +// for _, ans := range answers { +// err := s.queries.CreateAssessmentAnswer(ctx, dbgen.CreateAssessmentAnswerParams{ +// AttemptID: attemptRow.ID, +// QuestionID: ans.QuestionID, +// SelectedOptionID: pgtype.Int8{Int64: ans.SelectedOptionID, Valid: true}, +// ShortAnswer: pgtype.Text{String: ans.ShortAnswer, Valid: true}, +// IsCorrect: ans.IsCorrect, +// }) +// if err != nil { +// return domain.AssessmentAttempt{}, err +// } +// } - return domain.AssessmentAttempt{ - ID: attemptRow.ID, - UserID: userID, - TotalQuestions: total, - CorrectAnswers: correct, - ScorePercentage: score, - KnowledgeLevel: knowledgeLevel, - CompletedAt: attemptRow.CompletedAt.Time, - }, nil -} +// return domain.AssessmentAttempt{ +// ID: attemptRow.ID, +// UserID: userID, +// TotalQuestions: total, +// CorrectAnswers: correct, +// ScorePercentage: score, +// KnowledgeLevel: knowledgeLevel, +// CompletedAt: attemptRow.CompletedAt.Time, +// }, nil +// } // GetOptionByID fetches a single option to validate correctness func (s *Store) GetOptionByID(ctx context.Context, optionID int64) (domain.AssessmentOption, error) { diff --git a/internal/repository/user.go b/internal/repository/user.go index 14cc43d..cc77069 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -254,25 +254,41 @@ func (s *Store) GetAllUsers( limit, offset int32, ) ([]domain.User, int64, error) { - params := dbgen.GetAllUsersParams{ - Limit: pgtype.Int4{Int32: limit, Valid: true}, - Offset: pgtype.Int4{Int32: offset, Valid: true}, + 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 } - if role != nil { - params.Role = *role - } - - if query != nil { - params.Query = pgtype.Text{String: *query, Valid: true} - } - - if createdBefore != nil { - params.CreatedBefore = pgtype.Timestamptz{Time: *createdBefore, Valid: true} + 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 { - params.CreatedAfter = pgtype.Timestamptz{Time: *createdAfter, Valid: true} + 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} + } + + 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), } rows, err := s.queries.GetAllUsers(ctx, params) diff --git a/internal/services/assessment/initial_assessment.go b/internal/services/assessment/initial_assessment.go index f02b267..e6e272e 100644 --- a/internal/services/assessment/initial_assessment.go +++ b/internal/services/assessment/initial_assessment.go @@ -4,7 +4,6 @@ import ( "Yimaru-Backend/internal/domain" "context" "errors" - "time" ) func (s *Service) GetActiveAssessmentQuestions( @@ -70,77 +69,77 @@ func (s *Service) CreateAssessmentQuestion( return s.initialAssessmentStore.CreateAssessmentQuestion(ctx, q) } -func (s *Service) SubmitAssessment( - ctx context.Context, - userID int64, - responses []domain.UserAnswer, -) (domain.AssessmentAttempt, error) { +// func (s *Service) SubmitAssessment( +// ctx context.Context, +// userID int64, +// responses []domain.UserAnswer, +// ) (domain.AssessmentAttempt, error) { - if userID <= 0 { - return domain.AssessmentAttempt{}, errors.New("invalid user id") - } +// if userID <= 0 { +// return domain.AssessmentAttempt{}, errors.New("invalid user id") +// } - if len(responses) == 0 { - return domain.AssessmentAttempt{}, errors.New("no responses submitted") - } +// if len(responses) == 0 { +// return domain.AssessmentAttempt{}, errors.New("no responses submitted") +// } - // Step 1: Validate and evaluate answers - for i, ans := range responses { - if ans.QuestionID == 0 { - return domain.AssessmentAttempt{}, errors.New("invalid question id") - } +// // Step 1: Validate and evaluate answers +// for i, ans := range responses { +// if ans.QuestionID == 0 { +// return domain.AssessmentAttempt{}, errors.New("invalid question id") +// } - isCorrect, err := s.validateAnswer(ctx, ans) - if err != nil { - return domain.AssessmentAttempt{}, err - } +// isCorrect, err := s.validateAnswer(ctx, ans) +// if err != nil { +// return domain.AssessmentAttempt{}, err +// } - responses[i].IsCorrect = isCorrect - } +// responses[i].IsCorrect = isCorrect +// } - // Step 2: Persist assessment attempt + answers - attempt, err := s.initialAssessmentStore.SaveAssessmentAttempt( - ctx, - userID, - responses, - ) - if err != nil { - return domain.AssessmentAttempt{}, err - } +// // Step 2: Persist assessment attempt + answers +// attempt, err := s.initialAssessmentStore.SaveAssessmentAttempt( +// ctx, +// userID, +// responses, +// ) +// if err != nil { +// return domain.AssessmentAttempt{}, err +// } - // Step 3: Update user's knowledge level - if err := s.userStore.UpdateUserKnowledgeLevel( - ctx, - userID, - attempt.KnowledgeLevel, - ); err != nil { - return domain.AssessmentAttempt{}, err - } +// // Step 3: Update user's knowledge level +// if err := s.userStore.UpdateUserKnowledgeLevel( +// ctx, +// userID, +// attempt.KnowledgeLevel, +// ); err != nil { +// return domain.AssessmentAttempt{}, err +// } - // Step 4: Send in-app notification - notification := &domain.Notification{ +// // Step 4: Send in-app notification +// notification := &domain.Notification{ - RecipientID: userID, - Level: domain.NotificationLevelInfo, - Reciever: domain.NotificationRecieverSideCustomer, - IsRead: false, - DeliveryStatus: domain.DeliveryStatusSent, - DeliveryChannel: domain.DeliveryChannelInApp, - Payload: domain.NotificationPayload{ - Headline: "Knowledge Assessment Completed", - Message: "Your knowledge assessment is complete. Your knowledge level is " + attempt.KnowledgeLevel + ".", - Tags: []string{"assessment", "knowledge-level"}, - }, - Timestamp: time.Now(), - Type: domain.NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE, - } +// RecipientID: userID, +// Level: domain.NotificationLevelInfo, +// Reciever: domain.NotificationRecieverSideCustomer, +// IsRead: false, +// DeliveryStatus: domain.DeliveryStatusSent, +// DeliveryChannel: domain.DeliveryChannelInApp, +// Payload: domain.NotificationPayload{ +// Headline: "Knowledge Assessment Completed", +// Message: "Your knowledge assessment is complete. Your knowledge level is " + attempt.KnowledgeLevel + ".", +// Tags: []string{"assessment", "knowledge-level"}, +// }, +// Timestamp: time.Now(), +// Type: domain.NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE, +// } - if err := s.notificationSvc.SendNotification(ctx, notification); err != nil { - return domain.AssessmentAttempt{}, err - } +// if err := s.notificationSvc.SendNotification(ctx, notification); err != nil { +// return domain.AssessmentAttempt{}, err +// } - return attempt, nil -} +// return attempt, nil +// } func (s *Service) validateAnswer( ctx context.Context, diff --git a/internal/services/authentication/impl.go b/internal/services/authentication/impl.go index dce8474..0318690 100644 --- a/internal/services/authentication/impl.go +++ b/internal/services/authentication/impl.go @@ -21,35 +21,49 @@ var ( ) type LoginSuccess struct { - UserId int64 - Role domain.Role - RfToken string + 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, - userName, password string, + req LoginRequest, ) (LoginSuccess, error) { - user, err := s.userStore.GetUserByUserName(ctx, userName) + // 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 } - if user.Status == domain.UserStatusPending{ + if user.Status == domain.UserStatusPending { return LoginSuccess{}, domain.ErrUserNotVerified } - // Verify password - if err := matchPassword(password, user.Password); err != nil { - return LoginSuccess{}, err - } - // Status check instead of Suspended if user.Status == domain.UserStatusSuspended { return LoginSuccess{}, ErrUserSuspended } + if req.Email != "" { + if err := matchPassword(req.Password, user.Password); err != nil { + return LoginSuccess{}, err + } + } else if req.PhoneNumber != "" { + if err := s.UserSvc.VerifyOtp(ctx, req.Email, req.PhoneNumber, req.OTPCode); err != nil { + return LoginSuccess{}, err + } + } + // Handle existing refresh token oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID) if err != nil && !errors.Is(err, ErrRefreshTokenNotFound) { @@ -80,9 +94,9 @@ func (s *Service) Login( // Return login success payload return LoginSuccess{ - UserId: user.ID, - Role: user.Role, - RfToken: refreshToken, + UserId: user.ID, + Role: user.Role, + RfToken: refreshToken, }, nil } diff --git a/internal/services/authentication/service.go b/internal/services/authentication/service.go index ad428b8..30d0e96 100644 --- a/internal/services/authentication/service.go +++ b/internal/services/authentication/service.go @@ -1,6 +1,9 @@ package authentication -import "Yimaru-Backend/internal/ports" +import ( + "Yimaru-Backend/internal/ports" + "Yimaru-Backend/internal/services/user" +) // type EmailPhone struct { // Email ValidString @@ -17,13 +20,15 @@ type Tokens struct { } type Service struct { userStore ports.UserStore + UserSvc user.Service tokenStore ports.TokenStore RefreshExpiry int } -func NewService(userStore ports.UserStore, tokenStore ports.TokenStore, RefreshExpiry int) *Service { +func NewService(userStore ports.UserStore, userSvc user.Service, tokenStore ports.TokenStore, RefreshExpiry int) *Service { return &Service{ userStore: userStore, + UserSvc: userSvc, tokenStore: tokenStore, RefreshExpiry: RefreshExpiry, } diff --git a/internal/services/messenger/sms.go b/internal/services/messenger/sms.go index 5f7f809..c1767d0 100644 --- a/internal/services/messenger/sms.go +++ b/internal/services/messenger/sms.go @@ -27,7 +27,7 @@ func (s *Service) SendSMS(ctx context.Context, receiverPhone, message string) er switch settingsList.SMSProvider { case domain.AfroMessage: - return s.SendAfroMessageSMSLatest(ctx, receiverPhone, message, nil) + return s.SendAfroMessageSMS(ctx, receiverPhone, message) case domain.TwilioSms: return s.SendTwilioSMS(ctx, receiverPhone, message) default: @@ -44,12 +44,14 @@ func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message // API endpoint has been updated // TODO: no need for package for the afro message operations (pretty simple stuff) request := afro.GetRequest(apiKey, endpoint, hostURL) - request.BaseURL = "https://api.afromessage.com/api/send" + request.BaseURL = "https://api.afromessage.com" request.Method = "GET" request.Sender(senderName) request.To(receiverPhone, message) + fmt.Printf("the afro SMS request is: %v", request) + response, err := afro.MakeRequestWithContext(ctx, request) if err != nil { return err @@ -76,7 +78,7 @@ func (s *Service) SendAfroMessageSMSLatest( params := url.Values{} params.Set("to", receiverPhone) params.Set("message", message) - params.Set("sender", s.config.AFRO_SMS_SENDER_NAME) + params.Set("sender", s.config.AFROSMSConfig.AfroSMSSenderName) // Optional parameters if s.config.AFROSMSConfig.AfroSMSIdentifierID != "" { @@ -88,7 +90,7 @@ func (s *Service) SendAfroMessageSMSLatest( } // Construct full URL - reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) + reqURL := fmt.Sprintf("%s?%s", baseURL+"/api/send", params.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { @@ -96,7 +98,7 @@ func (s *Service) SendAfroMessageSMSLatest( } // AfroMessage authentication (API key) - req.Header.Set("Authorization", "Bearer "+s.config.AFRO_SMS_API_KEY) + req.Header.Set("Authorization", "Bearer "+s.config.AFROSMSConfig.AfroSMSAPIKey) req.Header.Set("Accept", "application/json") client := &http.Client{ diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go index b85dc36..c1817a0 100644 --- a/internal/services/notification/service.go +++ b/internal/services/notification/service.go @@ -23,6 +23,7 @@ import ( // "github.com/segmentio/kafka-go" "go.uber.org/zap" // afro "github.com/amanuelabay/afrosms-go" + afro "github.com/amanuelabay/afrosms-go" "github.com/gorilla/websocket" // "github.com/redis/go-redis/v9" ) @@ -72,6 +73,38 @@ func New( return svc } +func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message string) error { + apiKey := s.config.AFRO_SMS_API_KEY + senderName := s.config.AFRO_SMS_SENDER_NAME + + baseURL := "https://api.afromessage.com" + endpoint := "/api/send" + + request := afro.GetRequest(apiKey, endpoint, baseURL) + + // MUST be POST + request.Method = "POST" + + request.Sender(senderName) + request.To(receiverPhone, message) + + response, err := afro.MakeRequestWithContext(ctx, request) + if err != nil { + return err + } + + ack, ok := response["acknowledge"].(string) + if !ok { + return fmt.Errorf("unexpected SMS response format: %v", response) + } + + if ack != "success" { + return fmt.Errorf("SMS delivery failed: %v", response) + } + + return nil +} + func (s *Service) SendAfroMessageSMSTemp( ctx context.Context, receiverPhone string, diff --git a/internal/services/user/common.go b/internal/services/user/common.go index 6f0ac12..c67af5d 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -10,11 +10,69 @@ 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.UserName) + 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.UpdateUserReq{ + UserID: user.ID, + Status: domain.ValidString{ + Value: string(domain.UserStatusActive), + Valid: true, + }, + } + + s.userStore.UpdateUserStatus(ctx, newUser) + + return nil +} + func (s *Service) ResendOtp( ctx context.Context, - userName string, + email, phone string, ) error { + user, err := s.userStore.GetUserByEmailPhone(ctx, email, phone) + if err != nil { + return err + } + otpCode := helpers.GenerateOTP() message := fmt.Sprintf( @@ -22,7 +80,7 @@ func (s *Service) ResendOtp( otpCode, ) - otp, err := s.otpStore.GetOtp(ctx, userName) + otp, err := s.otpStore.GetOtp(ctx, user.UserName) if err != nil { return err } @@ -48,7 +106,7 @@ func (s *Service) ResendOtp( return fmt.Errorf("invalid otp medium: %s", otp.Medium) } - if err := s.otpStore.UpdateOtp(ctx, otpCode, userName); err != nil { + if err := s.otpStore.UpdateOtp(ctx, otpCode, user.UserName); err != nil { return err } diff --git a/internal/services/user/register.go b/internal/services/user/register.go index 82976d7..1bbb71b 100644 --- a/internal/services/user/register.go +++ b/internal/services/user/register.go @@ -6,54 +6,6 @@ import ( "time" ) -func (s *Service) VerifyOtp(ctx context.Context, userName string, otpCode string) error { - // 1. Retrieve the OTP from the store - storedOtp, err := s.otpStore.GetOtp(ctx, userName) - 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.UpdateUserReq{ - UserID: user.ID, - Status: domain.ValidString{ - Value: string(domain.UserStatusActive), - Valid: true, - }, - } - - s.userStore.UpdateUserStatus(ctx, newUser) - - return nil -} - func (s *Service) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { // email,phone,error return s.userStore.CheckPhoneEmailExist(ctx, phoneNum, email) } diff --git a/internal/services/user/user.go b/internal/services/user/user.go index a192786..8c28a48 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -6,6 +6,14 @@ import ( "strconv" ) +func (s *Service) GetUserByEmailPhone( + ctx context.Context, + email string, + phone string, +) (domain.User, error) { + return s.userStore.GetUserByEmailPhone(ctx, email, phone) +} + func (s *Service) IsUserPending(ctx context.Context, userName string) (bool, error) { return s.userStore.IsUserPending(ctx, userName) } @@ -65,3 +73,40 @@ func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) erro func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { return s.userStore.GetUserByID(ctx, id) } + +// GetAllUsers retrieves users based on the provided filter +// func (s *Service) GetAllUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) { +// var role *string +// if filter.Role != "" { +// role = &filter.Role +// } + +// var query *string +// if filter.Query.Valid { +// q := filter.Query.Value +// query = &q +// } + +// var createdBefore *time.Time +// if filter.CreatedBefore.Valid { +// b := filter.CreatedBefore.Value +// createdBefore = &b +// } + +// var createdAfter *time.Time +// if filter.CreatedAfter.Valid { +// a := filter.CreatedAfter.Value +// createdAfter = &a +// } + +// var limit int32 = 10 +// var offset int32 = 0 +// if filter.PageSize.Valid { +// limit = int32(filter.PageSize.Value) +// } +// if filter.Page.Valid && filter.PageSize.Valid { +// offset = int32(filter.Page.Value * filter.PageSize.Value) +// } + +// return s.userStore.GetAllUsers(ctx, role, query, createdBefore, createdAfter, limit, offset) +// } diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 2ef8f01..e09956c 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -15,8 +15,12 @@ import ( // loginUserReq represents the request body for the Loginuser endpoint. type loginUserReq struct { - UserName string `json:"user_name" validate:"required" example:"johndoe"` - Password string `json:"password" validate:"required" example:"password123"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Password string `json:"password"` + OtpCode string `json:"otp_code"` + // UserName string `json:"user_name" validate:"required" example:"johndoe"` + // Password string `json:"password" validate:"required" example:"password123"` } // loginUserRes represents the response body for the Loginuser endpoint. @@ -24,6 +28,7 @@ type loginUserRes struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` Role string `json:"role"` + UserID int64 `json:"user_id"` } // Loginuser godoc @@ -32,14 +37,14 @@ type loginUserRes struct { // @Tags auth // @Accept json // @Produce json -// @Param login body loginUserReq true "Login user" +// @Param login body authentication.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 loginUserReq + var req authentication.LoginRequest if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse LoginUser request", zap.Int("status_code", fiber.StatusBadRequest), @@ -63,13 +68,14 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error { }) } - successRes, err := h.authSvc.Login(c.Context(), req.UserName, req.Password) + successRes, err := h.authSvc.Login(c.Context(), req) if err != nil { switch { case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): h.mongoLoggerSvc.Info("Login attempt failed: Invalid credentials", zap.Int("status_code", fiber.StatusUnauthorized), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), + zap.String("phone_number", req.PhoneNumber), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -80,7 +86,8 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error { case errors.Is(err, authentication.ErrUserSuspended): h.mongoLoggerSvc.Info("Login attempt failed: User login has been locked", zap.Int("status_code", fiber.StatusUnauthorized), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), + zap.String("phone_number", req.PhoneNumber), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -105,7 +112,8 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error { h.mongoLoggerSvc.Info("Login attempt: user login of other role", zap.Int("status_code", fiber.StatusForbidden), zap.String("role", string(successRes.Role)), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), + zap.String("phone_number", req.PhoneNumber), zap.Time("timestamp", time.Now()), ) return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ @@ -137,6 +145,7 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error { AccessToken: accessToken, RefreshToken: successRes.RfToken, Role: string(successRes.Role), + UserID: successRes.UserId, } h.mongoLoggerSvc.Info("Login successful", @@ -154,7 +163,7 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error { // loginAdminReq represents the request body for the LoginAdmin endpoint. type loginAdminReq struct { - UserName string `json:"user_name" validate:"required" example:"adminuser"` + Email string `json:"email" validate:"required" example:"adminuser"` Password string `json:"password" validate:"required" example:"password123"` } @@ -171,14 +180,14 @@ type LoginAdminRes struct { // @Tags auth // @Accept json // @Produce json -// @Param login body loginAdminReq true "Login admin" +// @Param login body authentication.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 loginAdminReq + var req authentication.LoginRequest if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request", zap.Int("status_code", fiber.StatusBadRequest), @@ -196,13 +205,13 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, errMsg) } - successRes, err := h.authSvc.Login(c.Context(), req.UserName, req.Password) + successRes, err := h.authSvc.Login(c.Context(), authentication.LoginRequest(req)) if err != nil { switch { case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): h.mongoLoggerSvc.Info("Login attempt failed: Invalid credentials", zap.Int("status_code", fiber.StatusBadRequest), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -210,7 +219,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error { case errors.Is(err, authentication.ErrUserSuspended): h.mongoLoggerSvc.Info("Login attempt failed: User login has been locked", zap.Int("status_code", fiber.StatusForbidden), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -229,7 +238,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error { h.mongoLoggerSvc.Warn("Login attempt: admin login of user", zap.Int("status_code", fiber.StatusForbidden), zap.String("role", string(successRes.Role)), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -251,6 +260,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error { AccessToken: accessToken, RefreshToken: successRes.RfToken, Role: string(successRes.Role), + UserID: successRes.UserId, } h.mongoLoggerSvc.Info("Login successful", @@ -269,14 +279,14 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error { // @Tags auth // @Accept json // @Produce json -// @Param login body loginAdminReq true "Login super-admin" +// @Param login body authentication.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 loginAdminReq + var req authentication.LoginRequest if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request", zap.Int("status_code", fiber.StatusBadRequest), @@ -294,13 +304,13 @@ func (h *Handler) LoginSuper(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, errMsg) } - successRes, err := h.authSvc.Login(c.Context(), req.UserName, req.Password) + successRes, err := h.authSvc.Login(c.Context(), authentication.LoginRequest(req)) if err != nil { switch { case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): h.mongoLoggerSvc.Info("Login attempt failed: Invalid credentials", zap.Int("status_code", fiber.StatusBadRequest), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -308,7 +318,7 @@ func (h *Handler) LoginSuper(c *fiber.Ctx) error { case errors.Is(err, authentication.ErrUserSuspended): h.mongoLoggerSvc.Info("Login attempt failed: User login has been locked", zap.Int("status_code", fiber.StatusForbidden), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -327,7 +337,7 @@ func (h *Handler) LoginSuper(c *fiber.Ctx) error { h.mongoLoggerSvc.Warn("Login attempt: super-admin login of non-super-admin", zap.Int("status_code", fiber.StatusForbidden), zap.String("role", string(successRes.Role)), - zap.String("user_name", req.UserName), + zap.String("email", req.Email), zap.Error(err), zap.Time("timestamp", time.Now()), ) @@ -384,6 +394,7 @@ func (h *Handler) RefreshToken(c *fiber.Ctx) error { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` Role string `json:"role"` + UserID int64 `json:"user_id"` } var req refreshToken diff --git a/internal/web_server/handlers/initial_assessment.go b/internal/web_server/handlers/initial_assessment.go index ba8a10f..3a5d303 100644 --- a/internal/web_server/handlers/initial_assessment.go +++ b/internal/web_server/handlers/initial_assessment.go @@ -2,9 +2,6 @@ package handlers import ( "Yimaru-Backend/internal/domain" - "Yimaru-Backend/internal/services/authentication" - "errors" - "strconv" "github.com/gofiber/fiber/v2" ) @@ -80,63 +77,63 @@ func (h *Handler) GetActiveAssessmentQuestions(c *fiber.Ctx) error { // @Failure 404 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/{tenant_slug}/assessment/submit [post] -func (h *Handler) SubmitAssessment(c *fiber.Ctx) error { +// func (h *Handler) SubmitAssessment(c *fiber.Ctx) error { - // User ID (from auth context or path, depending on your setup) - userIDStr, ok := c.Locals("user_id").(string) - if !ok || userIDStr == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid user context", - Error: "User ID not found in request context", - }) - } +// // User ID (from auth context or path, depending on your setup) +// userIDStr, ok := c.Locals("user_id").(string) +// if !ok || userIDStr == "" { +// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ +// Message: "Invalid user context", +// Error: "User ID not found in request context", +// }) +// } - userID, err := strconv.ParseInt(userIDStr, 10, 64) - if err != nil || userID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid user ID", - Error: "User ID must be a positive integer", - }) - } +// userID, err := strconv.ParseInt(userIDStr, 10, 64) +// if err != nil || userID <= 0 { +// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ +// Message: "Invalid user ID", +// Error: "User ID must be a positive integer", +// }) +// } - // Parse request body - var req domain.SubmitAssessmentReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } +// // Parse request body +// var req domain.SubmitAssessmentReq +// if err := c.BodyParser(&req); err != nil { +// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ +// Message: "Invalid request body", +// Error: err.Error(), +// }) +// } - if len(req.Answers) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "No answers submitted", - Error: "Assessment answers cannot be empty", - }) - } +// if len(req.Answers) == 0 { +// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ +// Message: "No answers submitted", +// Error: "Assessment answers cannot be empty", +// }) +// } - // Submit assessment - attempt, err := h.assessmentSvc.SubmitAssessment( - c.Context(), - userID, - req.Answers, - ) - if err != nil { - if errors.Is(err, authentication.ErrUserNotFound) { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "User not found", - Error: err.Error(), - }) - } +// // Submit assessment +// attempt, err := h.assessmentSvc.SubmitAssessment( +// c.Context(), +// userID, +// req.Answers, +// ) +// if err != nil { +// if errors.Is(err, authentication.ErrUserNotFound) { +// return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ +// Message: "User not found", +// Error: err.Error(), +// }) +// } - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to submit assessment", - Error: err.Error(), - }) - } +// return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ +// Message: "Failed to submit assessment", +// Error: err.Error(), +// }) +// } - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Assessment submitted successfully", - Data: attempt, - }) -} +// return c.Status(fiber.StatusOK).JSON(domain.Response{ +// Message: "Assessment submitted successfully", +// Data: attempt, +// }) +// } diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index e08b83c..f61d0b1 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -497,11 +497,10 @@ func (h *Handler) SendSingleAfroSMS(c *fiber.Ctx) error { } // Send SMS via service - if err := h.notificationSvc.SendAfroMessageSMSTemp( + if err := h.notificationSvc.SendAfroMessageSMS( c.Context(), req.Recipient, req.Message, - nil, ); err != nil { h.mongoLoggerSvc.Error("Failed to send AfroMessage SMS", diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 750d4e2..79d8e38 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -174,10 +174,11 @@ func (h *Handler) ResendOtp(c *fiber.Ctx) error { }) } - user, err := h.userSvc.GetUserByUserName(c.Context(), req.UserName) + user, err := h.userSvc.GetUserByEmailPhone(c.Context(), req.Email, req.PhoneNumber) if err != nil { h.mongoLoggerSvc.Info("Failed to get user by user name", - zap.String("user_name", req.UserName), + zap.String("email", req.Email), + zap.String("phone_number", req.PhoneNumber), zap.Int("status_code", fiber.StatusBadRequest), zap.Error(err), zap.Time("timestamp", time.Now()), @@ -212,7 +213,8 @@ func (h *Handler) ResendOtp(c *fiber.Ctx) error { if err := h.userSvc.ResendOtp( c.Context(), - req.UserName, + req.Email, + req.PhoneNumber, ); err != nil { h.mongoLoggerSvc.Error("Failed to resend OTP", @@ -316,6 +318,123 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error { }) } +// GetAllUsers godoc +// @Summary Get all users +// @Description Get users with optional filters +// @Tags user +// @Accept json +// @Produce json +// @Param role query string false "Role filter" +// @Param query query string false "Search query" +// @Param page query int false "Page number" +// @Param page_size query int false "Page size" +// @Param created_before query string false "Created before (RFC3339)" +// @Param created_after query string false "Created after (RFC3339)" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/{tenant_slug}/users [get] +func (h *Handler) GetAllUsers(c *fiber.Ctx) error { + searchQuery := c.Query("query") + searchString := domain.ValidString{ + Value: searchQuery, + Valid: searchQuery != "", + } + + createdBeforeQuery := c.Query("created_before") + var createdBefore domain.ValidTime + if createdBeforeQuery != "" { + parsed, err := time.Parse(time.RFC3339, createdBeforeQuery) + if err != nil { + h.logger.Info("invalid created_before format", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid created_before format") + } + createdBefore = domain.ValidTime{Value: parsed, Valid: true} + } + + createdAfterQuery := c.Query("created_after") + var createdAfter domain.ValidTime + if createdAfterQuery != "" { + parsed, err := time.Parse(time.RFC3339, createdAfterQuery) + if err != nil { + h.logger.Info("invalid created_after format", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid created_after format") + } + createdAfter = domain.ValidTime{Value: parsed, Valid: true} + } + + filter := domain.UserFilter{ + Role: c.Query("role"), + Page: domain.ValidInt{ + Value: c.QueryInt("page", 1) - 1, + Valid: true, + }, + PageSize: domain.ValidInt{ + Value: c.QueryInt("page_size", 10), + Valid: true, + }, + Query: searchString, + CreatedBefore: createdBefore, + CreatedAfter: createdAfter, + } + + if valErrs, ok := h.validator.Validate(c, filter); !ok { + var errMsg string + for f, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", f, msg) + } + h.mongoLoggerSvc.Info("invalid filter values in GetAllUsers request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now())) + return fiber.NewError(fiber.StatusBadRequest, errMsg) + } + + users, total, err := h.userSvc.GetAllUsers(c.Context(), filter) + if err != nil { + h.mongoLoggerSvc.Error("failed to get users", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Any("filter", filter), + zap.Error(err), + zap.Time("timestamp", time.Now())) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get users: "+err.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, + FavoutiteTopic: u.FavoutiteTopic, + 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) +} + // VerifyOtp godoc // @Summary Verify OTP // @Description Verify OTP for registration or other actions @@ -353,7 +472,7 @@ func (h *Handler) VerifyOtp(c *fiber.Ctx) error { } // Call service to verify OTP - err := h.userSvc.VerifyOtp(c.Context(), req.UserName, req.Otp) + err := h.userSvc.VerifyOtp(c.Context(), req.Email, req.PhoneNumber, req.Otp) if err != nil { var errMsg string switch { diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 8dc993f..67cd64f 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -84,7 +84,7 @@ func (a *App) initAppRoutes() { //assessment Routes groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion) groupV1.Get("/assessment/questions", h.GetActiveAssessmentQuestions) - groupV1.Post("/assessment/submit", a.authMiddleware, h.SubmitAssessment) + // groupV1.Post("/assessment/submit", a.authMiddleware, h.SubmitAssessment) // Course Management Routes groupV1.Post("/course-categories", h.CreateCourseCategory) @@ -156,6 +156,7 @@ func (a *App) initAppRoutes() { // groupV1.Get("/arifpay/payment-methods", a.authMiddleware, h.GetArifpayPaymentMethodsHandler // User Routes + groupV1.Get("/users", a.authMiddleware, h.GetAllUsers) groupV1.Put("/user", a.authMiddleware, h.UpdateUser) groupV1.Put("/user/knowledge-level", h.UpdateUserKnowledgeLevel) groupV1.Get("/user/:user_name/is-unique", h.CheckUserNameUnique)