team management + minor fixes

This commit is contained in:
Yared Yemane 2026-02-06 10:47:42 -08:00
parent 834a807edc
commit 97c4f3d28f
10 changed files with 285 additions and 139 deletions

View File

@ -72,10 +72,10 @@ VALUES
'Admin', 'Admin',
'Female', 'Female',
'1995-01-01', '1995-01-01',
'admin@yimaru.com', 'yaredyemane1@gmail.com',
'0911001100', '0911001100',
'ADMIN', 'ADMIN',
crypt('password@123', gen_salt('bf'))::bytea, crypt('password123', gen_salt('bf'))::bytea,
'35_44', '35_44',
'Master', 'Master',
'Ethiopia', 'Ethiopia',
@ -410,3 +410,172 @@ VALUES
(3, 12, 1), (3, 12, 1),
(4, 10, 1), (4, 12, 2) (4, 10, 1), (4, 12, 2)
ON CONFLICT (question_set_id, user_id) DO NOTHING; ON CONFLICT (question_set_id, user_id) DO NOTHING;
-- ======================================================
-- Team Members (Internal LMS Staff)
-- ======================================================
INSERT INTO team_members (
id,
first_name,
last_name,
email,
phone_number,
password,
team_role,
department,
job_title,
employment_type,
hire_date,
bio,
status,
email_verified,
permissions,
created_at
)
VALUES
(
1,
'Yared',
'Yemane',
'yared@yimaru.com',
'0911001100',
crypt('password@123', gen_salt('bf'))::bytea,
'SUPER_ADMIN',
'Engineering',
'CTO',
'full_time',
'2024-01-01',
'Platform super administrator with full system access.',
'active',
TRUE,
'["*"]'::jsonb,
CURRENT_TIMESTAMP
),
(
2,
'Admin',
'User',
'admin@yimaru.com',
'0922001100',
crypt('password@123', gen_salt('bf'))::bytea,
'ADMIN',
'Operations',
'Operations Manager',
'full_time',
'2024-02-01',
'Administrative staff managing day-to-day operations.',
'active',
TRUE,
'["users.manage", "courses.manage", "settings.manage"]'::jsonb,
CURRENT_TIMESTAMP
),
(
3,
'Content',
'MANAGER',
'content@yimaru.com',
'0933001100',
crypt('password@123', gen_salt('bf'))::bytea,
'CONTENT_MANAGER',
'Content',
'Content Lead',
'full_time',
'2024-03-01',
'Manages all course content and curriculum.',
'active',
TRUE,
'["courses.manage", "courses.publish", "content.manage"]'::jsonb,
CURRENT_TIMESTAMP
),
(
4,
'Support',
'AGENT',
'support-team@yimaru.com',
'0944001100',
crypt('password@123', gen_salt('bf'))::bytea,
'SUPPORT_AGENT',
'Support',
'Customer Support Specialist',
'full_time',
'2024-03-15',
'Handles customer inquiries and support tickets.',
'active',
TRUE,
'["users.view", "tickets.manage", "support.manage"]'::jsonb,
CURRENT_TIMESTAMP
),
(
5,
'INSTRUCTOR',
'Demo',
'instructor@yimaru.com',
'0955001100',
crypt('password@123', gen_salt('bf'))::bytea,
'INSTRUCTOR',
'Education',
'Senior Instructor',
'full_time',
'2024-04-01',
'Creates and manages course materials.',
'active',
TRUE,
'["courses.create", "courses.edit", "students.view"]'::jsonb,
CURRENT_TIMESTAMP
),
(
6,
'FINANCE',
'Officer',
'finance@yimaru.com',
'0966001100',
crypt('password@123', gen_salt('bf'))::bytea,
'FINANCE',
'Finance',
'Finance Officer',
'full_time',
'2024-04-15',
'Manages payments, subscriptions, and financial reports.',
'active',
TRUE,
'["payments.manage", "subscriptions.manage", "reports.finance"]'::jsonb,
CURRENT_TIMESTAMP
),
(
7,
'HR',
'MANAGER',
'hr@yimaru.com',
'0977001100',
crypt('password@123', gen_salt('bf'))::bytea,
'HR',
'Human Resources',
'HR Manager',
'full_time',
'2024-05-01',
'Manages team members and HR operations.',
'active',
TRUE,
'["team.manage", "team.create", "team.delete"]'::jsonb,
CURRENT_TIMESTAMP
),
(
8,
'Data',
'Analyst',
'analyst@yimaru.com',
'0988001100',
crypt('password@123', gen_salt('bf'))::bytea,
'ANALYST',
'Analytics',
'Data Analyst',
'contract',
'2024-06-01',
'Generates reports and analyzes platform metrics.',
'active',
TRUE,
'["reports.view", "analytics.view", "users.view"]'::jsonb,
CURRENT_TIMESTAMP
)
ON CONFLICT (id) DO NOTHING;

