From 8a1491c1f57e4799bd3fa43ac9ebb78ac3d43f47 Mon Sep 17 00:00:00 2001 From: Daffa Fawwaz Date: Fri, 24 Oct 2025 09:54:19 +0700 Subject: [PATCH 1/3] feat: auth, user-management --- api/handlers/user_handler.go | 44 ++++++++++++++++++++ api/routes/user_routes.go | 2 + main.go | 4 +- pkg/auth/repository.go | 4 +- pkg/auth/service.go | 4 +- pkg/user/repository.go | 4 ++ pkg/user/service.go | 80 +++++++++++++++++++++++++----------- 7 files changed, 112 insertions(+), 30 deletions(-) diff --git a/api/handlers/user_handler.go b/api/handlers/user_handler.go index f4adf70..e27443d 100644 --- a/api/handlers/user_handler.go +++ b/api/handlers/user_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "net/http" "api-shiners/pkg/user" @@ -42,3 +43,46 @@ func (ctrl *UserController) GetUserByID(c *fiber.Ctx) error { return utils.Success(c, http.StatusOK, "Get user by ID successfully", user, nil) } + +func (ctrl *UserController) SetUserRole(c *fiber.Ctx) error { + ctx := context.Background() + + // 🔹 Ambil user_id dari URL + userIDParam := c.Params("id") + userID, err := uuid.Parse(userIDParam) + if err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid user ID format", "InvalidUUID", nil) + } + + // 🔹 Ambil nama role dari body + var req struct { + Role string `json:"role"` + } + + if err := c.BodyParser(&req); err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid request body", "BadRequestException", nil) + } + + if req.Role == "" { + return utils.Error(c, http.StatusBadRequest, "Role name is required", "BadRequestException", nil) + } + + // 🔹 Jalankan service → ambil user hasil update + updatedUser, err := ctrl.userService.SetUserRole(ctx, userID, req.Role) + if err != nil { + return utils.Error(c, http.StatusBadRequest, err.Error(), "SetRoleException", nil) + } + + // 🔹 Siapkan response data + data := fiber.Map{ + "user_id": updatedUser.ID, + "name": updatedUser.Name, + "email": updatedUser.Email, + "role": req.Role, + } + + // 🔹 Response sukses + return utils.Success(c, http.StatusOK, "User role assigned successfully", data, nil) +} + + diff --git a/api/routes/user_routes.go b/api/routes/user_routes.go index 431ad9f..254177d 100644 --- a/api/routes/user_routes.go +++ b/api/routes/user_routes.go @@ -11,4 +11,6 @@ func UserRoutes(app *fiber.App, userController *handlers.UserController) { api := app.Group("/api") api.Get("/users", middleware.AdminMiddleware, userController.GetAllUsers) api.Get("/users/:id", middleware.AuthMiddleware, userController.GetUserByID) + + api.Post("/users/:id/role", middleware.AdminMiddleware, userController.SetUserRole) } \ No newline at end of file diff --git a/main.go b/main.go index d5fdc29..c552af8 100644 --- a/main.go +++ b/main.go @@ -18,8 +18,8 @@ import ( "api-shiners/api/handlers" "api-shiners/api/routes" "api-shiners/pkg/auth" - "api-shiners/pkg/user" "api-shiners/pkg/config" + "api-shiners/pkg/user" _ "api-shiners/docs" @@ -65,7 +65,7 @@ func main() { healthController := handlers.NewHealthController() userRepo := user.NewUserRepository(config.DB) - userService := user.NewUserService(userRepo) + userService := user.NewUserService(userRepo, authRepo) userController := handlers.NewUserController(userService) routes.UserRoutes(app, userController) diff --git a/pkg/auth/repository.go b/pkg/auth/repository.go index 203ec29..c8862e5 100644 --- a/pkg/auth/repository.go +++ b/pkg/auth/repository.go @@ -10,7 +10,7 @@ import ( "gorm.io/gorm" ) -type UserRepository interface { +type AuthRepository interface { FindByEmail(ctx context.Context, email string) (*entities.User, error) CreateUser(ctx context.Context, user *entities.User) error FindRoleByName(ctx context.Context, name string) (*entities.Role, error) @@ -28,7 +28,7 @@ type userRepository struct { db *gorm.DB } -func NewUserRepository(db *gorm.DB) UserRepository { +func NewUserRepository(db *gorm.DB) AuthRepository { return &userRepository{db: db} } diff --git a/pkg/auth/service.go b/pkg/auth/service.go index 88188ec..2b50715 100644 --- a/pkg/auth/service.go +++ b/pkg/auth/service.go @@ -34,10 +34,10 @@ type AuthService interface { } type authService struct { - userRepo UserRepository + userRepo AuthRepository } -func NewAuthService(userRepo UserRepository) AuthService { +func NewAuthService(userRepo AuthRepository) AuthService { return &authService{userRepo: userRepo} } diff --git a/pkg/user/repository.go b/pkg/user/repository.go index 05f52d7..2874367 100644 --- a/pkg/user/repository.go +++ b/pkg/user/repository.go @@ -31,3 +31,7 @@ func (r *userRepository) GetByID(id uuid.UUID) (entities.User, error) { err := r.db.Preload("Roles").First(&user, "id = ?", id).Error return user, err } + + + + diff --git a/pkg/user/service.go b/pkg/user/service.go index 02b0c9a..484fac6 100644 --- a/pkg/user/service.go +++ b/pkg/user/service.go @@ -1,29 +1,35 @@ package user import ( + "api-shiners/pkg/auth" + "api-shiners/pkg/config" + "api-shiners/pkg/entities" "context" "encoding/json" + "errors" "fmt" - "log" "time" - "api-shiners/pkg/config" - "api-shiners/pkg/entities" - "github.com/google/uuid" ) type UserService interface { GetAllUsers() ([]entities.User, error) GetUserByID(id uuid.UUID) (entities.User, error) + SetUserRole(ctx context.Context, userID uuid.UUID, roleName string) (*entities.User, error) } type userService struct { userRepo UserRepository + authRepo auth.AuthRepository } -func NewUserService(userRepo UserRepository) UserService { - return &userService{userRepo: userRepo} +// ✅ Constructor tunggal — wajib dipakai +func NewUserService(userRepo UserRepository, authRepo auth.AuthRepository) UserService { + return &userService{ + userRepo: userRepo, + authRepo: authRepo, + } } // ===== GET ALL USERS (dengan caching Redis opsional) ===== @@ -33,15 +39,13 @@ func (s *userService) GetAllUsers() ([]entities.User, error) { var users []entities.User - // 🔹 Coba ambil dari Redis (jika aktif) + // 🔹 Coba ambil dari Redis if config.RedisClient != nil { val, err := config.RedisClient.Get(ctx, cacheKey).Result() if err == nil && val != "" { if err := json.Unmarshal([]byte(val), &users); err == nil { return users, nil } - } else if err != nil && err.Error() != "redis: client is closed" { - log.Println("⚠️ Redis not available or not running, skip caching...") } } @@ -51,51 +55,79 @@ func (s *userService) GetAllUsers() ([]entities.User, error) { return nil, err } - // 🔹 Simpan ke cache (jika Redis aktif) + // 🔹 Simpan ke Redis if config.RedisClient != nil { data, _ := json.Marshal(users) - err := config.RedisClient.Set(ctx, cacheKey, data, 5*time.Minute).Err() - if err != nil { - log.Println("⚠️ Failed to cache users:", err) - } + config.RedisClient.Set(ctx, cacheKey, data, 5*time.Minute) } return users, nil } -// ===== GET USER BY ID (dengan caching Redis opsional) ===== +// ===== GET USER BY ID ===== func (s *userService) GetUserByID(id uuid.UUID) (entities.User, error) { ctx := context.Background() cacheKey := fmt.Sprintf("user:%s", id.String()) var user entities.User - // 🔹 Coba ambil dari Redis (jika aktif) if config.RedisClient != nil { val, err := config.RedisClient.Get(ctx, cacheKey).Result() if err == nil && val != "" { if err := json.Unmarshal([]byte(val), &user); err == nil { return user, nil } - } else if err != nil && err.Error() != "redis: client is closed" { - log.Println("⚠️ Redis not available or not running, skip caching...") } } - // 🔹 Ambil dari DB user, err := s.userRepo.GetByID(id) if err != nil { return entities.User{}, err } - // 🔹 Simpan ke Redis (jika aktif) if config.RedisClient != nil { data, _ := json.Marshal(user) - err := config.RedisClient.Set(ctx, cacheKey, data, 10*time.Minute).Err() - if err != nil { - log.Println("⚠️ Failed to cache user:", err) - } + config.RedisClient.Set(ctx, cacheKey, data, 10*time.Minute) } return user, nil } + +// ===== SET USER ROLE ===== +func (s *userService) SetUserRole(ctx context.Context, userID uuid.UUID, roleName string) (*entities.User, error) { + // Cek dependency dulu + if s.userRepo == nil || s.authRepo == nil { + return nil, errors.New("userRepo atau authRepo belum diinisialisasi dengan benar") + } + + // 1. Cari user berdasarkan ID + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, errors.New("user not found") + } + + // 2. Cari role berdasarkan nama + role, err := s.authRepo.FindRoleByName(ctx, roleName) + if err != nil { + return nil, errors.New("role not found") + } + + // 3. Buat entitas relasi user-role + userRole := &entities.UserRole{ + UserID: user.ID, + RoleID: role.ID, + } + + // 4. Assign role ke user + if err := s.authRepo.AssignUserRole(ctx, userRole); err != nil { + return nil, err + } + + // 5. Ambil ulang user untuk dikembalikan (dengan role terbaru) + updatedUser, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, err + } + + return &updatedUser, nil +} From 347b1ec88633af419a479ae5e22235b187850265 Mon Sep 17 00:00:00 2001 From: Daffa Fawwaz Date: Fri, 24 Oct 2025 16:49:20 +0700 Subject: [PATCH 2/3] fix: set role user --- pkg/auth/repository.go | 25 +++++++++++++++++++++++++ pkg/test/auth_test.go | 5 +++++ pkg/user/service.go | 10 +++++++--- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/pkg/auth/repository.go b/pkg/auth/repository.go index c8862e5..3cd6e0e 100644 --- a/pkg/auth/repository.go +++ b/pkg/auth/repository.go @@ -16,6 +16,7 @@ type AuthRepository interface { FindRoleByName(ctx context.Context, name string) (*entities.Role, error) AssignUserRole(ctx context.Context, userRole *entities.UserRole) error UpdateUser(ctx context.Context, user *entities.User) error + RemoveAllRolesFromUser(ctx context.Context, userID uuid.UUID) error // 🔹 Tambahan untuk Forgot & Reset Password SaveResetToken(ctx context.Context, userID uuid.UUID, token string, expiresAt time.Time) error @@ -62,9 +63,33 @@ func (r *userRepository) FindRoleByName(ctx context.Context, name string) (*enti } func (r *userRepository) AssignUserRole(ctx context.Context, userRole *entities.UserRole) error { + // Cek apakah user sudah punya role ini + var existing entities.UserRole + err := r.db.WithContext(ctx). + Where("user_id = ? AND role_id = ?", userRole.UserID, userRole.RoleID). + First(&existing).Error + + if err == nil { + // Sudah ada, tidak perlu insert ulang + return nil + } + + if !errors.Is(err, gorm.ErrRecordNotFound) { + // Error lain (misal DB error) + return err + } + + // Belum ada → insert baru return r.db.WithContext(ctx).Create(userRole).Error } +func (r *userRepository) RemoveAllRolesFromUser(ctx context.Context, userID uuid.UUID) error { + return r.db.WithContext(ctx). + Where("user_id = ?", userID). + Delete(&entities.UserRole{}).Error +} + + func (r *userRepository) UpdateUser(ctx context.Context, user *entities.User) error { return r.db.WithContext(ctx).Save(user).Error } diff --git a/pkg/test/auth_test.go b/pkg/test/auth_test.go index 9ae755b..c615c6f 100644 --- a/pkg/test/auth_test.go +++ b/pkg/test/auth_test.go @@ -233,3 +233,8 @@ func TestResetPassword_InvalidToken(t *testing.T) { assert.Error(t, err) assert.EqualError(t, err, "invalid or expired token") } + +func (m *MockUserRepo) RemoveAllRolesFromUser(ctx context.Context, userID uuid.UUID) error { + args := m.Called(ctx, userID) + return args.Error(0) +} diff --git a/pkg/user/service.go b/pkg/user/service.go index 484fac6..8a628a5 100644 --- a/pkg/user/service.go +++ b/pkg/user/service.go @@ -112,13 +112,16 @@ func (s *userService) SetUserRole(ctx context.Context, userID uuid.UUID, roleNam return nil, errors.New("role not found") } - // 3. Buat entitas relasi user-role + // 3. Hapus semua role lama user + if err := s.authRepo.RemoveAllRolesFromUser(ctx, user.ID); err != nil { + return nil, fmt.Errorf("failed to clear old roles: %v", err) + } + + // 4. Tambahkan role baru userRole := &entities.UserRole{ UserID: user.ID, RoleID: role.ID, } - - // 4. Assign role ke user if err := s.authRepo.AssignUserRole(ctx, userRole); err != nil { return nil, err } @@ -131,3 +134,4 @@ func (s *userService) SetUserRole(ctx context.Context, userID uuid.UUID, roleNam return &updatedUser, nil } + From ecebc0576e374d28075ccec25fd00276c40628f5 Mon Sep 17 00:00:00 2001 From: Daffa Fawwaz Date: Sat, 1 Nov 2025 15:10:06 +0700 Subject: [PATCH 3/3] feat: doc-swagger/feedback --- api/handlers/auth_handler.go | 80 +- api/handlers/dto/auth_request.go | 45 ++ api/handlers/dto/feedback_request.go | 10 + api/handlers/dto/user_request.go | 55 ++ api/handlers/feedback_handler.go | 147 ++++ api/handlers/health_handler.go | 18 +- api/handlers/user_handler.go | 260 ++++++- api/routes/feedback_routes.go | 21 + api/routes/user_routes.go | 6 + docs/docs.go | 1005 ++++++++++++++++++++++++-- docs/swagger.json | 1005 ++++++++++++++++++++++++-- docs/swagger.yaml | 660 ++++++++++++++++- index.html | 61 ++ main.go | 6 + pkg/auth/repository.go | 19 +- pkg/auth/service.go | 49 +- pkg/config/database.go | 2 + pkg/entities/answer.go | 1 - pkg/entities/choice.go | 1 - pkg/entities/course.go | 2 - pkg/entities/enrollment.go | 3 - pkg/entities/feedback_answer.go | 18 + pkg/entities/feedback_question.go | 17 + pkg/entities/log_book.go | 3 - pkg/entities/log_book_entrie.go | 2 - pkg/entities/material.go | 2 - pkg/entities/question.go | 1 - pkg/entities/quiz_attempt.go | 1 - pkg/entities/quizz.go | 1 - pkg/entities/role.go | 1 - pkg/entities/user.go | 1 - pkg/feedback/repository.go | 61 ++ pkg/feedback/service.go | 53 ++ pkg/middleware/admin_middleware.go | 42 +- pkg/middleware/teacher_middleware.go | 83 +++ pkg/user/repository.go | 58 +- pkg/user/service.go | 111 ++- pkg/utils/permission.go | 33 + pkg/utils/response.go | 4 - 39 files changed, 3618 insertions(+), 330 deletions(-) create mode 100644 api/handlers/dto/auth_request.go create mode 100644 api/handlers/dto/feedback_request.go create mode 100644 api/handlers/dto/user_request.go create mode 100644 api/handlers/feedback_handler.go create mode 100644 api/routes/feedback_routes.go create mode 100644 index.html create mode 100644 pkg/entities/feedback_answer.go create mode 100644 pkg/entities/feedback_question.go create mode 100644 pkg/feedback/repository.go create mode 100644 pkg/feedback/service.go create mode 100644 pkg/middleware/teacher_middleware.go create mode 100644 pkg/utils/permission.go diff --git a/api/handlers/auth_handler.go b/api/handlers/auth_handler.go index 7786195..a4620c4 100644 --- a/api/handlers/auth_handler.go +++ b/api/handlers/auth_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "api-shiners/api/handlers/dto" "api-shiners/pkg/auth" "api-shiners/pkg/utils" "context" @@ -10,7 +11,6 @@ import ( "github.com/gofiber/fiber/v2" ) -// AuthController handles authentication related endpoints type AuthController struct { authService auth.AuthService } @@ -19,17 +19,16 @@ func NewAuthController(authService auth.AuthService) AuthController { return AuthController{authService: authService} } -// ==================== REGISTER ==================== -// Register godoc // @Summary Register a new user -// @Description Create a new user account +// @Description Membuat akun user baru // @Tags Auth // @Accept json // @Produce json -// @Param request body auth.RegisterRequest true "Register Request" -// @Success 201 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} +// @Param request body dto.RegisterRequest true "Register Request" +// @Success 201 {object} dto.RegisterResponse +// @Failure 400 {object} utils.ErrorResponse +// @Failure 500 {object} utils.ErrorResponse // @Router /api/auth/register [post] func (ctrl *AuthController) Register(c *fiber.Ctx) error { var req auth.RegisterRequest @@ -45,17 +44,16 @@ func (ctrl *AuthController) Register(c *fiber.Ctx) error { return utils.Success(c, http.StatusCreated, "User registered successfully", createdUser, nil) } -// ==================== LOGIN ==================== -// Login godoc // @Summary Login user -// @Description Authenticate user and return JWT token +// @Description Autentikasi user dan mendapatkan JWT token // @Tags Auth // @Accept json // @Produce json -// @Param request body auth.LoginRequest true "Login Request" -// @Success 200 {object} map[string]interface{} -// @Failure 401 {object} map[string]interface{} +// @Param request body dto.LoginRequest true "Login Request" +// @Success 200 {object} dto.LoginResponse +// @Failure 400 {object} utils.ErrorResponse +// @Failure 401 {object} utils.ErrorResponse // @Router /api/auth/login [post] func (ctrl *AuthController) Login(c *fiber.Ctx) error { var req auth.LoginRequest @@ -63,31 +61,36 @@ func (ctrl *AuthController) Login(c *fiber.Ctx) error { return utils.Error(c, http.StatusBadRequest, "Invalid request body", "BadRequestException", nil) } - token, exp, err := ctrl.authService.Login(context.Background(), req) + user, token, exp, permissions, err := ctrl.authService.LoginCore(context.Background(), req) if err != nil { return utils.Error(c, http.StatusUnauthorized, err.Error(), "UnauthorizedException", nil) } data := fiber.Map{ - "token": token, - "expires_in": exp.Format(time.RFC3339), - "token_type": "Bearer", + "token": token, + "expires_in": exp.Format(time.RFC3339), + "token_type": "Bearer", + "user": fiber.Map{ + "id": user.ID, + "name": user.Name, + "role": user.Roles, + "permissions": permissions, + }, } return utils.Success(c, http.StatusOK, "Login successful", data, nil) } -// ==================== LOGOUT ==================== -// Logout godoc // @Summary Logout user -// @Description Invalidate user token +// @Description Mengakhiri sesi dan menonaktifkan token // @Tags Auth // @Accept json // @Produce json // @Security BearerAuth -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} +// @Success 200 {object} dto.GenericResponse +// @Failure 400 {object} utils.ErrorResponse +// @Failure 401 {object} utils.ErrorResponse // @Router /api/auth/logout [post] func (ctrl *AuthController) Logout(c *fiber.Ctx) error { token := c.Get("Authorization") @@ -107,22 +110,19 @@ func (ctrl *AuthController) Logout(c *fiber.Ctx) error { return utils.Success(c, http.StatusOK, "Logout successful", nil, nil) } -// ==================== FORGOT PASSWORD ==================== -// ForgotPassword godoc // @Summary Request password reset -// @Description Generate reset token and send it to user's email +// @Description Generate reset token dan kirim ke email user // @Tags Auth // @Accept json // @Produce json -// @Param request body map[string]string true "Email Request" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} +// @Param request body dto.ForgotPasswordRequest true "Forgot Password Request" +// @Success 200 {object} dto.GenericResponse +// @Failure 400 {object} utils.ErrorResponse +// @Failure 500 {object} utils.ErrorResponse // @Router /api/auth/forgot-password [post] func (ctrl *AuthController) ForgotPassword(c *fiber.Ctx) error { - var req struct { - Email string `json:"email"` - } + var req dto.ForgotPasswordRequest if err := c.BodyParser(&req); err != nil || req.Email == "" { return utils.Error(c, http.StatusBadRequest, "Email is required", "BadRequestException", nil) } @@ -134,27 +134,23 @@ func (ctrl *AuthController) ForgotPassword(c *fiber.Ctx) error { return utils.Success(c, http.StatusOK, "Password reset token generated", fiber.Map{ "email": req.Email, - "token": token, // tampilkan untuk testing + "token": token, // ditampilkan untuk keperluan testing }, nil) } -// ==================== RESET PASSWORD ==================== -// ResetPassword godoc // @Summary Reset user password -// @Description Reset password using valid reset token +// @Description Reset password menggunakan reset token yang valid // @Tags Auth // @Accept json // @Produce json -// @Param request body map[string]string true "Reset Password Request" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} +// @Param request body dto.ResetPasswordRequest true "Reset Password Request" +// @Success 200 {object} dto.GenericResponse +// @Failure 400 {object} utils.ErrorResponse +// @Failure 500 {object} utils.ErrorResponse // @Router /api/auth/reset-password [post] func (ctrl *AuthController) ResetPassword(c *fiber.Ctx) error { - var req struct { - Token string `json:"token"` - NewPassword string `json:"new_password"` - } + var req dto.ResetPasswordRequest if err := c.BodyParser(&req); err != nil || req.Token == "" || req.NewPassword == "" { return utils.Error(c, http.StatusBadRequest, "Token and new password required", "BadRequestException", nil) } diff --git a/api/handlers/dto/auth_request.go b/api/handlers/dto/auth_request.go new file mode 100644 index 0000000..d9a8cc0 --- /dev/null +++ b/api/handlers/dto/auth_request.go @@ -0,0 +1,45 @@ +package dto + +// RegisterRequest — payload untuk registrasi user +type RegisterRequest struct { + Name string `json:"name" example:"John Doe"` + Email string `json:"email" example:"john@example.com"` + Password string `json:"password" example:"strongpassword123"` +} + +// RegisterResponse — response sukses registrasi +type RegisterResponse struct { + ID string `json:"id" example:"a3b2c1d4-56ef-7890-gh12-ijk345lmn678"` + Name string `json:"name" example:"John Doe"` + Email string `json:"email" example:"john@example.com"` +} + +// LoginRequest — payload login user +type LoginRequest struct { + Email string `json:"email" example:"john@example.com"` + Password string `json:"password" example:"strongpassword123"` +} + +// LoginResponse — response sukses login +type LoginResponse struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"` + ExpiresIn string `json:"expires_in" example:"2025-10-18T15:04:05Z"` + TokenType string `json:"token_type" example:"Bearer"` + User interface{} `json:"user"` +} + +// ForgotPasswordRequest — payload untuk lupa password +type ForgotPasswordRequest struct { + Email string `json:"email" example:"john@example.com"` +} + +// ResetPasswordRequest — payload untuk reset password +type ResetPasswordRequest struct { + Token string `json:"token" example:"123456"` + NewPassword string `json:"new_password" example:"newStrongPassword123"` +} + +// GenericResponse — response umum (sukses tanpa data) +type GenericResponse struct { + Message string `json:"message" example:"Operation successful"` +} diff --git a/api/handlers/dto/feedback_request.go b/api/handlers/dto/feedback_request.go new file mode 100644 index 0000000..8766c6f --- /dev/null +++ b/api/handlers/dto/feedback_request.go @@ -0,0 +1,10 @@ +package dto + +type CreateQuestionRequest struct { + Question string `json:"question" example:"Apa pendapat Anda tentang pelatihan ini?"` +} + +type SubmitAnswerRequest struct { + QuestionID string `json:"question_id" example:"b5a1c6c3-1234-4bcd-9123-a12b34cd56ef"` + Answer string `json:"answer" example:"Sangat bermanfaat dan jelas"` +} \ No newline at end of file diff --git a/api/handlers/dto/user_request.go b/api/handlers/dto/user_request.go new file mode 100644 index 0000000..2a4c150 --- /dev/null +++ b/api/handlers/dto/user_request.go @@ -0,0 +1,55 @@ +package dto + +// ==================== REQUEST DTO ==================== + +type SetRoleRequest struct { + Role string `json:"role" example:"ADMIN"` +} + +// ==================== RESPONSE DTO ==================== + +type UserResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + Role string `json:"role,omitempty"` +} + +type UserRoleResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` +} + +type UserStatusResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + IsActive bool `json:"is_active"` +} + +type UserProfileResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + Roles []string `json:"roles"` +} + +// ==================== PAGINATION META ==================== + +// MetaResponse digunakan untuk metadata pagination di response +type MetaResponse struct { + Page int `json:"page" example:"1"` + PerPage int `json:"per_page" example:"10"` + Total int `json:"total" example:"100"` +} + +// ==================== PAGINATED RESPONSE ==================== + +type PaginatedUsersResponse struct { + Data []UserResponse `json:"data"` + Meta MetaResponse `json:"meta"` +} diff --git a/api/handlers/feedback_handler.go b/api/handlers/feedback_handler.go new file mode 100644 index 0000000..de98190 --- /dev/null +++ b/api/handlers/feedback_handler.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "api-shiners/api/handlers/dto" + "api-shiners/pkg/feedback" + "api-shiners/pkg/utils" + "context" + "net/http" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type FeedbackController struct { + service feedback.FeedbackService +} + +func NewFeedbackController(service feedback.FeedbackService) *FeedbackController { + return &FeedbackController{service: service} +} + + +// @Summary Create new feedback question +// @Description Admin membuat pertanyaan feedback baru +// @Tags Feedback +// @Accept json +// @Produce json +// @Param request body dto.CreateQuestionRequest true "Question payload" +// @Success 201 {object} entities.FeedbackQuestion +// @Failure 400 {object} utils.ErrorResponse +// @Failure 401 {object} utils.ErrorResponse +// @Failure 500 {object} utils.ErrorResponse +// @Router /api/feedback/questions [post] +func (h *FeedbackController) CreateQuestion(c *fiber.Ctx) error { + var req dto.CreateQuestionRequest + if err := c.BodyParser(&req); err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid request body", "BadRequest", nil) + } + + if req.Question == "" { + return utils.Error(c, http.StatusBadRequest, "Question is required", "ValidationError", nil) + } + + userIDStr := c.Locals("user_id") + if userIDStr == nil { + return utils.Error(c, http.StatusUnauthorized, "Unauthorized", "Unauthorized", nil) + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + return utils.Error(c, http.StatusUnauthorized, "Invalid user ID", "Unauthorized", nil) + } + + ctx := context.Background() + + question, err := h.service.CreateQuestion(ctx, req.Question, userID) + if err != nil { + return utils.Error(c, http.StatusInternalServerError, "Failed to create question", "InternalServerError", nil) + } + + return utils.Success(c, http.StatusCreated, "Feedback question created successfully", question, nil) +} + + +// @Summary Get all feedback questions +// @Description Mendapatkan semua pertanyaan feedback +// @Tags Feedback +// @Produce json +// @Success 200 {array} entities.FeedbackQuestion +// @Failure 500 {object} utils.ErrorResponse +// @Router /api/feedback/questions [get] +func (h *FeedbackController) GetAllQuestions(c *fiber.Ctx) error { + ctx := context.Background() + questions, err := h.service.GetAllQuestions(ctx) + if err != nil { + return utils.Error(c, http.StatusInternalServerError, "Failed to get questions", "InternalServerError", nil) + } + + return utils.Success(c, http.StatusOK, "Get all feedback questions successfully", questions, nil) +} + + +// @Summary Submit feedback answer +// @Description Mahasiswa mengirimkan jawaban feedback +// @Tags Feedback +// @Accept json +// @Produce json +// @Param request body dto.SubmitAnswerRequest true "Answer payload" +// @Success 201 {object} utils.SuccessResponse +// @Failure 400 {object} utils.ErrorResponse +// @Failure 401 {object} utils.ErrorResponse +// @Failure 500 {object} utils.ErrorResponse +// @Router /api/feedback/answers [post] +func (h *FeedbackController) SubmitAnswer(c *fiber.Ctx) error { + var req dto.SubmitAnswerRequest + if err := c.BodyParser(&req); err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid request body", "BadRequest", nil) + } + + if req.QuestionID == "" || req.Answer == "" { + return utils.Error(c, http.StatusBadRequest, "QuestionID and Answer are required", "ValidationError", nil) + } + + userIDStr := c.Locals("user_id") + if userIDStr == nil { + return utils.Error(c, http.StatusUnauthorized, "Unauthorized", "Unauthorized", nil) + } + + studentID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + return utils.Error(c, http.StatusUnauthorized, "Invalid user ID", "Unauthorized", nil) + } + + questionID, err := uuid.Parse(req.QuestionID) + if err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid question ID", "BadRequest", nil) + } + + ctx := context.Background() + if err := h.service.SubmitAnswer(ctx, questionID, studentID, req.Answer); err != nil { + return utils.Error(c, http.StatusInternalServerError, "Failed to submit answer", "InternalServerError", nil) + } + + return utils.Success(c, http.StatusCreated, "Answer submitted successfully", nil, nil) +} + + + +func (h *FeedbackController) GetQuestionsWithAnswersByTeacher(c *fiber.Ctx) error { + userIDStr := c.Locals("user_id") + if userIDStr == nil { + return utils.Error(c, http.StatusUnauthorized, "Unauthorized", "Unauthorized", nil) + } + + teacherID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + return utils.Error(c, http.StatusUnauthorized, "Invalid teacher ID", "Unauthorized", nil) + } + + ctx := context.Background() + questions, err := h.service.GetQuestionsWithAnswersByTeacher(ctx, teacherID) + if err != nil { + return utils.Error(c, http.StatusInternalServerError, "Failed to get feedback data", "InternalServerError", nil) + } + + return utils.Success(c, http.StatusOK, "Get feedback with answers successfully", questions, nil) +} diff --git a/api/handlers/health_handler.go b/api/handlers/health_handler.go index a148e10..8d38af0 100644 --- a/api/handlers/health_handler.go +++ b/api/handlers/health_handler.go @@ -10,10 +10,8 @@ import ( "github.com/gofiber/fiber/v2" ) -// HealthController untuk handle health check API type HealthController struct{} -// NewHealthController membuat instance baru HealthController func NewHealthController() HealthController { return HealthController{} } @@ -25,13 +23,12 @@ func NewHealthController() HealthController { // @Produce json // @Success 200 {object} map[string]interface{} // @Failure 503 {object} map[string]interface{} -// @Router /api/health [get] +// @Router /api/health/database [get] func (ctrl *HealthController) HealthCheckDatabase(c *fiber.Ctx) error { status := fiber.Map{ "status": "ok", } - // ✅ Cek koneksi database PostgreSQL sqlDB, err := config.DB.DB() if err != nil { status["database"] = "error: cannot access sql.DB" @@ -50,28 +47,33 @@ func (ctrl *HealthController) HealthCheckDatabase(c *fiber.Ctx) error { } + +// HealthCheck godoc +// @Summary Check service health +// @Description Check database connection status +// @Tags Health +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 503 {object} map[string]interface{} +// @Router /api/health/redis [get] func (ctrl *HealthController) HealthCheckRedis(c *fiber.Ctx) error { status := fiber.Map{ "status": "ok", } - // 🔹 Gunakan context lokal ctx := context.Background() - // 🔹 Cek apakah RedisClient tersedia if config.RedisClient == nil { status["redis"] = "not connected" return utils.Success(c, http.StatusOK, "Redis not initialized (dev mode or disabled)", status, nil) } - // 🔹 Coba ping ke Redis _, err := config.RedisClient.Ping(ctx).Result() if err != nil { status["redis"] = "not connected" return utils.Success(c, http.StatusOK, fmt.Sprintf("Redis not connected: %v", err.Error()), status, nil) } - // 🔹 Jika Redis sehat status["redis"] = "connected" return utils.Success(c, http.StatusOK, "Redis connected successfully", status, nil) } diff --git a/api/handlers/user_handler.go b/api/handlers/user_handler.go index e27443d..e606f88 100644 --- a/api/handlers/user_handler.go +++ b/api/handlers/user_handler.go @@ -1,11 +1,11 @@ package handlers import ( - "context" - "net/http" - + "api-shiners/api/handlers/dto" "api-shiners/pkg/user" "api-shiners/pkg/utils" + "context" + "net/http" "github.com/gofiber/fiber/v2" "github.com/google/uuid" @@ -16,73 +16,269 @@ type UserController struct { } func NewUserController(userService user.UserService) *UserController { - return &UserController{userService} + return &UserController{userService: userService} } + +// GetAllUsers godoc +// @Summary Get all users +// @Description Retrieve a paginated list of all users +// @Tags Users +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} dto.PaginatedUsersResponse +// @Failure 500 {object} utils.ErrorResponse +// @Router /api/users [get] func (ctrl *UserController) GetAllUsers(c *fiber.Ctx) error { - users, err := ctrl.userService.GetAllUsers() + page := c.QueryInt("page", 1) + perPage := c.QueryInt("per_page", 10) + + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 10 + } + + users, total, err := ctrl.userService.GetAllUsers(page, perPage) if err != nil { return utils.Error(c, http.StatusInternalServerError, err.Error(), "InternalServerError", nil) } - return utils.Success(c, http.StatusOK, "Get all users successfully", users, nil) + // mapping ke DTO + var userDTOs []dto.UserResponse + for _, u := range users { + userDTOs = append(userDTOs, dto.UserResponse{ + ID: u.ID.String(), + Name: u.Name, + Email: u.Email, + IsActive: u.IsActive, + }) + } + + meta := dto.MetaResponse{ + Page: page, + PerPage: perPage, + Total: int(total), + } + + response := dto.PaginatedUsersResponse{ + Data: userDTOs, + Meta: meta, + } + + return utils.Success(c, http.StatusOK, "Get all users successfully", response, nil) } +// GetUserByID godoc +// @Summary Get user by ID +// @Description Retrieve user details by user ID +// @Tags Users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Success 200 {object} dto.UserResponse +// @Failure 400 {object} utils.ErrorResponse +// @Failure 404 {object} utils.ErrorResponse +// @Router /api/users/{id} [get] func (ctrl *UserController) GetUserByID(c *fiber.Ctx) error { - idParam := c.Params("id") - userID, err := uuid.Parse(idParam) + userID, err := uuid.Parse(c.Params("id")) if err != nil { return utils.Error(c, http.StatusBadRequest, "Invalid user ID format", "InvalidUUID", nil) } - user, err := ctrl.userService.GetUserByID(userID) + u, err := ctrl.userService.GetUserByID(userID) if err != nil { return utils.Error(c, http.StatusNotFound, "User not found", "UserNotFound", nil) } - return utils.Success(c, http.StatusOK, "Get user by ID successfully", user, nil) + resp := dto.UserResponse{ + ID: u.ID.String(), + Name: u.Name, + Email: u.Email, + IsActive: u.IsActive, + } + + return utils.Success(c, http.StatusOK, "Get user by ID successfully", resp, nil) } + +// SetUserRole godoc +// @Summary Set user role +// @Description Assign or update a user's role +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param request body dto.SetRoleRequest true "Set Role Request" +// @Success 200 {object} dto.UserRoleResponse +// @Failure 400 {object} utils.ErrorResponse +// @Router /api/users/{id}/role [put] func (ctrl *UserController) SetUserRole(c *fiber.Ctx) error { - ctx := context.Background() + var req dto.SetRoleRequest + if err := c.BodyParser(&req); err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid request body", "BadRequestException", nil) + } + + if req.Role == "" { + return utils.Error(c, http.StatusBadRequest, "Role is required", "BadRequestException", nil) + } - // 🔹 Ambil user_id dari URL - userIDParam := c.Params("id") - userID, err := uuid.Parse(userIDParam) + userID, err := uuid.Parse(c.Params("id")) if err != nil { return utils.Error(c, http.StatusBadRequest, "Invalid user ID format", "InvalidUUID", nil) } - // 🔹 Ambil nama role dari body - var req struct { - Role string `json:"role"` + updatedUser, err := ctrl.userService.SetUserRole(context.Background(), userID, req.Role) + if err != nil { + return utils.Error(c, http.StatusBadRequest, err.Error(), "SetRoleException", nil) } - if err := c.BodyParser(&req); err != nil { - return utils.Error(c, http.StatusBadRequest, "Invalid request body", "BadRequestException", nil) + resp := dto.UserRoleResponse{ + ID: updatedUser.ID.String(), + Name: updatedUser.Name, + Email: updatedUser.Email, + Role: req.Role, } - if req.Role == "" { - return utils.Error(c, http.StatusBadRequest, "Role name is required", "BadRequestException", nil) + return utils.Success(c, http.StatusOK, "User role assigned successfully", resp, nil) +} + + +// DeactivateUser godoc +// @Summary Deactivate user +// @Description Deactivate a user's account by ID +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {object} dto.UserStatusResponse +// @Failure 400 {object} utils.ErrorResponse +// @Router /api/users/{id}/deactivate [put] +func (ctrl *UserController) DeactivateUser(c *fiber.Ctx) error { + userID, err := uuid.Parse(c.Params("id")) + if err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid user ID format", "InvalidUUID", nil) } - // 🔹 Jalankan service → ambil user hasil update - updatedUser, err := ctrl.userService.SetUserRole(ctx, userID, req.Role) + deactivatedUser, err := ctrl.userService.DeactivateUser(context.Background(), userID) if err != nil { - return utils.Error(c, http.StatusBadRequest, err.Error(), "SetRoleException", nil) + return utils.Error(c, http.StatusBadRequest, err.Error(), "DeactivateUserException", nil) + } + + resp := dto.UserStatusResponse{ + ID: deactivatedUser.ID.String(), + Name: deactivatedUser.Name, + Email: deactivatedUser.Email, + IsActive: deactivatedUser.IsActive, + } + + return utils.Success(c, http.StatusOK, "User deactivated successfully", resp, nil) +} + + +// ActivateUser godoc +// @Summary Activate user +// @Description Activate a previously deactivated user account +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {object} dto.UserStatusResponse +// @Failure 400 {object} utils.ErrorResponse +// @Router /api/users/{id}/activate [put] +func (ctrl *UserController) ActivateUser(c *fiber.Ctx) error { + userID, err := uuid.Parse(c.Params("id")) + if err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid user ID format", "InvalidUUID", nil) + } + + activatedUser, err := ctrl.userService.ActivateUser(context.Background(), userID) + if err != nil { + return utils.Error(c, http.StatusBadRequest, err.Error(), "ActivateUserException", nil) + } + + resp := dto.UserStatusResponse{ + ID: activatedUser.ID.String(), + Name: activatedUser.Name, + Email: activatedUser.Email, + IsActive: activatedUser.IsActive, + } + + return utils.Success(c, http.StatusOK, "User activated successfully", resp, nil) +} + + +// Profile godoc +// @Summary Get user profile +// @Description Retrieve authenticated user's profile +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.UserProfileResponse +// @Failure 401 {object} utils.ErrorResponse +// @Failure 404 {object} utils.ErrorResponse +// @Router /api/users/profile [get] +func (ctrl *UserController) Profile(c *fiber.Ctx) error { + userID := c.Locals("user_id") + if userID == nil { + return utils.Error(c, http.StatusUnauthorized, "Unauthorized", "UnauthorizedException", nil) + } + + profile, err := ctrl.userService.GetProfile(context.Background(), userID.(string)) + if err != nil { + return utils.Error(c, http.StatusNotFound, "User not found", "NotFoundException", nil) } - // 🔹 Siapkan response data - data := fiber.Map{ - "user_id": updatedUser.ID, - "name": updatedUser.Name, - "email": updatedUser.Email, - "role": req.Role, + var roles []string + for _, r := range profile.Roles { + roles = append(roles, string(r.Name)) } - // 🔹 Response sukses - return utils.Success(c, http.StatusOK, "User role assigned successfully", data, nil) + resp := dto.UserProfileResponse{ + ID: profile.ID.String(), + Name: profile.Name, + Email: profile.Email, + IsActive: profile.IsActive, + Roles: roles, + } + + return utils.Success(c, http.StatusOK, "Profile fetched successfully", resp, nil) +} + +type UpdateProfileRequest struct { + Name string `json:"name"` + Email string `json:"email"` } +func (h *UserController) UpdateProfile(c *fiber.Ctx) error { + userIDStr := c.Locals("user_id") + if userIDStr == nil { + return utils.Error(c, http.StatusUnauthorized, "Unauthorized", "Unauthorized", nil) + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + return utils.Error(c, http.StatusUnauthorized, "Invalid user ID", "Unauthorized", nil) + } + var req UpdateProfileRequest + if err := c.BodyParser(&req); err != nil { + return utils.Error(c, http.StatusBadRequest, "Invalid request body", "BadRequest", nil) + } + + ctx := context.Background() + updatedUser, err := h.userService.UpdateProfile(ctx, userID, req.Name, req.Email) + if err != nil { + return utils.Error(c, http.StatusInternalServerError, err.Error(), "InternalServerError", nil) + } + + return utils.Success(c, http.StatusOK, "Profile updated successfully", updatedUser, nil) +} diff --git a/api/routes/feedback_routes.go b/api/routes/feedback_routes.go new file mode 100644 index 0000000..208df40 --- /dev/null +++ b/api/routes/feedback_routes.go @@ -0,0 +1,21 @@ +package routes + +import ( + "api-shiners/api/handlers" + "api-shiners/pkg/middleware" + + "github.com/gofiber/fiber/v2" +) + +func FeedbackRoutes(app *fiber.App, feedbackController *handlers.FeedbackController) { + api := app.Group("/api") + + api.Post("/feedback/questions",middleware.TeacherMiddleware, feedbackController.CreateQuestion) + api.Get("/feedback/questions", middleware.AuthMiddleware, feedbackController.GetAllQuestions) + + api.Post("/feedback/answers", middleware.AuthMiddleware, feedbackController.SubmitAnswer) + + api.Get("/feedback/teacher", middleware.TeacherMiddleware, feedbackController.GetQuestionsWithAnswersByTeacher) +} + + diff --git a/api/routes/user_routes.go b/api/routes/user_routes.go index 254177d..e7def26 100644 --- a/api/routes/user_routes.go +++ b/api/routes/user_routes.go @@ -13,4 +13,10 @@ func UserRoutes(app *fiber.App, userController *handlers.UserController) { api.Get("/users/:id", middleware.AuthMiddleware, userController.GetUserByID) api.Post("/users/:id/role", middleware.AdminMiddleware, userController.SetUserRole) + + api.Post("/users/:id/deactivate", middleware.AdminMiddleware, userController.DeactivateUser) + api.Post("/users/:id/activate", middleware.AdminMiddleware, userController.ActivateUser) + + api.Get("/profile", middleware.AuthMiddleware, userController.Profile) + api.Put("/profile", middleware.AuthMiddleware, userController.UpdateProfile) } \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 633dc49..618c6ab 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -17,7 +17,7 @@ const docTemplate = `{ "paths": { "/api/auth/forgot-password": { "post": { - "description": "Generate reset token and send it to user's email", + "description": "Generate reset token dan kirim ke email user", "consumes": [ "application/json" ], @@ -30,15 +30,12 @@ const docTemplate = `{ "summary": "Request password reset", "parameters": [ { - "description": "Email Request", + "description": "Forgot Password Request", "name": "request", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ForgotPasswordRequest" } } ], @@ -46,15 +43,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.GenericResponse" } }, "400": { "description": "Bad Request", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -62,7 +63,7 @@ const docTemplate = `{ }, "/api/auth/login": { "post": { - "description": "Authenticate user and return JWT token", + "description": "Autentikasi user dan mendapatkan JWT token", "consumes": [ "application/json" ], @@ -80,7 +81,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/auth.LoginRequest" + "$ref": "#/definitions/dto.LoginRequest" } } ], @@ -88,15 +89,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -109,7 +114,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Invalidate user token", + "description": "Mengakhiri sesi dan menonaktifkan token", "consumes": [ "application/json" ], @@ -124,15 +129,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.GenericResponse" } }, "400": { "description": "Bad Request", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -140,7 +149,7 @@ const docTemplate = `{ }, "/api/auth/register": { "post": { - "description": "Create a new user account", + "description": "Membuat akun user baru", "consumes": [ "application/json" ], @@ -158,7 +167,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/auth.RegisterRequest" + "$ref": "#/definitions/dto.RegisterRequest" } } ], @@ -166,15 +175,19 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.RegisterResponse" } }, "400": { "description": "Bad Request", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -182,7 +195,7 @@ const docTemplate = `{ }, "/api/auth/reset-password": { "post": { - "description": "Reset password using valid reset token", + "description": "Reset password menggunakan reset token yang valid", "consumes": [ "application/json" ], @@ -200,13 +213,202 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" + "$ref": "#/definitions/dto.ResetPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.GenericResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/feedback/answers": { + "post": { + "description": "Mahasiswa mengirimkan jawaban feedback", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Submit feedback answer", + "parameters": [ + { + "description": "Answer payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SubmitAnswerRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/utils.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/feedback/questions": { + "get": { + "description": "Mendapatkan semua pertanyaan feedback", + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Get all feedback questions", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.FeedbackQuestion" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + }, + "post": { + "description": "Admin membuat pertanyaan feedback baru", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Create new feedback question", + "parameters": [ + { + "description": "Question payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CreateQuestionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/entities.FeedbackQuestion" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/feedback/questions/answers": { + "get": { + "description": "Mendapatkan semua pertanyaan beserta jawaban mahasiswa", + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Get all questions with answers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.FeedbackQuestion" } } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } } + } + } + }, + "/api/health/database": { + "get": { + "description": "Check database connection status", + "produces": [ + "application/json" ], + "tags": [ + "Health" + ], + "summary": "Check service health", "responses": { "200": { "description": "OK", @@ -215,8 +417,8 @@ const docTemplate = `{ "additionalProperties": true } }, - "400": { - "description": "Bad Request", + "503": { + "description": "Service Unavailable", "schema": { "type": "object", "additionalProperties": true @@ -224,30 +426,725 @@ const docTemplate = `{ } } } - } - }, - "definitions": { - "auth.LoginRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" + }, + "/api/health/redis": { + "get": { + "description": "Check database connection status", + "produces": [ + "application/json" + ], + "tags": [ + "Health" + ], + "summary": "Check service health", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } } } }, - "auth.RegisterRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "name": { - "type": "string" - }, - "password": { + "/api/users": { + "get": { + "description": "Retrieve a paginated list of all users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get all users", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.PaginatedUsersResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve authenticated user's profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserProfileResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}": { + "get": { + "description": "Retrieve user details by user ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}/activate": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Activate a previously deactivated user account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Activate user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserStatusResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}/deactivate": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deactivate a user's account by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Deactivate user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserStatusResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}/role": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Assign or update a user's role", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Set user role", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Set Role Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SetRoleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserRoleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "dto.CreateQuestionRequest": { + "type": "object", + "properties": { + "question": { + "type": "string", + "example": "Apa pendapat Anda tentang pelatihan ini?" + } + } + }, + "dto.ForgotPasswordRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + } + } + }, + "dto.GenericResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation successful" + } + } + }, + "dto.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "example": "strongpassword123" + } + } + }, + "dto.LoginResponse": { + "type": "object", + "properties": { + "expires_in": { + "type": "string", + "example": "2025-10-18T15:04:05Z" + }, + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "token_type": { + "type": "string", + "example": "Bearer" + }, + "user": {} + } + }, + "dto.MetaResponse": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 10 + }, + "total": { + "type": "integer", + "example": 100 + } + } + }, + "dto.PaginatedUsersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserResponse" + } + }, + "meta": { + "$ref": "#/definitions/dto.MetaResponse" + } + } + }, + "dto.RegisterRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "name": { + "type": "string", + "example": "John Doe" + }, + "password": { + "type": "string", + "example": "strongpassword123" + } + } + }, + "dto.RegisterResponse": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "id": { + "type": "string", + "example": "a3b2c1d4-56ef-7890-gh12-ijk345lmn678" + }, + "name": { + "type": "string", + "example": "John Doe" + } + } + }, + "dto.ResetPasswordRequest": { + "type": "object", + "properties": { + "new_password": { + "type": "string", + "example": "newStrongPassword123" + }, + "token": { + "type": "string", + "example": "123456" + } + } + }, + "dto.SetRoleRequest": { + "type": "object", + "properties": { + "role": { + "type": "string", + "example": "ADMIN" + } + } + }, + "dto.SubmitAnswerRequest": { + "type": "object", + "properties": { + "answer": { + "type": "string", + "example": "Sangat bermanfaat dan jelas" + }, + "question_id": { + "type": "string", + "example": "b5a1c6c3-1234-4bcd-9123-a12b34cd56ef" + } + } + }, + "dto.UserProfileResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "dto.UserResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "dto.UserRoleResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "dto.UserStatusResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "entities.FeedbackAnswer": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "question_id": { + "type": "string" + }, + "student": { + "$ref": "#/definitions/entities.User" + }, + "student_id": { + "type": "string" + } + } + }, + "entities.FeedbackQuestion": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.FeedbackAnswer" + } + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "question": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "entities.Role": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/entities.RoleName" + }, + "updated_at": { + "type": "string" + } + } + }, + "entities.RoleName": { + "type": "string", + "enum": [ + "ADMIN", + "TEACHER", + "STUDENT" + ], + "x-enum-varnames": [ + "ADMIN", + "TEACHER", + "STUDENT" + ] + }, + "entities.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Role" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "utils.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/utils.FieldError" + } + }, + "message": { + "type": "string" + }, + "path": { + "type": "string" + }, + "statusCode": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "timestamp": { + "type": "string" + } + } + }, + "utils.FieldError": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "utils.Meta": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "utils.SuccessResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/utils.Meta" + }, + "path": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "timestamp": { "type": "string" } } diff --git a/docs/swagger.json b/docs/swagger.json index 9a11c87..3a410e3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -14,7 +14,7 @@ "paths": { "/api/auth/forgot-password": { "post": { - "description": "Generate reset token and send it to user's email", + "description": "Generate reset token dan kirim ke email user", "consumes": [ "application/json" ], @@ -27,15 +27,12 @@ "summary": "Request password reset", "parameters": [ { - "description": "Email Request", + "description": "Forgot Password Request", "name": "request", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ForgotPasswordRequest" } } ], @@ -43,15 +40,19 @@ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.GenericResponse" } }, "400": { "description": "Bad Request", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -59,7 +60,7 @@ }, "/api/auth/login": { "post": { - "description": "Authenticate user and return JWT token", + "description": "Autentikasi user dan mendapatkan JWT token", "consumes": [ "application/json" ], @@ -77,7 +78,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/auth.LoginRequest" + "$ref": "#/definitions/dto.LoginRequest" } } ], @@ -85,15 +86,19 @@ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -106,7 +111,7 @@ "BearerAuth": [] } ], - "description": "Invalidate user token", + "description": "Mengakhiri sesi dan menonaktifkan token", "consumes": [ "application/json" ], @@ -121,15 +126,19 @@ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.GenericResponse" } }, "400": { "description": "Bad Request", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -137,7 +146,7 @@ }, "/api/auth/register": { "post": { - "description": "Create a new user account", + "description": "Membuat akun user baru", "consumes": [ "application/json" ], @@ -155,7 +164,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/auth.RegisterRequest" + "$ref": "#/definitions/dto.RegisterRequest" } } ], @@ -163,15 +172,19 @@ "201": { "description": "Created", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/dto.RegisterResponse" } }, "400": { "description": "Bad Request", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" } } } @@ -179,7 +192,7 @@ }, "/api/auth/reset-password": { "post": { - "description": "Reset password using valid reset token", + "description": "Reset password menggunakan reset token yang valid", "consumes": [ "application/json" ], @@ -197,13 +210,202 @@ "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" + "$ref": "#/definitions/dto.ResetPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.GenericResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/feedback/answers": { + "post": { + "description": "Mahasiswa mengirimkan jawaban feedback", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Submit feedback answer", + "parameters": [ + { + "description": "Answer payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SubmitAnswerRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/utils.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/feedback/questions": { + "get": { + "description": "Mendapatkan semua pertanyaan feedback", + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Get all feedback questions", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.FeedbackQuestion" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + }, + "post": { + "description": "Admin membuat pertanyaan feedback baru", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Create new feedback question", + "parameters": [ + { + "description": "Question payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CreateQuestionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/entities.FeedbackQuestion" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/feedback/questions/answers": { + "get": { + "description": "Mendapatkan semua pertanyaan beserta jawaban mahasiswa", + "produces": [ + "application/json" + ], + "tags": [ + "Feedback" + ], + "summary": "Get all questions with answers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.FeedbackQuestion" } } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } } + } + } + }, + "/api/health/database": { + "get": { + "description": "Check database connection status", + "produces": [ + "application/json" ], + "tags": [ + "Health" + ], + "summary": "Check service health", "responses": { "200": { "description": "OK", @@ -212,8 +414,8 @@ "additionalProperties": true } }, - "400": { - "description": "Bad Request", + "503": { + "description": "Service Unavailable", "schema": { "type": "object", "additionalProperties": true @@ -221,30 +423,725 @@ } } } - } - }, - "definitions": { - "auth.LoginRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" + }, + "/api/health/redis": { + "get": { + "description": "Check database connection status", + "produces": [ + "application/json" + ], + "tags": [ + "Health" + ], + "summary": "Check service health", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } } } }, - "auth.RegisterRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "name": { - "type": "string" - }, - "password": { + "/api/users": { + "get": { + "description": "Retrieve a paginated list of all users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get all users", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.PaginatedUsersResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve authenticated user's profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserProfileResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}": { + "get": { + "description": "Retrieve user details by user ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}/activate": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Activate a previously deactivated user account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Activate user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserStatusResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}/deactivate": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deactivate a user's account by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Deactivate user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserStatusResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + }, + "/api/users/{id}/role": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Assign or update a user's role", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Set user role", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Set Role Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SetRoleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserRoleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/utils.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "dto.CreateQuestionRequest": { + "type": "object", + "properties": { + "question": { + "type": "string", + "example": "Apa pendapat Anda tentang pelatihan ini?" + } + } + }, + "dto.ForgotPasswordRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + } + } + }, + "dto.GenericResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation successful" + } + } + }, + "dto.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "example": "strongpassword123" + } + } + }, + "dto.LoginResponse": { + "type": "object", + "properties": { + "expires_in": { + "type": "string", + "example": "2025-10-18T15:04:05Z" + }, + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "token_type": { + "type": "string", + "example": "Bearer" + }, + "user": {} + } + }, + "dto.MetaResponse": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 10 + }, + "total": { + "type": "integer", + "example": 100 + } + } + }, + "dto.PaginatedUsersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserResponse" + } + }, + "meta": { + "$ref": "#/definitions/dto.MetaResponse" + } + } + }, + "dto.RegisterRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "name": { + "type": "string", + "example": "John Doe" + }, + "password": { + "type": "string", + "example": "strongpassword123" + } + } + }, + "dto.RegisterResponse": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "id": { + "type": "string", + "example": "a3b2c1d4-56ef-7890-gh12-ijk345lmn678" + }, + "name": { + "type": "string", + "example": "John Doe" + } + } + }, + "dto.ResetPasswordRequest": { + "type": "object", + "properties": { + "new_password": { + "type": "string", + "example": "newStrongPassword123" + }, + "token": { + "type": "string", + "example": "123456" + } + } + }, + "dto.SetRoleRequest": { + "type": "object", + "properties": { + "role": { + "type": "string", + "example": "ADMIN" + } + } + }, + "dto.SubmitAnswerRequest": { + "type": "object", + "properties": { + "answer": { + "type": "string", + "example": "Sangat bermanfaat dan jelas" + }, + "question_id": { + "type": "string", + "example": "b5a1c6c3-1234-4bcd-9123-a12b34cd56ef" + } + } + }, + "dto.UserProfileResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "dto.UserResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "dto.UserRoleResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "dto.UserStatusResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "entities.FeedbackAnswer": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "question_id": { + "type": "string" + }, + "student": { + "$ref": "#/definitions/entities.User" + }, + "student_id": { + "type": "string" + } + } + }, + "entities.FeedbackQuestion": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.FeedbackAnswer" + } + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "question": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "entities.Role": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/entities.RoleName" + }, + "updated_at": { + "type": "string" + } + } + }, + "entities.RoleName": { + "type": "string", + "enum": [ + "ADMIN", + "TEACHER", + "STUDENT" + ], + "x-enum-varnames": [ + "ADMIN", + "TEACHER", + "STUDENT" + ] + }, + "entities.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Role" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "utils.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/utils.FieldError" + } + }, + "message": { + "type": "string" + }, + "path": { + "type": "string" + }, + "statusCode": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "timestamp": { + "type": "string" + } + } + }, + "utils.FieldError": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "utils.Meta": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "utils.SuccessResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/utils.Meta" + }, + "path": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "timestamp": { "type": "string" } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6739312..5244e77 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,19 +1,289 @@ basePath: / definitions: - auth.LoginRequest: + dto.CreateQuestionRequest: + properties: + question: + example: Apa pendapat Anda tentang pelatihan ini? + type: string + type: object + dto.ForgotPasswordRequest: properties: email: + example: john@example.com + type: string + type: object + dto.GenericResponse: + properties: + message: + example: Operation successful + type: string + type: object + dto.LoginRequest: + properties: + email: + example: john@example.com type: string password: + example: strongpassword123 type: string type: object - auth.RegisterRequest: + dto.LoginResponse: + properties: + expires_in: + example: "2025-10-18T15:04:05Z" + type: string + token: + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 + type: string + token_type: + example: Bearer + type: string + user: {} + type: object + dto.MetaResponse: + properties: + page: + example: 1 + type: integer + per_page: + example: 10 + type: integer + total: + example: 100 + type: integer + type: object + dto.PaginatedUsersResponse: + properties: + data: + items: + $ref: '#/definitions/dto.UserResponse' + type: array + meta: + $ref: '#/definitions/dto.MetaResponse' + type: object + dto.RegisterRequest: properties: email: + example: john@example.com type: string name: + example: John Doe type: string password: + example: strongpassword123 + type: string + type: object + dto.RegisterResponse: + properties: + email: + example: john@example.com + type: string + id: + example: a3b2c1d4-56ef-7890-gh12-ijk345lmn678 + type: string + name: + example: John Doe + type: string + type: object + dto.ResetPasswordRequest: + properties: + new_password: + example: newStrongPassword123 + type: string + token: + example: "123456" + type: string + type: object + dto.SetRoleRequest: + properties: + role: + example: ADMIN + type: string + type: object + dto.SubmitAnswerRequest: + properties: + answer: + example: Sangat bermanfaat dan jelas + type: string + question_id: + example: b5a1c6c3-1234-4bcd-9123-a12b34cd56ef + type: string + type: object + dto.UserProfileResponse: + properties: + email: + type: string + id: + type: string + is_active: + type: boolean + name: + type: string + roles: + items: + type: string + type: array + type: object + dto.UserResponse: + properties: + email: + type: string + id: + type: string + is_active: + type: boolean + name: + type: string + role: + type: string + type: object + dto.UserRoleResponse: + properties: + email: + type: string + id: + type: string + name: + type: string + role: + type: string + type: object + dto.UserStatusResponse: + properties: + email: + type: string + id: + type: string + is_active: + type: boolean + name: + type: string + type: object + entities.FeedbackAnswer: + properties: + answer: + type: string + created_at: + type: string + id: + type: string + question_id: + type: string + student: + $ref: '#/definitions/entities.User' + student_id: + type: string + type: object + entities.FeedbackQuestion: + properties: + answers: + items: + $ref: '#/definitions/entities.FeedbackAnswer' + type: array + created_at: + type: string + created_by: + type: string + id: + type: string + question: + type: string + updated_at: + type: string + type: object + entities.Role: + properties: + created_at: + type: string + description: + type: string + id: + type: string + name: + $ref: '#/definitions/entities.RoleName' + updated_at: + type: string + type: object + entities.RoleName: + enum: + - ADMIN + - TEACHER + - STUDENT + type: string + x-enum-varnames: + - ADMIN + - TEACHER + - STUDENT + entities.User: + properties: + created_at: + type: string + email: + type: string + id: + type: string + is_active: + type: boolean + name: + type: string + roles: + items: + $ref: '#/definitions/entities.Role' + type: array + updated_at: + type: string + type: object + utils.ErrorResponse: + properties: + error: + type: string + errors: + items: + $ref: '#/definitions/utils.FieldError' + type: array + message: + type: string + path: + type: string + statusCode: + type: integer + success: + type: boolean + timestamp: + type: string + type: object + utils.FieldError: + properties: + field: + type: string + message: + type: string + messages: + items: + type: string + type: array + type: object + utils.Meta: + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + type: object + utils.SuccessResponse: + properties: + data: {} + message: + type: string + meta: + $ref: '#/definitions/utils.Meta' + path: + type: string + status: + type: integer + timestamp: type: string type: object host: localhost:3000 @@ -27,29 +297,29 @@ paths: post: consumes: - application/json - description: Generate reset token and send it to user's email + description: Generate reset token dan kirim ke email user parameters: - - description: Email Request + - description: Forgot Password Request in: body name: request required: true schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ForgotPasswordRequest' produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/dto.GenericResponse' "400": description: Bad Request schema: - additionalProperties: true - type: object + $ref: '#/definitions/utils.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' summary: Request password reset tags: - Auth @@ -57,27 +327,29 @@ paths: post: consumes: - application/json - description: Authenticate user and return JWT token + description: Autentikasi user dan mendapatkan JWT token parameters: - description: Login Request in: body name: request required: true schema: - $ref: '#/definitions/auth.LoginRequest' + $ref: '#/definitions/dto.LoginRequest' produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/dto.LoginResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/utils.ErrorResponse' "401": description: Unauthorized schema: - additionalProperties: true - type: object + $ref: '#/definitions/utils.ErrorResponse' summary: Login user tags: - Auth @@ -85,20 +357,22 @@ paths: post: consumes: - application/json - description: Invalidate user token + description: Mengakhiri sesi dan menonaktifkan token produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/dto.GenericResponse' "400": description: Bad Request schema: - additionalProperties: true - type: object + $ref: '#/definitions/utils.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/utils.ErrorResponse' security: - BearerAuth: [] summary: Logout user @@ -108,27 +382,29 @@ paths: post: consumes: - application/json - description: Create a new user account + description: Membuat akun user baru parameters: - description: Register Request in: body name: request required: true schema: - $ref: '#/definitions/auth.RegisterRequest' + $ref: '#/definitions/dto.RegisterRequest' produces: - application/json responses: "201": description: Created schema: - additionalProperties: true - type: object + $ref: '#/definitions/dto.RegisterResponse' "400": description: Bad Request schema: - additionalProperties: true - type: object + $ref: '#/definitions/utils.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' summary: Register a new user tags: - Auth @@ -136,32 +412,344 @@ paths: post: consumes: - application/json - description: Reset password using valid reset token + description: Reset password menggunakan reset token yang valid parameters: - description: Reset Password Request in: body name: request required: true schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ResetPasswordRequest' produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/dto.GenericResponse' "400": description: Bad Request schema: - additionalProperties: true - type: object + $ref: '#/definitions/utils.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' summary: Reset user password tags: - Auth + /api/feedback/answers: + post: + consumes: + - application/json + description: Mahasiswa mengirimkan jawaban feedback + parameters: + - description: Answer payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/dto.SubmitAnswerRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/utils.SuccessResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/utils.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/utils.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' + summary: Submit feedback answer + tags: + - Feedback + /api/feedback/questions: + get: + description: Mendapatkan semua pertanyaan feedback + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/entities.FeedbackQuestion' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' + summary: Get all feedback questions + tags: + - Feedback + post: + consumes: + - application/json + description: Admin membuat pertanyaan feedback baru + parameters: + - description: Question payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/dto.CreateQuestionRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/entities.FeedbackQuestion' + "400": + description: Bad Request + schema: + $ref: '#/definitions/utils.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/utils.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' + summary: Create new feedback question + tags: + - Feedback + /api/feedback/questions/answers: + get: + description: Mendapatkan semua pertanyaan beserta jawaban mahasiswa + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/entities.FeedbackQuestion' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' + summary: Get all questions with answers + tags: + - Feedback + /api/health/database: + get: + description: Check database connection status + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: true + type: object + summary: Check service health + tags: + - Health + /api/health/redis: + get: + description: Check database connection status + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: true + type: object + summary: Check service health + tags: + - Health + /api/users: + get: + consumes: + - application/json + description: Retrieve a paginated list of all users + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.PaginatedUsersResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.ErrorResponse' + summary: Get all users + tags: + - Users + /api/users/{id}: + get: + consumes: + - application/json + description: Retrieve user details by user ID + parameters: + - description: User ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.UserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/utils.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/utils.ErrorResponse' + summary: Get user by ID + tags: + - Users + /api/users/{id}/activate: + put: + consumes: + - application/json + description: Activate a previously deactivated user account + parameters: + - description: User ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.UserStatusResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/utils.ErrorResponse' + security: + - BearerAuth: [] + summary: Activate user + tags: + - Users + /api/users/{id}/deactivate: + put: + consumes: + - application/json + description: Deactivate a user's account by ID + parameters: + - description: User ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.UserStatusResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/utils.ErrorResponse' + security: + - BearerAuth: [] + summary: Deactivate user + tags: + - Users + /api/users/{id}/role: + put: + consumes: + - application/json + description: Assign or update a user's role + parameters: + - description: User ID + in: path + name: id + required: true + type: string + - description: Set Role Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/dto.SetRoleRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.UserRoleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/utils.ErrorResponse' + security: + - BearerAuth: [] + summary: Set user role + tags: + - Users + /api/users/profile: + get: + consumes: + - application/json + description: Retrieve authenticated user's profile + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.UserProfileResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/utils.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/utils.ErrorResponse' + security: + - BearerAuth: [] + summary: Get user profile + tags: + - Users schemes: - http securityDefinitions: diff --git a/index.html b/index.html new file mode 100644 index 0000000..d6aed36 --- /dev/null +++ b/index.html @@ -0,0 +1,61 @@ + + + + + + Document + + + + +

hello world

+ +
+
+
+
+
+
+
+
+ + diff --git a/main.go b/main.go index c552af8..a7ca218 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ import ( "api-shiners/api/routes" "api-shiners/pkg/auth" "api-shiners/pkg/config" + "api-shiners/pkg/feedback" "api-shiners/pkg/user" _ "api-shiners/docs" @@ -68,6 +69,11 @@ func main() { userService := user.NewUserService(userRepo, authRepo) userController := handlers.NewUserController(userService) + feedbackRepo := feedback.NewFeedbackRepository(config.DB) + feedbackService := feedback.NewFeedbackService(feedbackRepo) + feedbackController := handlers.NewFeedbackController(feedbackService) + + routes.FeedbackRoutes(app, feedbackController) routes.UserRoutes(app, userController) routes.HealthRoutes(app, healthController) routes.AuthRoutes(app, authController) diff --git a/pkg/auth/repository.go b/pkg/auth/repository.go index 3cd6e0e..88c919d 100644 --- a/pkg/auth/repository.go +++ b/pkg/auth/repository.go @@ -15,10 +15,7 @@ type AuthRepository interface { CreateUser(ctx context.Context, user *entities.User) error FindRoleByName(ctx context.Context, name string) (*entities.Role, error) AssignUserRole(ctx context.Context, userRole *entities.UserRole) error - UpdateUser(ctx context.Context, user *entities.User) error RemoveAllRolesFromUser(ctx context.Context, userID uuid.UUID) error - - // 🔹 Tambahan untuk Forgot & Reset Password SaveResetToken(ctx context.Context, userID uuid.UUID, token string, expiresAt time.Time) error FindByResetToken(ctx context.Context, token string) (*entities.User, error) UpdatePassword(ctx context.Context, userID uuid.UUID, newPasswordHash string) error @@ -33,9 +30,6 @@ func NewUserRepository(db *gorm.DB) AuthRepository { return &userRepository{db: db} } -// ================================================================ -// BASIC USER CRUD -// ================================================================ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) { var user entities.User @@ -63,23 +57,20 @@ func (r *userRepository) FindRoleByName(ctx context.Context, name string) (*enti } func (r *userRepository) AssignUserRole(ctx context.Context, userRole *entities.UserRole) error { - // Cek apakah user sudah punya role ini + var existing entities.UserRole err := r.db.WithContext(ctx). Where("user_id = ? AND role_id = ?", userRole.UserID, userRole.RoleID). First(&existing).Error if err == nil { - // Sudah ada, tidak perlu insert ulang return nil } if !errors.Is(err, gorm.ErrRecordNotFound) { - // Error lain (misal DB error) return err } - // Belum ada → insert baru return r.db.WithContext(ctx).Create(userRole).Error } @@ -90,14 +81,6 @@ func (r *userRepository) RemoveAllRolesFromUser(ctx context.Context, userID uuid } -func (r *userRepository) UpdateUser(ctx context.Context, user *entities.User) error { - return r.db.WithContext(ctx).Save(user).Error -} - -// ================================================================ -// FORGOT & RESET PASSWORD (REAL DYNAMIC VERSION) -// ================================================================ - func (r *userRepository) SaveResetToken(ctx context.Context, userID uuid.UUID, token string, expiresAt time.Time) error { return r.db.WithContext(ctx).Model(&entities.User{}). Where("id = ?", userID). diff --git a/pkg/auth/service.go b/pkg/auth/service.go index 2b50715..d18529c 100644 --- a/pkg/auth/service.go +++ b/pkg/auth/service.go @@ -28,6 +28,7 @@ type LoginRequest struct { type AuthService interface { Register(ctx context.Context, req RegisterRequest) (*entities.User, error) Login(ctx context.Context, req LoginRequest) (string, time.Time, error) + LoginCore(ctx context.Context, req LoginRequest) (*entities.User, string, time.Time, []string, error) Logout(ctx context.Context, token string) error GenerateResetToken(ctx context.Context, email string) (string, error) ResetPassword(ctx context.Context, token, newPassword string) error @@ -41,17 +42,13 @@ func NewAuthService(userRepo AuthRepository) AuthService { return &authService{userRepo: userRepo} } -// ============================= -// REGISTER -// ============================= func (s *authService) Register(ctx context.Context, req RegisterRequest) (*entities.User, error) { - // Cek duplikasi email + existing, _ := s.userRepo.FindByEmail(ctx, req.Email) if existing != nil { return nil, errors.New("email already registered") } - // Hash password hashed, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) user := &entities.User{ @@ -65,7 +62,6 @@ func (s *authService) Register(ctx context.Context, req RegisterRequest) (*entit return nil, err } - // Assign role default: STUDENT role, err := s.userRepo.FindRoleByName(ctx, string(entities.STUDENT)) if err == nil { _ = s.userRepo.AssignUserRole(ctx, &entities.UserRole{ @@ -77,58 +73,67 @@ func (s *authService) Register(ctx context.Context, req RegisterRequest) (*entit return user, nil } -// ============================= -// LOGIN (Generate JWT Token) -// ============================= + func (s *authService) Login(ctx context.Context, req LoginRequest) (string, time.Time, error) { + _, token, exp, _, err := s.LoginCore(ctx, req) + return token, exp, err +} + +func (s *authService) LoginCore(ctx context.Context, req LoginRequest) (*entities.User, string, time.Time, []string, error) { user, err := s.userRepo.FindByEmail(ctx, req.Email) if err != nil { - return "", time.Time{}, errors.New("invalid email or password") + return nil, "", time.Time{}, nil, errors.New("invalid email or password") + } + + if !user.IsActive { + return nil, "", time.Time{}, nil, errors.New("account is deactivated, please contact admin") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - return "", time.Time{}, errors.New("invalid email or password") + return nil, "", time.Time{}, nil, errors.New("invalid email or password") } secret := os.Getenv("JWT_SECRET") if secret == "" { - return "", time.Time{}, errors.New("JWT_SECRET not set in environment") + return nil, "", time.Time{}, nil, errors.New("JWT_SECRET not set in environment") } expiration := time.Now().Add(24 * time.Hour) - if os.Getenv("JWT_EXPIRE_HOURS") != "" { if d, err := time.ParseDuration(os.Getenv("JWT_EXPIRE_HOURS") + "h"); err == nil { expiration = time.Now().Add(d) } } - rememberMe := false - if val, ok := ctx.Value("remember_me").(bool); ok { - rememberMe = val - } - if rememberMe { - expiration = time.Now().Add(7 * 24 * time.Hour) + // Ambil role utama user + roleName := "" + if len(user.Roles) > 0 { + roleName = string(user.Roles[0].Name) // ✅ fix type RoleName jadi string } + // Ambil permissions dari utils + permissions := utils.GetPermissionsByRole(roleName) + + // Buat JWT claims := jwt.MapClaims{ "user_id": user.ID.String(), "email": user.Email, "exp": expiration.Unix(), "iat": time.Now().Unix(), - "roles": user.Roles, + "roles": roleName, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signedToken, err := token.SignedString([]byte(secret)) if err != nil { - return "", time.Time{}, fmt.Errorf("failed to generate token: %v", err) + return nil, "", time.Time{}, nil, fmt.Errorf("failed to generate token: %v", err) } - return signedToken, expiration, nil + return user, signedToken, expiration, permissions, nil } + // LOGOUT func (s *authService) Logout(ctx context.Context, token string) error { return nil diff --git a/pkg/config/database.go b/pkg/config/database.go index 131276a..aa0b195 100644 --- a/pkg/config/database.go +++ b/pkg/config/database.go @@ -62,6 +62,8 @@ func ConnectDatabase() { &entities.Question{}, &entities.Course{}, &entities.CourseModule{}, + &entities.FeedbackQuestion{}, + &entities.FeedbackAnswer{}, ) if err != nil { log.Fatal("❌ Failed to migrate:", err) diff --git a/pkg/entities/answer.go b/pkg/entities/answer.go index e48f65a..c7ae3d4 100644 --- a/pkg/entities/answer.go +++ b/pkg/entities/answer.go @@ -15,7 +15,6 @@ type Answer struct { CreatedAt time.Time `gorm:"default:now()"` UpdatedAt time.Time `gorm:"default:now()"` - // --- Relations --- Attempt QuizAttempt `gorm:"foreignKey:AttemptID;constraint:OnDelete:CASCADE"` Question Question `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"` Choice *Choice `gorm:"foreignKey:ChoiceID"` diff --git a/pkg/entities/choice.go b/pkg/entities/choice.go index 86c291a..1fb9b5b 100644 --- a/pkg/entities/choice.go +++ b/pkg/entities/choice.go @@ -15,6 +15,5 @@ type Choice struct { CreatedAt time.Time `gorm:"default:now()"` UpdatedAt time.Time `gorm:"default:now()"` - // --- Relations --- Question Question `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"` } diff --git a/pkg/entities/course.go b/pkg/entities/course.go index 3b0edbc..07a15b6 100644 --- a/pkg/entities/course.go +++ b/pkg/entities/course.go @@ -16,9 +16,7 @@ type Course struct { CreatedAt time.Time `gorm:"default:now()" json:"created_at"` UpdatedAt time.Time `gorm:"default:now()" json:"updated_at"` - // Relasi ke User (teacher) OwnerTeacher *User `gorm:"foreignKey:OwnerTeacherID;constraint:OnDelete:SET NULL;" json:"owner_teacher,omitempty"` - // Relasi ke CourseModules (has many) Modules []CourseModule `gorm:"foreignKey:CourseID;constraint:OnDelete:CASCADE;" json:"modules,omitempty"` } diff --git a/pkg/entities/enrollment.go b/pkg/entities/enrollment.go index 39e2dd7..f463c40 100644 --- a/pkg/entities/enrollment.go +++ b/pkg/entities/enrollment.go @@ -6,7 +6,6 @@ import ( "github.com/google/uuid" ) -// Enum di sisi Go type CourseRole string const ( @@ -14,7 +13,6 @@ const ( CourseRoleTeacher CourseRole = "TEACHER" ) -// Entity Enrollment type Enrollment struct { ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` UserID uuid.UUID `gorm:"type:uuid;not null"` @@ -22,7 +20,6 @@ type Enrollment struct { RoleInCourse CourseRole `gorm:"type:varchar(50);not null"` // pakai VARCHAR EnrolledAt time.Time `gorm:"default:now()"` - // --- Relations --- User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` Course Course `gorm:"foreignKey:CourseID;constraint:OnDelete:CASCADE"` } diff --git a/pkg/entities/feedback_answer.go b/pkg/entities/feedback_answer.go new file mode 100644 index 0000000..524bfa3 --- /dev/null +++ b/pkg/entities/feedback_answer.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type FeedbackAnswer struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + QuestionID uuid.UUID `gorm:"type:uuid;not null" json:"question_id"` + StudentID uuid.UUID `gorm:"type:uuid;not null" json:"student_id"` + Answer string `gorm:"type:text;not null" json:"answer"` + CreatedAt time.Time `gorm:"default:now()" json:"created_at"` + + Question FeedbackQuestion `gorm:"foreignKey:QuestionID" json:"-"` + Student User `gorm:"foreignKey:StudentID;references:ID" json:"student"` +} diff --git a/pkg/entities/feedback_question.go b/pkg/entities/feedback_question.go new file mode 100644 index 0000000..452e0b5 --- /dev/null +++ b/pkg/entities/feedback_question.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type FeedbackQuestion struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + Question string `gorm:"type:text;not null" json:"question"` + CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"` + CreatedAt time.Time `gorm:"default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"default:now()" json:"updated_at"` + + Answers []FeedbackAnswer `gorm:"foreignKey:QuestionID" json:"answers,omitempty"` +} diff --git a/pkg/entities/log_book.go b/pkg/entities/log_book.go index 0811adf..bc90007 100644 --- a/pkg/entities/log_book.go +++ b/pkg/entities/log_book.go @@ -6,7 +6,6 @@ import ( "github.com/google/uuid" ) -// Enum untuk status logbook type StatusType string const ( @@ -15,7 +14,6 @@ const ( StatusLocked StatusType = "LOCKED" ) -// Entity LogBook type LogBook struct { ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` CourseID uuid.UUID `gorm:"type:uuid;not null"` @@ -28,7 +26,6 @@ type LogBook struct { CreatedAt time.Time `gorm:"default:now()"` UpdatedAt time.Time `gorm:"default:now()"` - // --- Relations --- Course Course `gorm:"foreignKey:CourseID;constraint:OnDelete:CASCADE"` Student User `gorm:"foreignKey:StudentID;constraint:OnDelete:CASCADE"` Entries []LogBookEntry `gorm:"foreignKey:LogBookID"` diff --git a/pkg/entities/log_book_entrie.go b/pkg/entities/log_book_entrie.go index e27b02f..5ed4844 100644 --- a/pkg/entities/log_book_entrie.go +++ b/pkg/entities/log_book_entrie.go @@ -6,7 +6,6 @@ import ( "github.com/google/uuid" ) -// Entity LogBookEntry type LogBookEntry struct { ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` LogBookID uuid.UUID `gorm:"type:uuid;not null"` @@ -15,6 +14,5 @@ type LogBookEntry struct { CreatedAt time.Time `gorm:"default:now()"` UpdatedAt time.Time `gorm:"default:now()"` - // --- Relation --- LogBook LogBook `gorm:"foreignKey:LogBookID;constraint:OnDelete:CASCADE"` } diff --git a/pkg/entities/material.go b/pkg/entities/material.go index 2dfe546..8c00f0f 100644 --- a/pkg/entities/material.go +++ b/pkg/entities/material.go @@ -7,7 +7,6 @@ import ( "gorm.io/gorm" ) -// MaterialType — enum di database ('FILE', 'LINK', 'TEXT') type MaterialType string const ( @@ -16,7 +15,6 @@ const ( MaterialTypeText MaterialType = "TEXT" ) -// Material entity type Material struct { ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` ModuleID uuid.UUID `gorm:"type:uuid;not null" json:"module_id"` diff --git a/pkg/entities/question.go b/pkg/entities/question.go index 854ba80..dba5bff 100644 --- a/pkg/entities/question.go +++ b/pkg/entities/question.go @@ -15,6 +15,5 @@ type Question struct { CreatedAt time.Time `gorm:"default:now()"` UpdatedAt time.Time `gorm:"default:now()"` - // --- Relations --- Quiz Quiz `gorm:"foreignKey:QuizID;constraint:OnDelete:CASCADE"` } diff --git a/pkg/entities/quiz_attempt.go b/pkg/entities/quiz_attempt.go index 7f327b8..ed9de50 100644 --- a/pkg/entities/quiz_attempt.go +++ b/pkg/entities/quiz_attempt.go @@ -17,7 +17,6 @@ type QuizAttempt struct { CreatedAt time.Time `gorm:"default:now()"` UpdatedAt time.Time `gorm:"default:now()"` - // --- Relations --- Quiz Quiz `gorm:"foreignKey:QuizID;constraint:OnDelete:CASCADE"` Student User `gorm:"foreignKey:StudentID;constraint:OnDelete:CASCADE"` Answers []Answer `gorm:"foreignKey:AttemptID"` diff --git a/pkg/entities/quizz.go b/pkg/entities/quizz.go index 18c1088..5e2a987 100644 --- a/pkg/entities/quizz.go +++ b/pkg/entities/quizz.go @@ -19,7 +19,6 @@ type Quiz struct { CreatedAt time.Time `gorm:"default:now()"` UpdatedAt time.Time `gorm:"default:now()"` - // --- Relations --- Module CourseModule `gorm:"foreignKey:ModuleID;constraint:OnDelete:CASCADE"` Questions []Question `gorm:"foreignKey:QuizID"` } diff --git a/pkg/entities/role.go b/pkg/entities/role.go index 8b89533..0428dd0 100644 --- a/pkg/entities/role.go +++ b/pkg/entities/role.go @@ -21,6 +21,5 @@ type Role struct { CreatedAt time.Time `gorm:"default:now()" json:"created_at"` UpdatedAt time.Time `gorm:"default:now()" json:"updated_at"` - // Relasi ke User (many-to-many) Users []*User `gorm:"many2many:user_roles;" json:"-"` } diff --git a/pkg/entities/user.go b/pkg/entities/user.go index af07698..07db160 100644 --- a/pkg/entities/user.go +++ b/pkg/entities/user.go @@ -20,6 +20,5 @@ type User struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - // Relasi ke Role (many-to-many) Roles []*Role `gorm:"many2many:user_roles;constraint:OnDelete:CASCADE;" json:"roles,omitempty"` } diff --git a/pkg/feedback/repository.go b/pkg/feedback/repository.go new file mode 100644 index 0000000..0de92ee --- /dev/null +++ b/pkg/feedback/repository.go @@ -0,0 +1,61 @@ +package feedback + +import ( + "api-shiners/pkg/entities" + "context" + + "gorm.io/gorm/clause" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type FeedbackRepository interface { + CreateQuestion(ctx context.Context, question *entities.FeedbackQuestion) error + GetAllQuestions(ctx context.Context) ([]entities.FeedbackQuestion, error) + GetQuestionByID(ctx context.Context, id uuid.UUID) (*entities.FeedbackQuestion, error) + SubmitAnswer(ctx context.Context, answer *entities.FeedbackAnswer) error + GetQuestionsWithAnswersByTeacher(ctx context.Context, teacherID uuid.UUID) ([]entities.FeedbackQuestion, error) +} + +type feedbackRepository struct { + db *gorm.DB +} + +func NewFeedbackRepository(db *gorm.DB) FeedbackRepository { + return &feedbackRepository{db} +} + +func (r *feedbackRepository) CreateQuestion(ctx context.Context, question *entities.FeedbackQuestion) error { + return r.db.WithContext(ctx). + Clauses(clause.Returning{}). + Create(question).Error +} + +func (r *feedbackRepository) GetAllQuestions(ctx context.Context) ([]entities.FeedbackQuestion, error) { + var questions []entities.FeedbackQuestion + err := r.db.WithContext(ctx).Find(&questions).Error + return questions, err +} + + +func (r *feedbackRepository) GetQuestionByID(ctx context.Context, id uuid.UUID) (*entities.FeedbackQuestion, error) { + var question entities.FeedbackQuestion + err := r.db.WithContext(ctx).Preload("Answers").First(&question, "id = ?", id).Error + return &question, err +} + +func (r *feedbackRepository) SubmitAnswer(ctx context.Context, answer *entities.FeedbackAnswer) error { + return r.db.WithContext(ctx).Create(answer).Error +} + + +func (r *feedbackRepository) GetQuestionsWithAnswersByTeacher(ctx context.Context, teacherID uuid.UUID) ([]entities.FeedbackQuestion, error) { + var questions []entities.FeedbackQuestion + err := r.db.WithContext(ctx). + Preload("Answers"). + Preload("Answers.Student"). + Where("created_by = ?", teacherID). + Find(&questions).Error + return questions, err +} diff --git a/pkg/feedback/service.go b/pkg/feedback/service.go new file mode 100644 index 0000000..424336c --- /dev/null +++ b/pkg/feedback/service.go @@ -0,0 +1,53 @@ +package feedback + +import ( + "api-shiners/pkg/entities" + "context" + + "github.com/google/uuid" +) + +type FeedbackService interface { + CreateQuestion(ctx context.Context, questionText string, createdBy uuid.UUID) (*entities.FeedbackQuestion, error) + GetAllQuestions(ctx context.Context) ([]entities.FeedbackQuestion, error) + SubmitAnswer(ctx context.Context, questionID uuid.UUID, studentID uuid.UUID, answer string) error + // GetStudentAnswers(ctx context.Context, studentID uuid.UUID) ([]entities.FeedbackAnswer, error) + GetQuestionsWithAnswersByTeacher(ctx context.Context, teacherID uuid.UUID) ([]entities.FeedbackQuestion, error) +} + +type feedbackService struct { + repo FeedbackRepository +} + +func NewFeedbackService(repo FeedbackRepository) FeedbackService { + return &feedbackService{repo} +} + +func (s *feedbackService) CreateQuestion(ctx context.Context, questionText string, createdBy uuid.UUID) (*entities.FeedbackQuestion, error) { + q := &entities.FeedbackQuestion{ + Question: questionText, + CreatedBy: createdBy, + } + if err := s.repo.CreateQuestion(ctx, q); err != nil { + return nil, err + } + return q, nil +} + +func (s *feedbackService) GetAllQuestions(ctx context.Context) ([]entities.FeedbackQuestion, error) { + return s.repo.GetAllQuestions(ctx) +} + +func (s *feedbackService) SubmitAnswer(ctx context.Context, questionID uuid.UUID, studentID uuid.UUID, answer string) error { + a := &entities.FeedbackAnswer{ + QuestionID: questionID, + StudentID: studentID, + Answer: answer, + } + return s.repo.SubmitAnswer(ctx, a) +} + + +func (s *feedbackService) GetQuestionsWithAnswersByTeacher(ctx context.Context, teacherID uuid.UUID) ([]entities.FeedbackQuestion, error) { + return s.repo.GetQuestionsWithAnswersByTeacher(ctx, teacherID) +} \ No newline at end of file diff --git a/pkg/middleware/admin_middleware.go b/pkg/middleware/admin_middleware.go index cd46530..b98decf 100644 --- a/pkg/middleware/admin_middleware.go +++ b/pkg/middleware/admin_middleware.go @@ -1,30 +1,28 @@ package middleware import ( + "api-shiners/pkg/utils" + "fmt" "net/http" "os" "strings" - "api-shiners/pkg/utils" - "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v5" ) -// AdminOnly middleware hanya mengizinkan akses untuk role ADMIN func AdminMiddleware(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") if authHeader == "" { - return utils.Error(c, http.StatusUnauthorized, "Missing authorization header", "UnauthorizedException", nil) + return utils.Error(c, http.StatusUnauthorized, "Missing Authorization header", "UnauthorizedException", nil) } - // Ambil token dari header: "Bearer " tokenString := strings.TrimPrefix(authHeader, "Bearer ") if tokenString == authHeader { return utils.Error(c, http.StatusUnauthorized, "Invalid token format", "UnauthorizedException", nil) } - // Parse token secret := os.Getenv("JWT_SECRET") token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { @@ -36,32 +34,46 @@ func AdminMiddleware(c *fiber.Ctx) error { return utils.Error(c, http.StatusUnauthorized, "Invalid or expired token", "UnauthorizedException", nil) } - // Ambil claims claims, ok := token.Claims.(jwt.MapClaims) if !ok { return utils.Error(c, http.StatusUnauthorized, "Invalid token claims", "UnauthorizedException", nil) } - // Ambil roles dari claims - roles, ok := claims["roles"].([]interface{}) - if !ok { - return utils.Error(c, http.StatusForbidden, "You are not authorized", "ForbiddenException", nil) + rawRoles, exists := claims["roles"] + if !exists { + return utils.Error(c, http.StatusForbidden, "No roles found in token", "ForbiddenException", nil) } - // Cek apakah user memiliki role ADMIN isAdmin := false - for _, r := range roles { - if roleMap, ok := r.(map[string]interface{}); ok { - if roleName, ok := roleMap["name"].(string); ok && roleName == "ADMIN" { + + switch roles := rawRoles.(type) { + case string: + if strings.EqualFold(roles, "ADMIN") { + isAdmin = true + } + + case []interface{}: + for _, r := range roles { + if rs, ok := r.(string); ok && strings.EqualFold(rs, "ADMIN") { isAdmin = true break } } + + default: + roleStr := strings.ToUpper(strings.TrimSpace(fmt.Sprint(rawRoles))) + if strings.Contains(roleStr, "ADMIN") { + isAdmin = true + } } if !isAdmin { return utils.Error(c, http.StatusForbidden, "Access restricted to ADMIN only", "ForbiddenException", nil) } + c.Locals("user_id", claims["user_id"]) + c.Locals("email", claims["email"]) + c.Locals("roles", rawRoles) + return c.Next() } diff --git a/pkg/middleware/teacher_middleware.go b/pkg/middleware/teacher_middleware.go new file mode 100644 index 0000000..2660d61 --- /dev/null +++ b/pkg/middleware/teacher_middleware.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "api-shiners/pkg/utils" + "fmt" + "net/http" + "os" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +// TeacherMiddleware memastikan hanya TEACHER yang bisa mengakses route +func TeacherMiddleware(c *fiber.Ctx) error { + // Ambil header Authorization + authHeader := c.Get("Authorization") + if authHeader == "" { + return utils.Error(c, http.StatusUnauthorized, "Missing Authorization header", "UnauthorizedException", nil) + } + + // Ambil token dari header + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + return utils.Error(c, http.StatusUnauthorized, "Invalid token format", "UnauthorizedException", nil) + } + + // Parse JWT token + secret := os.Getenv("JWT_SECRET") + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fiber.NewError(http.StatusUnauthorized, "Invalid token signing method") + } + return []byte(secret), nil + }) + if err != nil || !token.Valid { + return utils.Error(c, http.StatusUnauthorized, "Invalid or expired token", "UnauthorizedException", nil) + } + + // Ambil claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return utils.Error(c, http.StatusUnauthorized, "Invalid token claims", "UnauthorizedException", nil) + } + + // Ambil role dari token (bisa string atau array) + rawRoles, exists := claims["roles"] + if !exists { + return utils.Error(c, http.StatusForbidden, "No roles found in token", "ForbiddenException", nil) + } + + isTeacher := false + + switch roles := rawRoles.(type) { + case string: + if strings.EqualFold(roles, "TEACHER") { + isTeacher = true + } + case []interface{}: + for _, r := range roles { + if rs, ok := r.(string); ok && strings.EqualFold(rs, "TEACHER") { + isTeacher = true + break + } + } + default: + roleStr := strings.ToUpper(strings.TrimSpace(fmt.Sprint(rawRoles))) + if strings.Contains(roleStr, "TEACHER") { + isTeacher = true + } + } + + if !isTeacher { + return utils.Error(c, http.StatusForbidden, "Access restricted to TEACHER only", "ForbiddenException", nil) + } + + // Simpan info user ke context + c.Locals("user_id", claims["user_id"]) + c.Locals("email", claims["email"]) + c.Locals("roles", rawRoles) + + return c.Next() +} diff --git a/pkg/user/repository.go b/pkg/user/repository.go index 2874367..184be44 100644 --- a/pkg/user/repository.go +++ b/pkg/user/repository.go @@ -2,14 +2,18 @@ package user import ( "api-shiners/pkg/entities" + "context" "github.com/google/uuid" "gorm.io/gorm" ) type UserRepository interface { - GetAll() ([]entities.User, error) + GetAll(page, perPage int) ([]entities.User, int64, error) GetByID(id uuid.UUID) (entities.User, error) + UpdateProfile(ctx context.Context, userID uuid.UUID, name, email string) (*entities.User, error) + DeactivateUser(ctx context.Context, userID uuid.UUID) error + ActivateUser(ctx context.Context, userID uuid.UUID) error } type userRepository struct { @@ -20,10 +24,23 @@ func NewUserRepository(db *gorm.DB) UserRepository { return &userRepository{db} } -func (r *userRepository) GetAll() ([]entities.User, error) { +func (r *userRepository) GetAll(page, perPage int) ([]entities.User, int64, error) { var users []entities.User - err := r.db.Preload("Roles").Find(&users).Error - return users, err + var total int64 + + query := r.db.Model(&entities.User{}).Preload("Roles") + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * perPage + err := query.Limit(perPage).Offset(offset).Find(&users).Error + if err != nil { + return nil, 0, err + } + + return users, total, nil } func (r *userRepository) GetByID(id uuid.UUID) (entities.User, error) { @@ -32,6 +49,39 @@ func (r *userRepository) GetByID(id uuid.UUID) (entities.User, error) { return user, err } +func (r *userRepository) UpdateProfile(ctx context.Context, userID uuid.UUID, name, email string) (*entities.User, error) { + var user entities.User + if err := r.db.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil { + return nil, err + } + + user.Name = name + user.Email = email + + if err := r.db.WithContext(ctx).Save(&user).Error; err != nil { + return nil, err + } + + return &user, nil +} + + +func (r *userRepository) DeactivateUser(ctx context.Context, userID uuid.UUID) error { + return r.db.WithContext(ctx). + Model(&entities.User{}). + Where("id = ?", userID). + Update("is_active", false).Error +} + +func (r *userRepository) ActivateUser(ctx context.Context, userID uuid.UUID) error { + return r.db.WithContext(ctx). + Model(&entities.User{}). + Where("id = ?", userID). + Update("is_active", true).Error +} + + + diff --git a/pkg/user/service.go b/pkg/user/service.go index 8a628a5..a9022da 100644 --- a/pkg/user/service.go +++ b/pkg/user/service.go @@ -14,9 +14,13 @@ import ( ) type UserService interface { - GetAllUsers() ([]entities.User, error) + GetAllUsers(page, perPage int) ([]entities.User, int64, error) GetUserByID(id uuid.UUID) (entities.User, error) SetUserRole(ctx context.Context, userID uuid.UUID, roleName string) (*entities.User, error) + DeactivateUser(ctx context.Context, userID uuid.UUID) (*entities.User, error) + ActivateUser(ctx context.Context, userID uuid.UUID) (*entities.User, error) + GetProfile(ctx context.Context, userID string) (*entities.User, error) + UpdateProfile(ctx context.Context, userID uuid.UUID, name, email string) (*entities.User, error) } type userService struct { @@ -24,7 +28,6 @@ type userService struct { authRepo auth.AuthRepository } -// ✅ Constructor tunggal — wajib dipakai func NewUserService(userRepo UserRepository, authRepo auth.AuthRepository) UserService { return &userService{ userRepo: userRepo, @@ -32,39 +35,46 @@ func NewUserService(userRepo UserRepository, authRepo auth.AuthRepository) UserS } } -// ===== GET ALL USERS (dengan caching Redis opsional) ===== -func (s *userService) GetAllUsers() ([]entities.User, error) { + +func (s *userService) GetAllUsers(page, perPage int) ([]entities.User, int64, error) { ctx := context.Background() - cacheKey := "users:all" + cacheKey := fmt.Sprintf("users:page:%d:perpage:%d", page, perPage) var users []entities.User + var total int64 - // 🔹 Coba ambil dari Redis if config.RedisClient != nil { val, err := config.RedisClient.Get(ctx, cacheKey).Result() if err == nil && val != "" { - if err := json.Unmarshal([]byte(val), &users); err == nil { - return users, nil + var cached struct { + Users []entities.User `json:"users"` + Total int64 `json:"total"` + } + if err := json.Unmarshal([]byte(val), &cached); err == nil { + return cached.Users, cached.Total, nil } } } - // 🔹 Ambil dari DB - users, err := s.userRepo.GetAll() + users, total, err := s.userRepo.GetAll(page, perPage) if err != nil { - return nil, err + return nil, 0, err } - // 🔹 Simpan ke Redis if config.RedisClient != nil { - data, _ := json.Marshal(users) + cached := struct { + Users []entities.User `json:"users"` + Total int64 `json:"total"` + }{Users: users, Total: total} + + data, _ := json.Marshal(cached) config.RedisClient.Set(ctx, cacheKey, data, 5*time.Minute) } - return users, nil + return users, total, nil } -// ===== GET USER BY ID ===== + func (s *userService) GetUserByID(id uuid.UUID) (entities.User, error) { ctx := context.Background() cacheKey := fmt.Sprintf("user:%s", id.String()) @@ -93,31 +103,26 @@ func (s *userService) GetUserByID(id uuid.UUID) (entities.User, error) { return user, nil } -// ===== SET USER ROLE ===== func (s *userService) SetUserRole(ctx context.Context, userID uuid.UUID, roleName string) (*entities.User, error) { - // Cek dependency dulu + if s.userRepo == nil || s.authRepo == nil { return nil, errors.New("userRepo atau authRepo belum diinisialisasi dengan benar") } - // 1. Cari user berdasarkan ID user, err := s.userRepo.GetByID(userID) if err != nil { return nil, errors.New("user not found") } - // 2. Cari role berdasarkan nama role, err := s.authRepo.FindRoleByName(ctx, roleName) if err != nil { return nil, errors.New("role not found") } - // 3. Hapus semua role lama user if err := s.authRepo.RemoveAllRolesFromUser(ctx, user.ID); err != nil { return nil, fmt.Errorf("failed to clear old roles: %v", err) } - // 4. Tambahkan role baru userRole := &entities.UserRole{ UserID: user.ID, RoleID: role.ID, @@ -126,7 +131,6 @@ func (s *userService) SetUserRole(ctx context.Context, userID uuid.UUID, roleNam return nil, err } - // 5. Ambil ulang user untuk dikembalikan (dengan role terbaru) updatedUser, err := s.userRepo.GetByID(userID) if err != nil { return nil, err @@ -135,3 +139,66 @@ func (s *userService) SetUserRole(ctx context.Context, userID uuid.UUID, roleNam return &updatedUser, nil } +func (s *userService) DeactivateUser(ctx context.Context, userID uuid.UUID) (*entities.User, error) { + + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, errors.New("user not found") + } + + if err := s.userRepo.DeactivateUser(ctx, userID); err != nil { + return nil, err + } + + user.IsActive = false + + return &user, nil +} + +func (s *userService) ActivateUser(ctx context.Context, userID uuid.UUID) (*entities.User, error) { + + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, errors.New("user not found") + } + + if err := s.userRepo.ActivateUser(ctx, userID); err != nil { + return nil, err + } + + user.IsActive = true + + return &user, nil +} + + +func (s *userService) GetProfile(ctx context.Context, userID string) (*entities.User, error) { + uid, err := uuid.Parse(userID) + if err != nil { + return nil, err + } + + user, err := s.userRepo.GetByID(uid) + if err != nil { + return nil, err + } + + return &user, nil +} + +func (s *userService) UpdateProfile(ctx context.Context, userID uuid.UUID, name, email string) (*entities.User, error) { + if name == "" || email == "" { + return nil, errors.New("name and email are required") + } + + user, err := s.userRepo.UpdateProfile(ctx, userID, name, email) + if err != nil { + return nil, err + } + + return user, nil +} + + + + diff --git a/pkg/utils/permission.go b/pkg/utils/permission.go new file mode 100644 index 0000000..55f3a1c --- /dev/null +++ b/pkg/utils/permission.go @@ -0,0 +1,33 @@ +package utils + +func GetPermissionsByRole(role string) []string { + switch role { + case "ADMIN": + return []string{ + "management user (crud, activate user, deactivate user, set role)", + "management course (crud)", + "log book management (crud)", + "feedback management (crud)", + "quiz management (crud)", + } + + case "TEACHER": + return []string{ + "management course (crud)", + "log book management (crud)", + "feedback management (crud)", + "quiz management (crud)", + } + + case "STUDENT": + return []string{ + "course (read)", + "answer (create)", + "feedback (create, read)", + "log book (read)", + } + + default: + return []string{} + } +} diff --git a/pkg/utils/response.go b/pkg/utils/response.go index 18aaffc..2c52dae 100644 --- a/pkg/utils/response.go +++ b/pkg/utils/response.go @@ -6,7 +6,6 @@ import ( "github.com/gofiber/fiber/v2" ) -// Struktur response sukses type Meta struct { Page int `json:"page,omitempty"` PerPage int `json:"per_page,omitempty"` @@ -22,7 +21,6 @@ type SuccessResponse struct { Path string `json:"path"` } -// Struktur response error type FieldError struct { Field string `json:"field"` Messages []string `json:"messages"` @@ -39,7 +37,6 @@ type ErrorResponse struct { Errors []FieldError `json:"errors,omitempty"` } -// Fungsi helper sukses func Success(c *fiber.Ctx, status int, message string, data interface{}, meta *Meta) error { resp := SuccessResponse{ Status: status, @@ -52,7 +49,6 @@ func Success(c *fiber.Ctx, status int, message string, data interface{}, meta *M return c.Status(status).JSON(resp) } -// Fungsi helper error func Error(c *fiber.Ctx, status int, message string, errType string, fieldErrors []FieldError) error { resp := ErrorResponse{ Success: false,