From d94774c1385bb4cac26632732aa9625f7713c265 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 31 Dec 2025 07:53:59 -0800 Subject: [PATCH] schema adjustment and profile management fixes --- db/migrations/000001_yimaru.up.sql | 74 ++++++------ db/query/otp.sql | 15 ++- db/query/user.sql | 47 ++++---- docker-compose.yml | 34 +++--- gen/db/otp.sql.go | 17 +-- gen/db/user.sql.go | 49 ++++---- internal/domain/user.go | 2 + internal/ports/user.go | 3 +- internal/repository/auth.go | 18 ++- internal/repository/otp.go | 17 ++- internal/repository/user.go | 7 ++ internal/services/user/common.go | 5 +- internal/services/user/register.go | 33 +++++- internal/services/user/reset.go | 4 +- internal/services/user/user.go | 27 +++-- internal/web_server/handlers/auth_handler.go | 47 +++++--- internal/web_server/handlers/user.go | 114 ++++++++++++++++++- internal/web_server/middleware.go | 58 +++++----- internal/web_server/routes.go | 69 +++++------ 19 files changed, 416 insertions(+), 224 deletions(-) diff --git a/db/migrations/000001_yimaru.up.sql b/db/migrations/000001_yimaru.up.sql index e24f68f..d0ad527 100644 --- a/db/migrations/000001_yimaru.up.sql +++ b/db/migrations/000001_yimaru.up.sql @@ -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, diff --git a/db/query/otp.sql b/db/query/otp.sql index d858b33..2c3e906 100644 --- a/db/query/otp.sql +++ b/db/query/otp.sql @@ -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 diff --git a/db/query/user.sql b/db/query/user.sql index e061e3c..bf4ab29 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -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, - updated_at = CURRENT_TIMESTAMP -WHERE id = $21; + 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 = $22; -- name: DeleteUser :exec DELETE FROM users diff --git a/docker-compose.yml b/docker-compose.yml index 0563fda..05bafaf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/gen/db/otp.sql.go b/gen/db/otp.sql.go index 47c2ecb..7b0d5f4 100644 --- a/gen/db/otp.sql.go +++ b/gen/db/otp.sql.go @@ -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 { diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 84b15c7..81f36d1 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -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, - updated_at = CURRENT_TIMESTAMP -WHERE id = $21 + 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 = $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, diff --git a/internal/domain/user.go b/internal/domain/user.go index f47ea80..7ffc425 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -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 diff --git a/internal/ports/user.go b/internal/ports/user.go index 202ea60..55fe0fb 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -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) diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 2284352..0458310 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -21,12 +21,20 @@ 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}, - Revoked: rt.Revoked, + UserID: rt.UserID, + Token: rt.Token, + ExpiresAt: pgtype.Timestamptz{ + Time: rt.ExpiresAt, + Valid: true, + }, + CreatedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + Revoked: rt.Revoked, }) } diff --git a/internal/repository/otp.go b/internal/repository/otp.go index 4698bef..862027c 100644 --- a/internal/repository/otp.go +++ b/internal/repository/otp.go @@ -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,20 +29,18 @@ 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{ - SentTo: otp.SentTo, - Medium: string(otp.Medium), - OtpFor: string(otp.For), - Otp: otp.Otp, + UserName: otp.UserName, + SentTo: otp.SentTo, + Medium: string(otp.Medium), + OtpFor: string(otp.For), + Otp: otp.Otp, ExpiresAt: pgtype.Timestamptz{ 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), diff --git a/internal/repository/user.go b/internal/repository/user.go index 0f858be..14cc43d 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -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, diff --git a/internal/services/user/common.go b/internal/services/user/common.go index a49fcea..6f0ac12 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -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, diff --git a/internal/services/user/register.go b/internal/services/user/register.go index ba8df9b..82976d7 100644 --- a/internal/services/user/register.go +++ b/internal/services/user/register.go @@ -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,7 +118,17 @@ func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterU Status: domain.UserStatusPending, ProfileCompleted: false, PreferredLanguage: registerReq.PreferredLanguage, - CreatedAt: time.Now(), + + // 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(), } var sentTo string @@ -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 } diff --git a/internal/services/user/reset.go b/internal/services/user/reset.go index 5da2071..3fe50b4 100644 --- a/internal/services/user/reset.go +++ b/internal/services/user/reset.go @@ -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) } diff --git a/internal/services/user/user.go b/internal/services/user/user.go index b199197..a192786 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -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, - UserName: req.UserName.Value, - Age: req.Age.Value, - EducationLevel: req.EducationLevel.Value, - Country: req.Country.Value, - Region: req.Region.Value, + 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 diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 8366e11..2ef8f01 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -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. diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 9c7a151..750d4e2 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -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 @@ -157,8 +230,10 @@ func (h *Handler) ResendOtp(c *fiber.Ctx) error { } return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "OTP resent successfully", - Data: nil, + 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)), diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index 7684af6..c565d5a 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -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) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 1b3b6d2..8dc993f 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)