schema adjustment and profile management fixes

This commit is contained in:
Yared Yemane 2025-12-31 07:53:59 -08:00
parent 8ed0a5f1c6
commit d94774c138
19 changed files with 416 additions and 224 deletions

View File

@ -1,40 +1,3 @@
CREATE TABLE assessment_questions (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
question_type VARCHAR(50) NOT NULL, -- MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER
difficulty_level VARCHAR(50) NOT NULL, -- BEGINNER, INTERMEDIATE, ADVANCED
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE TABLE assessment_question_options (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE,
option_text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE assessment_attempts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total_questions INT NOT NULL,
correct_answers INT NOT NULL,
score_percentage NUMERIC(5,2) NOT NULL,
knowledge_level VARCHAR(50) NOT NULL, -- BEGINNER, INTERMEDIATE, ADVANCED
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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,
is_correct BOOLEAN NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
first_name VARCHAR(255) NOT NULL,
@ -73,6 +36,43 @@ CREATE TABLE IF NOT EXISTS users (
CHECK (email IS NOT NULL OR phone_number IS NOT NULL)
);
CREATE TABLE assessment_questions (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
question_type VARCHAR(50) NOT NULL, -- MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER
difficulty_level VARCHAR(50) NOT NULL, -- BEGINNER, INTERMEDIATE, ADVANCED
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE TABLE assessment_question_options (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE,
option_text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE assessment_attempts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total_questions INT NOT NULL,
correct_answers INT NOT NULL,
score_percentage NUMERIC(5,2) NOT NULL,
knowledge_level VARCHAR(50) NOT NULL, -- BEGINNER, INTERMEDIATE, ADVANCED
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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,
is_correct BOOLEAN NOT NULL
);
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@ -5,13 +5,18 @@ SET
used = FALSE,
used_at = NULL,
expires_at = $3
WHERE
user_name = $1
AND expires_at <= NOW();
WHERE user_name = $1;
-- name: CreateOtp :exec
INSERT INTO otps (user_name, sent_to, medium, otp_for, otp, used, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, FALSE, $6, $7);
INSERT INTO otps (
user_name,
sent_to,
medium,
otp_for,
otp,
expires_at
)
VALUES ($1, $2, $3, $4, $5, $6);
-- name: GetOtp :one
SELECT id, user_name, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at

View File

@ -210,30 +210,29 @@ WHERE (
-- name: UpdateUser :exec
UPDATE users
SET
first_name = $1,
last_name = $2,
user_name = $3,
age = $4,
education_level = $5,
country = $6,
region = $7,
nick_name = $8,
occupation = $9,
learning_goal = $10,
language_goal = $11,
language_challange = $12,
favoutite_topic = $13,
initial_assessment_completed = $14,
email_verified = $15,
phone_verified = $16,
status = $17,
profile_completed = $18,
profile_picture_url = $19,
preferred_language = $20,
first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
user_name = COALESCE($3, user_name),
knowledge_level = COALESCE($4, knowledge_level),
age = COALESCE($5, age),
education_level = COALESCE($6, education_level),
country = COALESCE($7, country),
region = COALESCE($8, region),
nick_name = COALESCE($9, nick_name),
occupation = COALESCE($10, occupation),
learning_goal = COALESCE($11, learning_goal),
language_goal = COALESCE($12, language_goal),
language_challange = COALESCE($13, language_challange),
favoutite_topic = COALESCE($14, favoutite_topic),
initial_assessment_completed = COALESCE($15, initial_assessment_completed),
email_verified = COALESCE($16, email_verified),
phone_verified = COALESCE($17, phone_verified),
status = COALESCE($18, status),
profile_completed = COALESCE($19, profile_completed),
profile_picture_url = COALESCE($20, profile_picture_url),
preferred_language = COALESCE($21, preferred_language),
updated_at = CURRENT_TIMESTAMP
WHERE id = $21;
WHERE id = $22;
-- name: DeleteUser :exec
DELETE FROM users

View File

@ -21,6 +21,23 @@ services:
- postgres_data:/var/lib/postgresql/data
- ./exports:/exports
pgadmin:
container_name: yimaru-pgadmin
image: dpage/pgadmin4:latest
restart: always
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@local.dev
PGADMIN_DEFAULT_PASSWORD: admin
depends_on:
postgres:
condition: service_healthy
networks:
- app
volumes:
- pgadmin_data:/var/lib/pgadmin
mongo:
container_name: yimaru-mongo
image: mongo:7.0.11
@ -53,21 +70,11 @@ services:
"/migrations",
"-database",
"postgresql://root:secret@postgres:5432/gh?sslmode=disable",
"up",
"up"
]
networks:
- app
# redis:
# image: redis:7-alpine
# ports:
# - "6379:6379"
# networks:
# - app
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# interval: 10s
# timeout: 5s
# retries: 5
app:
build:
context: .
@ -84,8 +91,6 @@ services:
networks:
- app
command: ["/app/bin/web"]
# volumes:
# - "C:/Users/User/Desktop:/host-desktop"
test:
build:
@ -105,3 +110,4 @@ networks:
volumes:
postgres_data:
mongo_data:
pgadmin_data:

View File

@ -12,8 +12,15 @@ import (
)
const CreateOtp = `-- name: CreateOtp :exec
INSERT INTO otps (user_name, sent_to, medium, otp_for, otp, used, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, FALSE, $6, $7)
INSERT INTO otps (
user_name,
sent_to,
medium,
otp_for,
otp,
expires_at
)
VALUES ($1, $2, $3, $4, $5, $6)
`
type CreateOtpParams struct {
@ -22,7 +29,6 @@ type CreateOtpParams struct {
Medium string `json:"medium"`
OtpFor string `json:"otp_for"`
Otp string `json:"otp"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
@ -33,7 +39,6 @@ func (q *Queries) CreateOtp(ctx context.Context, arg CreateOtpParams) error {
arg.Medium,
arg.OtpFor,
arg.Otp,
arg.CreatedAt,
arg.ExpiresAt,
)
return err
@ -100,9 +105,7 @@ SET
used = FALSE,
used_at = NULL,
expires_at = $3
WHERE
user_name = $1
AND expires_at <= NOW()
WHERE user_name = $1
`
type UpdateExpiredOtpParams struct {

View File

@ -842,36 +842,36 @@ func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams)
const UpdateUser = `-- name: UpdateUser :exec
UPDATE users
SET
first_name = $1,
last_name = $2,
user_name = $3,
age = $4,
education_level = $5,
country = $6,
region = $7,
nick_name = $8,
occupation = $9,
learning_goal = $10,
language_goal = $11,
language_challange = $12,
favoutite_topic = $13,
initial_assessment_completed = $14,
email_verified = $15,
phone_verified = $16,
status = $17,
profile_completed = $18,
profile_picture_url = $19,
preferred_language = $20,
first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
user_name = COALESCE($3, user_name),
knowledge_level = COALESCE($4, knowledge_level),
age = COALESCE($5, age),
education_level = COALESCE($6, education_level),
country = COALESCE($7, country),
region = COALESCE($8, region),
nick_name = COALESCE($9, nick_name),
occupation = COALESCE($10, occupation),
learning_goal = COALESCE($11, learning_goal),
language_goal = COALESCE($12, language_goal),
language_challange = COALESCE($13, language_challange),
favoutite_topic = COALESCE($14, favoutite_topic),
initial_assessment_completed = COALESCE($15, initial_assessment_completed),
email_verified = COALESCE($16, email_verified),
phone_verified = COALESCE($17, phone_verified),
status = COALESCE($18, status),
profile_completed = COALESCE($19, profile_completed),
profile_picture_url = COALESCE($20, profile_picture_url),
preferred_language = COALESCE($21, preferred_language),
updated_at = CURRENT_TIMESTAMP
WHERE id = $21
WHERE id = $22
`
type UpdateUserParams struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
UserName string `json:"user_name"`
KnowledgeLevel pgtype.Text `json:"knowledge_level"`
Age pgtype.Int4 `json:"age"`
EducationLevel pgtype.Text `json:"education_level"`
Country pgtype.Text `json:"country"`
@ -897,6 +897,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
arg.FirstName,
arg.LastName,
arg.UserName,
arg.KnowledgeLevel,
arg.Age,
arg.EducationLevel,
arg.Country,

View File

@ -46,6 +46,7 @@ type User struct {
Region string
// Profile fields
KnowledgeLevel string
initial_assessment_completed bool
NickName string
Occupation string
@ -185,6 +186,7 @@ type UpdateUserReq struct {
Region ValidString
// Profile fields
KnowledgeLevel ValidString
NickName ValidString
Occupation ValidString
LearningGoal ValidString

View File

@ -9,6 +9,7 @@ import (
)
type UserStore interface {
UpdateUserStatus(ctx context.Context, user domain.UpdateUserReq) error
GetCorrectOptionForQuestion(
ctx context.Context,
questionID int64,
@ -67,7 +68,7 @@ type EmailGateway interface {
SendEmailOTP(ctx context.Context, email string, otp string) error
}
type OtpStore interface {
UpdateExpiredOtp(ctx context.Context, otp, userName string) error
UpdateOtp(ctx context.Context, otp, userName string) error
MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error
CreateOtp(ctx context.Context, otp domain.Otp) error
GetOtp(ctx context.Context, userName string) (domain.Otp, error)

View File

@ -21,11 +21,19 @@ func NewTokenStore(s *Store) ports.TokenStore {
// CreateRefreshToken inserts a new refresh token into the database
func (s *Store) CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error {
rt.ExpiresAt = time.Now().Add(10 * time.Minute)
return s.queries.CreateRefreshToken(ctx, dbgen.CreateRefreshTokenParams{
UserID: rt.UserID,
Token: rt.Token,
ExpiresAt: pgtype.Timestamptz{Time: rt.ExpiresAt},
CreatedAt: pgtype.Timestamptz{Time: rt.CreatedAt},
ExpiresAt: pgtype.Timestamptz{
Time: rt.ExpiresAt,
Valid: true,
},
CreatedAt: pgtype.Timestamptz{
Time: time.Now(),
Valid: true,
},
Revoked: rt.Revoked,
})
}

View File

@ -16,7 +16,7 @@ import (
// Interface for creating new otp store
func NewOTPStore(s *Store) ports.OtpStore { return s }
func (s *Store) UpdateExpiredOtp(ctx context.Context, otp, userName string) error {
func (s *Store) UpdateOtp(ctx context.Context, otp, userName string) error {
return s.queries.UpdateExpiredOtp(ctx, dbgen.UpdateExpiredOtpParams{
UserName: userName,
Otp: otp,
@ -29,6 +29,7 @@ func (s *Store) UpdateExpiredOtp(ctx context.Context, otp, userName string) erro
func (s *Store) CreateOtp(ctx context.Context, otp domain.Otp) error {
return s.queries.CreateOtp(ctx, dbgen.CreateOtpParams{
UserName: otp.UserName,
SentTo: otp.SentTo,
Medium: string(otp.Medium),
OtpFor: string(otp.For),
@ -37,12 +38,9 @@ func (s *Store) CreateOtp(ctx context.Context, otp domain.Otp) error {
Time: otp.ExpiresAt,
Valid: true,
},
CreatedAt: pgtype.Timestamptz{
Time: otp.CreatedAt,
Valid: true,
},
})
}
func (s *Store) GetOtp(ctx context.Context, userName string) (domain.Otp, error) {
row, err := s.queries.GetOtp(ctx, userName)
if err != nil {
@ -54,6 +52,7 @@ func (s *Store) GetOtp(ctx context.Context, userName string) (domain.Otp, error)
}
return domain.Otp{
ID: row.ID,
UserName: row.UserName,
SentTo: row.SentTo,
Medium: domain.OtpMedium(row.Medium),
For: domain.OtpFor(row.OtpFor),

View File

@ -42,6 +42,13 @@ func (s *Store) IsUserNameUnique(ctx context.Context, userName string) (bool, er
return isUnique, nil
}
func (s *Store) UpdateUserStatus(ctx context.Context, user domain.UpdateUserReq) error {
return s.queries.UpdateUserStatus(ctx, dbgen.UpdateUserStatusParams{
Status: user.Status.Value,
ID: user.UserID,
})
}
func (s *Store) CreateUserWithoutOtp(
ctx context.Context,
user domain.User,

View File

@ -48,14 +48,14 @@ func (s *Service) ResendOtp(
return fmt.Errorf("invalid otp medium: %s", otp.Medium)
}
if err := s.otpStore.UpdateExpiredOtp(ctx, otp.Otp, userName); err != nil {
if err := s.otpStore.UpdateOtp(ctx, otpCode, userName); err != nil {
return err
}
return nil
}
func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error {
func (s *Service) SendOtp(ctx context.Context, userName string, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error {
otpCode := helpers.GenerateOTP()
message := fmt.Sprintf("Welcome to Yimaru Online Learning Platform, your OTP is %s please don't share with anyone.", otpCode)
@ -82,6 +82,7 @@ func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpF
}
otp := domain.Otp{
UserName: userName,
SentTo: sentTo,
Medium: medium,
For: otpFor,

View File

@ -36,6 +36,21 @@ func (s *Service) VerifyOtp(ctx context.Context, userName string, otpCode string
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
}
@ -59,7 +74,7 @@ func (s *Service) SendRegisterCode(ctx context.Context, medium domain.OtpMedium,
}
// send otp based on the medium
return s.SendOtp(ctx, sentTo, domain.OtpRegister, medium, provider)
return s.SendOtp(ctx, "", sentTo, domain.OtpRegister, medium, provider)
}
func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterUserReq) (domain.User, error) {
@ -94,7 +109,7 @@ func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterU
PhoneNumber: registerReq.PhoneNumber,
Password: hashedPassword,
Role: domain.RoleStudent,
EmailVerified: false, // verification pending via OTP
EmailVerified: false,
PhoneVerified: false,
EducationLevel: registerReq.EducationLevel,
Age: registerReq.Age,
@ -103,6 +118,16 @@ func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterU
Status: domain.UserStatusPending,
ProfileCompleted: false,
PreferredLanguage: registerReq.PreferredLanguage,
// Optional fields
NickName: registerReq.NickName,
Occupation: registerReq.Occupation,
LearningGoal: registerReq.LearningGoal,
LanguageGoal: registerReq.LanguageGoal,
LanguageChallange: registerReq.LanguageChallange,
FavoutiteTopic: registerReq.FavoutiteTopic,
// ProfilePictureURL: registerReq.ProfilePictureURL,
CreatedAt: time.Now(),
}
@ -115,7 +140,7 @@ func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterU
}
// Send OTP to the user (email/SMS)
if err := s.SendOtp(ctx, sentTo, domain.OtpRegister, registerReq.OtpMedium, domain.TwilioSms); err != nil {
if err := s.SendOtp(ctx, registerReq.UserName, sentTo, domain.OtpRegister, registerReq.OtpMedium, domain.TwilioSms); err != nil {
return domain.User{}, err
}

View File

@ -7,7 +7,7 @@ import (
"time"
)
func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error {
func (s *Service) SendResetCode(ctx context.Context, userName string, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error {
var err error
// check if user exists
@ -22,7 +22,7 @@ func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, se
return err
}
return s.SendOtp(ctx, sentTo, domain.OtpReset, medium, provider)
return s.SendOtp(ctx, userName, sentTo, domain.OtpReset, medium, provider)
}

View File

@ -31,16 +31,27 @@ func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString stri
return s.userStore.SearchUserByNameOrPhone(ctx, searchString, roleStr)
}
func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) error {
newUser := domain.User{
ID: req.UserID,
FirstName: req.FirstName.Value,
LastName: req.LastName.Value,
KnowledgeLevel: req.KnowledgeLevel.Value,
UserName: req.UserName.Value,
Age: req.Age.Value,
EducationLevel: req.EducationLevel.Value,
Country: req.Country.Value,
Region: req.Region.Value,
Status: domain.UserStatus(req.Status.Value),
NickName: req.NickName.Value,
Occupation: req.Occupation.Value,
LearningGoal: req.LearningGoal.Value,
LanguageGoal: req.LanguageGoal.Value,
LanguageChallange: req.LanguageChallange.Value,
FavoutiteTopic: req.FavoutiteTopic.Value,
PreferredLanguage: req.PreferredLanguage.Value,
ProfilePictureURL: req.ProfilePictureURL.Value,
}
// Update user in the store

View File

@ -39,11 +39,6 @@ type loginUserRes struct {
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/{tenant_slug}/user-login [post]
func (h *Handler) LoginUser(c *fiber.Ctx) error {
// OrganizationID := c.Locals("company_id").(domain.ValidInt64)
// if !OrganizationID.Valid {
// h.BadRequestLogger().Error("invalid company id")
// return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
// }
var req loginUserReq
if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("Failed to parse LoginUser request",
@ -51,7 +46,10 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error {
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body"+err.Error())
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: "Invalid request body: " + err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
@ -59,11 +57,13 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error {
for field, msg := range valErrs {
errMsg += fmt.Sprintf("%s: %s; ", field, msg)
}
return fiber.NewError(fiber.StatusBadRequest, errMsg)
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: errMsg,
})
}
successRes, err := h.authSvc.Login(c.Context(), req.UserName, req.Password)
if err != nil {
switch {
case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound):
@ -73,7 +73,10 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error {
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusUnauthorized, "Invalid credentials")
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: fmt.Sprintf("Invalid credentials: %v", err),
})
case errors.Is(err, authentication.ErrUserSuspended):
h.mongoLoggerSvc.Info("Login attempt failed: User login has been locked",
zap.Int("status_code", fiber.StatusUnauthorized),
@ -81,14 +84,20 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error {
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusUnauthorized, "User login has been locked")
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: fmt.Sprintf("User login has been locked: %v", err),
})
default:
h.mongoLoggerSvc.Error("Login failed",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: err.Error(),
})
}
}
@ -97,10 +106,12 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error {
zap.Int("status_code", fiber.StatusForbidden),
zap.String("role", string(successRes.Role)),
zap.String("user_name", req.UserName),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusForbidden, "Only users are allowed to login ")
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: "Only users are allowed to login",
})
}
accessToken, err := jwtutil.CreateJwt(
@ -116,7 +127,10 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error {
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate access token")
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to login",
Error: "Failed to generate access token",
})
}
res := loginUserRes{
@ -132,7 +146,10 @@ func (h *Handler) LoginUser(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil)
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Login successful",
Data: res,
})
}
// loginAdminReq represents the request body for the LoginAdmin endpoint.

View File

@ -13,6 +13,79 @@ import (
"go.uber.org/zap"
)
// UpdateUser godoc
// @Summary Update user profile
// @Description Updates user profile information (partial updates supported)
// @Tags user
// @Accept json
// @Produce json
// @Param user_id path int true "User ID"
// @Param body body domain.UpdateUserReq true "Update user payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/{tenant_slug}/user [put]
func (h *Handler) UpdateUser(c *fiber.Ctx) error {
// Extract user ID from context
userIDStr, ok := c.Locals("user_id").(string)
if !ok {
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",
})
}
// Parse request body
var req domain.UpdateUserReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
// Enforce user identity
req.UserID = userID
// Optional: lightweight validation (example)
// if req.Status.IsSet() {
// if !domain.(req.Status.Value) {
// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
// Message: "Invalid status value",
// Error: "Unsupported user status",
// })
// }
// }
// Call service
if err := h.userSvc.UpdateUser(c.Context(), req); 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 update user",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "User updated successfully",
})
}
// UpdateUserKnowledgeLevel godoc
// @Summary Update user's knowledge level
// @Description Updates the knowledge level of the specified user after initial assessment
@ -158,6 +231,8 @@ func (h *Handler) ResendOtp(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "OTP resent successfully",
Success: true,
StatusCode: fiber.StatusOK,
Data: nil,
})
}
@ -560,6 +635,13 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error {
Country: req.Country,
Region: req.Region,
PreferredLanguage: req.PreferredLanguage,
NickName: req.NickName,
Occupation: req.Occupation,
LearningGoal: req.LearningGoal,
LanguageGoal: req.LanguageGoal,
LanguageChallange: req.LanguageChallange,
FavoutiteTopic: req.FavoutiteTopic,
}
medium, err := getMedium(req.Email, req.PhoneNumber)
@ -600,6 +682,30 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error {
})
}
func MapRegisterReqToUser(req domain.RegisterUserReq) domain.User {
return domain.User{
FirstName: req.FirstName,
LastName: req.LastName,
UserName: req.UserName,
Email: req.Email,
PhoneNumber: req.PhoneNumber,
Password: []byte(req.Password), // or hashed password
Role: domain.Role(req.Role),
Age: req.Age,
EducationLevel: req.EducationLevel,
Country: req.Country,
Region: req.Region,
PreferredLanguage: req.PreferredLanguage,
NickName: req.NickName,
Occupation: req.Occupation,
LearningGoal: req.LearningGoal,
LanguageGoal: req.LanguageGoal,
LanguageChallange: req.LanguageChallange,
FavoutiteTopic: req.FavoutiteTopic,
}
}
type ResetCodeReq struct {
Email string `json:"email" example:"john.doe@example.com"`
PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"`
@ -654,7 +760,7 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided")
}
if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo, domain.AfroMessage); err != nil {
if err := h.userSvc.SendResetCode(c.Context(), "", medium, sentTo, domain.AfroMessage); err != nil {
h.mongoLoggerSvc.Error("Failed to send reset code",
zap.String("medium", string(medium)),
zap.String("sentTo", string(sentTo)),
@ -721,7 +827,7 @@ func (h *Handler) SendTenantResetCode(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided")
}
if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo, domain.AfroMessage); err != nil {
if err := h.userSvc.SendResetCode(c.Context(), "", medium, sentTo, domain.AfroMessage); err != nil {
h.mongoLoggerSvc.Error("Failed to send reset code",
zap.String("medium", string(medium)),
zap.String("sentTo", string(sentTo)),

View File

@ -70,24 +70,24 @@ func (a *App) authMiddleware(c *fiber.Ctx) error {
}
// Asserting to make sure that only the super admin can have a nil company ID
if claim.Role != domain.RoleSuperAdmin && !claim.CompanyID.Valid {
a.mongoLoggerSvc.Error("Company Role without Company ID",
zap.Int64("userID", claim.UserId),
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Company Role without Company ID")
}
// if claim.Role != domain.RoleSuperAdmin && !claim.CompanyID.Valid {
// a.mongoLoggerSvc.Error("Company Role without Company ID",
// zap.Int64("userID", claim.UserId),
// zap.Int("status_code", fiber.StatusInternalServerError),
// zap.Error(err),
// zap.Time("timestamp", time.Now()),
// )
// return fiber.NewError(fiber.StatusInternalServerError, "Company Role without Company ID")
// }
c.Locals("user_id", claim.UserId)
c.Locals("role", claim.Role)
c.Locals("company_id", domain.ValidInt64{
Value: claim.CompanyID.Value,
Valid: claim.CompanyID.Valid,
})
// c.Locals("company_id", domain.ValidInt64{
// Value: claim.CompanyID.Value,
// Valid: claim.CompanyID.Valid,
// })
c.Locals("refresh_token", refreshToken)
var branchID domain.ValidInt64
// var branchID domain.ValidInt64
if claim.Role == domain.RoleAdmin {
// branch, err := a.branchSvc.GetBranchByCashier(c.Context(), claim.UserId)
@ -107,7 +107,7 @@ func (a *App) authMiddleware(c *fiber.Ctx) error {
}
c.Locals("branch_id", branchID)
// c.Locals("branch_id", branchID)
return c.Next()
}
@ -126,20 +126,20 @@ func (a *App) SuperAdminOnly(c *fiber.Ctx) error {
return c.Next()
}
func (a *App) CompanyOnly(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
userRole := c.Locals("role").(domain.Role)
if userRole == domain.RoleStudent {
a.mongoLoggerSvc.Warn("Attempt to access restricted CompanyOnly route",
zap.Int64("userID", userID),
zap.String("role", string(userRole)),
zap.Int("status_code", fiber.StatusForbidden),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusForbidden, "This route is restricted")
}
return c.Next()
}
// func (a *App) CompanyOnly(c *fiber.Ctx) error {
// userID := c.Locals("user_id").(int64)
// userRole := c.Locals("role").(domain.Role)
// if userRole == domain.RoleStudent {
// a.mongoLoggerSvc.Warn("Attempt to access restricted CompanyOnly route",
// zap.Int64("userID", userID),
// zap.String("role", string(userRole)),
// zap.Int("status_code", fiber.StatusForbidden),
// zap.Time("timestamp", time.Now()),
// )
// return fiber.NewError(fiber.StatusForbidden, "This route is restricted")
// }
// return c.Next()
// }
func (a *App) OnlyAdminAndAbove(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)

View File

@ -42,31 +42,31 @@ func (a *App) initAppRoutes() {
// Groups
groupV1 := a.fiber.Group("/api/v1")
tenant := groupV1.Group("/tenant/:tenant_slug", a.TenantMiddleware)
tenant.Get("/test", a.authMiddleware, a.authMiddleware, func(c *fiber.Ctx) error {
fmt.Printf("\nTest Route %v\n", c.Route().Path)
companyID := c.Locals("company_id").(domain.ValidInt64)
if !companyID.Valid {
h.BadRequestLogger().Error("invalid company id")
return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
}
// tenant := groupV1.Group("/tenant/:tenant_slug", a.TenantMiddleware)
// groupV1.Get("/test", a.authMiddleware, a.authMiddleware, func(c *fiber.Ctx) error {
// fmt.Printf("\nTest Route %v\n", c.Route().Path)
// companyID := c.Locals("company_id").(domain.ValidInt64)
// if !companyID.Valid {
// h.BadRequestLogger().Error("invalid company id")
// return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
// }
fmt.Printf("In the tenant auth test \n")
return c.JSON(fiber.Map{
"message": "Is is fine",
})
})
tenant.Get("/", func(c *fiber.Ctx) error {
fmt.Printf("\nTenant Route %v\n", c.Route().Path)
companyID := c.Locals("company_id").(domain.ValidInt64)
if !companyID.Valid {
h.BadRequestLogger().Error("invalid company id")
return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
}
return c.JSON(fiber.Map{
"message": "Company Tenant Active",
})
})
// fmt.Printf("In the tenant auth test \n")
// return c.JSON(fiber.Map{
// "message": "Is is fine",
// })
// })
// groupV1.Get("/", func(c *fiber.Ctx) error {
// fmt.Printf("\nTenant Route %v\n", c.Route().Path)
// companyID := c.Locals("company_id").(domain.ValidInt64)
// if !companyID.Valid {
// h.BadRequestLogger().Error("invalid company id")
// return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
// }
// return c.JSON(fiber.Map{
// "message": "Company Tenant Active",
// })
// })
// Get S
groupV1.Get("/tenant", a.authMiddleware, h.GetTenantSlugByToken)
@ -84,7 +84,7 @@ func (a *App) initAppRoutes() {
//assessment Routes
groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion)
groupV1.Get("/assessment/questions", h.GetActiveAssessmentQuestions)
tenant.Post("/assessment/submit", a.authMiddleware, h.SubmitAssessment)
groupV1.Post("/assessment/submit", a.authMiddleware, h.SubmitAssessment)
// Course Management Routes
groupV1.Post("/course-categories", h.CreateCourseCategory)
@ -114,8 +114,8 @@ func (a *App) initAppRoutes() {
groupV1.Post("/levels", h.CreateLevel)
// Auth Routes
tenant.Post("/auth/customer-login", h.LoginUser)
tenant.Post("/auth/admin-login", h.LoginAdmin)
groupV1.Post("/auth/customer-login", h.LoginUser)
groupV1.Post("/auth/admin-login", h.LoginAdmin)
groupV1.Post("/auth/super-login", h.LoginSuper)
groupV1.Post("/auth/refresh", h.RefreshToken)
groupV1.Post("/auth/logout", a.authMiddleware, h.LogOutuser)
@ -156,7 +156,8 @@ func (a *App) initAppRoutes() {
// groupV1.Get("/arifpay/payment-methods", a.authMiddleware, h.GetArifpayPaymentMethodsHandler
// User Routes
tenant.Put("/user/knowledge-level", h.UpdateUserKnowledgeLevel)
groupV1.Put("/user", a.authMiddleware, h.UpdateUser)
groupV1.Put("/user/knowledge-level", h.UpdateUserKnowledgeLevel)
groupV1.Get("/user/:user_name/is-unique", h.CheckUserNameUnique)
groupV1.Get("/user/:user_name/is-pending", h.CheckUserPending)
groupV1.Post("/user/resetPassword", h.ResetPassword)
@ -164,15 +165,15 @@ func (a *App) initAppRoutes() {
groupV1.Post("/user/verify-otp", h.VerifyOtp)
groupV1.Post("/user/resend-otp", h.ResendOtp)
tenant.Post("/user/resetPassword", h.ResetTenantPassword)
tenant.Post("/user/sendResetCode", h.SendTenantResetCode)
tenant.Post("/user/register", h.RegisterUser)
tenant.Post("/user/sendRegisterCode", h.SendRegisterCode)
tenant.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist)
groupV1.Post("/user/resetPassword", h.ResetTenantPassword)
groupV1.Post("/user/sendResetCode", h.SendTenantResetCode)
groupV1.Post("/user/register", h.RegisterUser)
groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode)
groupV1.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist)
groupV1.Get("/user/admin-profile", a.authMiddleware, h.AdminProfile)
tenant.Get("/user/user-profile", a.authMiddleware, h.GetUserProfile)
groupV1.Get("/user/user-profile", a.authMiddleware, h.GetUserProfile)
groupV1.Get("/user/single/:id", a.authMiddleware, h.GetUserByID)
groupV1.Delete("/user/delete/:id", a.authMiddleware, h.DeleteUser)