updated the authentication method from username to email/phone_numner

This commit is contained in:
Yared Yemane 2026-01-03 06:52:38 -08:00
parent 3afb2ec878
commit 7309a2bc83
28 changed files with 1299 additions and 751 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 || '%'
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 || '%'
OR $2 IS NULL
)
AND (
created_at >= $3
OR $3 IS NULL
)
AND (
created_at <= $4
OR $4 IS NULL
)
LIMIT $6
OFFSET $5
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,

View File

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

View File

@ -40,10 +40,12 @@ type Otp struct {
}
type VerifyOtpReq struct {
UserName string `json:"user_name" 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"`
}

View File

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

View File

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

View File

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

View File

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

View File

@ -26,30 +26,44 @@ type LoginSuccess struct {
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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