View File

@ -14,14 +14,14 @@ CREATE TABLE IF NOT EXISTS team_members (
-- Role within the team (different from learner roles) -- Role within the team (different from learner roles)
team_role VARCHAR(50) NOT NULL CHECK ( team_role VARCHAR(50) NOT NULL CHECK (
team_role IN ( team_role IN (
'super_admin', -- Full system access 'SUPER_ADMIN', -- Full system access
'admin', -- Administrative tasks 'ADMIN', -- Administrative tasks
'content_manager', -- Manages courses, content 'CONTENT_MANAGER', -- Manages courses, content
'support_agent', -- Customer support 'SUPPORT_AGENT', -- Customer support
'instructor', -- Creates/manages courses 'INSTRUCTOR', -- Creates/manages courses
'finance', -- Payment/subscription management 'FINANCE', -- Payment/subscription management
'hr', -- Team member management 'HR', -- Team member management
'analyst' -- Reports and analytics 'ANALYST' -- Reports and analytics
) )
), ),

View File

@ -3,11 +3,11 @@ package domain
type Role string type Role string
const ( const (
RoleSuperAdmin Role = "super_admin" RoleSuperAdmin Role = "SUPER_ADMIN"
RoleAdmin Role = "admin" RoleAdmin Role = "ADMIN"
RoleStudent Role = "student" RoleStudent Role = "STUDENT"
RoleInstructor Role = "instructor" RoleInstructor Role = "INSTRUCTOR"
RoleSupport Role = "support" RoleSupport Role = "SUPPORT"
) )
func (r Role) IsValid() bool { func (r Role) IsValid() bool {

View File

@ -169,7 +169,8 @@ type CreateUserReq struct {
} }
type ResetPasswordReq struct { type ResetPasswordReq struct {
UserID int64 Email string
PhoneNumber string
Password string Password string
OtpCode string OtpCode string
} }

View File

@ -71,6 +71,7 @@ type UserStore interface {
phone string, phone string,
) (domain.User, error) ) (domain.User, error)
UpdatePassword(ctx context.Context, password string, userID int64) error UpdatePassword(ctx context.Context, password string, userID int64) error
UpdatePasswordHash(ctx context.Context, hashedPassword []byte, userID int64) error
RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error
GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error) GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error)
DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error

View File

@ -751,7 +751,7 @@ func (s *Store) GetUserByEmailPhone(
}, nil }, nil
} }
// UpdatePassword updates a user's password // UpdatePassword updates a user's password (deprecated - use UpdatePasswordHash)
func (s *Store) UpdatePassword(ctx context.Context, password string, userID int64) error { func (s *Store) UpdatePassword(ctx context.Context, password string, userID int64) error {
return s.queries.UpdatePassword(ctx, dbgen.UpdatePasswordParams{ return s.queries.UpdatePassword(ctx, dbgen.UpdatePasswordParams{
Password: []byte(password), Password: []byte(password),
@ -759,6 +759,14 @@ func (s *Store) UpdatePassword(ctx context.Context, password string, userID int6
}) })
} }
// UpdatePasswordHash updates a user's password with a pre-hashed value
func (s *Store) UpdatePasswordHash(ctx context.Context, hashedPassword []byte, userID int64) error {
return s.queries.UpdatePassword(ctx, dbgen.UpdatePasswordParams{
Password: hashedPassword,
ID: userID,
})
}
// mapUser converts dbgen.User to domain.User // mapUser converts dbgen.User to domain.User
func mapCreateUserResult( func mapCreateUserResult(
userRes dbgen.CreateUserRow, userRes dbgen.CreateUserRow,

View File

@ -3,54 +3,91 @@ package user
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"context" "context"
"crypto/subtle"
"errors"
"time" "time"
) )
func (s *Service) SendResetCode(ctx context.Context, userID int64, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error { func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error {
var user domain.User
var err error var err error
// check if user exists
// Look up user by email or phone to get the actual userID
switch medium { switch medium {
case domain.OtpMediumEmail: case domain.OtpMediumEmail:
_, err = s.userStore.GetUserByEmailPhone(ctx, sentTo, "") user, err = s.userStore.GetUserByEmailPhone(ctx, sentTo, "")
case domain.OtpMediumSms: case domain.OtpMediumSms:
_, err = s.userStore.GetUserByEmailPhone(ctx, "", sentTo) user, err = s.userStore.GetUserByEmailPhone(ctx, "", sentTo)
default:
return errors.New("invalid OTP medium")
} }
if err != nil { if err != nil {
return err return err
} }
return s.SendOtp(ctx, userID, sentTo, domain.OtpReset, medium, provider) // Use the actual user ID when storing OTP
return s.SendOtp(ctx, user.ID, sentTo, domain.OtpReset, medium, provider)
} }
func (s *Service) ResetPassword(ctx context.Context, resetReq domain.ResetPasswordReq) error { func (s *Service) ResetPassword(ctx context.Context, resetReq domain.ResetPasswordReq) error {
// Look up user by email or phone (don't trust client-provided user_id)
var user domain.User
var err error
if resetReq.Email != "" {
user, err = s.userStore.GetUserByEmailPhone(ctx, resetReq.Email, "")
} else if resetReq.PhoneNumber != "" {
user, err = s.userStore.GetUserByEmailPhone(ctx, "", resetReq.PhoneNumber)
} else {
return errors.New("email or phone number is required")
}
otp, err := s.otpStore.GetOtp(ctx, resetReq.UserID)
if err != nil { if err != nil {
return err return err
} }
// user, err := s.userStore.GetUserByUserName(ctx, resetReq.UserName) // Get OTP for the actual user
// if err != nil { otp, err := s.otpStore.GetOtp(ctx, user.ID)
// return err if err != nil {
// } return err
}
// Validate OTP purpose (should be for reset, not registration)
if otp.For != domain.OtpReset {
return domain.ErrInvalidOtp
}
if otp.Used { if otp.Used {
return domain.ErrOtpAlreadyUsed return domain.ErrOtpAlreadyUsed
} }
if time.Now().After(otp.ExpiresAt) { if time.Now().After(otp.ExpiresAt) {
return domain.ErrOtpExpired return domain.ErrOtpExpired
} }
if otp.Otp != resetReq.OtpCode {
// Use constant-time comparison for OTP
if subtle.ConstantTimeCompare([]byte(otp.Otp), []byte(resetReq.OtpCode)) != 1 {
return domain.ErrInvalidOtp return domain.ErrInvalidOtp
} }
err = s.userStore.UpdatePassword(ctx, resetReq.Password, resetReq.UserID) // Hash the new password before storing
hashedPassword, err := hashPassword(resetReq.Password)
if err != nil { if err != nil {
return err return err
} }
// Update password with hashed value
err = s.userStore.UpdatePasswordHash(ctx, hashedPassword, user.ID)
if err != nil {
return err
}
// Mark OTP as used to prevent replay attacks
err = s.otpStore.MarkOtpAsUsed(ctx, otp)
if err != nil {
return err
}
return nil return nil
} }

View File

@ -73,11 +73,11 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Login successful", Message: "Login successful",
Data: fiber.Map{ Data: loginUserRes{
"accessToken": accessToken, AccessToken: accessToken,
"refreshToken": loginRes.RfToken, RefreshToken: loginRes.RfToken,
"userId": loginRes.UserId, Role: string(loginRes.Role),
"role": loginRes.Role, UserID: loginRes.UserId,
}, },
}) })
} }
@ -145,11 +145,11 @@ func (h *Handler) GoogleCallback(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Login successful", Message: "Login successful",
Data: fiber.Map{ Data: loginUserRes{
"accessToken": accessToken, AccessToken: accessToken,
"refreshToken": loginRes.RfToken, RefreshToken: loginRes.RfToken,
"userId": loginRes.UserId, Role: string(loginRes.Role),
"role": loginRes.Role, UserID: loginRes.UserId,
}, },
}) })
} }

View File

@ -1001,7 +1001,7 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided") return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided")
} }
if err := h.userSvc.SendResetCode(c.Context(), 0, 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", h.mongoLoggerSvc.Error("Failed to send reset code",
zap.String("medium", string(medium)), zap.String("medium", string(medium)),
zap.String("sentTo", string(sentTo)), zap.String("sentTo", string(sentTo)),
@ -1068,7 +1068,7 @@ func (h *Handler) SendTenantResetCode(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided") return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided")
} }
if err := h.userSvc.SendResetCode(c.Context(), 0, 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", h.mongoLoggerSvc.Error("Failed to send reset code",
zap.String("medium", string(medium)), zap.String("medium", string(medium)),
zap.String("sentTo", string(sentTo)), zap.String("sentTo", string(sentTo)),
@ -1083,7 +1083,8 @@ func (h *Handler) SendTenantResetCode(c *fiber.Ctx) error {
} }
type ResetPasswordReq struct { type ResetPasswordReq struct {
UserID int64 `json:"user_name" validate:"required" example:"johndoe"` Email string `json:"email" example:"john.doe@example.com"`
PhoneNumber string `json:"phone_number" example:"1234567890"`
Password string `json:"password" validate:"required,min=8" example:"newpassword123"` Password string `json:"password" validate:"required,min=8" example:"newpassword123"`
Otp string `json:"otp" validate:"required" example:"123456"` Otp string `json:"otp" validate:"required" example:"123456"`
} }
@ -1100,7 +1101,6 @@ type ResetPasswordReq struct {
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /api/v1/user/resetPassword [post] // @Router /api/v1/user/resetPassword [post]
func (h *Handler) ResetPassword(c *fiber.Ctx) error { func (h *Handler) ResetPassword(c *fiber.Ctx) error {
var req ResetPasswordReq var req ResetPasswordReq
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("Failed to parse ResetPassword request", h.mongoLoggerSvc.Info("Failed to parse ResetPassword request",
@ -1119,37 +1119,21 @@ func (h *Handler) ResetPassword(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, errMsg) return fiber.NewError(fiber.StatusBadRequest, errMsg)
} }
// user, err := h.userSvc.GetUserByUserName(c.Context(), req.UserName) if req.Email == "" && req.PhoneNumber == "" {
// if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Email or phone number is required")
// h.mongoLoggerSvc.Info("Failed to get user by user name", }
// zap.String("user_name", req.UserName),
// zap.Int("status_code", fiber.StatusBadRequest),
// zap.Error(err),
// zap.Time("timestamp", time.Now()),
// )
// }
// medium, err := getMedium(user.Email, user.PhoneNumber)
// if err != nil {
// h.mongoLoggerSvc.Info("Failed to determine medium for ResetPassword",
// zap.String("Email", user.Email),
// zap.String("Phone Number", user.PhoneNumber),
// zap.Int("status_code", fiber.StatusBadRequest),
// zap.Error(err),
// zap.Time("timestamp", time.Now()),
// )
// return fiber.NewError(fiber.StatusBadRequest, err.Error())
// }
resetReq := domain.ResetPasswordReq{ resetReq := domain.ResetPasswordReq{
UserID: req.UserID, Email: req.Email,
PhoneNumber: req.PhoneNumber,
Password: req.Password, Password: req.Password,
OtpCode: req.Otp, OtpCode: req.Otp,
} }
if err := h.userSvc.ResetPassword(c.Context(), resetReq); err != nil { if err := h.userSvc.ResetPassword(c.Context(), resetReq); err != nil {
h.mongoLoggerSvc.Error("Failed to reset password", h.mongoLoggerSvc.Error("Failed to reset password",
zap.Any("userID", resetReq), zap.String("email", req.Email),
zap.String("phone", req.PhoneNumber),
zap.Int("status_code", fiber.StatusInternalServerError), zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err), zap.Error(err),
zap.Time("timestamp", time.Now()), zap.Time("timestamp", time.Now()),
@ -1172,59 +1156,8 @@ func (h *Handler) ResetPassword(c *fiber.Ctx) error {
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /api/v1/{tenant_slug}/user/resetPassword [post] // @Router /api/v1/{tenant_slug}/user/resetPassword [post]
func (h *Handler) ResetTenantPassword(c *fiber.Ctx) error { func (h *Handler) ResetTenantPassword(c *fiber.Ctx) error {
// companyID := c.Locals("company_id").(domain.ValidInt64) // Reuse the main ResetPassword handler
// if !companyID.Valid { return h.ResetPassword(c)
// h.BadRequestLogger().Error("invalid company id")
// return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
// }
var req ResetPasswordReq
if err := c.BodyParser(&req); err != nil {
h.mongoLoggerSvc.Info("Failed to parse ResetPassword request",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error())
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
var errMsg string
for field, msg := range valErrs {
errMsg += fmt.Sprintf("%s: %s; ", field, msg)
}
return fiber.NewError(fiber.StatusBadRequest, errMsg)
}
// medium, err := getMedium(req.Email, req.PhoneNumber)
// if err != nil {
// h.mongoLoggerSvc.Info("Failed to determine medium for ResetPassword",
// 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()),
// )
// return fiber.NewError(fiber.StatusBadRequest, err.Error())
// }
resetReq := domain.ResetPasswordReq{
UserID: req.UserID,
Password: req.Password,
OtpCode: req.Otp,
}
if err := h.userSvc.ResetPassword(c.Context(), resetReq); err != nil {
h.mongoLoggerSvc.Error("Failed to reset password",
zap.Any("userID", resetReq),
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset password:"+err.Error())
}
return response.WriteJSON(c, fiber.StatusOK, "Password reset successful", nil, nil)
} }
// CustomerProfile godoc // CustomerProfile godoc

View File

@ -288,9 +288,6 @@ func (a *App) initAppRoutes() {
groupV1.Post("/user/sendResetCode", h.SendResetCode) groupV1.Post("/user/sendResetCode", h.SendResetCode)
groupV1.Post("/user/verify-otp", h.VerifyOtp) groupV1.Post("/user/verify-otp", h.VerifyOtp)
groupV1.Post("/user/resend-otp", h.ResendOtp) groupV1.Post("/user/resend-otp", h.ResendOtp)
groupV1.Post("/user/resetPassword", h.ResetTenantPassword)
groupV1.Post("/user/sendResetCode", h.SendTenantResetCode)
groupV1.Post("/user/register", h.RegisterUser) groupV1.Post("/user/register", h.RegisterUser)
groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode) groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode)
groupV1.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist) groupV1.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist)
@ -314,7 +311,7 @@ func (a *App) initAppRoutes() {
// groupV1.Put("/t-approver/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTransactionApprover) // groupV1.Put("/t-approver/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTransactionApprover)
//mongoDB logs //mongoDB logs
groupV1.Get("/logs", a.authMiddleware, a.SuperAdminOnly, handlers.GetLogsHandler(context.Background())) groupV1.Get("/logs", a.authMiddleware, a.OnlyAdminAndAbove, handlers.GetLogsHandler(context.Background()))
// groupV1.Get("/shop/transaction", a.authMiddleware, a.CompanyOnly, h.GetAllTransactions) // groupV1.Get("/shop/transaction", a.authMiddleware, a.CompanyOnly, h.GetAllTransactions)
// groupV1.Get("/shop/transaction/:id", a.authMiddleware, a.CompanyOnly, h.GetTransactionByID) // groupV1.Get("/shop/transaction/:id", a.authMiddleware, a.CompanyOnly, h.GetTransactionByID)